├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── index.html ├── scripts ├── api.js ├── conf.js-dist ├── lib │ ├── backbone.js │ ├── jquery-1.9.1.js │ ├── jquery.mobile-1.3.1.js │ ├── require.js │ └── underscore.js ├── main.js ├── models.js ├── router.js ├── templates.js ├── utils.js └── views.js ├── style ├── images │ ├── ajax-loader.gif │ ├── icons-18-black.png │ ├── icons-18-white.png │ ├── icons-36-black.png │ └── icons-36-white.png ├── jquery.mobile-1.3.1.css └── main.css ├── touch-icon-iphone-retina.png ├── touch-icon-iphone.png ├── touch-startup-image-320x460.png ├── touch-startup-image-640x1096.png └── touch-startup-image-640x920.png /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | src/scripts/conf.js 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 1.0 2 | === 3 | 4 | On 2013-05-14 5 | 6 | * bug fixes 7 | * articles rows in the list are thinner 8 | * articles titles in the list wrap to the next line when too long 9 | * doc updates 10 | * removal of the webappPath config entry 11 | 12 | 1.0-beta 13 | ======== 14 | 15 | On 2013-04-23 16 | 17 | * It really should be faster than previous releases. It only refresh the lists when really needed. 18 | * labels were removed for now 19 | * code is modular and cleaner 20 | * on startup, it's a bit dirty and let you see the page unstyled 21 | * lot of bug fixes 22 | * read/unread list elements are marked with normal/bold font weigth (no more grouping) 23 | * API timeout set at 15s 24 | * better error reporting (when conf.js is not OK for example) 25 | * you can choose the articles sort order from the setting page 26 | * removal of the config property webappPath 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 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 Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | How to Apply These Terms to Your New Programs 622 | 623 | If you develop a new program, and you want it to be of the greatest 624 | possible use to the public, the best way to achieve this is to make it 625 | free software which everyone can redistribute and change under these terms. 626 | 627 | To do so, attach the following notices to the program. It is safest 628 | to attach them to the start of each source file to most effectively 629 | state the exclusion of warranty; and each file should have at least 630 | the "copyright" line and a pointer to where the full notice is found. 631 | 632 | 633 | Copyright (C) 634 | 635 | This program is free software: you can redistribute it and/or modify 636 | it under the terms of the GNU Affero General Public License as published 637 | by the Free Software Foundation, either version 3 of the License, or 638 | (at your option) any later version. 639 | 640 | This program is distributed in the hope that it will be useful, 641 | but WITHOUT ANY WARRANTY; without even the implied warranty of 642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 | GNU Affero General Public License for more details. 644 | 645 | You should have received a copy of the GNU Affero General Public License 646 | along with this program. If not, see . 647 | 648 | Also add information on how to contact you by electronic and paper mail. 649 | 650 | If your software can interact with users remotely through a computer 651 | network, you should also make sure that it provides a way for users to 652 | get its source. For example, if your program is a web application, its 653 | interface could display a "Source" link that leads users to an archive 654 | of the code. There are many ways you could offer source, and different 655 | solutions will be better for different programs; see section 13 for the 656 | specific requirements. 657 | 658 | You should also get your employer (if you work as a programmer) or school, 659 | if any, to sign a "copyright disclaimer" for the program, if necessary. 660 | For more information on this, and how to apply and follow the GNU AGPL, see 661 | . 662 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ttrss-mobile 2 | ============ 3 | 4 | A mobile webapp for *Tiny Tiny RSS* 5 | 6 | What is it? 7 | ----------- 8 | 9 | This webapp is a client for [Tiny Tiny RSS](http://tt-rss.org). 10 | It uses its [JSON API](http://tt-rss.org/redmine/projects/tt-rss/wiki/JsonApiReference). 11 | 12 | I started working on this because the default mobile version was somtimes slow, 13 | limited and not in good shape for future development. 14 | 15 | ttrss-mobile is using: 16 | * [jQuery Mobile](http://jquerymobile.com/) 17 | * [Backbone.js](http://backbonejs.org/) 18 | * [RequireJS](http://requirejs.org/) 19 | 20 | 21 | How to install? 22 | -------------- 23 | 24 | * Download the latest release available [here](https://github.com/mboinet/ttrss-mobile/releases). 25 | * Unpack the archive somewhere on your server. 26 | * Alternatively, you can clone the repository where you want it on your web server. 27 | * In the scripts dir, copy `conf.js-dist` to `conf.js` and set the variable `window.apiPath` pointing to your *Tiny Tiny RSS* installation. 28 | 29 | ### Updates 30 | * Download the update 31 | * Unpack it over your previous installation 32 | * Compare the new conf.js-dist with yours and merge them 33 | * It should be ready 34 | * (Don't forget to clear your cache if you see something strange) 35 | 36 | How to hack on it? 37 | ------------------ 38 | 39 | You need *[node.js](http://nodejs.org/)* to make a build but you can still 40 | hack without it. 41 | 42 | * Clone this repo on your webserver 43 | * Hack on the files in *src* 44 | * Test on the version in *src* 45 | * Build with make/make.sh 46 | * Test with the built version in build 47 | * Make a pull request with your awesome contribution 48 | 49 | Caveats 50 | ------- 51 | 52 | * You should not put this webapp in a subdir of your *Tiny Tiny RSS* install. On update, it could 53 | be wiped. For more info, see [this post](http://tt-rss.org/forum/viewtopic.php?f=10&t=1216&p=8411#p8359) 54 | from *HunterZ* on the forum. 55 | 56 | * Make sure that the user you'll use to connect has the API activated in *Tiny Tiny RSS* preferences : 57 | * in *Tiny Tiny RSS* go into `Actions` -> `Preferences` 58 | * `Configuration` -> `Enable external API` 59 | 60 | * If you want to host this webapp on another hostname than your *Tiny Tiny RSS* instance, 61 | you'll find a solution using *CORS* in [this issue](https://github.com/mboinet/ttrss-mobile/issues/36). 62 | 63 | 64 | Current features 65 | ---------------- 66 | 67 | * mark all as read/unread 68 | * categories support 69 | * feeds icon display 70 | * image & objects adapted to the screen size (`max-width: 100%` in CSS) 71 | * link to the original article 72 | * unread count display 73 | * special feeds 74 | * publish/unpublish article support 75 | * star/unstar article support 76 | * mark as read/unread article support 77 | * iPhone webapp support (startup image & icon) 78 | * SINGLE_USER_MODE support 79 | * settings page: only number of articles to load as of now 80 | 81 | Other features to come are tracked as issues. 82 | Feel free to give a hand or request things :-) 83 | 84 | License 85 | ------------------ 86 | 87 | ttrss-mobile is Free Software under the [AGPLv3](LICENSE) 88 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Reader 5 | 6 | 7 | 8 | 9 | 10 | 11 | 13 | 16 | 17 | 18 | 19 | 22 | 23 | 26 | 27 | 28 | 29 | 30 |
31 |
32 | Settings 35 |

Categories

36 | Menu 39 |
40 |
41 |
    42 |
43 |
44 | 45 |
47 | 51 |
52 |
53 | 54 | 55 |
56 |
57 |

Reader - Login

58 |
59 |
60 |
61 |
62 | 63 | 65 |
66 |
67 | 68 | 69 |
70 | 71 |
72 |
73 |
74 | 75 | 76 |
77 |
78 | Back 81 |

Category

82 | Menu 85 |
86 |
87 |
    88 |
89 |
90 |
92 | 96 |
97 |
98 | 99 |
100 |
101 | Back 104 |

Feed

105 | Menu 108 |
109 |
110 |
    111 |
112 |
113 | 114 |
116 | 121 |
122 |
123 | 124 |
125 |
126 | Back 129 |

130 | Menu 133 |
134 |
135 |
136 |

137 |

Feed:

138 |

Updated:

139 |
140 |
141 |
142 |
143 | 144 |
146 | 152 |
153 |
154 | 155 |
156 |
157 | Close 160 |

Settings

161 |
162 |
163 |
164 | 165 | 167 | 168 | 169 | 170 | 171 |
172 |
173 |
174 |

Version : 1.0

175 |
176 |
177 | 178 | 179 | 180 | -------------------------------------------------------------------------------- /scripts/api.js: -------------------------------------------------------------------------------- 1 | 2 | // API functions 3 | 4 | define(['require','jquery','conf','router','models','utils'], 5 | function(require, $, conf, router, models, utils){ 6 | 7 | // AJAX defaults 8 | $.ajaxSetup({ 9 | url: conf.apiPath + 'api/', 10 | contentType: "application/json", 11 | dataType: 'json', 12 | cache: 'false', 13 | type: 'post', 14 | timeout: 15000 // 15s by default 15 | }); 16 | 17 | 18 | /* AJAX error handler */ 19 | function ajaxErrorHandler(event, jqXHR, ajaxSettings, thrownError){ 20 | // state of the XHR 21 | var state = jqXHR.readyState; 22 | 23 | if (state == 4){ 24 | //DONE 25 | 26 | if (jqXHR.status != 200){ 27 | alert ("There is probably a configuration error." + 28 | " An API call returned: "+ jqXHR.status + 29 | " (" + jqXHR.statusText + ")"); 30 | } else { 31 | // API errors go to the console 32 | utils.log('API error: ' + thrownError); 33 | } 34 | } else { 35 | // other states also go to the console too 36 | utils.log("API error with state " + state + ": " + 37 | thrownError); 38 | } 39 | } 40 | 41 | /* Most of the calls (except login, logout, isLoggedIn) 42 | require valid login session or will return this 43 | error object: {"error":"NOT_LOGGED_IN"} */ 44 | function apiErrorHandler(msg){ 45 | if (msg.error != "NOT_LOGGED_IN"){ 46 | // real error 47 | alert('apiErrorHandler\nUnknown API error message' + msg.error); 48 | 49 | } else { 50 | // need to login 51 | if (! location.hash.startsWith("#login")){ 52 | 53 | // before redirecting user to the login page 54 | // we need to test if TTRSS is in SINGLE USER MODE 55 | jQuery.ajax({ 56 | data: JSON.stringify({op: "login"}), 57 | async: false 58 | }).done(function(data){ 59 | if (data.status == 1){ 60 | // we're really not logged in 61 | var dest = "login"; // new destination 62 | 63 | if (location.hash != ""){ 64 | // we store where we're coming from in a query string 65 | dest += "?from=" + location.hash; 66 | } 67 | require('router').myRouter.navigate(dest, {trigger: true}); 68 | 69 | } else { 70 | // SINGLE_USER_MODE 71 | require('models').settings.set("sid", data.content.session_id); 72 | require('models').settings.save(); 73 | 74 | window.location.reload(true); 75 | } 76 | }); 77 | 78 | } // else user is already where he needs to be 79 | } 80 | } // apiErrorHandler 81 | 82 | // my handler for AJAX errors 83 | $(document).ajaxError(ajaxErrorHandler); 84 | 85 | return { 86 | 87 | /* function to call TTRSS 88 | - req => the request as a JSON object 89 | - success => the success callback (one param the content) 90 | - async => async call? */ 91 | ttRssApiCall: function(req, success, async){ 92 | var data = req; 93 | // circular dependency for models 94 | var sid = require('models').settings.get("sid"); 95 | 96 | if (sid != undefined){ 97 | data.sid = sid; 98 | } 99 | 100 | jQuery.ajax( 101 | { 102 | data: JSON.stringify(data), 103 | async: async 104 | } 105 | ) 106 | .done(function(data){ 107 | if (data.status == 0){ 108 | success(data.content); 109 | } else { 110 | apiErrorHandler(data.content); 111 | } 112 | }); 113 | }, // ttRssApiCall 114 | 115 | 116 | 117 | /* to make a logout call */ 118 | logout: function(){ 119 | var msg = { 120 | 'op': 'logout' 121 | }; 122 | 123 | this.ttRssApiCall(msg, 124 | function(){ 125 | require('router').myRouter.navigate('login', {trigger: true}); 126 | }, 127 | function(m){ 128 | alert('Could not logout :\n' + m); 129 | }, true 130 | ); 131 | } //logout 132 | 133 | } //return 134 | 135 | 136 | }); //define 137 | -------------------------------------------------------------------------------- /scripts/conf.js-dist: -------------------------------------------------------------------------------- 1 | /* Copy conf.js-dist to conf.js 2 | and set it up for your environment. 3 | 4 | DO NOT USE ANY FILESYSTEM PATH */ 5 | 6 | define({ 7 | 8 | /* URL to access your Tiny Tiny RSS installation */ 9 | apiPath: "/tt-rss/" 10 | 11 | }); 12 | -------------------------------------------------------------------------------- /scripts/lib/require.js: -------------------------------------------------------------------------------- 1 | /* 2 | RequireJS 2.1.5 Copyright (c) 2010-2012, The Dojo Foundation All Rights Reserved. 3 | Available via the MIT or new BSD license. 4 | see: http://github.com/jrburke/requirejs for details 5 | */ 6 | var requirejs,require,define; 7 | (function(aa){function I(b){return"[object Function]"===L.call(b)}function J(b){return"[object Array]"===L.call(b)}function y(b,c){if(b){var d;for(d=0;dthis.depCount&&!this.defined){if(I(n)){if(this.events.error)try{e=i.execCb(c,n,b,e)}catch(d){a=d}else e=i.execCb(c,n,b,e);this.map.isDefine&&((b=this.module)&&void 0!==b.exports&&b.exports!==this.exports?e=b.exports:void 0===e&&this.usingExports&&(e=this.exports));if(a)return a.requireMap=this.map,a.requireModules=[this.map.id],a.requireType="define",v(this.error= 19 | a)}else e=n;this.exports=e;if(this.map.isDefine&&!this.ignore&&(q[c]=e,l.onResourceLoad))l.onResourceLoad(i,this.map,this.depMaps);x(c);this.defined=!0}this.defining=!1;this.defined&&!this.defineEmitted&&(this.defineEmitted=!0,this.emit("defined",this.exports),this.defineEmitComplete=!0)}}else this.fetch()}},callPlugin:function(){var a=this.map,b=a.id,d=j(a.prefix);this.depMaps.push(d);t(d,"defined",u(this,function(e){var n,d;d=this.map.name;var g=this.map.parentMap?this.map.parentMap.name:null,h= 20 | i.makeRequire(a.parentMap,{enableBuildCallback:!0});if(this.map.unnormalized){if(e.normalize&&(d=e.normalize(d,function(a){return c(a,g,!0)})||""),e=j(a.prefix+"!"+d,this.map.parentMap),t(e,"defined",u(this,function(a){this.init([],function(){return a},null,{enabled:!0,ignore:!0})})),d=m(p,e.id)){this.depMaps.push(e);if(this.events.error)d.on("error",u(this,function(a){this.emit("error",a)}));d.enable()}}else n=u(this,function(a){this.init([],function(){return a},null,{enabled:!0})}),n.error=u(this, 21 | function(a){this.inited=!0;this.error=a;a.requireModules=[b];G(p,function(a){0===a.map.id.indexOf(b+"_unnormalized")&&x(a.map.id)});v(a)}),n.fromText=u(this,function(e,c){var d=a.name,g=j(d),C=O;c&&(e=c);C&&(O=!1);r(g);s(k.config,b)&&(k.config[d]=k.config[b]);try{l.exec(e)}catch(ca){return v(B("fromtexteval","fromText eval for "+b+" failed: "+ca,ca,[b]))}C&&(O=!0);this.depMaps.push(g);i.completeLoad(d);h([d],n)}),e.load(a.name,h,n,k)}));i.enable(d,this);this.pluginMaps[d.id]=d},enable:function(){V[this.map.id]= 22 | this;this.enabling=this.enabled=!0;y(this.depMaps,u(this,function(a,b){var c,e;if("string"===typeof a){a=j(a,this.map.isDefine?this.map:this.map.parentMap,!1,!this.skipMap);this.depMaps[b]=a;if(c=m(N,a.id)){this.depExports[b]=c(this);return}this.depCount+=1;t(a,"defined",u(this,function(a){this.defineDep(b,a);this.check()}));this.errback&&t(a,"error",this.errback)}c=a.id;e=p[c];!s(N,c)&&(e&&!e.enabled)&&i.enable(a,this)}));G(this.pluginMaps,u(this,function(a){var b=m(p,a.id);b&&!b.enabled&&i.enable(a, 23 | this)}));this.enabling=!1;this.check()},on:function(a,b){var c=this.events[a];c||(c=this.events[a]=[]);c.push(b)},emit:function(a,b){y(this.events[a],function(a){a(b)});"error"===a&&delete this.events[a]}};i={config:k,contextName:b,registry:p,defined:q,urlFetched:U,defQueue:H,Module:Z,makeModuleMap:j,nextTick:l.nextTick,onError:v,configure:function(a){a.baseUrl&&"/"!==a.baseUrl.charAt(a.baseUrl.length-1)&&(a.baseUrl+="/");var b=k.pkgs,c=k.shim,e={paths:!0,config:!0,map:!0};G(a,function(a,b){e[b]? 24 | "map"===b?(k.map||(k.map={}),R(k[b],a,!0,!0)):R(k[b],a,!0):k[b]=a});a.shim&&(G(a.shim,function(a,b){J(a)&&(a={deps:a});if((a.exports||a.init)&&!a.exportsFn)a.exportsFn=i.makeShimExports(a);c[b]=a}),k.shim=c);a.packages&&(y(a.packages,function(a){a="string"===typeof a?{name:a}:a;b[a.name]={name:a.name,location:a.location||a.name,main:(a.main||"main").replace(ja,"").replace(ea,"")}}),k.pkgs=b);G(p,function(a,b){!a.inited&&!a.map.unnormalized&&(a.map=j(b))});if(a.deps||a.callback)i.require(a.deps||[], 25 | a.callback)},makeShimExports:function(a){return function(){var b;a.init&&(b=a.init.apply(aa,arguments));return b||a.exports&&ba(a.exports)}},makeRequire:function(a,f){function d(e,c,h){var g,k;f.enableBuildCallback&&(c&&I(c))&&(c.__requireJsBuild=!0);if("string"===typeof e){if(I(c))return v(B("requireargs","Invalid require call"),h);if(a&&s(N,e))return N[e](p[a.id]);if(l.get)return l.get(i,e,a,d);g=j(e,a,!1,!0);g=g.id;return!s(q,g)?v(B("notloaded",'Module name "'+g+'" has not been loaded yet for context: '+ 26 | b+(a?"":". Use require([])"))):q[g]}L();i.nextTick(function(){L();k=r(j(null,a));k.skipMap=f.skipMap;k.init(e,c,h,{enabled:!0});D()});return d}f=f||{};R(d,{isBrowser:A,toUrl:function(b){var d,f=b.lastIndexOf("."),g=b.split("/")[0];if(-1!==f&&(!("."===g||".."===g)||1h.attachEvent.toString().indexOf("[native code"))&&!Y?(O=!0,h.attachEvent("onreadystatechange",b.onScriptLoad)):(h.addEventListener("load",b.onScriptLoad,!1),h.addEventListener("error",b.onScriptError,!1)),h.src=d,K=h,D?x.insertBefore(h,D):x.appendChild(h),K=null,h;if(da)try{importScripts(d),b.completeLoad(c)}catch(j){b.onError(B("importscripts","importScripts failed for "+c+" at "+d,j,[c]))}};A&&M(document.getElementsByTagName("script"),function(b){x||(x= 34 | b.parentNode);if(t=b.getAttribute("data-main"))return r.baseUrl||(E=t.split("/"),Q=E.pop(),fa=E.length?E.join("/")+"/":"./",r.baseUrl=fa,t=Q),t=t.replace(ea,""),r.deps=r.deps?r.deps.concat(t):[t],!0});define=function(b,c,d){var l,h;"string"!==typeof b&&(d=c,c=b,b=null);J(c)||(d=c,c=[]);!c.length&&I(d)&&d.length&&(d.toString().replace(la,"").replace(ma,function(b,d){c.push(d)}),c=(1===d.length?["require"]:["require","exports","module"]).concat(c));if(O){if(!(l=K))P&&"interactive"===P.readyState||M(document.getElementsByTagName("script"), 35 | function(b){if("interactive"===b.readyState)return P=b}),l=P;l&&(b||(b=l.getAttribute("data-requiremodule")),h=F[l.getAttribute("data-requirecontext")])}(h?h.defQueue:T).push([b,c,d])};define.amd={jQuery:!0};l.exec=function(b){return eval(b)};l(r)}})(this); 36 | -------------------------------------------------------------------------------- /scripts/lib/underscore.js: -------------------------------------------------------------------------------- 1 | // Underscore.js 1.4.4 2 | // =================== 3 | 4 | // > http://underscorejs.org 5 | // > (c) 2009-2013 Jeremy Ashkenas, DocumentCloud Inc. 6 | // > Underscore may be freely distributed under the MIT license. 7 | 8 | // Baseline setup 9 | // -------------- 10 | (function() { 11 | 12 | // Establish the root object, `window` in the browser, or `global` on the server. 13 | var root = this; 14 | 15 | // Save the previous value of the `_` variable. 16 | var previousUnderscore = root._; 17 | 18 | // Establish the object that gets returned to break out of a loop iteration. 19 | var breaker = {}; 20 | 21 | // Save bytes in the minified (but not gzipped) version: 22 | var ArrayProto = Array.prototype, ObjProto = Object.prototype, FuncProto = Function.prototype; 23 | 24 | // Create quick reference variables for speed access to core prototypes. 25 | var push = ArrayProto.push, 26 | slice = ArrayProto.slice, 27 | concat = ArrayProto.concat, 28 | toString = ObjProto.toString, 29 | hasOwnProperty = ObjProto.hasOwnProperty; 30 | 31 | // All **ECMAScript 5** native function implementations that we hope to use 32 | // are declared here. 33 | var 34 | nativeForEach = ArrayProto.forEach, 35 | nativeMap = ArrayProto.map, 36 | nativeReduce = ArrayProto.reduce, 37 | nativeReduceRight = ArrayProto.reduceRight, 38 | nativeFilter = ArrayProto.filter, 39 | nativeEvery = ArrayProto.every, 40 | nativeSome = ArrayProto.some, 41 | nativeIndexOf = ArrayProto.indexOf, 42 | nativeLastIndexOf = ArrayProto.lastIndexOf, 43 | nativeIsArray = Array.isArray, 44 | nativeKeys = Object.keys, 45 | nativeBind = FuncProto.bind; 46 | 47 | // Create a safe reference to the Underscore object for use below. 48 | var _ = function(obj) { 49 | if (obj instanceof _) return obj; 50 | if (!(this instanceof _)) return new _(obj); 51 | this._wrapped = obj; 52 | }; 53 | 54 | // Export the Underscore object for **Node.js**, with 55 | // backwards-compatibility for the old `require()` API. If we're in 56 | // the browser, add `_` as a global object via a string identifier, 57 | // for Closure Compiler "advanced" mode. 58 | if (typeof exports !== 'undefined') { 59 | if (typeof module !== 'undefined' && module.exports) { 60 | exports = module.exports = _; 61 | } 62 | exports._ = _; 63 | } else { 64 | root._ = _; 65 | } 66 | 67 | // Current version. 68 | _.VERSION = '1.4.4'; 69 | 70 | // Collection Functions 71 | // -------------------- 72 | 73 | // The cornerstone, an `each` implementation, aka `forEach`. 74 | // Handles objects with the built-in `forEach`, arrays, and raw objects. 75 | // Delegates to **ECMAScript 5**'s native `forEach` if available. 76 | var each = _.each = _.forEach = function(obj, iterator, context) { 77 | if (obj == null) return; 78 | if (nativeForEach && obj.forEach === nativeForEach) { 79 | obj.forEach(iterator, context); 80 | } else if (obj.length === +obj.length) { 81 | for (var i = 0, l = obj.length; i < l; i++) { 82 | if (iterator.call(context, obj[i], i, obj) === breaker) return; 83 | } 84 | } else { 85 | for (var key in obj) { 86 | if (_.has(obj, key)) { 87 | if (iterator.call(context, obj[key], key, obj) === breaker) return; 88 | } 89 | } 90 | } 91 | }; 92 | 93 | // Return the results of applying the iterator to each element. 94 | // Delegates to **ECMAScript 5**'s native `map` if available. 95 | _.map = _.collect = function(obj, iterator, context) { 96 | var results = []; 97 | if (obj == null) return results; 98 | if (nativeMap && obj.map === nativeMap) return obj.map(iterator, context); 99 | each(obj, function(value, index, list) { 100 | results[results.length] = iterator.call(context, value, index, list); 101 | }); 102 | return results; 103 | }; 104 | 105 | var reduceError = 'Reduce of empty array with no initial value'; 106 | 107 | // **Reduce** builds up a single result from a list of values, aka `inject`, 108 | // or `foldl`. Delegates to **ECMAScript 5**'s native `reduce` if available. 109 | _.reduce = _.foldl = _.inject = function(obj, iterator, memo, context) { 110 | var initial = arguments.length > 2; 111 | if (obj == null) obj = []; 112 | if (nativeReduce && obj.reduce === nativeReduce) { 113 | if (context) iterator = _.bind(iterator, context); 114 | return initial ? obj.reduce(iterator, memo) : obj.reduce(iterator); 115 | } 116 | each(obj, function(value, index, list) { 117 | if (!initial) { 118 | memo = value; 119 | initial = true; 120 | } else { 121 | memo = iterator.call(context, memo, value, index, list); 122 | } 123 | }); 124 | if (!initial) throw new TypeError(reduceError); 125 | return memo; 126 | }; 127 | 128 | // The right-associative version of reduce, also known as `foldr`. 129 | // Delegates to **ECMAScript 5**'s native `reduceRight` if available. 130 | _.reduceRight = _.foldr = function(obj, iterator, memo, context) { 131 | var initial = arguments.length > 2; 132 | if (obj == null) obj = []; 133 | if (nativeReduceRight && obj.reduceRight === nativeReduceRight) { 134 | if (context) iterator = _.bind(iterator, context); 135 | return initial ? obj.reduceRight(iterator, memo) : obj.reduceRight(iterator); 136 | } 137 | var length = obj.length; 138 | if (length !== +length) { 139 | var keys = _.keys(obj); 140 | length = keys.length; 141 | } 142 | each(obj, function(value, index, list) { 143 | index = keys ? keys[--length] : --length; 144 | if (!initial) { 145 | memo = obj[index]; 146 | initial = true; 147 | } else { 148 | memo = iterator.call(context, memo, obj[index], index, list); 149 | } 150 | }); 151 | if (!initial) throw new TypeError(reduceError); 152 | return memo; 153 | }; 154 | 155 | // Return the first value which passes a truth test. Aliased as `detect`. 156 | _.find = _.detect = function(obj, iterator, context) { 157 | var result; 158 | any(obj, function(value, index, list) { 159 | if (iterator.call(context, value, index, list)) { 160 | result = value; 161 | return true; 162 | } 163 | }); 164 | return result; 165 | }; 166 | 167 | // Return all the elements that pass a truth test. 168 | // Delegates to **ECMAScript 5**'s native `filter` if available. 169 | // Aliased as `select`. 170 | _.filter = _.select = function(obj, iterator, context) { 171 | var results = []; 172 | if (obj == null) return results; 173 | if (nativeFilter && obj.filter === nativeFilter) return obj.filter(iterator, context); 174 | each(obj, function(value, index, list) { 175 | if (iterator.call(context, value, index, list)) results[results.length] = value; 176 | }); 177 | return results; 178 | }; 179 | 180 | // Return all the elements for which a truth test fails. 181 | _.reject = function(obj, iterator, context) { 182 | return _.filter(obj, function(value, index, list) { 183 | return !iterator.call(context, value, index, list); 184 | }, context); 185 | }; 186 | 187 | // Determine whether all of the elements match a truth test. 188 | // Delegates to **ECMAScript 5**'s native `every` if available. 189 | // Aliased as `all`. 190 | _.every = _.all = function(obj, iterator, context) { 191 | iterator || (iterator = _.identity); 192 | var result = true; 193 | if (obj == null) return result; 194 | if (nativeEvery && obj.every === nativeEvery) return obj.every(iterator, context); 195 | each(obj, function(value, index, list) { 196 | if (!(result = result && iterator.call(context, value, index, list))) return breaker; 197 | }); 198 | return !!result; 199 | }; 200 | 201 | // Determine if at least one element in the object matches a truth test. 202 | // Delegates to **ECMAScript 5**'s native `some` if available. 203 | // Aliased as `any`. 204 | var any = _.some = _.any = function(obj, iterator, context) { 205 | iterator || (iterator = _.identity); 206 | var result = false; 207 | if (obj == null) return result; 208 | if (nativeSome && obj.some === nativeSome) return obj.some(iterator, context); 209 | each(obj, function(value, index, list) { 210 | if (result || (result = iterator.call(context, value, index, list))) return breaker; 211 | }); 212 | return !!result; 213 | }; 214 | 215 | // Determine if the array or object contains a given value (using `===`). 216 | // Aliased as `include`. 217 | _.contains = _.include = function(obj, target) { 218 | if (obj == null) return false; 219 | if (nativeIndexOf && obj.indexOf === nativeIndexOf) return obj.indexOf(target) != -1; 220 | return any(obj, function(value) { 221 | return value === target; 222 | }); 223 | }; 224 | 225 | // Invoke a method (with arguments) on every item in a collection. 226 | _.invoke = function(obj, method) { 227 | var args = slice.call(arguments, 2); 228 | var isFunc = _.isFunction(method); 229 | return _.map(obj, function(value) { 230 | return (isFunc ? method : value[method]).apply(value, args); 231 | }); 232 | }; 233 | 234 | // Convenience version of a common use case of `map`: fetching a property. 235 | _.pluck = function(obj, key) { 236 | return _.map(obj, function(value){ return value[key]; }); 237 | }; 238 | 239 | // Convenience version of a common use case of `filter`: selecting only objects 240 | // containing specific `key:value` pairs. 241 | _.where = function(obj, attrs, first) { 242 | if (_.isEmpty(attrs)) return first ? null : []; 243 | return _[first ? 'find' : 'filter'](obj, function(value) { 244 | for (var key in attrs) { 245 | if (attrs[key] !== value[key]) return false; 246 | } 247 | return true; 248 | }); 249 | }; 250 | 251 | // Convenience version of a common use case of `find`: getting the first object 252 | // containing specific `key:value` pairs. 253 | _.findWhere = function(obj, attrs) { 254 | return _.where(obj, attrs, true); 255 | }; 256 | 257 | // Return the maximum element or (element-based computation). 258 | // Can't optimize arrays of integers longer than 65,535 elements. 259 | // See: https://bugs.webkit.org/show_bug.cgi?id=80797 260 | _.max = function(obj, iterator, context) { 261 | if (!iterator && _.isArray(obj) && obj[0] === +obj[0] && obj.length < 65535) { 262 | return Math.max.apply(Math, obj); 263 | } 264 | if (!iterator && _.isEmpty(obj)) return -Infinity; 265 | var result = {computed : -Infinity, value: -Infinity}; 266 | each(obj, function(value, index, list) { 267 | var computed = iterator ? iterator.call(context, value, index, list) : value; 268 | computed >= result.computed && (result = {value : value, computed : computed}); 269 | }); 270 | return result.value; 271 | }; 272 | 273 | // Return the minimum element (or element-based computation). 274 | _.min = function(obj, iterator, context) { 275 | if (!iterator && _.isArray(obj) && obj[0] === +obj[0] && obj.length < 65535) { 276 | return Math.min.apply(Math, obj); 277 | } 278 | if (!iterator && _.isEmpty(obj)) return Infinity; 279 | var result = {computed : Infinity, value: Infinity}; 280 | each(obj, function(value, index, list) { 281 | var computed = iterator ? iterator.call(context, value, index, list) : value; 282 | computed < result.computed && (result = {value : value, computed : computed}); 283 | }); 284 | return result.value; 285 | }; 286 | 287 | // Shuffle an array. 288 | _.shuffle = function(obj) { 289 | var rand; 290 | var index = 0; 291 | var shuffled = []; 292 | each(obj, function(value) { 293 | rand = _.random(index++); 294 | shuffled[index - 1] = shuffled[rand]; 295 | shuffled[rand] = value; 296 | }); 297 | return shuffled; 298 | }; 299 | 300 | // An internal function to generate lookup iterators. 301 | var lookupIterator = function(value) { 302 | return _.isFunction(value) ? value : function(obj){ return obj[value]; }; 303 | }; 304 | 305 | // Sort the object's values by a criterion produced by an iterator. 306 | _.sortBy = function(obj, value, context) { 307 | var iterator = lookupIterator(value); 308 | return _.pluck(_.map(obj, function(value, index, list) { 309 | return { 310 | value : value, 311 | index : index, 312 | criteria : iterator.call(context, value, index, list) 313 | }; 314 | }).sort(function(left, right) { 315 | var a = left.criteria; 316 | var b = right.criteria; 317 | if (a !== b) { 318 | if (a > b || a === void 0) return 1; 319 | if (a < b || b === void 0) return -1; 320 | } 321 | return left.index < right.index ? -1 : 1; 322 | }), 'value'); 323 | }; 324 | 325 | // An internal function used for aggregate "group by" operations. 326 | var group = function(obj, value, context, behavior) { 327 | var result = {}; 328 | var iterator = lookupIterator(value || _.identity); 329 | each(obj, function(value, index) { 330 | var key = iterator.call(context, value, index, obj); 331 | behavior(result, key, value); 332 | }); 333 | return result; 334 | }; 335 | 336 | // Groups the object's values by a criterion. Pass either a string attribute 337 | // to group by, or a function that returns the criterion. 338 | _.groupBy = function(obj, value, context) { 339 | return group(obj, value, context, function(result, key, value) { 340 | (_.has(result, key) ? result[key] : (result[key] = [])).push(value); 341 | }); 342 | }; 343 | 344 | // Counts instances of an object that group by a certain criterion. Pass 345 | // either a string attribute to count by, or a function that returns the 346 | // criterion. 347 | _.countBy = function(obj, value, context) { 348 | return group(obj, value, context, function(result, key) { 349 | if (!_.has(result, key)) result[key] = 0; 350 | result[key]++; 351 | }); 352 | }; 353 | 354 | // Use a comparator function to figure out the smallest index at which 355 | // an object should be inserted so as to maintain order. Uses binary search. 356 | _.sortedIndex = function(array, obj, iterator, context) { 357 | iterator = iterator == null ? _.identity : lookupIterator(iterator); 358 | var value = iterator.call(context, obj); 359 | var low = 0, high = array.length; 360 | while (low < high) { 361 | var mid = (low + high) >>> 1; 362 | iterator.call(context, array[mid]) < value ? low = mid + 1 : high = mid; 363 | } 364 | return low; 365 | }; 366 | 367 | // Safely convert anything iterable into a real, live array. 368 | _.toArray = function(obj) { 369 | if (!obj) return []; 370 | if (_.isArray(obj)) return slice.call(obj); 371 | if (obj.length === +obj.length) return _.map(obj, _.identity); 372 | return _.values(obj); 373 | }; 374 | 375 | // Return the number of elements in an object. 376 | _.size = function(obj) { 377 | if (obj == null) return 0; 378 | return (obj.length === +obj.length) ? obj.length : _.keys(obj).length; 379 | }; 380 | 381 | // Array Functions 382 | // --------------- 383 | 384 | // Get the first element of an array. Passing **n** will return the first N 385 | // values in the array. Aliased as `head` and `take`. The **guard** check 386 | // allows it to work with `_.map`. 387 | _.first = _.head = _.take = function(array, n, guard) { 388 | if (array == null) return void 0; 389 | return (n != null) && !guard ? slice.call(array, 0, n) : array[0]; 390 | }; 391 | 392 | // Returns everything but the last entry of the array. Especially useful on 393 | // the arguments object. Passing **n** will return all the values in 394 | // the array, excluding the last N. The **guard** check allows it to work with 395 | // `_.map`. 396 | _.initial = function(array, n, guard) { 397 | return slice.call(array, 0, array.length - ((n == null) || guard ? 1 : n)); 398 | }; 399 | 400 | // Get the last element of an array. Passing **n** will return the last N 401 | // values in the array. The **guard** check allows it to work with `_.map`. 402 | _.last = function(array, n, guard) { 403 | if (array == null) return void 0; 404 | if ((n != null) && !guard) { 405 | return slice.call(array, Math.max(array.length - n, 0)); 406 | } else { 407 | return array[array.length - 1]; 408 | } 409 | }; 410 | 411 | // Returns everything but the first entry of the array. Aliased as `tail` and `drop`. 412 | // Especially useful on the arguments object. Passing an **n** will return 413 | // the rest N values in the array. The **guard** 414 | // check allows it to work with `_.map`. 415 | _.rest = _.tail = _.drop = function(array, n, guard) { 416 | return slice.call(array, (n == null) || guard ? 1 : n); 417 | }; 418 | 419 | // Trim out all falsy values from an array. 420 | _.compact = function(array) { 421 | return _.filter(array, _.identity); 422 | }; 423 | 424 | // Internal implementation of a recursive `flatten` function. 425 | var flatten = function(input, shallow, output) { 426 | each(input, function(value) { 427 | if (_.isArray(value)) { 428 | shallow ? push.apply(output, value) : flatten(value, shallow, output); 429 | } else { 430 | output.push(value); 431 | } 432 | }); 433 | return output; 434 | }; 435 | 436 | // Return a completely flattened version of an array. 437 | _.flatten = function(array, shallow) { 438 | return flatten(array, shallow, []); 439 | }; 440 | 441 | // Return a version of the array that does not contain the specified value(s). 442 | _.without = function(array) { 443 | return _.difference(array, slice.call(arguments, 1)); 444 | }; 445 | 446 | // Produce a duplicate-free version of the array. If the array has already 447 | // been sorted, you have the option of using a faster algorithm. 448 | // Aliased as `unique`. 449 | _.uniq = _.unique = function(array, isSorted, iterator, context) { 450 | if (_.isFunction(isSorted)) { 451 | context = iterator; 452 | iterator = isSorted; 453 | isSorted = false; 454 | } 455 | var initial = iterator ? _.map(array, iterator, context) : array; 456 | var results = []; 457 | var seen = []; 458 | each(initial, function(value, index) { 459 | if (isSorted ? (!index || seen[seen.length - 1] !== value) : !_.contains(seen, value)) { 460 | seen.push(value); 461 | results.push(array[index]); 462 | } 463 | }); 464 | return results; 465 | }; 466 | 467 | // Produce an array that contains the union: each distinct element from all of 468 | // the passed-in arrays. 469 | _.union = function() { 470 | return _.uniq(concat.apply(ArrayProto, arguments)); 471 | }; 472 | 473 | // Produce an array that contains every item shared between all the 474 | // passed-in arrays. 475 | _.intersection = function(array) { 476 | var rest = slice.call(arguments, 1); 477 | return _.filter(_.uniq(array), function(item) { 478 | return _.every(rest, function(other) { 479 | return _.indexOf(other, item) >= 0; 480 | }); 481 | }); 482 | }; 483 | 484 | // Take the difference between one array and a number of other arrays. 485 | // Only the elements present in just the first array will remain. 486 | _.difference = function(array) { 487 | var rest = concat.apply(ArrayProto, slice.call(arguments, 1)); 488 | return _.filter(array, function(value){ return !_.contains(rest, value); }); 489 | }; 490 | 491 | // Zip together multiple lists into a single array -- elements that share 492 | // an index go together. 493 | _.zip = function() { 494 | var args = slice.call(arguments); 495 | var length = _.max(_.pluck(args, 'length')); 496 | var results = new Array(length); 497 | for (var i = 0; i < length; i++) { 498 | results[i] = _.pluck(args, "" + i); 499 | } 500 | return results; 501 | }; 502 | 503 | // Converts lists into objects. Pass either a single array of `[key, value]` 504 | // pairs, or two parallel arrays of the same length -- one of keys, and one of 505 | // the corresponding values. 506 | _.object = function(list, values) { 507 | if (list == null) return {}; 508 | var result = {}; 509 | for (var i = 0, l = list.length; i < l; i++) { 510 | if (values) { 511 | result[list[i]] = values[i]; 512 | } else { 513 | result[list[i][0]] = list[i][1]; 514 | } 515 | } 516 | return result; 517 | }; 518 | 519 | // If the browser doesn't supply us with indexOf (I'm looking at you, **MSIE**), 520 | // we need this function. Return the position of the first occurrence of an 521 | // item in an array, or -1 if the item is not included in the array. 522 | // Delegates to **ECMAScript 5**'s native `indexOf` if available. 523 | // If the array is large and already in sort order, pass `true` 524 | // for **isSorted** to use binary search. 525 | _.indexOf = function(array, item, isSorted) { 526 | if (array == null) return -1; 527 | var i = 0, l = array.length; 528 | if (isSorted) { 529 | if (typeof isSorted == 'number') { 530 | i = (isSorted < 0 ? Math.max(0, l + isSorted) : isSorted); 531 | } else { 532 | i = _.sortedIndex(array, item); 533 | return array[i] === item ? i : -1; 534 | } 535 | } 536 | if (nativeIndexOf && array.indexOf === nativeIndexOf) return array.indexOf(item, isSorted); 537 | for (; i < l; i++) if (array[i] === item) return i; 538 | return -1; 539 | }; 540 | 541 | // Delegates to **ECMAScript 5**'s native `lastIndexOf` if available. 542 | _.lastIndexOf = function(array, item, from) { 543 | if (array == null) return -1; 544 | var hasIndex = from != null; 545 | if (nativeLastIndexOf && array.lastIndexOf === nativeLastIndexOf) { 546 | return hasIndex ? array.lastIndexOf(item, from) : array.lastIndexOf(item); 547 | } 548 | var i = (hasIndex ? from : array.length); 549 | while (i--) if (array[i] === item) return i; 550 | return -1; 551 | }; 552 | 553 | // Generate an integer Array containing an arithmetic progression. A port of 554 | // the native Python `range()` function. See 555 | // [the Python documentation](http://docs.python.org/library/functions.html#range). 556 | _.range = function(start, stop, step) { 557 | if (arguments.length <= 1) { 558 | stop = start || 0; 559 | start = 0; 560 | } 561 | step = arguments[2] || 1; 562 | 563 | var len = Math.max(Math.ceil((stop - start) / step), 0); 564 | var idx = 0; 565 | var range = new Array(len); 566 | 567 | while(idx < len) { 568 | range[idx++] = start; 569 | start += step; 570 | } 571 | 572 | return range; 573 | }; 574 | 575 | // Function (ahem) Functions 576 | // ------------------ 577 | 578 | // Create a function bound to a given object (assigning `this`, and arguments, 579 | // optionally). Delegates to **ECMAScript 5**'s native `Function.bind` if 580 | // available. 581 | _.bind = function(func, context) { 582 | if (func.bind === nativeBind && nativeBind) return nativeBind.apply(func, slice.call(arguments, 1)); 583 | var args = slice.call(arguments, 2); 584 | return function() { 585 | return func.apply(context, args.concat(slice.call(arguments))); 586 | }; 587 | }; 588 | 589 | // Partially apply a function by creating a version that has had some of its 590 | // arguments pre-filled, without changing its dynamic `this` context. 591 | _.partial = function(func) { 592 | var args = slice.call(arguments, 1); 593 | return function() { 594 | return func.apply(this, args.concat(slice.call(arguments))); 595 | }; 596 | }; 597 | 598 | // Bind all of an object's methods to that object. Useful for ensuring that 599 | // all callbacks defined on an object belong to it. 600 | _.bindAll = function(obj) { 601 | var funcs = slice.call(arguments, 1); 602 | if (funcs.length === 0) funcs = _.functions(obj); 603 | each(funcs, function(f) { obj[f] = _.bind(obj[f], obj); }); 604 | return obj; 605 | }; 606 | 607 | // Memoize an expensive function by storing its results. 608 | _.memoize = function(func, hasher) { 609 | var memo = {}; 610 | hasher || (hasher = _.identity); 611 | return function() { 612 | var key = hasher.apply(this, arguments); 613 | return _.has(memo, key) ? memo[key] : (memo[key] = func.apply(this, arguments)); 614 | }; 615 | }; 616 | 617 | // Delays a function for the given number of milliseconds, and then calls 618 | // it with the arguments supplied. 619 | _.delay = function(func, wait) { 620 | var args = slice.call(arguments, 2); 621 | return setTimeout(function(){ return func.apply(null, args); }, wait); 622 | }; 623 | 624 | // Defers a function, scheduling it to run after the current call stack has 625 | // cleared. 626 | _.defer = function(func) { 627 | return _.delay.apply(_, [func, 1].concat(slice.call(arguments, 1))); 628 | }; 629 | 630 | // Returns a function, that, when invoked, will only be triggered at most once 631 | // during a given window of time. 632 | _.throttle = function(func, wait) { 633 | var context, args, timeout, result; 634 | var previous = 0; 635 | var later = function() { 636 | previous = new Date; 637 | timeout = null; 638 | result = func.apply(context, args); 639 | }; 640 | return function() { 641 | var now = new Date; 642 | var remaining = wait - (now - previous); 643 | context = this; 644 | args = arguments; 645 | if (remaining <= 0) { 646 | clearTimeout(timeout); 647 | timeout = null; 648 | previous = now; 649 | result = func.apply(context, args); 650 | } else if (!timeout) { 651 | timeout = setTimeout(later, remaining); 652 | } 653 | return result; 654 | }; 655 | }; 656 | 657 | // Returns a function, that, as long as it continues to be invoked, will not 658 | // be triggered. The function will be called after it stops being called for 659 | // N milliseconds. If `immediate` is passed, trigger the function on the 660 | // leading edge, instead of the trailing. 661 | _.debounce = function(func, wait, immediate) { 662 | var timeout, result; 663 | return function() { 664 | var context = this, args = arguments; 665 | var later = function() { 666 | timeout = null; 667 | if (!immediate) result = func.apply(context, args); 668 | }; 669 | var callNow = immediate && !timeout; 670 | clearTimeout(timeout); 671 | timeout = setTimeout(later, wait); 672 | if (callNow) result = func.apply(context, args); 673 | return result; 674 | }; 675 | }; 676 | 677 | // Returns a function that will be executed at most one time, no matter how 678 | // often you call it. Useful for lazy initialization. 679 | _.once = function(func) { 680 | var ran = false, memo; 681 | return function() { 682 | if (ran) return memo; 683 | ran = true; 684 | memo = func.apply(this, arguments); 685 | func = null; 686 | return memo; 687 | }; 688 | }; 689 | 690 | // Returns the first function passed as an argument to the second, 691 | // allowing you to adjust arguments, run code before and after, and 692 | // conditionally execute the original function. 693 | _.wrap = function(func, wrapper) { 694 | return function() { 695 | var args = [func]; 696 | push.apply(args, arguments); 697 | return wrapper.apply(this, args); 698 | }; 699 | }; 700 | 701 | // Returns a function that is the composition of a list of functions, each 702 | // consuming the return value of the function that follows. 703 | _.compose = function() { 704 | var funcs = arguments; 705 | return function() { 706 | var args = arguments; 707 | for (var i = funcs.length - 1; i >= 0; i--) { 708 | args = [funcs[i].apply(this, args)]; 709 | } 710 | return args[0]; 711 | }; 712 | }; 713 | 714 | // Returns a function that will only be executed after being called N times. 715 | _.after = function(times, func) { 716 | if (times <= 0) return func(); 717 | return function() { 718 | if (--times < 1) { 719 | return func.apply(this, arguments); 720 | } 721 | }; 722 | }; 723 | 724 | // Object Functions 725 | // ---------------- 726 | 727 | // Retrieve the names of an object's properties. 728 | // Delegates to **ECMAScript 5**'s native `Object.keys` 729 | _.keys = nativeKeys || function(obj) { 730 | if (obj !== Object(obj)) throw new TypeError('Invalid object'); 731 | var keys = []; 732 | for (var key in obj) if (_.has(obj, key)) keys[keys.length] = key; 733 | return keys; 734 | }; 735 | 736 | // Retrieve the values of an object's properties. 737 | _.values = function(obj) { 738 | var values = []; 739 | for (var key in obj) if (_.has(obj, key)) values.push(obj[key]); 740 | return values; 741 | }; 742 | 743 | // Convert an object into a list of `[key, value]` pairs. 744 | _.pairs = function(obj) { 745 | var pairs = []; 746 | for (var key in obj) if (_.has(obj, key)) pairs.push([key, obj[key]]); 747 | return pairs; 748 | }; 749 | 750 | // Invert the keys and values of an object. The values must be serializable. 751 | _.invert = function(obj) { 752 | var result = {}; 753 | for (var key in obj) if (_.has(obj, key)) result[obj[key]] = key; 754 | return result; 755 | }; 756 | 757 | // Return a sorted list of the function names available on the object. 758 | // Aliased as `methods` 759 | _.functions = _.methods = function(obj) { 760 | var names = []; 761 | for (var key in obj) { 762 | if (_.isFunction(obj[key])) names.push(key); 763 | } 764 | return names.sort(); 765 | }; 766 | 767 | // Extend a given object with all the properties in passed-in object(s). 768 | _.extend = function(obj) { 769 | each(slice.call(arguments, 1), function(source) { 770 | if (source) { 771 | for (var prop in source) { 772 | obj[prop] = source[prop]; 773 | } 774 | } 775 | }); 776 | return obj; 777 | }; 778 | 779 | // Return a copy of the object only containing the whitelisted properties. 780 | _.pick = function(obj) { 781 | var copy = {}; 782 | var keys = concat.apply(ArrayProto, slice.call(arguments, 1)); 783 | each(keys, function(key) { 784 | if (key in obj) copy[key] = obj[key]; 785 | }); 786 | return copy; 787 | }; 788 | 789 | // Return a copy of the object without the blacklisted properties. 790 | _.omit = function(obj) { 791 | var copy = {}; 792 | var keys = concat.apply(ArrayProto, slice.call(arguments, 1)); 793 | for (var key in obj) { 794 | if (!_.contains(keys, key)) copy[key] = obj[key]; 795 | } 796 | return copy; 797 | }; 798 | 799 | // Fill in a given object with default properties. 800 | _.defaults = function(obj) { 801 | each(slice.call(arguments, 1), function(source) { 802 | if (source) { 803 | for (var prop in source) { 804 | if (obj[prop] == null) obj[prop] = source[prop]; 805 | } 806 | } 807 | }); 808 | return obj; 809 | }; 810 | 811 | // Create a (shallow-cloned) duplicate of an object. 812 | _.clone = function(obj) { 813 | if (!_.isObject(obj)) return obj; 814 | return _.isArray(obj) ? obj.slice() : _.extend({}, obj); 815 | }; 816 | 817 | // Invokes interceptor with the obj, and then returns obj. 818 | // The primary purpose of this method is to "tap into" a method chain, in 819 | // order to perform operations on intermediate results within the chain. 820 | _.tap = function(obj, interceptor) { 821 | interceptor(obj); 822 | return obj; 823 | }; 824 | 825 | // Internal recursive comparison function for `isEqual`. 826 | var eq = function(a, b, aStack, bStack) { 827 | // Identical objects are equal. `0 === -0`, but they aren't identical. 828 | // See the Harmony `egal` proposal: http://wiki.ecmascript.org/doku.php?id=harmony:egal. 829 | if (a === b) return a !== 0 || 1 / a == 1 / b; 830 | // A strict comparison is necessary because `null == undefined`. 831 | if (a == null || b == null) return a === b; 832 | // Unwrap any wrapped objects. 833 | if (a instanceof _) a = a._wrapped; 834 | if (b instanceof _) b = b._wrapped; 835 | // Compare `[[Class]]` names. 836 | var className = toString.call(a); 837 | if (className != toString.call(b)) return false; 838 | switch (className) { 839 | // Strings, numbers, dates, and booleans are compared by value. 840 | case '[object String]': 841 | // Primitives and their corresponding object wrappers are equivalent; thus, `"5"` is 842 | // equivalent to `new String("5")`. 843 | return a == String(b); 844 | case '[object Number]': 845 | // `NaN`s are equivalent, but non-reflexive. An `egal` comparison is performed for 846 | // other numeric values. 847 | return a != +a ? b != +b : (a == 0 ? 1 / a == 1 / b : a == +b); 848 | case '[object Date]': 849 | case '[object Boolean]': 850 | // Coerce dates and booleans to numeric primitive values. Dates are compared by their 851 | // millisecond representations. Note that invalid dates with millisecond representations 852 | // of `NaN` are not equivalent. 853 | return +a == +b; 854 | // RegExps are compared by their source patterns and flags. 855 | case '[object RegExp]': 856 | return a.source == b.source && 857 | a.global == b.global && 858 | a.multiline == b.multiline && 859 | a.ignoreCase == b.ignoreCase; 860 | } 861 | if (typeof a != 'object' || typeof b != 'object') return false; 862 | // Assume equality for cyclic structures. The algorithm for detecting cyclic 863 | // structures is adapted from ES 5.1 section 15.12.3, abstract operation `JO`. 864 | var length = aStack.length; 865 | while (length--) { 866 | // Linear search. Performance is inversely proportional to the number of 867 | // unique nested structures. 868 | if (aStack[length] == a) return bStack[length] == b; 869 | } 870 | // Add the first object to the stack of traversed objects. 871 | aStack.push(a); 872 | bStack.push(b); 873 | var size = 0, result = true; 874 | // Recursively compare objects and arrays. 875 | if (className == '[object Array]') { 876 | // Compare array lengths to determine if a deep comparison is necessary. 877 | size = a.length; 878 | result = size == b.length; 879 | if (result) { 880 | // Deep compare the contents, ignoring non-numeric properties. 881 | while (size--) { 882 | if (!(result = eq(a[size], b[size], aStack, bStack))) break; 883 | } 884 | } 885 | } else { 886 | // Objects with different constructors are not equivalent, but `Object`s 887 | // from different frames are. 888 | var aCtor = a.constructor, bCtor = b.constructor; 889 | if (aCtor !== bCtor && !(_.isFunction(aCtor) && (aCtor instanceof aCtor) && 890 | _.isFunction(bCtor) && (bCtor instanceof bCtor))) { 891 | return false; 892 | } 893 | // Deep compare objects. 894 | for (var key in a) { 895 | if (_.has(a, key)) { 896 | // Count the expected number of properties. 897 | size++; 898 | // Deep compare each member. 899 | if (!(result = _.has(b, key) && eq(a[key], b[key], aStack, bStack))) break; 900 | } 901 | } 902 | // Ensure that both objects contain the same number of properties. 903 | if (result) { 904 | for (key in b) { 905 | if (_.has(b, key) && !(size--)) break; 906 | } 907 | result = !size; 908 | } 909 | } 910 | // Remove the first object from the stack of traversed objects. 911 | aStack.pop(); 912 | bStack.pop(); 913 | return result; 914 | }; 915 | 916 | // Perform a deep comparison to check if two objects are equal. 917 | _.isEqual = function(a, b) { 918 | return eq(a, b, [], []); 919 | }; 920 | 921 | // Is a given array, string, or object empty? 922 | // An "empty" object has no enumerable own-properties. 923 | _.isEmpty = function(obj) { 924 | if (obj == null) return true; 925 | if (_.isArray(obj) || _.isString(obj)) return obj.length === 0; 926 | for (var key in obj) if (_.has(obj, key)) return false; 927 | return true; 928 | }; 929 | 930 | // Is a given value a DOM element? 931 | _.isElement = function(obj) { 932 | return !!(obj && obj.nodeType === 1); 933 | }; 934 | 935 | // Is a given value an array? 936 | // Delegates to ECMA5's native Array.isArray 937 | _.isArray = nativeIsArray || function(obj) { 938 | return toString.call(obj) == '[object Array]'; 939 | }; 940 | 941 | // Is a given variable an object? 942 | _.isObject = function(obj) { 943 | return obj === Object(obj); 944 | }; 945 | 946 | // Add some isType methods: isArguments, isFunction, isString, isNumber, isDate, isRegExp. 947 | each(['Arguments', 'Function', 'String', 'Number', 'Date', 'RegExp'], function(name) { 948 | _['is' + name] = function(obj) { 949 | return toString.call(obj) == '[object ' + name + ']'; 950 | }; 951 | }); 952 | 953 | // Define a fallback version of the method in browsers (ahem, IE), where 954 | // there isn't any inspectable "Arguments" type. 955 | if (!_.isArguments(arguments)) { 956 | _.isArguments = function(obj) { 957 | return !!(obj && _.has(obj, 'callee')); 958 | }; 959 | } 960 | 961 | // Optimize `isFunction` if appropriate. 962 | if (typeof (/./) !== 'function') { 963 | _.isFunction = function(obj) { 964 | return typeof obj === 'function'; 965 | }; 966 | } 967 | 968 | // Is a given object a finite number? 969 | _.isFinite = function(obj) { 970 | return isFinite(obj) && !isNaN(parseFloat(obj)); 971 | }; 972 | 973 | // Is the given value `NaN`? (NaN is the only number which does not equal itself). 974 | _.isNaN = function(obj) { 975 | return _.isNumber(obj) && obj != +obj; 976 | }; 977 | 978 | // Is a given value a boolean? 979 | _.isBoolean = function(obj) { 980 | return obj === true || obj === false || toString.call(obj) == '[object Boolean]'; 981 | }; 982 | 983 | // Is a given value equal to null? 984 | _.isNull = function(obj) { 985 | return obj === null; 986 | }; 987 | 988 | // Is a given variable undefined? 989 | _.isUndefined = function(obj) { 990 | return obj === void 0; 991 | }; 992 | 993 | // Shortcut function for checking if an object has a given property directly 994 | // on itself (in other words, not on a prototype). 995 | _.has = function(obj, key) { 996 | return hasOwnProperty.call(obj, key); 997 | }; 998 | 999 | // Utility Functions 1000 | // ----------------- 1001 | 1002 | // Run Underscore.js in *noConflict* mode, returning the `_` variable to its 1003 | // previous owner. Returns a reference to the Underscore object. 1004 | _.noConflict = function() { 1005 | root._ = previousUnderscore; 1006 | return this; 1007 | }; 1008 | 1009 | // Keep the identity function around for default iterators. 1010 | _.identity = function(value) { 1011 | return value; 1012 | }; 1013 | 1014 | // Run a function **n** times. 1015 | _.times = function(n, iterator, context) { 1016 | var accum = Array(n); 1017 | for (var i = 0; i < n; i++) accum[i] = iterator.call(context, i); 1018 | return accum; 1019 | }; 1020 | 1021 | // Return a random integer between min and max (inclusive). 1022 | _.random = function(min, max) { 1023 | if (max == null) { 1024 | max = min; 1025 | min = 0; 1026 | } 1027 | return min + Math.floor(Math.random() * (max - min + 1)); 1028 | }; 1029 | 1030 | // List of HTML entities for escaping. 1031 | var entityMap = { 1032 | escape: { 1033 | '&': '&', 1034 | '<': '<', 1035 | '>': '>', 1036 | '"': '"', 1037 | "'": ''', 1038 | '/': '/' 1039 | } 1040 | }; 1041 | entityMap.unescape = _.invert(entityMap.escape); 1042 | 1043 | // Regexes containing the keys and values listed immediately above. 1044 | var entityRegexes = { 1045 | escape: new RegExp('[' + _.keys(entityMap.escape).join('') + ']', 'g'), 1046 | unescape: new RegExp('(' + _.keys(entityMap.unescape).join('|') + ')', 'g') 1047 | }; 1048 | 1049 | // Functions for escaping and unescaping strings to/from HTML interpolation. 1050 | _.each(['escape', 'unescape'], function(method) { 1051 | _[method] = function(string) { 1052 | if (string == null) return ''; 1053 | return ('' + string).replace(entityRegexes[method], function(match) { 1054 | return entityMap[method][match]; 1055 | }); 1056 | }; 1057 | }); 1058 | 1059 | // If the value of the named property is a function then invoke it; 1060 | // otherwise, return it. 1061 | _.result = function(object, property) { 1062 | if (object == null) return null; 1063 | var value = object[property]; 1064 | return _.isFunction(value) ? value.call(object) : value; 1065 | }; 1066 | 1067 | // Add your own custom functions to the Underscore object. 1068 | _.mixin = function(obj) { 1069 | each(_.functions(obj), function(name){ 1070 | var func = _[name] = obj[name]; 1071 | _.prototype[name] = function() { 1072 | var args = [this._wrapped]; 1073 | push.apply(args, arguments); 1074 | return result.call(this, func.apply(_, args)); 1075 | }; 1076 | }); 1077 | }; 1078 | 1079 | // Generate a unique integer id (unique within the entire client session). 1080 | // Useful for temporary DOM ids. 1081 | var idCounter = 0; 1082 | _.uniqueId = function(prefix) { 1083 | var id = ++idCounter + ''; 1084 | return prefix ? prefix + id : id; 1085 | }; 1086 | 1087 | // By default, Underscore uses ERB-style template delimiters, change the 1088 | // following template settings to use alternative delimiters. 1089 | _.templateSettings = { 1090 | evaluate : /<%([\s\S]+?)%>/g, 1091 | interpolate : /<%=([\s\S]+?)%>/g, 1092 | escape : /<%-([\s\S]+?)%>/g 1093 | }; 1094 | 1095 | // When customizing `templateSettings`, if you don't want to define an 1096 | // interpolation, evaluation or escaping regex, we need one that is 1097 | // guaranteed not to match. 1098 | var noMatch = /(.)^/; 1099 | 1100 | // Certain characters need to be escaped so that they can be put into a 1101 | // string literal. 1102 | var escapes = { 1103 | "'": "'", 1104 | '\\': '\\', 1105 | '\r': 'r', 1106 | '\n': 'n', 1107 | '\t': 't', 1108 | '\u2028': 'u2028', 1109 | '\u2029': 'u2029' 1110 | }; 1111 | 1112 | var escaper = /\\|'|\r|\n|\t|\u2028|\u2029/g; 1113 | 1114 | // JavaScript micro-templating, similar to John Resig's implementation. 1115 | // Underscore templating handles arbitrary delimiters, preserves whitespace, 1116 | // and correctly escapes quotes within interpolated code. 1117 | _.template = function(text, data, settings) { 1118 | var render; 1119 | settings = _.defaults({}, settings, _.templateSettings); 1120 | 1121 | // Combine delimiters into one regular expression via alternation. 1122 | var matcher = new RegExp([ 1123 | (settings.escape || noMatch).source, 1124 | (settings.interpolate || noMatch).source, 1125 | (settings.evaluate || noMatch).source 1126 | ].join('|') + '|$', 'g'); 1127 | 1128 | // Compile the template source, escaping string literals appropriately. 1129 | var index = 0; 1130 | var source = "__p+='"; 1131 | text.replace(matcher, function(match, escape, interpolate, evaluate, offset) { 1132 | source += text.slice(index, offset) 1133 | .replace(escaper, function(match) { return '\\' + escapes[match]; }); 1134 | 1135 | if (escape) { 1136 | source += "'+\n((__t=(" + escape + "))==null?'':_.escape(__t))+\n'"; 1137 | } 1138 | if (interpolate) { 1139 | source += "'+\n((__t=(" + interpolate + "))==null?'':__t)+\n'"; 1140 | } 1141 | if (evaluate) { 1142 | source += "';\n" + evaluate + "\n__p+='"; 1143 | } 1144 | index = offset + match.length; 1145 | return match; 1146 | }); 1147 | source += "';\n"; 1148 | 1149 | // If a variable is not specified, place data values in local scope. 1150 | if (!settings.variable) source = 'with(obj||{}){\n' + source + '}\n'; 1151 | 1152 | source = "var __t,__p='',__j=Array.prototype.join," + 1153 | "print=function(){__p+=__j.call(arguments,'');};\n" + 1154 | source + "return __p;\n"; 1155 | 1156 | try { 1157 | render = new Function(settings.variable || 'obj', '_', source); 1158 | } catch (e) { 1159 | e.source = source; 1160 | throw e; 1161 | } 1162 | 1163 | if (data) return render(data, _); 1164 | var template = function(data) { 1165 | return render.call(this, data, _); 1166 | }; 1167 | 1168 | // Provide the compiled function source as a convenience for precompilation. 1169 | template.source = 'function(' + (settings.variable || 'obj') + '){\n' + source + '}'; 1170 | 1171 | return template; 1172 | }; 1173 | 1174 | // Add a "chain" function, which will delegate to the wrapper. 1175 | _.chain = function(obj) { 1176 | return _(obj).chain(); 1177 | }; 1178 | 1179 | // OOP 1180 | // --------------- 1181 | // If Underscore is called as a function, it returns a wrapped object that 1182 | // can be used OO-style. This wrapper holds altered versions of all the 1183 | // underscore functions. Wrapped objects may be chained. 1184 | 1185 | // Helper function to continue chaining intermediate results. 1186 | var result = function(obj) { 1187 | return this._chain ? _(obj).chain() : obj; 1188 | }; 1189 | 1190 | // Add all of the Underscore functions to the wrapper object. 1191 | _.mixin(_); 1192 | 1193 | // Add all mutator Array functions to the wrapper. 1194 | each(['pop', 'push', 'reverse', 'shift', 'sort', 'splice', 'unshift'], function(name) { 1195 | var method = ArrayProto[name]; 1196 | _.prototype[name] = function() { 1197 | var obj = this._wrapped; 1198 | method.apply(obj, arguments); 1199 | if ((name == 'shift' || name == 'splice') && obj.length === 0) delete obj[0]; 1200 | return result.call(this, obj); 1201 | }; 1202 | }); 1203 | 1204 | // Add all accessor Array functions to the wrapper. 1205 | each(['concat', 'join', 'slice'], function(name) { 1206 | var method = ArrayProto[name]; 1207 | _.prototype[name] = function() { 1208 | return result.call(this, method.apply(this._wrapped, arguments)); 1209 | }; 1210 | }); 1211 | 1212 | _.extend(_.prototype, { 1213 | 1214 | // Start chaining a wrapped Underscore object. 1215 | chain: function() { 1216 | this._chain = true; 1217 | return this; 1218 | }, 1219 | 1220 | // Extracts the result from a wrapped and chained object. 1221 | value: function() { 1222 | return this._wrapped; 1223 | } 1224 | 1225 | }); 1226 | 1227 | }).call(this); 1228 | -------------------------------------------------------------------------------- /scripts/main.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | /* require config */ 4 | 5 | requirejs.config({ 6 | shim: { 7 | 'backbone': { 8 | deps: ['underscore', 'jquery'], 9 | exports: 'Backbone' 10 | }, 11 | 'underscore': { 12 | exports: '_' 13 | }, 14 | 'jquerymobile': { 15 | deps: ['jquery'] 16 | } 17 | }, //shim 18 | 19 | paths: { 20 | 'backbone': 'lib/backbone', 21 | 'jquerymobile': 'lib/jquery.mobile-1.3.1', 22 | 'underscore': 'lib/underscore', 23 | 'jquery': 'lib/jquery-1.9.1' 24 | } //path 25 | }); 26 | 27 | requirejs(['jquery','backbone','conf','router','api','utils','models'], 28 | function($, Backbone, conf, router, api, utils, models){ 29 | 30 | /************* utilities ***********/ 31 | 32 | function registerLoginPageActions(){ 33 | 34 | // register login button action 35 | $('#login form').submit(function(e){ 36 | 37 | $.mobile.loading( 'show', { text: 'Authenticating...', textVisible: true} ); 38 | e.preventDefault(); 39 | 40 | // message to send 41 | var data = { 42 | op: "login", 43 | user: $('#loginInput').val(), 44 | password : $('#passwordInput').val() 45 | }; 46 | 47 | jQuery.ajax( 48 | { 49 | url: conf.apiPath + 'api/', 50 | contentType: "application/json", 51 | dataType: 'json', 52 | cache: 'false', 53 | data: JSON.stringify(data), 54 | type: 'post', 55 | async: false 56 | } 57 | ) 58 | .done(function(data){ 59 | if (data.status == 0){ 60 | // we store the sessions id 61 | models.settings.set("sid", data.content.session_id); 62 | models.settings.save(); 63 | 64 | router.myRouter.setNextTransOptions({reverse: true, transition: "slideup"}); 65 | 66 | // try to get from query string if it exists 67 | var fragment = location.hash; 68 | var re = /\?from=#(.+)/; 69 | var nextRoute = "cat"; 70 | var ex = re.exec(fragment) 71 | if (ex != null){ 72 | nextRoute = ex[1]; 73 | } 74 | 75 | router.myRouter.navigate(nextRoute, {trigger: true}); 76 | } else { 77 | var msg = "Unknown answer from the API:" + data.content; 78 | if (data.content.error == "API_DISABLED"){ 79 | msg = 'API is disabled for this user'; 80 | } else if (data.content.error == "LOGIN_ERROR"){ 81 | msg = "Specified username and password are incorrect"; 82 | } 83 | alert(msg); 84 | $.mobile.loading('hide'); 85 | } 86 | }); 87 | }); // login button 88 | } 89 | 90 | 91 | 92 | /************** init bindings *************/ 93 | 94 | $(document).bind('mobileinit', function(event){ 95 | 96 | // desactivate jQueryMobile routing (we use Backbone.Router) 97 | $.mobile.ajaxEnabled = false; 98 | $.mobile.linkBindingEnabled = false; 99 | $.mobile.hashListeningEnabled = false; 100 | $.mobile.pushStateEnabled = false; 101 | $.mobile.changePage.defaults.changeHash = false; 102 | $.mobile.defaultPageTransition = "slide"; 103 | }); 104 | 105 | 106 | 107 | 108 | var g_init = false; 109 | 110 | $(document).bind('pageinit', function(event){ 111 | 112 | if (! g_init){ 113 | 114 | g_init = true; 115 | 116 | // alternative to localStorage using cookies 117 | utils.localStorageSupport(); 118 | 119 | // events for login page 120 | registerLoginPageActions(); 121 | 122 | // initialize all logout buttons 123 | $('a.logoutButton').on('click', 124 | function(e){ 125 | e.preventDefault(); 126 | $.mobile.loading( 'show', { text: 'Logging out...', textVisible: true} ); 127 | api.logout(); 128 | } 129 | ); 130 | 131 | // initialize all back buttons 132 | $('a.backButton').on('click', 133 | function(e){ 134 | router.myRouter.setNextTransOptions({reverse: true}); 135 | } 136 | ); 137 | 138 | // initialize all menu buttons 139 | $("a[data-rel='popup']").on('click', 140 | function(e){ 141 | e.preventDefault(); 142 | var popupId = $(e.currentTarget).attr('href'); 143 | var transition = $(popupId).attr('data-transition'); 144 | 145 | $(popupId).popup("open", {transition: transition, 146 | positionTo: $(e.currentTarget) }); 147 | } 148 | ); 149 | 150 | // prepare all pages now 151 | $("div:jqmData(role='page')").page(); 152 | 153 | // first transition 154 | router.myRouter.setNextTransOptions({transition: "fade"}); 155 | 156 | // start Backbone router 157 | if (! Backbone.history.start({ 158 | pushState: false, 159 | root: window.location.pathname, 160 | silent: false 161 | })){ 162 | 163 | alert("Could not start router!"); 164 | } 165 | 166 | } 167 | }); 168 | 169 | 170 | //loading jQuery Mobile after everything 171 | require(['jquerymobile'], function(){}); 172 | 173 | }); //requirejs 174 | 175 | -------------------------------------------------------------------------------- /scripts/models.js: -------------------------------------------------------------------------------- 1 | 2 | /***************** Models *************/ 3 | define(['api','backbone','utils'], 4 | function(api, Backbone, utils){ 5 | 6 | /*********** webapp settings ********/ 7 | var Settings = Backbone.Model.extend({ 8 | sync: function(method, model){ 9 | if (method == "read"){ 10 | /* read from localStorage every attributes */ 11 | for (var i = 0; i < window.localStorage.length; i++){ 12 | var key = window.localStorage.key(i); 13 | var val = window.localStorage.getItem(key); 14 | 15 | // convert booleans values from string 16 | if (val == "true" || val == "false"){ 17 | val = (val == "true"); 18 | } 19 | 20 | model.set(key, val); 21 | } 22 | } else if (method == "update"){ 23 | /* write to localStorage every changed attributes */ 24 | _.each(model.changed, function(value, key, list){ 25 | window.localStorage.setItem(key, value); 26 | }, this); 27 | 28 | } else if (method == "create"){ 29 | // set an id to tell Backbone that the server has a copy 30 | model.set({id: "mySettings"}); 31 | /* write to localStorage every attributes */ 32 | _.each(model.attributes, function(value, key, list){ 33 | window.localStorage.setItem(key, value); 34 | }, this); 35 | } else { 36 | utils.log("Settings.sync called with unexpected method: " + method); 37 | } 38 | 39 | }, //sync 40 | 41 | defaults: { 42 | articlesNumber: 10, 43 | articlesOldestFirst: false, 44 | onlyUnread: false 45 | }, 46 | 47 | validate: function(attrs, options){ 48 | // test articlesNumber 49 | if (attrs.articlesNumber <= 0){ 50 | return "Must be greater than 0"; 51 | } 52 | 53 | if (attrs.articlesNumber > 200){ 54 | return "Cannot be greater than 200"; 55 | } 56 | } //validate 57 | }); // Settings 58 | 59 | // Settings as a variable to be available for other functions 60 | var settings = new Settings(); 61 | 62 | /************ categories ***********/ 63 | 64 | // default model to store a category 65 | var CategoryModel = Backbone.Model.extend(); 66 | 67 | // model for a collection of categories 68 | var CategoriesModel = Backbone.Collection.extend({ 69 | 70 | comparator: function(cat1, cat2){ 71 | // Special comes first, then by title 72 | 73 | if ((cat1.id < 0) && (cat2.id >= 0)){ 74 | // cat1 is special 75 | return -1; 76 | } else if ((cat1.id >= 0) && (cat2.id < 0)){ 77 | // cat2 is special 78 | return 1; 79 | } else { 80 | return cat1.get("title").localeCompare(cat2.get("title")); 81 | } 82 | 83 | }, //comparator 84 | 85 | model: CategoryModel, 86 | sync: function(method, collection, options){ 87 | if (method == "read"){ 88 | // only action for a category: read 89 | var request = { 90 | op: "getCategories", 91 | enable_nested: "false", // we want nested ones but they will not be nested yet 92 | unread_only: settings.attributes.onlyUnread // get only feeds with unread articles 93 | }; 94 | 95 | api.ttRssApiCall(request, function(res){ 96 | // efficiently set the collection 97 | collection.set(res); 98 | 99 | // notify by a sync that the sync worked 100 | collection.trigger('sync'); 101 | }, true); 102 | 103 | } else { 104 | utils.log("CategoriesModel.sync method called for an " + 105 | "unsupported method:" + method); 106 | } 107 | } 108 | }); 109 | 110 | 111 | /************* feeds ***************/ 112 | 113 | 114 | // default model to store a feed 115 | var FeedModel = Backbone.Model.extend(); 116 | 117 | 118 | // model for a collection of feeds from a category 119 | var FeedsModel = Backbone.Collection.extend({ 120 | comparator: "title", 121 | 122 | model: FeedModel, 123 | 124 | sync: function(method, collection, options){ 125 | 126 | // current category ID 127 | var catId = utils.getCurrentCatId(); 128 | 129 | // only action for a category: read 130 | if (method == "read"){ 131 | var request = { 132 | op: "getFeeds", 133 | cat_id: catId, 134 | include_nested: false, 135 | unread_only: settings.attributes.onlyUnread // get only feeds with unread articles 136 | }; 137 | 138 | api.ttRssApiCall( 139 | request, 140 | function(res){ 141 | // set collection with updated data 142 | collection.set(res); 143 | 144 | // notify by a sync that the sync worked 145 | collection.trigger('sync'); 146 | }, true); 147 | } else { 148 | utils.log("FeedsModel.sync called for an unsupported method: " + method); 149 | } 150 | }, // sync 151 | 152 | }); 153 | 154 | var feedsModel = new FeedsModel(); 155 | 156 | 157 | /************ 1 article ***************/ 158 | 159 | // model to store an article 160 | var ArticleModel = Backbone.Model.extend({ 161 | 162 | sync: function(method, model, options){ 163 | if (method == "read"){ 164 | api.ttRssApiCall( 165 | { op: 'getArticle', 166 | article_id: model.id }, 167 | function(m){ 168 | 169 | if (m.length == 0){ 170 | utils.log("ArticleModel.sync: received nothing for article " + 171 | model.id); 172 | model.set("title", "Error"); 173 | model.set("content", 174 | "The article with ID " + model.id + " could no be retrieved."); 175 | } else { 176 | 177 | model.set(m[0]); 178 | 179 | // add the model in the collection if needed 180 | if (!articlesModel.get(model.id)){ 181 | articlesModel.add([model]); 182 | } 183 | 184 | model.trigger("sync"); 185 | } 186 | }, true); 187 | } else if (method == "update"){ 188 | // save attributes that changed 189 | _.each(_.keys(this.changed), function(att){ 190 | this.toggle(att); 191 | }, this); 192 | 193 | model.trigger("sync"); 194 | } else { 195 | utils.log("ArticleModel.sync called on an unsupported method: " + method); 196 | } 197 | }, 198 | toggle: function(what){ 199 | 200 | var field; 201 | if (what == "marked"){ 202 | field = 0; // star 203 | } else if (what == "published"){ 204 | field = 1; 205 | } else if (what == "unread"){ 206 | field = 2; 207 | } 208 | 209 | /* 0 -> set to true 210 | 1 -> set to false */ 211 | var m = ((this.get(what)) == true ? 1 : 0 ); 212 | 213 | if (field != null){ 214 | api.ttRssApiCall( 215 | { op: 'updateArticle', 216 | article_ids: this.id, 217 | mode: m, 218 | field: field }, 219 | function(m){ jQuery.noop(); } , true); 220 | } else { 221 | utils.log("ArticleModel.toggle called with an " + 222 | "unexpected parameter : " + what); 223 | } 224 | }, // toggle 225 | 226 | unreadChanged: function(){ 227 | 228 | var prevVal = this.previous("unread"); 229 | var newVal = this.get("unread"); 230 | 231 | if ((prevVal != undefined) && 232 | (prevVal != newVal)){ 233 | 234 | // try to update the parent models of the unread count 235 | var count = new Number((newVal ? 1 : -1)); 236 | 237 | // update feed count 238 | var feedModel = feedsModel.get(utils.getCurrentFeedId()); 239 | if (feedModel){ 240 | var unread = new Number(feedModel.get("unread")); 241 | feedModel.set({unread: unread + count}); 242 | } 243 | } 244 | 245 | }, //unreadChanged 246 | 247 | initialize: function(){ 248 | 249 | // be notified when unread changed 250 | this.on("change:unread", this.unreadChanged, this); 251 | 252 | } //initialize 253 | 254 | }); 255 | 256 | 257 | 258 | /*********** articles *************/ 259 | 260 | // model for a collection of articles 261 | var ArticlesModel = Backbone.Collection.extend({ 262 | model: ArticleModel, 263 | 264 | // the feedId of the data in the collection 265 | // useful in the case we're in a special category 266 | // that regroup multiple feeds 267 | feedId: null, 268 | 269 | sync: function(method, collection, options) { 270 | 271 | if (method == "read"){ 272 | 273 | var feedId = utils.getCurrentFeedId(); 274 | 275 | var orderBy = settings.get("articlesOldestFirst") === true ? "date_reverse" : "feed_dates"; 276 | 277 | // set view_mode depending on options 278 | var viewMode = settings.get("onlyUnread") ? "unread" : "adaptive"; 279 | 280 | // we need to fetch the articles list for this feed 281 | var msg = { 282 | op: "getHeadlines", 283 | show_excerpt: false, 284 | view_mode: viewMode, 285 | show_content: true, 286 | limit: settings.get("articlesNumber"), 287 | order_by: orderBy 288 | }; 289 | 290 | if (feedId == -9){ 291 | // special case (all articles from a whole category) 292 | msg.feed_id = utils.getCurrentCatId(); 293 | msg.is_cat = true; 294 | } else { 295 | // normal case 296 | msg.feed_id = feedId; 297 | } 298 | 299 | api.ttRssApiCall( 300 | msg, function(res){ 301 | 302 | if (collection.feedId != feedId){ 303 | /* this is another feed, force a clean 304 | to trigger delete/add events */ 305 | collection.set([]); 306 | } 307 | 308 | // efficiently set the collection 309 | collection.set(res); 310 | 311 | // store the feedId of the collection 312 | collection.feedId = feedId; 313 | 314 | // notify by a sync that the sync worked 315 | collection.trigger('sync'); 316 | }, true); 317 | } else { 318 | utils.log("ArticlesModel.sync called for an unsupported method: " + method); 319 | } 320 | }, // sync() 321 | 322 | toggleUnread: function(){ 323 | 324 | var articles = ""; 325 | 326 | // do we need to mark all as read or unread? 327 | if (this.where({unread: true}).length > 0){ 328 | this.where({unread: true}).forEach( 329 | function(m){ 330 | m.set({unread: false}) 331 | articles += m.id + ","; 332 | }); 333 | } else { 334 | this.where({unread: false}).forEach( 335 | function(m){ 336 | m.set({unread: true}) 337 | articles += m.id + ","; 338 | }); 339 | } 340 | 341 | //remove last comma 342 | articles = articles.substr(0, articles.length - 1); 343 | 344 | var collection = this; 345 | 346 | // API call to mark as read 347 | api.ttRssApiCall( 348 | { op: 'updateArticle', 349 | article_ids: articles, 350 | mode: 2, 351 | field: 2 }, 352 | function(m){ 353 | // notify by a sync that the sync worked 354 | collection.trigger('sync'); 355 | } , true); 356 | }, 357 | 358 | 359 | onUnreadChange: function(model){ 360 | 361 | if (settings.get("onlyUnread") && 362 | ! model.get("unread")) { 363 | // A model was marked as read in onlyUnread mode 364 | 365 | this.remove(model); 366 | } 367 | 368 | }, //onUnreadUpdate 369 | 370 | initialize: function(){ 371 | 372 | // listen for unread changes 373 | this.on("change:unread", this.onUnreadChange, this); 374 | 375 | } //initialize 376 | 377 | }); // ArticlesModel 378 | 379 | var articlesModel = new ArticlesModel(); 380 | 381 | 382 | 383 | 384 | /*********** TTRSS config ********/ 385 | 386 | // a model to store configuration (from getConfig in the API) 387 | var ConfigModel = Backbone.Model.extend({ 388 | sync: function(method, model, options){ 389 | if (method == "read"){ 390 | api.ttRssApiCall( 391 | {'op': 'getConfig'}, 392 | function(m){ 393 | model.set(m); 394 | }, true); 395 | } 396 | } 397 | }); 398 | 399 | 400 | return { 401 | 402 | categoriesModel: new CategoriesModel(), 403 | feedsModel: feedsModel, 404 | articlesModel: articlesModel, 405 | configModel: new ConfigModel(), 406 | settings: settings, 407 | article: ArticleModel 408 | 409 | } 410 | 411 | }); //define 412 | -------------------------------------------------------------------------------- /scripts/router.js: -------------------------------------------------------------------------------- 1 | /*************** BACKBONE Router ************/ 2 | define(['backbone', 'views', 'models'], function(Backbone, views, models){ 3 | 4 | var MyRouter = Backbone.Router.extend({ 5 | 6 | routes: { 7 | "login": "login", // #login 8 | "login?from=*qr": "login", // #login?from=#cat4/feed23 9 | "": "categories", // # 10 | "cat:catId": "feeds", // #cat4 11 | "cat:catId/feed:feedId": "articles", // #cat4/feed23 12 | "cat:catId/feed:feedId/art:artId": "read", // #cat4/feed23/art1234 13 | "settings": "settings", // #settings 14 | "*path": "defaultRoute" // #* 15 | }, 16 | 17 | defaultRoute: function(path){ 18 | // go to homepage if route unknown 19 | this.navigate('', {trigger: true}); 20 | }, 21 | 22 | login: function() { 23 | this.transitionOptions = {transition: "slideup"}, 24 | this.goto('#login'); 25 | }, 26 | 27 | categories: function(){ 28 | // show the page and ask the data to be refeshed 29 | this.goto(views.categoriesPageView.refresh().$el); 30 | }, 31 | 32 | articles: function(catId, feedId){ 33 | // test feedId is an integer (negative or positive) 34 | var id = parseInt(feedId); 35 | if (isNaN(id)){ 36 | this.navigate('', {trigger: true}); 37 | } else { 38 | // show the page and ask the data to be refeshed 39 | this.goto(views.articlesPageView.refresh().$el); 40 | } 41 | }, 42 | 43 | feeds: function(catId){ 44 | // test catId is an integer (negative or positive) 45 | var id = parseInt(catId); 46 | if (isNaN(id)){ 47 | this.navigate('', {trigger: true}); 48 | } else { 49 | // go to the view and ask the data to be refreshed 50 | this.goto(views.feedsPageView.refresh().$el); 51 | } 52 | }, 53 | 54 | read: function(catName, feedName, artId){ 55 | var id = parseInt(artId); 56 | 57 | if (isNaN(id)){ 58 | // id invalid, go to categories page (Home) 59 | this.navigate('', {trigger: true}); 60 | } else { 61 | // go to the view and ask the data to be refreshed 62 | this.goto(views.articlePageView.refresh().$el); 63 | 64 | // scroll to top 65 | window.scroll(0,0); 66 | } 67 | }, 68 | 69 | transitionOptions: {}, 70 | setNextTransOptions : function(obj){ 71 | this.transitionOptions = obj; 72 | }, 73 | 74 | settings: function(){ 75 | this.setNextTransOptions({reverse: false, transition: "flip"}); 76 | this.goto(views.settingsPageView.render().$el); 77 | this.setNextTransOptions({reverse: true, transition: "flip"}); 78 | }, 79 | 80 | goto: function(page){ 81 | $.mobile.changePage(page, this.transitionOptions); 82 | 83 | // reset transitions options 84 | this.transitionOptions = {}; 85 | 86 | } // goto 87 | 88 | }); 89 | 90 | 91 | return { 92 | myRouter: new MyRouter() 93 | }; 94 | 95 | }); //define 96 | 97 | -------------------------------------------------------------------------------- /scripts/templates.js: -------------------------------------------------------------------------------- 1 | /* My module for this webapp templates */ 2 | 3 | define(['underscore'], function(_){ 4 | 5 | return { 6 | 7 | // a jQuery listview separator element 8 | listSeparator : 9 | _.template('
  • <%= text %>
  • ') 10 | , 11 | 12 | // a jQuery listview link element (to put inside a li) 13 | listElement : 14 | _.template('' + 15 | '<%= title %>' + 16 | '<%= count %>' + 17 | ''), 18 | 19 | // a jQuery listview link element with icon (to put inside a li) 20 | listElementWithIcon : 21 | _.template('' + 22 | '' + 23 | '<%= title %>' + 24 | '<%= count %>' + 25 | ''), 26 | 27 | // a jQuery listview read-only element 28 | roListElement : 29 | _.template('
  • <%= text %>
  • '), 30 | 31 | // the content of a LI element for an article 32 | articleLiElement : 33 | _.template('' + 34 | '

    <%= title %>

    ' + 35 | '

    <%= date %>

    '), 36 | 37 | // the content of a LI element for an article with the feed Name 38 | articleFeedLiElement : 39 | _.template( 40 | '' + 41 | '

    <%= title %>

    ' + 42 | '

    <%= feed %>

    ' + 43 | '

    <%= date %>

    ' 44 | ), 45 | 46 | // button for the prev/next 47 | gridLeftButton : 48 | _.template( 49 | '
    ' + 50 | '
    ' + 51 | 'previous' + 52 | '<%= title %>
    '), 53 | 54 | gridRightButton : 55 | _.template( 56 | '
    ' + 57 | 'next<%= title %>
    ' + 60 | '
    ') 61 | 62 | } //return 63 | 64 | }); //define 65 | -------------------------------------------------------------------------------- /scripts/utils.js: -------------------------------------------------------------------------------- 1 | 2 | /* module for utilities functions */ 3 | 4 | define(['jquery'],function($){ 5 | 6 | // to check the start of a string 7 | if (typeof String.prototype.startsWith != 'function') { 8 | String.prototype.startsWith = function (str){ 9 | return this.indexOf(str) == 0; 10 | }; 11 | } 12 | 13 | return { 14 | 15 | // log a message to the console if it exists else discard 16 | log: function(m){ 17 | if (typeof console !== 'undefined'){ 18 | console.log(m); 19 | } 20 | }, 21 | 22 | // clean up a dom object (article to display) 23 | cleanArticle: function(content, domain){ 24 | var data = "
    " + content + "
    "; 25 | var $dom = $(data); 26 | 27 | /* ARS Technica styles DIVs */ 28 | $dom.find('div').removeAttr('style'); 29 | 30 | /* ARS technica bookmarks */ 31 | $dom.find('div.feedflare').remove(); 32 | 33 | /* Feedburner images */ 34 | $dom.find('img[href~="feedburner"]').remove(); 35 | 36 | 37 | var $toClean = $dom.find('img,object,iframe'); 38 | $toClean.removeAttr('height'); 39 | 40 | $toClean.each( 41 | function(index, e){ 42 | 43 | // if relativeURL, add domain 44 | var src = $(e).attr('src'); 45 | if ($.mobile.path.isRelativeUrl(src)){ 46 | var newsrc = $.mobile.path.makeUrlAbsolute( 47 | src, 48 | domain 49 | ); 50 | $(e).attr('src', newsrc); 51 | } 52 | } 53 | ); 54 | 55 | // make all links open in a new tab 56 | $toClean = $dom.find('a'); 57 | $toClean.each( 58 | function(index, e){ 59 | $(e).attr('target', '_blank'); 60 | } 61 | ); 62 | 63 | return $dom; 64 | }, //cleanArticle 65 | 66 | 67 | 68 | /* returns a valid formatted string of the update 69 | time representation of Tiny Tiny RSS */ 70 | updateTimeToString: function(time){ 71 | var date = new Date(time * 1000); 72 | var now = new Date(Date.now()); 73 | 74 | // date in YYYY-MM-DD 75 | var year = date.getFullYear(); 76 | var month = date.getMonth() + 1; 77 | month = (month < 10 ? "0" : "") + month; 78 | var day = date.getDate(); 79 | day = (day < 10 ? "0" : "") + day; 80 | var dateStr = year + "-" + month + "-" + day; 81 | 82 | // time in HH:MM 83 | var hour = date.getHours(); 84 | hour = (hour < 10 ? "0" : "") + hour; 85 | var min = date.getMinutes(); 86 | min = (min < 10 ? "0" : "") + min; 87 | var timeStr = hour + ":" + min; 88 | 89 | // now in YYYY-MM-DD 90 | year = now.getFullYear(); 91 | month = now.getMonth() + 1; 92 | month = (month < 10 ? "0" : "") + month; 93 | day = now.getDate(); 94 | day = (day < 10 ? "0" : "") + day; 95 | var nowStr = year + "-" + month + "-" + day; 96 | 97 | // if today, puts the time of day 98 | if (dateStr == nowStr){ 99 | // only time if it's today 100 | dateStr = timeStr; 101 | } else { 102 | dateStr = dateStr + " " + timeStr; 103 | } 104 | 105 | return dateStr; 106 | }, //updateTimeToString 107 | 108 | 109 | localStorageSupport: function(){ 110 | 111 | //taken from https://developer.mozilla.org/en-US/docs/DOM/Storage#localStorage 112 | if (!window.localStorage) { 113 | window.localStorage = { 114 | getItem: function (sKey) { 115 | if (!sKey || !this.hasOwnProperty(sKey)) { return null; } 116 | return unescape(document.cookie.replace(new RegExp("(?:^|.*;\\s*)" + escape(sKey).replace(/[\-\.\+\*]/g, "\\$&") + "\\s*\\=\\s*((?:[^;](?!;))*[^;]?).*"), "$1")); 117 | }, 118 | key: function (nKeyId) { 119 | return unescape(document.cookie.replace(/\s*\=(?:.(?!;))*$/, "").split(/\s*\=(?:[^;](?!;))*[^;]?;\s*/)[nKeyId]); 120 | }, 121 | setItem: function (sKey, sValue) { 122 | if(!sKey) { return; } 123 | document.cookie = escape(sKey) + "=" + escape(sValue) + "; expires=Tue, 19 Jan 2038 03:14:07 GMT; path=/"; 124 | this.length = document.cookie.match(/\=/g).length; 125 | }, 126 | length: 0, 127 | removeItem: function (sKey) { 128 | if (!sKey || !this.hasOwnProperty(sKey)) { return; } 129 | document.cookie = escape(sKey) + "=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/"; 130 | this.length--; 131 | }, 132 | hasOwnProperty: function (sKey) { 133 | return (new RegExp("(?:^|;\\s*)" + escape(sKey).replace(/[\-\.\+\*]/g, "\\$&") + "\\s*\\=")).test(document.cookie); 134 | } 135 | }; 136 | window.localStorage.length = (document.cookie.match(/\=/g) || window.localStorage).length; 137 | } //if 138 | }, // localStorageSupport 139 | 140 | removeAllAttributes: function(element){ 141 | 142 | while (element.attributes.length > 0){ 143 | element.removeAttribute( 144 | element.attributes[0].name 145 | ); 146 | } 147 | 148 | }, //removeAllAttributes 149 | 150 | // to get the current article ID from the fragment 151 | getCurrentArtId: function(){ 152 | var f = Backbone.history.fragment; 153 | var re = /^cat-?\d+\/feed-?\d+\/art(\d+)$/; 154 | var id = f.replace(re, "$1"); 155 | 156 | return parseInt(id); 157 | }, 158 | 159 | // to get the current feed ID from the fragment 160 | getCurrentFeedId: function(){ 161 | var f = Backbone.history.fragment; 162 | var re = /^cat-?\d+\/feed(-?\d+)(\/.*)?$/; 163 | var id = f.replace(re, "$1"); 164 | 165 | return parseInt(id); 166 | }, 167 | 168 | // to get the current feed ID from the fragment 169 | getCurrentCatId: function(){ 170 | var f = Backbone.history.fragment; 171 | var re = /^cat(-?\d+)(\/.*)?$/; 172 | var id = f.replace(re, "$1"); 173 | 174 | return parseInt(id); 175 | } 176 | 177 | } //return 178 | 179 | }); //define 180 | -------------------------------------------------------------------------------- /scripts/views.js: -------------------------------------------------------------------------------- 1 | /************ BACKBONE views*************/ 2 | 3 | define(['jquery', 'models', 'templates','conf','utils'], 4 | function ($, models, tpl, conf, utils){ 5 | 6 | /*********** categories *************/ 7 | 8 | // a view for each row of a categories list 9 | CategoryRowView = Backbone.View.extend({ 10 | render: function(){ 11 | var html = tpl.listElement({ 12 | href: '#cat' + this.model.id, 13 | title: this.model.get('title'), 14 | count: this.model.get('unread') }); 15 | 16 | this.el.innerHTML = html; 17 | 18 | // make articles with 0 unread not bold 19 | if (this.model.get('unread') == 0){ 20 | this.$el.addClass('read'); 21 | } 22 | return this; 23 | }, 24 | 25 | updateUnread: function(){ 26 | var newCount = this.model.get('unread'); 27 | this.$('span.ui-li-count').html(newCount); 28 | 29 | // make articles with 0 unread not bold 30 | if (this.model.get('unread') == 0){ 31 | this.$el.addClass('read'); 32 | } else { 33 | this.$el.removeClass('read'); 34 | } 35 | }, 36 | 37 | updateTitle: function(){ 38 | var newTitle = this.model.get('title'); 39 | this.$('a')[0].firstChild.data = newTitle; 40 | }, 41 | 42 | initialize: function() { 43 | this.model.on("change:unread", this.updateUnread, this); 44 | this.model.on("change:title", this.updateTitle, this); 45 | this.el = document.createElement('li'); 46 | this.$el = $(this.el); 47 | } 48 | }); 49 | 50 | // a view for page with all the categories 51 | var CategoriesPageView = Backbone.View.extend({ 52 | 53 | // category removed 54 | delCat : function(model){ 55 | this.$('#cat' + model.id).remove(); 56 | 57 | if (model.id < 0){ 58 | // this was a special category, also remove the separator 59 | this.$('li.ui-li-divider').remove(); 60 | } 61 | 62 | this.LVrefreshNeeded = true; 63 | }, //delCat 64 | 65 | 66 | // when a category is added 67 | addCat: function(model){ 68 | 69 | var catId = model.get('id'); 70 | var row = new CategoryRowView({model: model}); 71 | 72 | this.LVrefreshNeeded = true; 73 | 74 | // if nothing yet (only a static message), cleanup listview 75 | if (this.$('li.ui-li-static').length != 0){ 76 | this.$lv.empty(); 77 | } 78 | 79 | // li element to add 80 | var li = row.render().el; 81 | 82 | // add an id to the li element 83 | li.id = 'cat' + catId; 84 | 85 | if (catId < 0){ 86 | // Special category comes at the top with a separator 87 | this.$lv.prepend(tpl.listSeparator({ text: ' ' })); 88 | 89 | //TODO Labels category can be added here 90 | 91 | this.$lv.prepend(li); 92 | } else { 93 | // Other categories comes at the bottom, we order them 94 | // accordingly to the collection order 95 | 96 | // current position in the collection 97 | var pos = this.collection.indexOf(row.model); 98 | 99 | if (pos == this.collection.length - 1){ 100 | // the last one in the collection 101 | this.$lv.append(li); 102 | } else { 103 | // we insert it before the next in the collection 104 | var nextModel = this.collection.at(pos + 1); 105 | var nextLi = this.$('#cat' + nextModel.id); 106 | if (nextLi[0] != undefined){ 107 | $(nextLi[0]).before(li); 108 | } else { 109 | // nextModel has no view yet 110 | this.$lv.append(li); 111 | } 112 | } 113 | } 114 | }, //addCat 115 | 116 | // called when the data must be refreshed 117 | refresh: function(){ 118 | 119 | // update associated collection 120 | this.collection.fetch(); 121 | 122 | return this; 123 | }, 124 | 125 | // this is called each time the collection is 126 | // synced 127 | onSync: function(){ 128 | // in case there are no feeds after a refresh 129 | if (this.collection.length == 0){ 130 | 131 | var msg = "No categories"; 132 | 133 | if (models.settings.get("onlyUnread")){ 134 | msg = "Good job, you read it all :-)"; 135 | } 136 | 137 | // add the list element 138 | this.$lv.html(tpl.roListElement({text: msg})); 139 | this.LVrefreshNeeded = true; 140 | } 141 | 142 | if (this.LVrefreshNeeded){ 143 | this.$lv.listview("refresh"); 144 | this.LVrefreshNeeded = false; 145 | } 146 | }, 147 | 148 | initialize: function() { 149 | // when a category is added 150 | this.collection.on("add", this.addCat, this); 151 | // when a category is removed 152 | this.collection.on("remove", this.delCat, this); 153 | 154 | // a flag so that the view knows when a listview refresh is 155 | // needed 156 | this.LVrefreshNeeded = false; 157 | 158 | // when a sync goes well, refresh the list 159 | this.collection.on("sync", this.onSync, this); 160 | 161 | // refresh button for categories 162 | this.$('a.refreshButton').on('click', this, function(e){ 163 | e.data.refresh(); 164 | $('#catPopupMenu').popup('close'); 165 | e.preventDefault(); 166 | }); 167 | 168 | // store in the object a reference on the listview 169 | this.$lv = this.$('div[data-role="content"] ' + 170 | 'ul[data-role="listview"]'); 171 | 172 | // first time, no data yet in the collection 173 | this.$lv.html(tpl.roListElement({text: "Loading..."})); 174 | } // initialize 175 | }); 176 | 177 | 178 | var categoriesPage = 179 | new CategoriesPageView({ 180 | el: $("#categories"), 181 | collection: models.categoriesModel 182 | }); 183 | 184 | 185 | 186 | /************ Feeds **************/ 187 | 188 | // a view for each row of a feeds list 189 | FeedRowView = Backbone.View.extend({ 190 | 191 | // callback to add an icon to the list element 192 | addIcon: function(){ 193 | // get the icons directory from the conf 194 | var iconsDir = models.configModel.get("icons_dir"); 195 | 196 | var iconSrc = conf.apiPath + iconsDir + 197 | "/" + this.model.id + ".ico"; 198 | 199 | var img = document.createElement('img'); 200 | img.src = iconSrc; 201 | $(img).addClass("ui-li-icon"); 202 | $(img).addClass("ui-li-thumb"); 203 | 204 | // tell the li element to make space for the icon 205 | this.$el.addClass("ui-li-has-icon"); 206 | 207 | // add the image to the element 208 | this.$('a').prepend(img); 209 | }, 210 | 211 | render: function(event){ 212 | var html; 213 | 214 | // get the icons directory from the conf 215 | var iconsDir = models.configModel.get("icons_dir"); 216 | 217 | if ((iconsDir == undefined) && (this.model.get("has_icon"))){ 218 | // request to be notifed when icons path will be ready 219 | // asked by the page view 220 | models.configModel.once("change:icons_dir", this.addIcon, this); 221 | } 222 | 223 | // the link src 224 | var link = "#cat" + utils.getCurrentCatId() + "/feed" + this.model.id; 225 | 226 | if ((iconsDir == undefined) || (! this.model.get("has_icon"))){ 227 | // we can't display with icons or do not need them 228 | 229 | html = tpl.listElement({ 230 | href: link, 231 | title: this.model.get('title'), 232 | count: this.model.get('unread') 233 | }); 234 | 235 | } else { 236 | // we add an icon 237 | var iconSrc = conf.apiPath + iconsDir + "/" + this.model.id + ".ico"; 238 | 239 | html = tpl.listElementWithIcon({ 240 | href: link, 241 | title: this.model.get('title'), 242 | count: this.model.get('unread'), 243 | src: iconSrc 244 | }); 245 | } 246 | 247 | // make articles with 0 unread not bold 248 | if (this.model.get('unread') == 0){ 249 | this.$el.addClass('read'); 250 | } 251 | 252 | this.el.innerHTML = html; 253 | 254 | return this; 255 | }, 256 | 257 | updateUnread: function(){ 258 | var newCount = this.model.get('unread'); 259 | this.$('span.ui-li-count').html(newCount); 260 | 261 | // make articles with 0 unread not bold 262 | if (this.model.get('unread') == 0){ 263 | this.$el.addClass('read'); 264 | } else { 265 | this.$el.removeClass('read'); 266 | } 267 | }, 268 | 269 | updateTitle: function(){ 270 | var newTitle = this.model.get('title'); 271 | var childNodes = this.$('a')[0].childNodes; 272 | 273 | if (childNodes[0].nodeType == 3){ 274 | //no icon 275 | childNodes[0].data = newTitle; 276 | } else { 277 | //icon as firstChild 278 | childNodes[1].data = newTitle; 279 | } 280 | }, 281 | 282 | initialize: function() { 283 | this.model.on("change:unread", this.updateUnread, this); 284 | this.model.on("change:title", this.updateTitle, this); 285 | this.el = document.createElement('li'); 286 | this.$el = $(this.el); 287 | }, 288 | tagName: 'li' 289 | }); 290 | 291 | 292 | 293 | // a view for the page of the list of feeds of a category 294 | var FeedsPageView = Backbone.View.extend({ 295 | 296 | // callback to render the title in the header 297 | renderTitle: function(event){ 298 | // placeholder for the title of the category 299 | var $h1Tag = this.$("div:jqmData(role='header') h1"); 300 | 301 | // catId on the fragment 302 | var catId = utils.getCurrentCatId(); 303 | 304 | // cat model 305 | var catModel = models.categoriesModel.get(catId); 306 | 307 | if (catModel != undefined){ 308 | // title is available now 309 | $h1Tag.html(catModel.get("title")); 310 | } else { 311 | // default title 312 | $h1Tag.html("Feeds"); 313 | } 314 | }, // renderTitle 315 | 316 | // callback to delete a feed from the list 317 | delFeed: function(model){ 318 | this.$('#feed' + model.id).remove(); 319 | this.LVrefreshNeeded = true; 320 | }, 321 | 322 | // callback to add a feed to the list 323 | addFeed: function(model){ 324 | 325 | this.LVrefreshNeeded = true; 326 | 327 | // if nothing yet, cleanup listview 328 | if (this.$('li.ui-li-static').html() == "Loading..."){ 329 | this.$lv.empty(); 330 | } 331 | 332 | 333 | var row = new FeedRowView({model: model}); 334 | 335 | // li element to add 336 | var li = row.render().el; 337 | 338 | // add an id to the li element to find it back easily later 339 | li.id = 'feed' + model.id; 340 | 341 | // append it to the list at the good position 342 | var pos = this.collection.indexOf(row.model); 343 | 344 | if (pos == this.collection.length - 1){ 345 | // the last one in the collection 346 | this.$lv.append(li); 347 | } else { 348 | // we insert it before the next in the collection 349 | var nextModel = this.collection.at(pos + 1); 350 | var nextLi = this.$('#feed' + nextModel.id); 351 | if (nextLi[0] != undefined){ 352 | $(nextLi[0]).before(li); 353 | } else { 354 | // nextModel has no view yet 355 | this.$lv.append(li); 356 | } 357 | } 358 | }, //addFeed 359 | 360 | // called when the data must be refreshed 361 | refresh: function(){ 362 | 363 | // do we have feeds from this category? 364 | var catId = utils.getCurrentCatId(); 365 | if (this.collection.where({cat_id: catId}).length == 0){ 366 | // no, show loading info 367 | lvData = tpl.roListElement({text: "Loading..."}); 368 | 369 | this.$lv.html(lvData); 370 | this.$lv.listview("refresh"); 371 | } 372 | 373 | // update associated collection 374 | this.collection.fetch(); 375 | 376 | // do we have the icons_dir in the config 377 | // it will be necessary for the feeds 378 | if (models.configModel.get("icons_dir") == undefined){ 379 | models.configModel.fetch(); 380 | // rows will be notified 381 | } 382 | 383 | // render the title 384 | this.renderTitle(); 385 | 386 | // no category names yet 387 | if (models.categoriesModel.length == 0){ 388 | // request the categories and ask to be notified once 389 | models.categoriesModel.once("sync", 390 | this.renderTitle, 391 | this); 392 | models.categoriesModel.fetch(); 393 | } 394 | 395 | return this; 396 | }, 397 | 398 | // this is called each time the collection is 399 | // synced 400 | onSync: function(){ 401 | // in case there are no feeds after a refresh 402 | if (this.collection.length == 0){ 403 | 404 | var msg = "No feeds"; 405 | 406 | if (models.settings.get("onlyUnread")){ 407 | msg = "No unread feeds"; 408 | } 409 | 410 | // add the list element 411 | this.$lv.html(tpl.roListElement({text: msg})); 412 | this.LVrefreshNeeded = true; 413 | } 414 | 415 | if (this.LVrefreshNeeded){ 416 | this.$lv.listview("refresh"); 417 | this.LVrefreshNeeded = false; 418 | } 419 | 420 | }, 421 | 422 | initialize: function(){ 423 | // when a feed is added 424 | this.collection.on("add", this.addFeed, this); 425 | // when a feed is deleted 426 | this.collection.on("remove", this.delFeed, this); 427 | 428 | // register refresh button for feeds 429 | this.$("a.refreshButton").on( 430 | // this is on from jQuery 431 | "click", 432 | this, 433 | function(e){ 434 | e.data.refresh(); 435 | $('#feedsMenuPopup').popup('close'); 436 | e.preventDefault(); 437 | } 438 | ); 439 | 440 | // listview div 441 | this.$lv = this.$('div[data-role="content"] ' + 442 | 'ul[data-role="listview"]'); 443 | 444 | // a flag so that the view knows when a listview refresh is 445 | // needed 446 | this.LVrefreshNeeded = false; 447 | 448 | // when sync goes well, refresh the list 449 | this.collection.on("sync", this.onSync, this); 450 | 451 | // first time, no data yet in the collection 452 | this.$lv.html(tpl.roListElement({text: "Loading..."})); 453 | } // initialize 454 | 455 | }); //FeedsPageView 456 | 457 | 458 | 459 | 460 | 461 | /*************** Articles *************/ 462 | 463 | // a view for each row (article) of a feeds list 464 | var ArticleRowView = Backbone.View.extend({ 465 | 466 | render: function(event){ 467 | var link = "#cat" + utils.getCurrentCatId() + 468 | "/feed" + utils.getCurrentFeedId() + 469 | "/art" + this.model.id; 470 | 471 | var dateStr = utils.updateTimeToString(this.model.get("updated")); 472 | 473 | var html; 474 | var catId = utils.getCurrentCatId(); 475 | var feedId = utils.getCurrentFeedId(); 476 | var feedTitle = this.model.get("feed_title"); 477 | var unread = this.model.get("unread"); 478 | 479 | if (((catId >= 0) && (feedId != -9)) || (feedTitle == undefined)){ 480 | // normal cat, we don't need to show the feed name (it's in the header) 481 | // or we don't have it yet 482 | 483 | html = tpl.articleLiElement({ 484 | href: link, 485 | date: dateStr, 486 | title: this.model.get('title') }); 487 | 488 | } else { 489 | // special cat, we show the feed name 490 | 491 | html = tpl.articleFeedLiElement({ 492 | href: link, 493 | date: dateStr, 494 | title: this.model.get('title'), 495 | feed: feedTitle }); 496 | } 497 | 498 | this.el.innerHTML = html; 499 | if (! unread){ 500 | this.$el.addClass('read'); 501 | } 502 | 503 | return this; 504 | }, // render 505 | 506 | updateUnread: function(){ 507 | // callback when model unread changed 508 | this.$el.toggleClass('read'); 509 | }, 510 | 511 | initialize: function() { 512 | this.el = document.createElement('li'); 513 | this.$el = $(this.el); 514 | 515 | this.model.on('change:unread', this.updateUnread, this); 516 | }, 517 | 518 | tagName: 'li' 519 | }); 520 | 521 | 522 | // a view for the page with the list of articles of a feed 523 | var ArticlesPageView = Backbone.View.extend({ 524 | 525 | // callback to update the href of the back button 526 | updateBackButton: function(){ 527 | // back button href 528 | var href = Backbone.history.fragment; 529 | href = "#" + href.substr(0, href.lastIndexOf("/")); 530 | 531 | this.$("div:jqmData(role='header') a:first").attr("href", href); 532 | }, 533 | 534 | // callback to update the title in the header 535 | updateTitle: function(){ 536 | 537 | // placeholder for the title of the category 538 | var $h1Tag = this.$("div:jqmData(role='header') h1"); 539 | 540 | // feedId from the fragment 541 | var feedId = utils.getCurrentFeedId(); 542 | 543 | // feed model 544 | var feedModel = models.feedsModel.get(feedId); 545 | if (feedModel == undefined){ 546 | // default title 547 | $h1Tag.html("Articles"); 548 | } else { 549 | // title is available now 550 | $h1Tag.html(feedModel.get("title")); 551 | } 552 | }, //updateTitle 553 | 554 | addArt: function(model){ 555 | 556 | this.LVrefreshNeeded = true; 557 | 558 | // if nothing yet, cleanup listview 559 | if (this.$('li.ui-li-static').html() == "Loading..."){ 560 | this.$lv.empty(); 561 | } 562 | 563 | var row = new ArticleRowView({model: model}); 564 | 565 | // li element to add 566 | var li = row.render().el; 567 | 568 | // add an id to the li element to find it back easily later 569 | li.id = 'art' + model.id; 570 | 571 | // append it to the list at the good position 572 | var pos = this.collection.indexOf(row.model); 573 | 574 | if (pos == this.collection.length - 1){ 575 | // the last one in the collection 576 | this.$lv.append(li); 577 | } else { 578 | // we insert it before the next in the collection 579 | var nextModel = this.collection.at(pos + 1); 580 | var nextLi = this.$('#art' + nextModel.id); 581 | if (nextLi[0] != undefined){ 582 | $(nextLi[0]).before(li); 583 | } else { 584 | // nextModel has no view yet 585 | this.$lv.append(li); 586 | } 587 | } 588 | 589 | }, //addArt 590 | 591 | 592 | delArt: function(model){ 593 | this.$('#art' + model.id).remove(); 594 | this.LVrefreshNeeded = true; 595 | }, //delArt 596 | 597 | renderMarkAllButton: function(){ 598 | var but = this.$("a.toggleUnreadButton"); 599 | 600 | if (this.collection.length == 0){ 601 | // disable button, no articles in the list 602 | but.addClass("ui-disabled"); 603 | but.html("Mark all as ?"); 604 | } else { 605 | but.removeClass("ui-disabled"); 606 | if (this.collection.where({unread: true}).length > 0){ 607 | but.html("Mark all as read"); 608 | } else { 609 | but.html("Mark all as unread"); 610 | } 611 | } 612 | }, 613 | 614 | // called when the data must be refreshed 615 | refresh: function(){ 616 | 617 | var feedId = utils.getCurrentFeedId(); 618 | 619 | // do we have articles from this feed? 620 | if (this.collection.feedId != feedId){ 621 | // no, show loading info 622 | lvData = tpl.roListElement({text: "Loading..."}); 623 | this.$lv.html(lvData); 624 | this.$lv.listview("refresh"); 625 | } 626 | 627 | // update the collection 628 | this.collection.fetch(); 629 | 630 | // update the back button 631 | this.updateBackButton(); 632 | 633 | // render the title with the feed name 634 | this.updateTitle(); 635 | 636 | /* if the feed model isn't available, we need to 637 | fetch it and update the title when it will be 638 | ready */ 639 | var feedModel = models.feedsModel.get(feedId); 640 | if (feedModel == undefined){ 641 | models.feedsModel.once("sync", this.updateTitle, this); 642 | models.feedsModel.fetch(); 643 | } 644 | 645 | return this; 646 | }, 647 | 648 | // this is called each time the collection is 649 | // synced 650 | onSync: function(){ 651 | if (this.LVrefreshNeeded){ 652 | this.$lv.listview("refresh"); 653 | this.LVrefreshNeeded = false; 654 | } 655 | 656 | if (this.collection.length == 0){ 657 | // no elements in the collection 658 | 659 | // message depending on options 660 | var msg = models.settings.get("onlyUnread") ? 661 | "No unread articles" :"No articles" ; 662 | 663 | this.$lv.html(tpl.roListElement({text: msg})); 664 | this.$lv.listview("refresh"); 665 | } 666 | 667 | // update the mark all as read/unread button 668 | this.renderMarkAllButton(); 669 | }, 670 | 671 | initialize: function(){ 672 | 673 | this.collection.on("add", this.addArt, this); 674 | this.collection.on("remove", this.delArt, this); 675 | 676 | // register refresh button clicks 677 | this.$('a.refreshButton').on( 678 | 'click', 679 | this, 680 | function(e){ 681 | e.data.collection.fetch(); 682 | $("#artMenuPopup").popup('close'); 683 | e.preventDefault(); 684 | } 685 | ); 686 | 687 | // register mark all as read button 688 | this.$('a.toggleUnreadButton').on( 689 | 'click', 690 | this, 691 | function(e){ 692 | e.data.collection.toggleUnread(); 693 | $("#artMenuPopup").popup('close'); 694 | e.preventDefault(); 695 | } 696 | ); 697 | 698 | 699 | // a flag so that the view knows when a listview refresh is 700 | // needed 701 | this.LVrefreshNeeded = false; 702 | 703 | // after an update of the collection 704 | this.collection.on("sync", this.onSync, this); 705 | 706 | // listview div 707 | this.$lv = this.$('div[data-role="content"] ' + 708 | 'ul[data-role="listview"]'); 709 | } // initialize 710 | 711 | }); //ArticlesPageView 712 | 713 | 714 | /************** 1 ARTICLE view, reading **************/ 715 | 716 | var ArticlePageView = Backbone.View.extend({ 717 | 718 | // called when the data must be refreshed 719 | refresh: function(){ 720 | 721 | var artId = utils.getCurrentArtId(); 722 | 723 | if ((this.model == undefined) || 724 | (this.model.id != artId)){ 725 | 726 | if (this.model != undefined){ 727 | // stop listening for events from the old model 728 | this.stopListening(); 729 | } 730 | 731 | // no model associated with this view yet or 732 | // not the good one 733 | var m = models.articlesModel.get(artId); 734 | 735 | 736 | if (m != undefined){ 737 | // we found it in the collection 738 | this.model = m; 739 | } else { 740 | // we have to create it 741 | this.model = new models.article({id: artId}); 742 | } 743 | } 744 | 745 | // update the view parts 746 | this.updateBackButton(); 747 | 748 | this.updateLink(); 749 | if (! this.model.has("link")){ 750 | this.model.once("change:link", this.updateLink, this); 751 | } 752 | 753 | this.updateTitle(); 754 | if (! this.model.has("title")){ 755 | this.model.once("change:title", this.updateTitle, this); 756 | } 757 | 758 | this.updateFeedName(); 759 | if (utils.getCurrentCatId() < 0){ 760 | // this is a special feed 761 | 762 | if (! this.model.has("feed_title")){ 763 | models.articlesModel.once("change:feed_title", this.updateFeedName, this); 764 | // no need to fetch the articles, this will be done for 765 | // the next/prev links if needed 766 | } 767 | } else { 768 | // this is a normal feed 769 | var feedModel = models.feedsModel.get( 770 | utils.getCurrentFeedId() 771 | ); 772 | 773 | if (! feedModel){ 774 | models.feedsModel.once("sync", this.updateFeedName, this); 775 | models.feedsModel.fetch(); 776 | } 777 | } 778 | 779 | this.updateTime(); 780 | if (! this.model.has("updated")){ 781 | this.model.once("change:updated", this.updateTime, this); 782 | } 783 | 784 | this.updateContent(); 785 | if (! this.model.has("content")){ 786 | this.model.once("change:content", 787 | this.updateContent, this); 788 | // do the fetch now! 789 | this.model.fetch(); 790 | } 791 | 792 | // update previous/next links at the bottom 793 | if (models.articlesModel.length <= 1){ 794 | // collection empty, update it 795 | models.articlesModel.on("sync", this.renderPrevNext, this); 796 | models.articlesModel.fetch(); 797 | } else { 798 | this.renderPrevNext(); 799 | } 800 | 801 | this.renderUnreadToggleButton(); 802 | this.listenTo(this.model, "change:unread", 803 | this.renderUnreadToggleButton); 804 | 805 | this.renderStarredToggleButton(); 806 | this.listenTo(this.model, "change:marked", 807 | this.renderStarredToggleButton); 808 | 809 | this.renderPublishToggleButton(); 810 | this.listenTo(this.model, "change:published", 811 | this.renderPublishToggleButton); 812 | 813 | return this; 814 | }, //refresh 815 | 816 | // callback to update the href of the back button 817 | updateBackButton: function(){ 818 | // back button href 819 | var href = Backbone.history.fragment; 820 | href = "#" + href.substr(0, href.lastIndexOf("/")); 821 | 822 | this.$("div:jqmData(role='header') a:first").attr("href", href); 823 | }, 824 | 825 | updateLink: function(){ 826 | var link = this.model.get("link"); 827 | if (! link){ 828 | link = ""; 829 | } 830 | this.$("div:jqmData(role='content') > div.header > h3 > a"). 831 | attr("href", link); 832 | }, 833 | 834 | updateTitle: function(){ 835 | var title = this.model.get("title"); 836 | if (! title){ 837 | title = "Title loading..."; 838 | } 839 | this.$("div:jqmData(role='content') > div.header " + 840 | '> h3 > a'). html(title); 841 | }, 842 | 843 | updateFeedName: function(){ 844 | 845 | // try to use feed_title 846 | var feedTitle = this.model.get("feed_title"); 847 | 848 | if (! feedTitle){ 849 | // feed_title cannot be used, we can try on the feed model 850 | 851 | var feedModel = models.feedsModel.get(utils.getCurrentFeedId()); 852 | 853 | if (feedModel){ 854 | feedTitle = feedModel.get("title"); 855 | } else { 856 | // no model yet in the collection 857 | feedTitle = "loading..."; 858 | } 859 | } 860 | 861 | this.$("div:jqmData(role='content') > div.header " + 862 | '> p.feed span').html(feedTitle); 863 | }, 864 | 865 | updateTime: function(){ 866 | var time = this.model.get("updated"); 867 | if (! time){ 868 | time = "loading..."; 869 | } else { 870 | time = utils.updateTimeToString(time); 871 | } 872 | 873 | this.$("div:jqmData(role='content') > div.header " + 874 | '> p.updateTime span').html(time); 875 | }, 876 | 877 | // this callback can be called as a method or an event callback 878 | updateContent: function(){ 879 | 880 | // the div for the content 881 | var $contentDiv = this.$("div:jqmData(role='content') > div.main"); 882 | 883 | if (this.model.has("content")){ 884 | // this article is ready to be fully displayed 885 | var article = this.model.get("content"); 886 | 887 | // apply content filters 888 | article = utils.cleanArticle(article, this.model.get("link")); 889 | 890 | // display article 891 | $contentDiv.html(article); 892 | 893 | // remove any hardcoded sizes 894 | $contentDiv.find('img,object,iframe,audio,video').removeAttr("width"); 895 | $contentDiv.find('img,object,iframe,audio,video').removeAttr("height"); 896 | 897 | $contentDiv.trigger('create'); 898 | 899 | } else { 900 | $contentDiv.html("Content loading..."); 901 | } 902 | }, // renderContent 903 | 904 | renderUnreadToggleButton: function(){ 905 | var but = this.$("a.toggleUnreadButton"); 906 | 907 | if (this.model.get("unread")){ 908 | but.html("Mark as read"); 909 | } else { 910 | but.html("Mark as unread"); 911 | } 912 | 913 | }, 914 | 915 | renderStarredToggleButton: function(){ 916 | var but = this.$("a.toggleStarredButton"); 917 | 918 | if (this.model.get("marked")){ 919 | but.html("Remove star"); 920 | } else { 921 | but.html("Mark as starred"); 922 | } 923 | 924 | }, 925 | 926 | renderPublishToggleButton: function(){ 927 | var but = this.$("a.togglePublishButton"); 928 | 929 | if (this.model.get("published")){ 930 | but.html("Unpublish"); 931 | } else { 932 | but.html("Publish"); 933 | } 934 | 935 | }, 936 | 937 | renderPrevNext: function(){ 938 | // html to add 939 | var html = ""; 940 | 941 | // is the article in the collection 942 | var m = models.articlesModel.get(this.model.id); 943 | if (m == null){ 944 | return; 945 | } 946 | 947 | var index = models.articlesModel.indexOf(m); 948 | if (index == -1){ 949 | // nothing to do, article not in the collection 950 | return ; 951 | } 952 | 953 | // base link 954 | var ln = "#" + Backbone.history.fragment; 955 | ln = ln.substring(0, ln.lastIndexOf("art") + 3); 956 | 957 | // flag to tell if there is a prev/next article 958 | var hasPrev = false; 959 | var hasNext = false; 960 | 961 | if (index > 0){ 962 | // do we have a previous article? 963 | var prevArt = models.articlesModel.at(index - 1); 964 | 965 | html += tpl.gridLeftButton({ 966 | href: ln + prevArt.id, 967 | cl: "", 968 | title: prevArt.get("title") 969 | }); 970 | 971 | hasPrev = true; 972 | 973 | } else { 974 | // disabled button 975 | html += tpl.gridLeftButton({ 976 | href: "#", 977 | cl: "ui-disabled", 978 | title: "" 979 | }); 980 | } 981 | 982 | if (index + 1 < models.articlesModel.length){ 983 | // do we have a next article? 984 | var nextArt = models.articlesModel.at(index + 1); 985 | 986 | html += tpl.gridRightButton({ 987 | href: ln + nextArt.id, 988 | cl: "", 989 | title: nextArt.get("title") 990 | }); 991 | 992 | hasNext = true; 993 | } else { 994 | // disabled button 995 | html += tpl.gridRightButton({ 996 | href: "#", 997 | cl: "ui-disabled", 998 | title: "" 999 | }); 1000 | } 1001 | 1002 | // we now have the HTML ready, add it to the content 1003 | if (hasPrev || hasNext){ 1004 | // only if we need thoses links 1005 | 1006 | // do we already have the links? 1007 | var $links = 1008 | this.$("div:jqmData(role='content') > div.main > div.ui-grid-a"); 1009 | 1010 | if ($links.length == 1){ 1011 | $links.replaceWith(html); 1012 | this.$("div:jqmData(role='content') > div.main").trigger('create'); 1013 | } else { 1014 | this.$("div:jqmData(role='content') > div.main") 1015 | .append(html).trigger('create'); 1016 | } 1017 | } 1018 | 1019 | // mark as read and save it to the backend 1020 | if (this.model.get("unread")){ 1021 | this.model.save({unread: false}); 1022 | } 1023 | 1024 | }, 1025 | 1026 | initialize: function(){ 1027 | 1028 | // mark as unread button on an article 1029 | this.$('a.toggleUnreadButton').on('click', this, function(e){ 1030 | var artModel = e.data.model; 1031 | artModel.set("unread", ! artModel.get("unread")); 1032 | artModel.save(); 1033 | $('#readPopupMenu').popup('close'); 1034 | e.preventDefault(); 1035 | }); 1036 | 1037 | // mark as starred button on an article 1038 | this.$('a.toggleStarredButton').on('click', this, function(e){ 1039 | var artModel = e.data.model; 1040 | artModel.set("marked", ! artModel.get("marked")); 1041 | artModel.save(); 1042 | $('#readPopupMenu').popup('close'); 1043 | e.preventDefault(); 1044 | }); 1045 | 1046 | // publish button on an article 1047 | this.$('a.togglePublishButton').on('click', this, function(e){ 1048 | var artModel = e.data.model; 1049 | artModel.set("published", ! artModel.get("published")); 1050 | artModel.save(); 1051 | $('#readPopupMenu').popup('close'); 1052 | e.preventDefault(); 1053 | }); 1054 | 1055 | // store a reference on the listview 1056 | this.$lv = this.$('div[data-role="content"] ' + 1057 | 'ul[data-role="listview"]'); 1058 | } // initialize 1059 | 1060 | }); // ArticlePageView 1061 | 1062 | 1063 | 1064 | /******* for the settings page ****/ 1065 | 1066 | var SettingsPageView = Backbone.View.extend({ 1067 | 1068 | render: function(){ 1069 | var artNumber = this.model.get("articlesNumber"); 1070 | var artOldestFirst = this.model.get("articlesOldestFirst"); 1071 | var onlyUnread = this.model.get("onlyUnread"); 1072 | this.$("#articles-number").attr("value", artNumber); 1073 | this.$("#articles-oldest-first").prop("checked", artOldestFirst).checkboxradio("refresh"); 1074 | this.$("#only-unread").prop("checked", onlyUnread).checkboxradio("refresh"); 1075 | return this; 1076 | }, 1077 | 1078 | settingsChanged: function (event){ 1079 | /* function called when any form element 1080 | * change on the settings page */ 1081 | event.data.model.set( 1082 | { 1083 | articlesNumber: $("#articles-number").val(), 1084 | articlesOldestFirst: $("#articles-oldest-first").prop("checked"), 1085 | onlyUnread: $("#only-unread").prop("checked") 1086 | }, 1087 | {validate: true} 1088 | ); 1089 | 1090 | // persist data 1091 | event.data.model.save(); 1092 | }, //settingsChanged 1093 | 1094 | settingsError: function(event){ 1095 | alert(this.model.validationError); 1096 | 1097 | // reset articles number on error 1098 | document.getElementById('articles-number').value = 1099 | this.model.get("articlesNumber"); 1100 | }, 1101 | 1102 | initialize: function(){ 1103 | // bind the view to the model 1104 | this.model = models.settings; 1105 | 1106 | // load settings from localStorage & update values 1107 | this.model.fetch(); 1108 | 1109 | // bind settings change handler 1110 | this.$("form").change(this, this.settingsChanged); 1111 | 1112 | // prevent form from submitting 1113 | this.$("form").submit(this, function(e){e.preventDefault();}); 1114 | 1115 | // bind validation errors 1116 | this.model.on("invalid", this.settingsError, this); 1117 | } //init 1118 | }); 1119 | 1120 | 1121 | 1122 | return { 1123 | 1124 | categoriesPageView: categoriesPage, 1125 | 1126 | feedsPageView: 1127 | new FeedsPageView({ 1128 | el: $("#feeds"), 1129 | collection: models.feedsModel 1130 | }), 1131 | 1132 | articlesPageView: 1133 | new ArticlesPageView({ 1134 | el: $("#articles"), 1135 | collection: models.articlesModel 1136 | }), 1137 | 1138 | articlePageView: 1139 | new ArticlePageView({ 1140 | el: $("#read") 1141 | }), 1142 | 1143 | settingsPageView: 1144 | new SettingsPageView({ el: $("#settings") }) 1145 | 1146 | } //return 1147 | 1148 | 1149 | }); // module define 1150 | 1151 | -------------------------------------------------------------------------------- /style/images/ajax-loader.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mboinet/ttrss-mobile/a5cc9fc0a0aaf82c7712449d729f6a7f755e125b/style/images/ajax-loader.gif -------------------------------------------------------------------------------- /style/images/icons-18-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mboinet/ttrss-mobile/a5cc9fc0a0aaf82c7712449d729f6a7f755e125b/style/images/icons-18-black.png -------------------------------------------------------------------------------- /style/images/icons-18-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mboinet/ttrss-mobile/a5cc9fc0a0aaf82c7712449d729f6a7f755e125b/style/images/icons-18-white.png -------------------------------------------------------------------------------- /style/images/icons-36-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mboinet/ttrss-mobile/a5cc9fc0a0aaf82c7712449d729f6a7f755e125b/style/images/icons-36-black.png -------------------------------------------------------------------------------- /style/images/icons-36-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mboinet/ttrss-mobile/a5cc9fc0a0aaf82c7712449d729f6a7f755e125b/style/images/icons-36-white.png -------------------------------------------------------------------------------- /style/main.css: -------------------------------------------------------------------------------- 1 | @import url('jquery.mobile-1.3.1.css'); 2 | 3 | div#read div[data-role=content]>div.header>p{ 4 | margin: 0; 5 | font-style: italic; 6 | } 7 | 8 | div#read div[data-role=content]>div.header>h3{ 9 | margin-top: 0; 10 | margin-bottom: 0.2em; 11 | } 12 | 13 | div#read div[data-role=content]>div.header{ 14 | margin-bottom: 2em; 15 | } 16 | 17 | div#read div[data-role=content]>div.main img, 18 | div#read div[data-role=content]>div.main object, 19 | div#read div[data-role=content]>div.main iframe, 20 | div#read div[data-role=content]>div.main audio, 21 | div#read div[data-role=content]>div.main video { 22 | max-width: 100%; 23 | } 24 | 25 | div#read div[data-role=content]>div.main pre { 26 | white-space: pre-wrap; 27 | } 28 | 29 | #settings div.version { 30 | position: absolute; 31 | bottom: 0px; 32 | left: 0px; 33 | right: 0px; 34 | margin: 1em 0em 0.2em 0em; 35 | padding: 1em 0px 0px 0px; 36 | text-align: center; 37 | } 38 | 39 | #settings div.version p{ 40 | margin: 0px; 41 | padding: 0px; 42 | } 43 | 44 | /* override so that the title goes as wide as possible 45 | * without being over the buttons */ 46 | .ui-header .ui-title{ 47 | margin-left: 6.2em; 48 | margin-right: 6.2em; 49 | } 50 | 51 | /* unread elements in the articles view */ 52 | #articles ul.ui-listview li.read h3 { 53 | font-weight: normal; 54 | } 55 | 56 | /* read categories/feeds */ 57 | #categories ul.ui-listview li.read a, 58 | #feeds ul.ui-listview li.read a 59 | { 60 | font-weight: normal; 61 | } 62 | 63 | #articles .ui-li-heading { 64 | white-space: normal; 65 | font-size: 0.95em; 66 | } 67 | 68 | #articles .ui-li-desc { 69 | text-align: right; 70 | } 71 | 72 | /* article rows slimer */ 73 | #articles div.ui-content a { 74 | padding-top: 0em; 75 | padding-bottom: 0em; 76 | } 77 | -------------------------------------------------------------------------------- /touch-icon-iphone-retina.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mboinet/ttrss-mobile/a5cc9fc0a0aaf82c7712449d729f6a7f755e125b/touch-icon-iphone-retina.png -------------------------------------------------------------------------------- /touch-icon-iphone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mboinet/ttrss-mobile/a5cc9fc0a0aaf82c7712449d729f6a7f755e125b/touch-icon-iphone.png -------------------------------------------------------------------------------- /touch-startup-image-320x460.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mboinet/ttrss-mobile/a5cc9fc0a0aaf82c7712449d729f6a7f755e125b/touch-startup-image-320x460.png -------------------------------------------------------------------------------- /touch-startup-image-640x1096.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mboinet/ttrss-mobile/a5cc9fc0a0aaf82c7712449d729f6a7f755e125b/touch-startup-image-640x1096.png -------------------------------------------------------------------------------- /touch-startup-image-640x920.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mboinet/ttrss-mobile/a5cc9fc0a0aaf82c7712449d729f6a7f755e125b/touch-startup-image-640x920.png --------------------------------------------------------------------------------