├── .github └── ISSUE_TEMPLATE │ └── issue-report.md ├── LICENSE ├── README.md ├── _meta.lua ├── anki_configuration.lua ├── ankiconnect.lua ├── ankinote.lua ├── customcontextmenu.lua ├── extensions ├── EXT_dict_edit.lua ├── EXT_dict_word_lookup.lua ├── EXT_multi_def.lua ├── EXT_pitch_accent.lua └── README.md ├── forvo.lua ├── langsupport └── ja │ └── dictwrapper.lua ├── lua_utils ├── list.lua └── utils.lua ├── main.lua ├── menubuilder.lua └── profiles └── README.md /.github/ISSUE_TEMPLATE/issue-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Issue report 3 | about: Please provide the following information 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | Before creating an issue, please check if you're using the most up-to-date version of the plugin! Reinstall it if necessary. 11 | 12 | If you're using a non stable KOReader version (one of the nightly builds), you may want to use the [nightly](https://github.com/Ajatt-Tools/anki.koplugin/issues/53#issuecomment-2688753848) branch instead. 13 | 14 | * KOReader version: 15 | 16 | **Describe the Issue** 17 | Describe what issue you're having and how you managed to run into it. 18 | 19 | **How to Reproduce (if relevant)** 20 | Steps to reproduce the behavior: 21 | 1. Go to '...' 22 | 2. Click on '....' 23 | 3. Scroll down to '....' 24 | 4. See error 25 | 26 | 27 | **crash.log** 28 | `crash.log` is a file that is automatically created when KOReader crashes. It can normally be found in the KOReader directory: 29 | 30 | * `/mnt/private/koreader` for Cervantes 31 | * `koreader/` directory for Kindle 32 | * `.adds/koreader/` directory for Kobo 33 | * `applications/koreader/` directory for Pocketbook 34 | 35 | Android logs are kept in memory. Please go to [Menu] → Help → Bug Report to save these logs to a file. 36 | 37 | Please provide the crash.log even when KOreader did not actually crash! It usually contains the necessary information to ascertain what went wrong. 38 | 39 | **Additional context** 40 | Add any other context about the problem here. 41 | -------------------------------------------------------------------------------- /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 | # Anki plugin for KOReader 2 | 3 | KOReader plugin enabling Anki note generation for words looked up in the internal dictionary. 4 | 5 | ## Installation 6 | 7 | 1) Install [AnkiConnect](https://ankiweb.net/shared/info/2055492159) to enable this plugin to communicate with Anki. 8 | 9 | **NOTE:** as mentioned in the Anki-Connect [documentation](https://foosoft.net/projects/anki-connect/), you will not be able to connect to Anki-Connect from your e-reader without updating the default settings. 10 | 11 | > By default, Anki-Connect will only bind the HTTP server to the 127.0.0.1 IP address, so that you will only be able to access it from the same host on which it is running. 12 | > If you need to access it over a network, you can change the binding address in the configuration. 13 | > Go to Tools->Add-ons->AnkiConnect->Config and change the “webBindAddress” value. 14 | > For example, you can set it to 0.0.0.0 in order to bind it to all network interfaces on your host. This also requires a restart for Anki. 15 | 16 | 2) Download the plugin, unzip it in the `koreader/plugins` directory, make sure the folder is named `anki.koplugin`, this is necessary for KOReader to load it. 17 | 18 | Alternatively, use KOReader's built in SSH server: 19 | 20 | ```sh 21 | git clone "https://github.com/Ajatt-Tools/anki.koplugin.git" ./anki.koplugin 22 | sshfs -p 2222 root@:/mnt/onboard/.adds/koreader 23 | # be careful to not add a trailing / to the source directory, this creates the folder on your device 24 | rsync -Pruv --exclude=".git/" ./anki.koplugin /plugins/ 25 | ``` 26 | 27 | ## Usage 28 | 29 | When the plugin has been installed succesfully, there will be an extra button present on the reader's dictionary popup window, allowing the user to create an Anki note. 30 | 31 | 32 | ![image](https://user-images.githubusercontent.com/34285115/228915515-b6d3eef6-d9e3-4899-9922-db040a29f2b3.png) 33 | 34 | When pressed, the add-on will generate a note for the looked up word. 35 | 36 | ![image](https://github.com/Ajatt-Tools/anki.koplugin/assets/34285115/641bbb46-d23f-488f-9c1a-72c2e9db4125) 37 | 38 | ## Features 39 | The information extracted from the dictionary and book is sent to separate fields on the note. 40 | 41 | Below is a list of all fields, along with the setting that defines which field on your note it ends up on. 42 | These settings are configured via a user profile, see [Profiles](profiles/README.md) for more info. 43 | 44 |
45 | Available fields 46 | 47 | #### Selected word (`word_field`) 48 | The word selected in the book. 49 | #### Sentence context (`context_field`) 50 | The full sentence that the word occured in, extracted from the book. 51 | #### Translated Sentence (`translated_context_field`) 52 | The translation of the sentence context mentioned above. The target language is inherited from the translation settings in KOReader itself. 53 | #### Previous sentence count (`prev_sentence_count`) 54 | Define the amount of sentences which should be prepended to the word you looked up. Set this to `1` to have it complete the sentence the word occured in. 55 | #### Next sentence count (`next_sentence_count`) 56 | Define the amount of sentences which should be appended to the word you looked up. Set this to `1` to have it complete the sentence the word occured in. 57 | 58 | 59 | The exact context stored can be modified by pressing and holding the 'Add to Anki' button, and choosing the 'custom context' entry on the menu that pops up. 60 | 61 | #### Dictionary definition (`def_field`) 62 | The dictionary entry that was selected when pressing the button. 63 | #### Audio (`audio_field`) 64 | The plugin will query Forvo to get audio for the lookupword. The language used is determined by the dictionary's language, or by the book's language as fallback. 65 | #### Metadata (`meta_field`) 66 | Some information about the book: author, title and page number. 67 | 68 | This info is retrieved from the EPUB's metadata, or by parsing the filename with a Lua pattern (`"^%[([^%]]-)%]_(.-)_%[([^%]]-)%]%.[^%.]+"`) 69 | 70 | The pattern expects filenames with the following format: `[Author]_Title_[extra_info].epub`. The extension can be anything. 71 |
72 | 73 | ### Offline usage 74 | Notes are saved locally on the device when the remotely running Anki isn't available. When it becomes available again, the user will be reminded they have unsynced notes. 75 | 76 | This can also be done manually by pressing and holding the 'Add to Anki' button, and choosing the manual sync option. 77 | 78 | ### Extra options 79 | 80 | As mentioned earlier, when pressing and holding the 'Add to Anki' button, a separate menu is shown: 81 | 82 | ![image](https://github.com/Ajatt-Tools/anki.koplugin/assets/34285115/932df377-c9fe-4083-8964-8536780b2920) 83 | 84 | ##### Sync offline notes 85 | This option can be used to send the locally stored notes to Anki. 86 | ##### Custom tags 87 | This allows the user to allows the user to create a card with custom tags. 88 | ##### Custom context 89 | By default, the complete sentence the word occured in is stored on the note. In cases where this is too little or too much context, the user can modify it by pressing this button. This pops up a menu where the exact amount of text can be selected. 90 | #### Undo latest note 91 | It's also possible to undo the creation of the latest card, which can be handy when deciding you want to add some extra context to the note. 92 | #### Change profile 93 | Link the currently opened document with a different user profile. 94 | 95 | ## Configuration 96 | 97 | Configuration is done by defining profiles, See [Profiles](profiles/README.md) for more info. 98 | 99 | ### Edit configuration within KOreader 100 | There is code in place to create a menu, with which some of the fields can be edited on the reader itself. Adding an option in the KOreader's menu isn't possible from within a standalone plugin, but it can be done with a [user patch](https://github.com/koreader/koreader/wiki/User-patches). 101 | 102 |
103 | Show snippet 104 | Save the code snippet below in a file with the name `2-anki-menu-patch.lua`. This file should be stored in `koreader/patches`. 105 | 106 | ```lua 107 | local FileManagerMenuOrder = require("ui/elements/filemanager_menu_order") 108 | local ReaderMenuOrder = require("ui/elements/reader_menu_order") 109 | 110 | table.insert(FileManagerMenuOrder.search, 5, "anki_settings") 111 | table.insert(ReaderMenuOrder.search, 5, "anki_settings") 112 | ``` 113 | 114 |
115 | 116 | 117 | If everything goes right, this should add an extra option to the search menu: 118 | ![2023-03-30_19-57](https://user-images.githubusercontent.com/34285115/228923486-bc6f87ec-f65a-4789-bcb5-e053ba36aa5c.png) 119 | 120 | When editing a profile which is *not* the default one, it's possible to 'unset' a setting (meaning it falls back on whatever is present in the `default.lua` profile). This is done by pressing and holding the setting you would like to reset. 121 | 122 | ## FAQ 123 | 124 |
125 | Plugin can't detect the language of the word 126 | When the user has defined a value for the `audio_field` in the config, the plugin needs to know the language of the word you looked up, so it can look for the correct audio file. 127 | 128 | 129 | It looks for this language in 2 places 130 | - Stardict's `.ifo` file 131 | 132 | Each dictionary installed has its own folder consisting of, among other files, an `.ifo` file with some info about this dictionary, looking something like this: 133 | ``` 134 | StarDict's dict ifo file 135 | version=2.4.2 136 | wordcount=18244 137 | idxfilesize=405703 138 | bookname=Dutch-English dictionary 139 | date=2009.01.30 140 | sametypesequence=x 141 | description=Copyright: Converted by swaj under GNU Public License; Version: 1.1 142 | ``` 143 | In this case (Dutch-English), add the following line: `ifo_lang=nl-en` (just `ifo_lang=nl` would work too). 144 | 145 | This field is parsed by KOReader, and used by this plugin when available. This should already be present for dictionaries downloaded internally. 146 | 147 | - the language of the document 148 | 149 | In some documents, like `.epub` files, it is possible to define the language of the text with it. When this info is available, the plugin will use it. 150 | 151 | KOReader also allows you to edit a document's metadata manually, by opening the top menu > Hamburger menu > Book information > Tap and hold "Language" > Set custom. 152 | 153 | The expected format of this language is, like above, the ISO2 code. For example, to specify French, fill in 'fr' 154 | 155 | If you don't care about having audio, you can leave the `audio_field` blank. This will cause this step to be skipped completely. 156 | 157 |
158 | -------------------------------------------------------------------------------- /_meta.lua: -------------------------------------------------------------------------------- 1 | local _ = require("gettext") 2 | return { 3 | name = "anki", 4 | fullname = _("Anki addon"), 5 | description = _("Adds a button to the dictionary popup window allowing you to make an Anki card for that word"), 6 | } 7 | 8 | -------------------------------------------------------------------------------- /anki_configuration.lua: -------------------------------------------------------------------------------- 1 | local LuaSettings = require("luasettings") 2 | local DataStorage = require("datastorage") 3 | local lfs = require("libs/libkoreader-lfs") 4 | 5 | 6 | --[[ 7 | -- This represents a Setting defined by the user 8 | -- e.g. Deck name, note type, etc. 9 | --]] 10 | local Setting = {} 11 | local Setting_mt = { 12 | __index = function(t, key) return rawget(t, key) or Setting[key] end 13 | } 14 | 15 | -- TODO when you update a setting on the fly it doesn't immediately propagate 16 | function Setting:get_value_nodefault() 17 | return self.profile and self.profile.data[self.id] 18 | end 19 | 20 | function Setting:get_value() 21 | return self:get_value_nodefault() or self.default 22 | end 23 | 24 | function Setting:update_value(new) 25 | self.profile:update(self.id, new) 26 | end 27 | 28 | function Setting:delete() 29 | self.profile:delete(self.id) 30 | end 31 | 32 | function Setting:new(opts) 33 | return setmetatable(opts, Setting_mt) 34 | end 35 | 36 | function Setting:copy(opts) 37 | local new = {} 38 | for k,v in pairs(self) do 39 | new[k] = v 40 | end 41 | for k,v in pairs(opts) do 42 | new[k] = v 43 | end 44 | return setmetatable(new, Setting_mt) 45 | end 46 | 47 | 48 | 49 | --[[ 50 | -- This represents a Profile created by the user, either the default profile 51 | -- or anything in the ./profiles directory. 52 | --]] 53 | local Profile = {} 54 | 55 | function Profile:new(user_profile, full_path, data) 56 | return setmetatable({ 57 | name = user_profile, 58 | path = full_path, 59 | data = data 60 | }, { __index = function(t, v) return rawget(t, v) or Profile[v] end }) 61 | end 62 | 63 | function Profile:init_settings() 64 | if self.settings then return end 65 | self.settings = LuaSettings:open(self.path) 66 | end 67 | 68 | function Profile:update(id, new_value) 69 | self:init_settings() 70 | self.data[id] = new_value 71 | self.settings:saveSetting(id, new_value) 72 | end 73 | 74 | function Profile:delete(id) 75 | self:init_settings() 76 | self.data[id] = nil 77 | self.settings:delSetting(id) 78 | end 79 | 80 | 81 | 82 | --[[ 83 | -- This represents a Configuration, contains settings which can come from different profiles 84 | -- These entries could be coming from the main profile, or from the default fallback profile (if present) 85 | --]] 86 | local Configuration = { 87 | profiles = {}, 88 | active_profile = nil, -- the currently loaded configuration 89 | Setting:new{ id = 'url', required = true }, 90 | Setting:new{ id = 'api_key', required = false }, 91 | Setting:new{ id = 'deckName', required = true }, 92 | Setting:new{ id = 'modelName', required = true }, 93 | Setting:new{ id = 'word_field', required = true }, 94 | Setting:new{ id = 'def_field', required = true }, 95 | Setting:new{ id = 'dupe_scope', default = 'deck' }, 96 | Setting:new{ id = 'allow_dupes', default = false }, 97 | Setting:new{ id = 'custom_tags', default = {} }, 98 | Setting:new{ id = 'enabled_extensions', default = {} }, 99 | Setting:new{ id = 'context_field' }, 100 | Setting:new{ id = 'meta_field' }, 101 | Setting:new{ id = 'audio_field' }, 102 | Setting:new{ id = 'image_field' }, 103 | Setting:new{ id = 'translated_context_field' }, 104 | Setting:new{ id = 'prev_sentence_count', default = '1' }, 105 | Setting:new{ id = 'next_sentence_count', default = '1' }, 106 | } 107 | for _,s in ipairs(Configuration) do 108 | Configuration[s.id] = s 109 | end 110 | 111 | local plugin_directory = DataStorage:getFullDataDir() .. "/plugins/anki.koplugin/" 112 | 113 | function Configuration:load_profile(profile_name) 114 | if self.active_profile == profile_name then return end 115 | local main_profile, default_profile = assert(self.profiles[profile_name], ("Non existing profile %s!"):format(profile_name)), self.profiles['default'] 116 | local missing = {} 117 | for _, opt in ipairs(self) do 118 | if main_profile.data[opt.id] then 119 | opt.profile = main_profile 120 | opt.value = main_profile.data[opt.id] 121 | elseif default_profile and default_profile.data[opt.id] then 122 | opt.profile = default_profile 123 | opt.value = default_profile.data[opt.id] 124 | elseif opt.required then 125 | table.insert(missing, opt.id) 126 | end 127 | end 128 | assert(#missing == 0, ("The following required configuration options are missing:\n - %s"):format(table.concat(missing, "\n - "))) 129 | self.active_profile = profile_name 130 | end 131 | 132 | function Configuration:is_active(profile_name) 133 | return self.active_profile == profile_name 134 | end 135 | 136 | function Configuration:init_profiles() 137 | local function init_profile(user_profile) 138 | if user_profile == "default" then 139 | local default_profiles = { "profiles/default.lua", "config.lua" } 140 | for _, fn in ipairs(default_profiles) do 141 | local full_path = plugin_directory .. fn 142 | local mod = loadfile(full_path) 143 | if mod then 144 | return Profile:new(user_profile, full_path, mod()) 145 | end 146 | end 147 | return 148 | end 149 | 150 | local full_path = plugin_directory .. "profiles/" .. user_profile 151 | local mod, err = loadfile(full_path) 152 | if not mod then 153 | error(("Could not load profile '%s' in %s: %s"):format(user_profile, plugin_directory, err)) 154 | end 155 | return Profile:new(user_profile, full_path, mod()) 156 | end 157 | 158 | self.profiles.default = init_profile('default') 159 | for entry in lfs.dir(plugin_directory .. "/profiles") do 160 | if entry:match(".*%.lua$") then 161 | local profile = entry:gsub(".lua$", "", 1) 162 | self.profiles[profile] = init_profile(entry) 163 | end 164 | end 165 | end 166 | 167 | function Configuration:save() 168 | for _,p in pairs(self.profiles) do 169 | if p.settings then 170 | p.settings:close() 171 | p.settings = nil 172 | end 173 | end 174 | 175 | end 176 | 177 | Configuration:init_profiles() 178 | return Configuration 179 | -------------------------------------------------------------------------------- /ankiconnect.lua: -------------------------------------------------------------------------------- 1 | local http = require("socket.http") 2 | local socket = require("socket") 3 | local socketutil = require("socketutil") 4 | local logger = require("logger") 5 | local json = require("rapidjson") 6 | local ltn12 = require("ltn12") 7 | local util = require("util") 8 | local Font = require("ui/font") 9 | local UIManager = require("ui/uimanager") 10 | local ConfirmBox = require("ui/widget/confirmbox") 11 | local InfoMessage = require("ui/widget/infomessage") 12 | local NetworkMgr = require("ui/network/manager") 13 | local DataStorage = require("datastorage") 14 | local Translator = require("ui/translator") 15 | local forvo = require("forvo") 16 | local u = require("lua_utils/utils") 17 | local conf = require("anki_configuration") 18 | 19 | local AnkiConnect = { 20 | settings_dir = DataStorage:getSettingsDir(), 21 | } 22 | 23 | --[[ 24 | LuaSocket returns somewhat cryptic errors sometimes 25 | - user forgets to add the HTTP prefix -> schemedefs nil 26 | - user uses HTTPS instead of HTTP -> wantread 27 | We can prevent this by modifying/adding the scheme when it's wrong/missing 28 | --]] 29 | function AnkiConnect:get_url() 30 | local url = conf.url:get_value() 31 | if self.last_url == url then 32 | return (assert(self.valid_url, "URL was not validated yet, we should not get here")) 33 | end 34 | local valid_url = url 35 | local _, scheme_end_idx, scheme, ssl = url:find("^(http(s?)://)") 36 | if not scheme then 37 | valid_url = 'http://'..url 38 | elseif ssl then 39 | valid_url = 'http://'..url:sub(scheme_end_idx+1, #url) 40 | end 41 | self.last_url = url 42 | self.valid_url = valid_url 43 | if url ~= valid_url then 44 | logger.info(("Corrected URL from '%s' to '%s'"):format(url, valid_url)) 45 | end 46 | return valid_url 47 | end 48 | 49 | function AnkiConnect:with_timeout(timeout, func) 50 | socketutil:set_timeout(timeout) 51 | local res = { func() } -- store all values returned by function 52 | socketutil:reset_timeout() 53 | return unpack(res) 54 | end 55 | 56 | function AnkiConnect:is_running() 57 | if not self.wifi_connected then 58 | return false, "WiFi disconnected." 59 | end 60 | local result, code, error = self:with_timeout(1, function() return self:post_requestpermission() end) 61 | logger.dbg(string.format("AnkiConnect#is_running = code: %s, error: %s, result: %s", code, error, result)) 62 | return code == 200, string.format("Unable to reach AnkiConnect.\n%s", error or code) 63 | end 64 | 65 | function AnkiConnect:post_requestpermission() 66 | local anki_connect_request = { action = "requestPermission", version = 6 } 67 | local json_payload = json.encode(anki_connect_request) 68 | local output_sink = {} -- contains data returned by request 69 | local request = { 70 | url = self:get_url(), 71 | method = "POST", 72 | headers = { 73 | ["Content-Type"] = "application/json", 74 | ["Content-Length"] = #json_payload, 75 | }, 76 | sink = ltn12.sink.table(output_sink), 77 | source = ltn12.source.string(json_payload), 78 | } 79 | local code, headers, status = socket.skip(1, http.request(request)) 80 | logger.dbg(string.format("AnkiConnect#post_requestpermission: code: %s, header: %s, status: %s\n", code, headers, status)) 81 | local result = table.concat(output_sink) 82 | logger.dbg("AnkiConnect#post_requestpermission: result: ", result) 83 | return result, code, self:get_requestpermission_error(code, result) 84 | end 85 | 86 | function AnkiConnect:get_requestpermission_error(http_return_code, request_data) 87 | if http_return_code ~= 200 then 88 | return string.format("Invalid return code: %s.", http_return_code) 89 | else 90 | local decoded_data = json.decode(request_data) 91 | local json_err = decoded_data.error 92 | if type(json_err) == "string" then 93 | return json_err 94 | end 95 | if decoded_data.result.permission == "denied" then 96 | return "Permission denied." 97 | end 98 | end 99 | end 100 | 101 | function AnkiConnect:post_request(note) 102 | local anki_connect_request = { action = "addNote", params = { note = note }, version = 6, key = conf.api_key:get_value() } 103 | local json_payload = json.encode(anki_connect_request) 104 | logger.dbg("AnkiConnect#post_request: building POST request with payload: ", json_payload) 105 | local output_sink = {} -- contains data returned by request 106 | local request = { 107 | url = self:get_url(), 108 | method = "POST", 109 | headers = { 110 | ["Content-Type"] = "application/json", 111 | ["Content-Length"] = #json_payload, 112 | }, 113 | sink = ltn12.sink.table(output_sink), 114 | source = ltn12.source.string(json_payload), 115 | } 116 | local code, headers, status = socket.skip(1, http.request(request)) 117 | logger.info(string.format("AnkiConnect#post_request: code: %s, header: %s, status: %s\n", code, headers, status)) 118 | local result = table.concat(output_sink) 119 | return result, self:get_request_error(code, result) 120 | end 121 | 122 | function AnkiConnect:get_request_error(http_return_code, request_data) 123 | if http_return_code ~= 200 then 124 | return string.format("Invalid return code: %s.", http_return_code) 125 | else 126 | local json_err = json.decode(request_data)['error'] 127 | -- this turns a json NULL in a userdata instance, actual error will be a string 128 | if type(json_err) == "string" then 129 | return json_err 130 | end 131 | end 132 | end 133 | 134 | function AnkiConnect:set_translated_context(_, context) 135 | local result = Translator:translate(context, Translator:getTargetLanguage(), Translator:getSourceLanguage()) 136 | logger.info(("Queried translation: '%s' -> '%s'"):format(context, result)) 137 | return true, result 138 | end 139 | 140 | function AnkiConnect:set_forvo_audio(field, word, language) 141 | logger.info(("Querying Forvo audio for '%s' in language: %s"):format(word, language)) 142 | local ok, forvo_url = forvo.get_pronunciation_url(word, language) 143 | if not ok then 144 | if forvo_url == "FORVO_403" then 145 | -- For 403 errors, return true but no audio data 146 | logger.warn("Forvo returned 403 error - continuing without audio") 147 | return true, nil 148 | end 149 | return false, ("Could not connect to forvo: %s"):format(forvo_url) 150 | end 151 | return true, forvo_url and { 152 | url = forvo_url, 153 | filename = string.format("forvo_%s.ogg", word), 154 | fields = { field } 155 | } or nil 156 | end 157 | 158 | function AnkiConnect:set_image_data(field, img_path) 159 | if not img_path then 160 | return true 161 | end 162 | local _,filename = util.splitFilePathName(img_path) 163 | local img_f = io.open(img_path, 'rb') 164 | if not img_f then 165 | return true 166 | end 167 | local data = forvo.base64e(img_f:read("*a")) 168 | logger.info(("added %d bytes of base64 encoded data"):format(#data)) 169 | os.remove(img_path) 170 | return true, { 171 | data = data, 172 | filename = filename, 173 | fields = { field } 174 | } 175 | end 176 | 177 | function AnkiConnect:handle_callbacks(note, on_err_func) 178 | local field_callbacks = note.field_callbacks 179 | for param, mod in pairs(field_callbacks) do 180 | if mod.field_name then 181 | local _, ok, result_or_err = pcall(self[mod.func], self, mod.field_name, unpack(mod.args)) 182 | if not ok then 183 | return on_err_func(result_or_err) 184 | end 185 | if param == "fields" then 186 | note.data.fields[mod.field_name] = result_or_err 187 | else 188 | assert(note.data[param] == nil, ("unexpected result: note property '%s' was already present!"):format(param)) 189 | note.data[param] = result_or_err 190 | end 191 | field_callbacks[param] = nil 192 | end 193 | end 194 | return true 195 | end 196 | 197 | function AnkiConnect:sync_offline_notes() 198 | if NetworkMgr:willRerunWhenOnline(function() self:sync_offline_notes() end) then 199 | return 200 | end 201 | 202 | local can_sync, err = self:is_running() 203 | if not can_sync then 204 | return self:show_popup(string.format("Synchronizing failed!\n%s", err), 3, true) 205 | end 206 | 207 | local synced, failed, errs = {}, {}, u.defaultdict(0) 208 | for _,note in ipairs(self.local_notes) do 209 | local sync_ok = self:handle_callbacks(note, function(callback_err) 210 | errs[callback_err] = errs[callback_err] + 1 211 | end) 212 | if sync_ok then 213 | local _, request_err = self:post_request(note.data) 214 | if request_err then 215 | sync_ok = false 216 | errs[request_err] = errs[request_err] + 1 217 | end 218 | end 219 | table.insert(sync_ok and synced or failed, note) 220 | end 221 | self.local_notes = failed 222 | local failed_as_json = {} 223 | for _,note in ipairs(failed) do 224 | table.insert(failed_as_json, json.encode(note)) 225 | end 226 | -- called even when there's no failed notes, this way it also gets rid of the notes which we managed to sync, no need to keep those around 227 | u.open_file(self.notes_filename, 'w', function(f) 228 | f:write(table.concat(failed_as_json, '\n')) 229 | if #failed_as_json > 0 then f:write('\n') end 230 | end) 231 | local sync_message_parts = {} 232 | if #synced > 0 then 233 | -- if any notes were synced succesfully, reset the latest added note (since it's not actually latest anymore) 234 | -- no point in saving the actual latest synced note, since the user won't know which note that was anyway 235 | self.latest_synced_note = nil 236 | table.insert(sync_message_parts, ("Finished synchronizing %d note(s)."):format(#synced)) 237 | end 238 | if #failed > 0 then 239 | table.insert(sync_message_parts, ("%d note(s) failed to sync:"):format(#failed)) 240 | for error_msg, count in pairs(errs) do 241 | table.insert(sync_message_parts, (" - %s (%d)"):format(error_msg, count)) 242 | end 243 | return UIManager:show(ConfirmBox:new { 244 | text = table.concat(sync_message_parts, "\n"), 245 | icon = "notice-warning", 246 | font = Font:getFace("smallinfofont", 9), 247 | ok_text = "Discard failures", 248 | cancel_text = "Keep", 249 | ok_callback = function() 250 | os.remove(self.notes_filename) 251 | self.local_notes = {} 252 | end 253 | }) 254 | end 255 | self:show_popup(table.concat(sync_message_parts, " "), 3, true) 256 | end 257 | 258 | function AnkiConnect:show_popup(text, timeout, show_always) 259 | -- don't reinform the user for something we already showed them 260 | if not (show_always or false) and self.last_message_text == text then 261 | return 262 | end 263 | logger.info(("Displaying popup with message: '%s'"):format(text)) 264 | self.last_message_text = text 265 | UIManager:show(InfoMessage:new { text = text, timeout = timeout }) 266 | end 267 | 268 | function AnkiConnect:delete_latest_note() 269 | local latest = self.latest_synced_note 270 | if not latest then 271 | return 272 | end 273 | if latest.state == "online" then 274 | local can_sync, err = self:is_running() 275 | if not can_sync then 276 | return self:show_popup(("Could not delete synced note: %s"):format(err), 3, true) 277 | end 278 | -- don't use rapidjson, the anki note ids are 64bit integers, they are turned into different numbers by the json library 279 | -- presumably because 32 vs 64 bit architecture 280 | local delete_request = ([[{"action": "deleteNotes", "version": 6, "params": {"notes": [%d]} }]]):format(latest.id) 281 | local _, err = self:post_request(delete_request) 282 | if err then 283 | return self:show_popup(("Couldn't delete note: %s!"):format(err), 3, true) 284 | end 285 | self:show_popup(("Removed note (id: %s)"):format(latest.id), 3, true) 286 | else 287 | table.remove(self.local_notes, #self.local_notes) 288 | self.local_notes[latest.id] = nil 289 | local entries_on_disk = {} 290 | u.open_file(self.notes_filename, 'r', function(f) 291 | for line in f:lines() do 292 | table.insert(entries_on_disk, line) 293 | end 294 | end) 295 | table.remove(entries_on_disk) 296 | u.open_file(self.notes_filename, 'w', function(f) 297 | f:write(table.concat(entries_on_disk, '\n')) 298 | if #entries_on_disk > 0 then 299 | f:write('\n') 300 | end 301 | end) 302 | self:show_popup(("Removed note (word: %s)"):format(latest.id), 3, true) 303 | end 304 | self.latest_synced_note = nil 305 | end 306 | 307 | function AnkiConnect:add_note(anki_note) 308 | local ok, note = pcall(anki_note.build, anki_note) 309 | if not ok then 310 | return self:show_popup(string.format("Error while creating note:\n\n%s", note), 10, true) 311 | end 312 | 313 | local can_sync, err = self:is_running() 314 | if not can_sync then 315 | return self:store_offline(note, err) 316 | end 317 | 318 | if #self.local_notes > 0 then 319 | UIManager:show(ConfirmBox:new { 320 | text = "There are offline notes which can be synced!", 321 | ok_text = "Synchronize", 322 | cancel_text = "Cancel", 323 | ok_callback = function() 324 | self:sync_offline_notes() 325 | end 326 | }) 327 | end 328 | local callback_ok = self:handle_callbacks(note, function(callback_err) 329 | return self:show_popup(string.format("Error while handling callbacks:\n\n%s", callback_err), 3, true) 330 | end) 331 | if not callback_ok then return end 332 | 333 | local result, request_err = self:post_request(note.data) 334 | if request_err then 335 | return self:show_popup(string.format("Error while synchronizing note:\n\n%s", request_err), 3, true) 336 | end 337 | self.latest_synced_note = { state = "online", id = json.decode(result).result } 338 | self.last_message_text = "" -- if we manage to sync once, a following error should be shown again 339 | logger.info("note added succesfully: " .. result) 340 | end 341 | 342 | function AnkiConnect:store_offline(note, reason, show_always) 343 | local id = note.data.fields[note.identifier] 344 | if self.local_notes[id] and not note.data.options.allowDuplicate then 345 | return self:show_popup("Cannot store duplicate note offline!", 6, true) 346 | end 347 | self.local_notes[id] = true 348 | table.insert(self.local_notes, note) 349 | u.open_file(self.notes_filename, 'a', function(f) f:write(json.encode(note) .. '\n') end) 350 | self.latest_synced_note = { state = "offline", id = id } 351 | return self:show_popup(string.format("%s\nStored note offline", reason), 3, show_always or false) 352 | end 353 | 354 | function AnkiConnect:load_notes() 355 | u.open_file(self.notes_filename, 'r', function(f) 356 | for note_json in f:lines() do 357 | local note, err = json.decode(note_json) 358 | assert(note, ("Could not parse note '%s': %s"):format(note_json, err)) 359 | table.insert(self.local_notes, note) 360 | if note.identifier then 361 | self.local_notes[note.data.fields[note.identifier]] = true 362 | end 363 | end 364 | end) 365 | logger.dbg(string.format("Loaded %d notes from disk.", #self.local_notes)) 366 | end 367 | 368 | -- [[ 369 | -- required args: 370 | -- * url: to connect to remote AnkiConnect session 371 | -- * ui: necessary to get context of word in AnkiNote 372 | -- ]] 373 | function AnkiConnect:new(opts) 374 | -- NetworkMgr func is device dependent, assume it's true when not implemented. 375 | self.wifi_connected = NetworkMgr.isWifiOn and NetworkMgr:isWifiOn() or true 376 | -- contains notes which we could not sync yet 377 | self.local_notes = {} 378 | -- path of notes stored locally when WiFi isn't available 379 | self.notes_filename = self.settings_dir .. "/anki.koplugin_notes.json" 380 | return setmetatable({} , { __index = self }) 381 | end 382 | 383 | return AnkiConnect 384 | -------------------------------------------------------------------------------- /ankinote.lua: -------------------------------------------------------------------------------- 1 | local logger = require("logger") 2 | local util = require("util") 3 | local u = require("lua_utils/utils") 4 | local conf = require("anki_configuration") 5 | 6 | local LANG_NOT_SET_ERROR = "Neither the dictionary, nor the document have its language set. See the FAQ section in the plugin's README." 7 | local AnkiNote = { 8 | } 9 | 10 | --[[ 11 | -- Determine trimmed word context for consecutive lookups. 12 | -- When a user updates the text in a dictionary popup window and thus gets a new popup 13 | -- the word selected in the book won't reflect the word in the dictionary. 14 | -- We want to know if last dict lookup is contained in first dict lookup. 15 | -- e.g.: '広大な' -> trimmed to '広大' -> context is '' (before), 'な' (after) 16 | --]] 17 | function AnkiNote:set_word_trim() 18 | local list = self.popup_dict.window_list 19 | if #list == 1 then 20 | return 21 | end 22 | local orig, last = list[1].word, list[#list].word 23 | logger.dbg(("first popup dict: %s, last dict : %s"):format(orig, last)) 24 | local s_idx, e_idx = orig:find(last, 1, true) 25 | if not s_idx then 26 | self.contextual_lookup = false 27 | else 28 | self.word_trim = { before = orig:sub(1, s_idx-1), after = orig:sub(e_idx+1, #orig) } 29 | end 30 | end 31 | 32 | 33 | function AnkiNote:convert_to_HTML(opts) 34 | local wrapper_template = opts.wrapper_template or "
    %s
" 35 | local entry_template = opts.entry_template or "
  • %s
  • " 36 | local list_items = {} 37 | for _,entry in ipairs(opts.entries) do 38 | table.insert(list_items, opts.build(entry, entry_template)) 39 | end 40 | return wrapper_template:format(opts.class, table.concat(list_items)) 41 | end 42 | 43 | -- [[ 44 | -- Create metadata string about the document the word came from. 45 | -- ]] 46 | function AnkiNote:get_metadata() 47 | local meta = self.ui.document._anki_metadata 48 | return string.format("%s - %s (%d/%d)", meta.author, meta.title, meta:current_page(), meta.pages()) 49 | end 50 | 51 | function AnkiNote:get_word_context() 52 | if not self.contextual_lookup then 53 | return self.popup_dict.word 54 | end 55 | local provider = self.ui.document.provider 56 | if self.ui.document.getSelectedWordContext then 57 | local before, after = self:get_custom_context(unpack(self.context)) 58 | return before .. "" .. self.popup_dict.word .. "" .. after 59 | elseif provider == "mupdf" then -- CBZ 60 | local ocr_text = self.ui['Mokuro'] and self.ui['Mokuro']:get_selection() 61 | logger.info("selected text: ", ocr_text) 62 | -- TODO is trim relevant here? 63 | return ocr_text or self.popup_dict.word 64 | end 65 | end 66 | 67 | --[[ 68 | -- Returns the context before and after the lookup word, the amount of context depends on the following parameters 69 | -- @param pre_s: amount of sentences prepended 70 | -- @param pre_c: amount of characters prepended 71 | -- @param post_s: amount of sentences appended 72 | -- @param post_c: amount of characters appended 73 | --]] 74 | function AnkiNote:get_custom_context(pre_s, pre_c, post_s, post_c) 75 | logger.info("AnkiNote#get_custom_context()", pre_s, pre_c, post_s, post_c) 76 | -- called when initial size `self.context_size` becomes too small. 77 | local function expand_content() 78 | self.context_size = self.context_size + self.context_size 79 | self:init_context_buffer(self.context_size) 80 | end 81 | 82 | -- apparently the mupdf provider does not add the trailing/leading spaces, so we have to do it ourselves 83 | local function add_spacing(context, idx) 84 | local context_table = { context } 85 | if self.ui.document.provider == 'mupdf' and #context > 0 then 86 | table.insert(context_table, idx or #context_table + 1, ' ') 87 | end 88 | return table.concat(context_table, "") 89 | end 90 | 91 | local delims_map = u.to_set(util.splitToChars("?」。.?!!")) 92 | -- calculate the slice of the `prev_context_table` array that should be prepended to the lookupword 93 | local prev_idx, prev_s_idx = 0, 0 94 | while prev_s_idx < pre_s do 95 | if #self.prev_context_table <= prev_idx then expand_content() end 96 | -- if we're still out of bounds after expanding content we're at the beginning of the doc 97 | if #self.prev_context_table <= prev_idx then break end 98 | local idx = #self.prev_context_table - prev_idx 99 | local ch = self.prev_context_table[idx] 100 | assert(ch ~= nil, ("Something went wrong when parsing previous context! idx: %d, context_table size: %d"):format(idx, #self.prev_context_table)) 101 | if delims_map[ch] then 102 | prev_s_idx = prev_s_idx + 1 103 | end 104 | prev_idx = prev_idx + 1 105 | end 106 | if prev_idx > 0 then 107 | -- do not include the trailing character (if we parsed any sentences above) 108 | prev_idx = prev_idx - 1 109 | end 110 | prev_idx = prev_idx + pre_c 111 | if #self.prev_context_table <= prev_idx then expand_content() end 112 | local i, j = #self.prev_context_table - prev_idx + 1, #self.prev_context_table 113 | local prepended_content = add_spacing(table.concat(self.prev_context_table, "", i, j)) 114 | 115 | -- calculate the slice of the `next_context_table` array that should be appended to the lookupword 116 | -- `next_idx` starts at 1 because that's the first index in the table 117 | local next_idx, next_s_idx = 1, 0 118 | while next_s_idx < post_s do 119 | if next_idx > #self.next_context_table then expand_content() end 120 | -- if we're still out of bounds after expanding content we're at the end of the doc 121 | if next_idx > #self.next_context_table then break end 122 | local ch = self.next_context_table[next_idx] 123 | assert(ch ~= nil, ("Something went wrong when parsing next context! idx: %d, context_table size: %d"):format(next_idx, #self.next_context_table)) 124 | if delims_map[ch] then 125 | next_s_idx = next_s_idx + 1 126 | end 127 | next_idx = next_idx + 1 128 | end 129 | -- do not include the trailing character 130 | next_idx = next_idx - 1 131 | next_idx = next_idx + post_c 132 | if next_idx > #self.next_context_table then expand_content() end 133 | local appended_content = add_spacing(table.concat(self.next_context_table, "", 1, next_idx), 1) 134 | -- These 2 variables can be used to detect if any content was prepended / appended 135 | self.has_prepended_content = prev_idx > 0 136 | self.has_appended_content = next_idx > 0 137 | return prepended_content, appended_content 138 | end 139 | 140 | function AnkiNote:get_picture_context() 141 | local meta = self.ui.document._anki_metadata 142 | if not meta then 143 | return 144 | end 145 | local provider, plugin = self.ui.document.provider, self.ui['Mokuro'] 146 | -- we only add pictures for CBZ (handled by ocr_popup widget) 147 | if provider == "mupdf" and plugin then 148 | local fn = string.format("%s/%s_%s.jpg", self.settings_dir, meta.title, os.date("%Y-%m-%d %H-%M-%S")) 149 | return plugin:get_context_picture(fn) and fn or nil 150 | end 151 | end 152 | 153 | function AnkiNote:run_extensions(note) 154 | for _, extension in ipairs(self.extensions) do 155 | note = extension:run(note) 156 | end 157 | return note 158 | end 159 | 160 | function AnkiNote:get_definition() 161 | return self:convert_to_HTML { 162 | entries = { self.popup_dict.results[self.popup_dict.dict_index] }, 163 | class = "definition", 164 | build = function(entry, entry_template) 165 | local def = entry.definition 166 | if entry.is_html then -- try adding dict name to opening div tag (if present) 167 | -- gsub wrapped in () so it only gives us the first result, and discards the index (2nd arg.) 168 | return (def:gsub("("))) 171 | end 172 | } 173 | end 174 | 175 | function AnkiNote:build() 176 | local fields = { 177 | [conf.word_field:get_value()] = self.popup_dict.word, 178 | [conf.def_field:get_value()] = self:get_definition() 179 | } 180 | local optional_fields = { 181 | [conf.context_field] = function() return self:get_word_context() end, 182 | [conf.meta_field] = function() return self:get_metadata() end, 183 | } 184 | for opt,fn in pairs(optional_fields) do 185 | local field_name = opt:get_value() 186 | if field_name then 187 | fields[field_name] = fn() 188 | end 189 | end 190 | local note = { 191 | deckName = conf.deckName:get_value(), 192 | modelName = conf.modelName:get_value(), 193 | fields = fields, 194 | options = { 195 | allowDuplicate = conf.allow_dupes:get_value(), 196 | duplicateScope = conf.dupe_scope:get_value(), 197 | }, 198 | tags = self.tags, 199 | } 200 | return { 201 | -- actual table passed to anki-connect later 202 | data = self:run_extensions(note), 203 | -- some fields require an internet connection, which we may not have at this point 204 | -- all info needed to populate them is stored as a callback, which is called when a connection is available 205 | field_callbacks = { 206 | audio = { 207 | func = "set_forvo_audio", 208 | field_name = conf.audio_field:get_value(), 209 | args = { self.popup_dict.word, self:get_language() } 210 | }, 211 | picture = { 212 | func = "set_image_data", 213 | field_name = conf.image_field:get_value(), 214 | args = { self:get_picture_context() } 215 | }, 216 | fields = { 217 | func = "set_translated_context", 218 | field_name = conf.translated_context_field:get_value(), 219 | args = { fields[conf.context_field:get_value()] or self:get_word_context(), self:get_language() } 220 | }, 221 | }, 222 | -- used as id to detect duplicates when storing notes offline 223 | identifier = conf.word_field:get_value() 224 | } 225 | end 226 | 227 | function AnkiNote:get_language() 228 | local ifo_lang = self.selected_dict.ifo_lang 229 | local language = ifo_lang and ifo_lang.lang_in or rawget(self.ui.document._anki_metadata, 'language') 230 | if not language then 231 | local selected_dict_name = self.popup_dict.results[self.popup_dict.dict_index].dict 232 | local document_title = rawget(self.ui.document._anki_metadata, "title") 233 | error(LANG_NOT_SET_ERROR:format(self.popup_dict.word, selected_dict_name, document_title), 0) 234 | end 235 | return language 236 | end 237 | 238 | function AnkiNote:init_context_buffer(size) 239 | logger.info(("(re)initializing context buffer with size: %d"):format(size)) 240 | if self.prev_context_table and self.next_context_table then 241 | logger.info(("before reinit: prev table = %d, next table = %d"):format(#self.prev_context_table, #self.next_context_table)) 242 | end 243 | local skipped_chars = u.to_set(util.splitToChars(("\n\r"))) 244 | local prev_c, next_c = self.ui.highlight:getSelectedWordContext(size) 245 | -- pass trimmed word context along to be modified 246 | prev_c = prev_c .. self.word_trim.before 247 | next_c = self.word_trim.after .. next_c 248 | self.prev_context_table = {} 249 | for _, ch in ipairs(util.splitToChars(prev_c)) do 250 | if not skipped_chars[ch] then table.insert(self.prev_context_table, ch) end 251 | end 252 | self.next_context_table = {} 253 | for _, ch in ipairs(util.splitToChars(next_c)) do 254 | if not skipped_chars[ch] then table.insert(self.next_context_table, ch) end 255 | end 256 | logger.info(("after reinit: prev table = %d, next table = %d"):format(#self.prev_context_table, #self.next_context_table)) 257 | end 258 | 259 | function AnkiNote:set_custom_context(pre_s, pre_c, post_s, post_c) 260 | self.context = { pre_s, pre_c, post_s, post_c } 261 | end 262 | 263 | function AnkiNote:add_tags(tags) 264 | for _,t in ipairs(tags) do 265 | table.insert(self.tags, t) 266 | end 267 | end 268 | 269 | -- each user extension gets access to the AnkiNote table as well 270 | function AnkiNote:load_extensions() 271 | self.extensions = {} 272 | local extension_set = u.to_set(conf.enabled_extensions:get_value()) 273 | for _, ext_filename in ipairs(self.ext_modules) do 274 | if extension_set[ext_filename] then 275 | local module = self.ext_modules[ext_filename] 276 | table.insert(self.extensions, setmetatable(module, { __index = function(t, v) return rawget(t, v) or self[v] end })) 277 | end 278 | end 279 | end 280 | 281 | -- This function should be called before using the 'class' at all 282 | function AnkiNote:extend(opts) 283 | -- dict containing various settings about the current state 284 | self.ui = opts.ui 285 | -- used to save screenshots in (CBZ only) 286 | self.settings_dir = opts.settings_dir 287 | -- used to store extension functions to run 288 | self.ext_modules = opts.ext_modules 289 | return self 290 | end 291 | 292 | function AnkiNote:new(popup_dict) 293 | local new = { 294 | context_size = 50, 295 | popup_dict = popup_dict, 296 | selected_dict = popup_dict.results[popup_dict.dict_index], 297 | -- indicates that popup_dict relates to word in book 298 | -- this can still be set to false later when the user looks up a word in a book, but then modifies the looked up word 299 | contextual_lookup = self.ui.highlight.selected_text ~= nil, 300 | word_trim = { before = "", after = "" }, 301 | tags = { "KOReader" }, 302 | } 303 | local new_mt = {} 304 | function new_mt.__index(t, v) 305 | return rawget(t, v) or self[v] 306 | end 307 | 308 | local note = setmetatable(new, new_mt) 309 | note:set_word_trim() 310 | note:load_extensions() 311 | -- TODO this can be delayed 312 | if note.contextual_lookup then 313 | note:init_context_buffer(note.context_size) 314 | note:set_custom_context(tonumber(conf.prev_sentence_count:get_value()), 0, tonumber(conf.next_sentence_count:get_value()), 0) 315 | end 316 | return note 317 | end 318 | 319 | return AnkiNote 320 | -------------------------------------------------------------------------------- /customcontextmenu.lua: -------------------------------------------------------------------------------- 1 | local Blitbuffer = require("ffi/blitbuffer") 2 | local Button = require("ui/widget/button") 3 | local Device = require("device") 4 | local FrameContainer = require("ui/widget/container/framecontainer") 5 | local FocusManager = require("ui/widget/focusmanager") 6 | local Geom = require("ui/geometry") 7 | local GestureRange = require("ui/gesturerange") 8 | local HorizontalGroup = require("ui/widget/horizontalgroup") 9 | local MovableContainer = require("ui/widget/container/movablecontainer") 10 | local ScrollHtmlWidget = require("ui/widget/scrollhtmlwidget") 11 | local Size = require("ui/size") 12 | local UIManager = require("ui/uimanager") 13 | local VerticalGroup = require("ui/widget/verticalgroup") 14 | local VerticalSpan = require("ui/widget/verticalspan") 15 | local WidgetContainer = require("ui/widget/container/widgetcontainer") 16 | local Screen = Device.screen 17 | local config = require("anki_configuration") 18 | 19 | local CustomContextMenu = FocusManager:extend{} 20 | 21 | local function make_button(text, width, cb, enabled_func) 22 | enabled_func = enabled_func or function() return true end 23 | return Button:new{ 24 | text = text, 25 | radius = 0, 26 | margin = 2, 27 | enabled_func = enabled_func, 28 | width = width, 29 | show_parent = CustomContextMenu, 30 | callback = cb, 31 | } 32 | end 33 | 34 | function CustomContextMenu:init() 35 | self:reset() -- first call is just for initializing 36 | self.font_size = 19 37 | local screen_width = Screen:getWidth() 38 | local screen_height = Screen:getHeight() 39 | 40 | if Device:hasKeys() then 41 | self.key_events.Close = { { Device.input.group.Back } } 42 | end 43 | if Device:isTouchDevice() then 44 | self.ges_events.Tap = { 45 | GestureRange:new{ 46 | ges = "tap", 47 | range = Geom:new{ 48 | x = 0, y = 0, 49 | w = screen_width, 50 | h = screen_height, 51 | } 52 | }, 53 | } 54 | end 55 | 56 | local row_span = VerticalSpan:new{ width = Size.padding.fullscreen } 57 | local frame_width = math.floor(math.min(screen_width, screen_height) * 0.85) 58 | local frame_height = math.floor(math.min(screen_width, screen_height) * 0.50) 59 | local frame_border_size = Size.border.window 60 | local frame_padding = Size.padding.fullscreen 61 | local inner_width = frame_width - 2 * (frame_border_size + frame_padding) 62 | local inner_height = frame_height - 2 * (frame_border_size + frame_padding) 63 | 64 | self.scroll_widget = ScrollHtmlWidget:new{ 65 | default_font_size = Screen:scaleBySize(self.font_size), 66 | width = inner_width, 67 | height = inner_height, 68 | dialog = self, 69 | } 70 | local button_span_unit_width = Size.span.horizontal_small 71 | local larger_span_units = 3 -- 3 x small span width 72 | local nb_span_units = 2 + 2*larger_span_units 73 | local btn_width = math.floor( ((inner_width+frame_padding) - nb_span_units * button_span_unit_width) * (1/6)) 74 | 75 | -- create some helper functions 76 | local update = function(opts) 77 | self.prev_c_cnt = opts.prev_c or self.prev_c_cnt 78 | self.prev_s_cnt = opts.prev_s or self.prev_s_cnt 79 | self.next_c_cnt = opts.next_c or self.next_c_cnt 80 | self.next_s_cnt = opts.next_s or self.next_s_cnt 81 | self:update_context() 82 | end 83 | local can_prepend = function() return self.note.has_prepended_content end 84 | local can_append = function() return self.note.has_appended_content end 85 | local prev_c_inc = function(inc) update({ prev_c = self.prev_c_cnt + inc}) end 86 | local next_c_inc = function(inc) update({ next_c = self.next_c_cnt + inc}) end 87 | -- char counter is reset to 0 when sentence count is changed 88 | local prev_s_inc = function(inc) update({ prev_c = 0, prev_s = self.prev_s_cnt + inc}) end 89 | local next_s_inc = function(inc) update({ next_c = 0, next_s = self.next_s_cnt + inc}) end 90 | 91 | local remove_prev_sentence = make_button("⏩", btn_width, function() prev_s_inc(-1) end, can_prepend) 92 | local remove_prev_char = make_button("1-", btn_width, function() prev_c_inc(-1) end, can_prepend) 93 | local append_prev_char = make_button("+1", btn_width, function() prev_c_inc(1) end) 94 | local append_prev_sentence = make_button("⏪", btn_width, function() prev_s_inc(1) end) 95 | local reset_prev = make_button("Reset", btn_width*2, function() self:reset_prev(); return self:update_context() end) 96 | 97 | local remove_next_sentence = make_button("⏪", btn_width, function() next_s_inc(-1) end, can_append) 98 | local remove_next_char = make_button("-1", btn_width, function() next_c_inc(-1) end, can_append) 99 | local append_next_char = make_button("1+", btn_width, function() next_c_inc(1) end) 100 | local append_next_sentence = make_button("⏩", btn_width, function() next_s_inc(1) end) 101 | local reset_next = make_button("Reset", btn_width*2, function() self:reset_next(); self:update_context() end) 102 | 103 | self.top_row = HorizontalGroup:new{ 104 | align = "center", 105 | append_prev_sentence, 106 | append_prev_char, 107 | reset_prev, 108 | remove_prev_char, 109 | remove_prev_sentence, 110 | } 111 | 112 | self.bottom_row = HorizontalGroup:new{ 113 | align = "center", 114 | remove_next_sentence, 115 | remove_next_char, 116 | reset_next, 117 | append_next_char, 118 | append_next_sentence, 119 | } 120 | 121 | self.confirm_row = HorizontalGroup:new{ 122 | align = "center", 123 | make_button("Cancel", btn_width*2, function() self:onClose() end), 124 | make_button("Save with custom context", btn_width*4, self.on_save_cb), 125 | } 126 | 127 | self.context_menu = FrameContainer:new{ 128 | margin = 0, 129 | bordersize = frame_border_size, 130 | padding = Size.padding.default, 131 | radius = Size.radius.window, 132 | background = Blitbuffer.COLOR_WHITE, 133 | VerticalGroup:new{ 134 | align = "center", 135 | self.top_row, 136 | self.scroll_widget, 137 | row_span, 138 | self.bottom_row, 139 | self.confirm_row, 140 | } 141 | } 142 | self.movable = MovableContainer:new{ 143 | self.context_menu, 144 | } 145 | self[1] = WidgetContainer:new{ 146 | align = "center", 147 | dimen = Geom:new{ 148 | x = 0, y = 0, 149 | w = screen_width, 150 | h = screen_height, 151 | }, 152 | self.movable, 153 | self.confirm_row, 154 | } 155 | self:update_context() 156 | end 157 | 158 | function CustomContextMenu:onClose() 159 | UIManager:close(self) 160 | end 161 | 162 | -- used to assure we do a repaint when the menu is closed 163 | function CustomContextMenu:onCloseWidget() 164 | UIManager:setDirty(nil, function() 165 | return "flashui", self.context_menu.dimen 166 | end) 167 | end 168 | 169 | -- used to repaint widget when the text is changed 170 | function CustomContextMenu:onShow() 171 | UIManager:setDirty(self, function() 172 | return "ui", self.movable.dimen 173 | end) 174 | end 175 | 176 | function CustomContextMenu:onTap(_, ges_ev) 177 | if not ges_ev.pos:intersectWith(self.context_menu.dimen) then 178 | self:onClose() 179 | end 180 | end 181 | 182 | function CustomContextMenu:reset() 183 | self:reset_prev() 184 | self:reset_next() 185 | end 186 | 187 | function CustomContextMenu:reset_prev() 188 | self.prev_s_cnt = tonumber(config.prev_sentence_count:get_value()) 189 | self.prev_c_cnt = 0 190 | end 191 | 192 | function CustomContextMenu:reset_next() 193 | self.next_s_cnt = tonumber(config.next_sentence_count:get_value()) 194 | self.next_c_cnt = 0 195 | end 196 | 197 | function CustomContextMenu:update_context() 198 | local prev, next_ = self.note:get_custom_context(self.prev_s_cnt, self.prev_c_cnt, self.next_s_cnt, self.next_c_cnt) 199 | local css = [[ 200 | h2 { 201 | display: inline; 202 | } 203 | .lookupword { 204 | display: inline; 205 | font-weight: bold; 206 | background-color: red; 207 | text-align: center; 208 | } 209 | @page { 210 | margin: 0; 211 | font-family: 'Noto Sans CJK'; 212 | } 213 | ]] 214 | local context_fmt = '

    %s

    %s

    %s

    ' 215 | local context = context_fmt:format(prev, self.note.popup_dict.word, next_) 216 | 217 | self[1]:free() 218 | self.scroll_widget.htmlbox_widget:setContent(context, css, Screen:scaleBySize(self.font_size)) 219 | self.scroll_widget:resetScroll() 220 | self:onShow() 221 | end 222 | 223 | return CustomContextMenu 224 | -------------------------------------------------------------------------------- /extensions/EXT_dict_edit.lua: -------------------------------------------------------------------------------- 1 | local conf = require("anki_configuration") 2 | local DictEdit = { 3 | description = "This extension can be used to replace certain patterns in specific dictionaries.", 4 | enabled_dictionaries = { 5 | ["新明解国語辞典 第五版"] = true, 6 | ["スーパー大辞林 3.0"] = true, 7 | }, 8 | patterns = { 9 | '%[[0-9]%]', 10 | '%[[0-9]%]:%[0-9%]' 11 | } 12 | } 13 | 14 | function DictEdit:run(note) 15 | local selected_dict = self.popup_dict.results[self.popup_dict.dict_index].dict 16 | if not self.enabled_dictionaries[selected_dict] then 17 | return note 18 | end 19 | local def = note.fields[conf.def_field:get_value()] 20 | for _,pattern in ipairs(self.patterns) do 21 | def = def:gsub(pattern, '') 22 | end 23 | note.fields[conf.def_field:get_value()] = def 24 | return note 25 | end 26 | 27 | return DictEdit 28 | -------------------------------------------------------------------------------- /extensions/EXT_dict_word_lookup.lua: -------------------------------------------------------------------------------- 1 | local conf = require("anki_configuration") 2 | local CustomWordLookup = { 3 | description = "This plugin modifies the default addon behavior. Instead of saving the word selected in the book, it selects the headword in the dictionary entry itself." 4 | } 5 | 6 | function CustomWordLookup:run(note) 7 | if not self.popup_dict.is_extended then 8 | self.popup_dict.results = require("langsupport/ja/dictwrapper").extend_dictionaries(self.popup_dict.results, self.conf) 9 | self.popup_dict.is_extended = true 10 | end 11 | local selected = self.popup_dict.results[self.popup_dict.dict_index] 12 | 13 | -- TODO pick the kanji representation which matches the one we looked up 14 | local parsed_word = selected:get_kanji_words()[1] or selected:get_kana_words()[1] 15 | if parsed_word then 16 | note.fields[conf.word_field:get_value()] = parsed_word 17 | end 18 | return note 19 | end 20 | 21 | return CustomWordLookup 22 | -------------------------------------------------------------------------------- /extensions/EXT_multi_def.lua: -------------------------------------------------------------------------------- 1 | local logger = require("logger") 2 | local u = require("lua_utils/utils") 3 | 4 | local MultiDefinition = { 5 | description = "When trying to make the monolingual transition, it can be helpful to create a card with the language in your target language, while still also inserting the definition in your native language in a separate field.", 6 | -- key: dictionary name as displayed in KOreader (received from dictionary's .ifo file) 7 | -- value: field on the note this dictionary entry should be sent to 8 | dict_field_map = { 9 | -- the below example sends dictionary entries from 'JMdict' to the field 'SentEng' on the anki note 10 | -- ["JMdict Rev. 1.9"] = "SentEng", 11 | } 12 | } 13 | 14 | function MultiDefinition:convert_dict_to_HTML(dictionaries) 15 | return self:convert_to_HTML { 16 | entries = dictionaries, 17 | class = "definition", 18 | build = function(entry, entry_template) 19 | -- TODO should we run the `definition_editor.lua` on this definition too? 20 | local def = entry.definition 21 | if entry.is_html then -- try adding dict name to opening div tag (if present) 22 | -- gsub wrapped in () so it only gives us the first result, and discards the index (2nd arg.) 23 | return (def:gsub("("))) 26 | end 27 | } 28 | end 29 | 30 | function MultiDefinition:run(note) 31 | if not self.popup_dict.is_extended then 32 | self.popup_dict.results = require("langsupport/ja/dictwrapper").extend_dictionaries(self.popup_dict.results, self.conf) 33 | self.popup_dict.is_extended = true 34 | end 35 | 36 | local selected_dict = self.popup_dict.results[self.popup_dict.dict_index] 37 | -- map of note fields with all dictionary entries which should be combined and saved in said field 38 | local field_dict_map = u.defaultdict(function() return {} end) 39 | for idx, result in ipairs(self.popup_dict.results) do 40 | -- don't add definitions where the dict word does not match the selected dict's word 41 | -- e.g.: 罵る vs 罵り -> noun vs verb -> we only add defs for the one we selected 42 | -- the info will be mostly the same, and the pitch accent might differ between noun and verb form 43 | if selected_dict:get_kana_words():contains_any(result:get_kana_words()) then 44 | logger.info(("EXT: multi_definition: handling result: %s"):format(result:as_string())) 45 | local is_selected = idx == self.popup_dict.dict_index 46 | local field = not is_selected and self.dict_field_map[result.dict] 47 | if field then 48 | local field_defs = field_dict_map[field] 49 | -- make sure that the selected dictionary is always inserted in the beginning 50 | table.insert(field_defs, is_selected and 1 or #field_defs+1, result) 51 | end 52 | else 53 | local skip_msg = "Skipping %s dict entry: kana word '%s' ~= selected dict word '%s'" 54 | logger.info(skip_msg:format(result.dict, result:get_kana_words(), selected_dict:get_kana_words())) 55 | end 56 | end 57 | for field, dicts in pairs(field_dict_map) do 58 | note.fields[field] = self:convert_dict_to_HTML(dicts) 59 | end 60 | return note 61 | end 62 | 63 | return MultiDefinition 64 | -------------------------------------------------------------------------------- /extensions/EXT_pitch_accent.lua: -------------------------------------------------------------------------------- 1 | local logger = require("logger") 2 | local util = require("util") 3 | local u = require("lua_utils/utils") 4 | 5 | local PitchAccent = { 6 | description = [[ 7 | Some definitions contain pitch accent information. 8 | e.g. さけ・ぶ [2]【叫ぶ】 9 | this extension extracts the [2] from the definition's headword and stores it as a html representation and/or a number. 10 | ]], 11 | -- These 2 fields should be modified to point to the desired field on the card 12 | field_pitch_html = 'VocabPitchPattern', 13 | field_pitch_num = 'VocabPitchNum', 14 | 15 | -- bunch of DOM element templates used to display pitch accent 16 | pitch_pattern = "%s", 17 | mark_accented = "%s", 18 | mark_downstep = "%s", 19 | unmarked_char = "%s", 20 | pitch_downstep_pattern = "(%[([0-9])%])", 21 | } 22 | 23 | function PitchAccent:convert_pitch_to_HTML(accents) 24 | local converter = nil 25 | if #accents == 0 then 26 | converter = function(_) return nil end 27 | elseif #accents == 1 then 28 | converter = function(field) return accents[1][field] end 29 | else 30 | converter = function(field) return self:convert_to_HTML { 31 | entries = accents, 32 | class = "pitch", 33 | build = function(accent) return string.format("
  • %s
  • ", accent[field]) end 34 | } 35 | end 36 | end 37 | return converter("pitch_num"), converter("pitch_accent") 38 | end 39 | 40 | function PitchAccent:split_morae(word) 41 | local small_aeio = u.to_set(util.splitToChars("ゅゃぃぇょゃ")) 42 | local morae = u.defaultdict(function() return {} end) 43 | for _,ch in ipairs(util.splitToChars(word)) do 44 | local is_small = small_aeio[ch] or false 45 | table.insert(morae[is_small and #morae or #morae+1], ch) 46 | end 47 | logger.info(("EXT: PitchAccent#split_morae(): split word %s into %d morae: "):format(word, #morae)) 48 | return morae 49 | end 50 | 51 | local function get_first_line(linestring) 52 | local start_idx = linestring:find('\n', 1, true) 53 | return start_idx and linestring:sub(1, start_idx + 1) or linestring 54 | end 55 | 56 | function PitchAccent:get_pitch_downsteps(dict_result) 57 | return string.gmatch(get_first_line(dict_result.definition), self.pitch_downstep_pattern) 58 | end 59 | 60 | 61 | function PitchAccent:get_pitch_accents(dict_result) 62 | local _morae = nil 63 | local function get_morae() 64 | if not _morae then 65 | _morae = self:split_morae(dict_result:get_kana_words()[1]) 66 | end 67 | return _morae 68 | end 69 | 70 | local function _convert(downstep) 71 | local pitch_visual = {} 72 | local is_heiban = downstep == "0" 73 | for idx, mora in ipairs(get_morae()) do 74 | local marking = nil 75 | if is_heiban then 76 | marking = idx == 1 and self.unmarked_char or self.mark_accented 77 | else 78 | if idx == tonumber(downstep) then 79 | marking = self.mark_downstep 80 | else 81 | marking = idx < tonumber(downstep) and self.mark_accented or self.unmarked_char 82 | end 83 | end 84 | -- when dealing with the downstep mora, we want the downstep to appear only on the last char of the mora 85 | local is_downstep = marking == self.mark_downstep 86 | logger.dbg("EXT: PitchAccent#get_pitch_accent(): determined marking for mora: ", idx, table.concat(mora), marking) 87 | for _, ch in ipairs(mora) do 88 | table.insert(pitch_visual, (is_downstep and self.mark_accented or marking):format(ch)) 89 | end 90 | if is_downstep then 91 | pitch_visual[#pitch_visual] = self.mark_downstep:format(mora[#mora]) 92 | end 93 | end 94 | return self.pitch_pattern:format(table.concat(pitch_visual)) 95 | end 96 | 97 | local downstep_iter = self:get_pitch_downsteps(dict_result) 98 | return function(iter) 99 | local with_brackets, downstep = iter() 100 | if downstep then 101 | return with_brackets, _convert(downstep) 102 | end 103 | end, downstep_iter 104 | end 105 | 106 | function PitchAccent:run(note) 107 | if not self.popup_dict.is_extended then 108 | self.popup_dict.results = require("langsupport/ja/dictwrapper").extend_dictionaries(self.popup_dict.results, self.conf) 109 | self.popup_dict.is_extended = true 110 | end 111 | local selected = self.popup_dict.results[self.popup_dict.dict_index] 112 | 113 | local pitch_accents = {} 114 | for _, result in ipairs(self.popup_dict.results) do 115 | if selected:get_kana_words():contains_any(result:get_kana_words()) then 116 | for num, accent in self:get_pitch_accents(result) do 117 | if not pitch_accents[num] then 118 | pitch_accents[num] = true -- add as k/v pair too so we can detect uniqueness 119 | table.insert(pitch_accents, { pitch_num = num, pitch_accent = accent }) 120 | end 121 | end 122 | end 123 | end 124 | local pitch_num, pitch_accent = self:convert_pitch_to_HTML(pitch_accents) 125 | note.fields[self.field_pitch_num] = pitch_num 126 | note.fields[self.field_pitch_html] = pitch_accent 127 | return note 128 | end 129 | 130 | return PitchAccent 131 | -------------------------------------------------------------------------------- /extensions/README.md: -------------------------------------------------------------------------------- 1 | # Extensions 2 | 3 | Custom behavior for note creation. 4 | 5 | Any .lua file present in the ./extensions folder will be loaded by the add-on, provided the filename starts with the "EXT_" prefix. 6 | 7 | ## Format 8 | 9 | An extension has the following format: 10 | 11 | ```lua 12 | local CustomExtension = { 13 | definition = "This extension does a thing to the note!" -- this can be left out 14 | } 15 | -- this is called when user creates a note 16 | function CustomExtension:run(note) 17 | -- make some additions to note we are about to save.. 18 | return note 19 | end 20 | return CustomExtension 21 | ``` 22 | 23 | ### The `note` parameter 24 | 25 | the `run` function shown above takes a `note` parameter. 26 | This parameter is a Lua table containing all the data that will be sent to Anki. 27 | The contents of this table are based on the 'note' parameter in the JSON request that is sent to anki-connect. 28 | An example can be seen in the [documentation](https://github.com/FooSoft/anki-connect#addnote) of the `addNote` action. 29 | The 'note' parameter in the example request has the same fields as the Lua table parameter. 30 | 31 | ### AnkiNote context 32 | 33 | On loading the user extensions, the user is also given access to the AnkiNote table as well (see ankinote.lua). 34 | 35 | This code can be accessed through the `self` parameter. 36 | 37 | In the example below, the extension prints the dictionary name received through this paramter. 38 | 39 | ```lua 40 | local Extension = {} 41 | function Extension:run(note) 42 | local selected_dict = self.popup_dict.results[self.popup_dict.dict_index].dict 43 | print(("Currently selected dictionary: %s"):format(selected_dict)) 44 | return note 45 | end 46 | return Extension 47 | ``` 48 | 49 | ### User configuration 50 | 51 | The user config can be accessed by importing the `anki_configuration.lua` module. 52 | 53 | The example below gets the entry for the `word_field` from the user config, and then saves it in the custom "word" and "key" fields. 54 | 55 | ```lua 56 | local conf = require("anki_configuration") 57 | 58 | local JP_mining_note = {} 59 | 60 | function JP_mining_note:run(note) 61 | local vocab_word = note.fields[conf.word_field:get_value()] 62 | note.fields["word"] = vocab_word 63 | note.fields["key"] = vocab_word 64 | return note 65 | end 66 | return JP_mining_note 67 | ``` 68 | -------------------------------------------------------------------------------- /forvo.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | Copyright: Ren Tatsumoto and contributors 3 | License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/gpl.html 4 | 5 | Utils for downloading pronunciations from Forvo 6 | ]] 7 | 8 | local http = require("socket.http") 9 | local socket = require("socket") 10 | local ltn12 = require("ltn12") 11 | local socketutil = require("socketutil") 12 | 13 | 14 | local function GET(url) 15 | local sink = {} 16 | socketutil:set_timeout(socketutil.LARGE_BLOCK_TIMEOUT, socketutil.LARGE_TOTAL_TIMEOUT) 17 | local request = { 18 | url = url, 19 | method = "GET", 20 | headers = { 21 | ['User-Agent'] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", 22 | ['Host'] = 'forvo.com', 23 | ['Accept-Language'] = "en-US,en;q=0.9", 24 | ['Accept'] = "*/*" 25 | }, 26 | sink = ltn12.sink.table(sink), 27 | } 28 | local code, _, status = socket.skip(1, http.request(request)) 29 | if code == 200 then 30 | return table.concat(sink) 31 | end 32 | -- Special handling for 403 error (likely rate limit or access restriction) 33 | if code == 403 then 34 | return false, "FORVO_403" 35 | end 36 | return false, ("[%d]: %s"):format(code or -1, status or "") 37 | end 38 | 39 | -- http://lua-users.org/wiki/BaseSixtyFour 40 | -- character table string 41 | local b = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' 42 | local function base64e(data) 43 | return ((data:gsub('.', function(x) 44 | local r,b='',x:byte() 45 | for i=8,1,-1 do r=r..(b%2^i-b%2^(i-1)>0 and '1' or '0') end 46 | return r; 47 | end)..'0000'):gsub('%d%d%d?%d?%d?%d?', function(x) 48 | if (#x < 6) then return '' end 49 | local c=0 50 | for i=1,6 do c=c+(x:sub(i,i)=='1' and 2^(6-i) or 0) end 51 | return b:sub(c+1,c+1) 52 | end)..({ '', '==', '=' })[#data%3+1]) 53 | end 54 | 55 | local function base64d(data) 56 | data = string.gsub(data, '[^'..b..'=]', '') 57 | return (data:gsub('.', function(x) 58 | if (x == '=') then return '' end 59 | local r,f='',(b:find(x)-1) 60 | for i=6,1,-1 do r=r..(f%2^i-f%2^(i-1)>0 and '1' or '0') end 61 | return r; 62 | end):gsub('%d%d%d?%d?%d?%d?%d?%d?', function(x) 63 | if (#x ~= 8) then return '' end 64 | local c=0 65 | for i=1,8 do c=c+(x:sub(i,i)=='1' and 2^(8-i) or 0) end 66 | return string.char(c) 67 | end)) 68 | end 69 | 70 | 71 | local function url_encode(url) 72 | -- https://gist.github.com/liukun/f9ce7d6d14fa45fe9b924a3eed5c3d99 73 | local char_to_hex = function(c) 74 | return string.format("%%%02X", string.byte(c)) 75 | end 76 | if url == nil then 77 | return 78 | end 79 | url = url:gsub("\n", "\r\n") 80 | url = url:gsub("([^%w _%%%-%.~])", char_to_hex) 81 | url = url:gsub(" ", "+") 82 | return url 83 | end 84 | 85 | local function get_pronunciation_url(word, language) 86 | local forvo_url = ('https://forvo.com/search/%s/%s'):format(url_encode(word), language) 87 | local forvo_page, err = GET(forvo_url) 88 | if not forvo_page then 89 | return false, err 90 | end 91 | local play_params = string.match(forvo_page, "Play%((.-)%);") 92 | 93 | local word_url = nil 94 | if play_params then 95 | local iter = string.gmatch(play_params, "'(.-)'") 96 | local formats = { mp3 = iter(), ogg = iter() } 97 | word_url = string.format('https://audio00.forvo.com/%s/%s', "ogg", base64d(formats["ogg"])) 98 | end 99 | return true, word_url 100 | end 101 | 102 | return { 103 | get_pronunciation_url = get_pronunciation_url, 104 | base64e = base64e, 105 | } 106 | -------------------------------------------------------------------------------- /langsupport/ja/dictwrapper.lua: -------------------------------------------------------------------------------- 1 | local util = require("util") 2 | local List = require("lua_utils.list") 3 | -- utility which wraps a dictionary sub-entry (the popup shown when looking up a word) 4 | -- with some extra functionality which isn't there by default 5 | DictEntryWrapper = { 6 | -- currently unused but might come in handy, scavenged from yomichan 7 | kana = 'うゔ-かが-きぎ-くぐ-けげ-こご-さざ-しじ-すず-せぜ-そぞ-ただ-ちぢ-つづ-てで-とど-はばぱひびぴふぶぷへべぺほぼぽワヷ-ヰヸ-ウヴ-ヱヹ-ヲヺ-カガ-キギ-クグ-ケゲ-コゴ-サザ-シジ-スズ-セゼ-ソゾ-タダ-チヂ-ツヅ-テデ-トド-ハバパヒビピフブプヘベペホボポ', 8 | kana_word_pattern = "(.*)【.*】", 9 | kanji_word_pattern = "【(.*)】", 10 | kanji_sep_chr = '・', 11 | -- A pattern can be provided which for each dictionary extracts the kana reading(s) of the word which was looked up. 12 | -- This is used to determine which dictionary entries should be added to the card (e.g. 帰り vs 帰る: if the noun was selected, the verb is skipped) 13 | -- if no pattern is provided for a given dictionary, we fall back on the patterns listed above 14 | kana_pattern = { 15 | -- key: dictionary name as displayed in KOreader (received from dictionary's .ifo file) 16 | -- value: a table containing 2 entries: 17 | -- 1) the dictionary field to look for the kana reading in (either 'word' or 'description') 18 | -- 2) a pattern which should return the kana reading(s) (the pattern will be looked for multiple times!) 19 | ["JMdict Rev. 1.9"] = {"definition", "(.-)"}, 20 | }, 21 | -- A pattern can be provided which for each dictionary extracts the kanji reading(s) of the word which was looked up. 22 | -- This is used to store in the `word_field` defined above 23 | kanji_pattern = { 24 | -- key: dictionary name as displayed in KOreader (received from dictionary's .ifo file) 25 | -- value: a table containing 2 entries: 26 | -- 1) the dictionary field to look for the kanji in (either 'word' or 'description') 27 | -- 2) a pattern which should return the kanji 28 | ["JMdict Rev. 1.9"] = {"word", ".*"}, 29 | } 30 | } 31 | 32 | 33 | function DictEntryWrapper.extend_dictionaries(results, config) 34 | local extended = {} 35 | for idx,dict in ipairs(results) do 36 | extended[idx] = DictEntryWrapper:new{ 37 | dict = dict, 38 | conf = config 39 | } 40 | end 41 | return extended 42 | end 43 | 44 | function DictEntryWrapper:new(opts) 45 | self.conf = opts.conf 46 | 47 | local index = function(table, k) 48 | return rawget(table, k) or rawget(self, k) or rawget(table.dictionary, k) 49 | end 50 | local kana_dictionary_field, kana_pattern = unpack(self.kana_pattern[opts.dict.dict] or {}) 51 | local kanji_dictionary_field, kanji_pattern = unpack(self.kanji_pattern[opts.dict.dict] or {}) 52 | local data = { 53 | dictionary = opts.dict, 54 | kana_pattern = kana_pattern or self.kana_word_pattern, 55 | kana_dict_field = kana_dictionary_field or "word", 56 | kanji_pattern = kanji_pattern or self.kanji_word_pattern, 57 | kanji_dict_field = kanji_dictionary_field or "word", 58 | } 59 | return setmetatable(data, { __index = function(table, k) return index(table, k) end }) 60 | end 61 | 62 | function DictEntryWrapper:get_kana_words() 63 | local entries = List:from_iter(self.dictionary[self.kana_dict_field]:gmatch(self.kana_pattern)) 64 | -- if the pattern doesn't match, return the plain word, chances are it's already in kana 65 | return entries:is_empty() and List:new({self.dictionary.word}) or entries 66 | end 67 | 68 | function DictEntryWrapper:get_kanji_words() 69 | local kanji_entries_str = self.dictionary[self.kanji_dict_field]:match(self.kanji_pattern) 70 | local brackets = { ['('] = 0, [')'] = 0, ['('] = 0, [')'] = 0 } 71 | -- word entries often look like this: ある【有る・在る】 72 | -- the kanji_match_pattern will give us: 有る・在る 73 | -- these 2 entries still need to be separated 74 | local kanji_entries, current = {}, {} 75 | for _,ch in pairs(util.splitToChars(kanji_entries_str)) do 76 | if ch == self.kanji_sep_chr then 77 | table.insert(kanji_entries, table.concat(current)) 78 | current = {} 79 | elseif brackets[ch] then 80 | -- some entries look like this: '振(り)方', the brackets should be ignored 81 | else 82 | table.insert(current, ch) 83 | end 84 | end 85 | if #current > 0 then 86 | table.insert(kanji_entries, table.concat(current)) 87 | end 88 | return List:new(kanji_entries) 89 | end 90 | 91 | function DictEntryWrapper:as_string() 92 | local fmt_string = "DictEntryWrapper: (%s) word: %s, kana: %s, kanji: %s" 93 | return fmt_string:format(self.dictionary.dict, self.dictionary.word, self:get_kana_words(), self:get_kanji_words()) 94 | end 95 | 96 | return DictEntryWrapper 97 | -------------------------------------------------------------------------------- /lua_utils/list.lua: -------------------------------------------------------------------------------- 1 | local List = {} 2 | local list_mt = { 3 | __index = function(t, k) return rawget(List, k) or rawget(t:get(), k) end, 4 | __tostring = function(x) return "['" .. table.concat(x:get(), "', '") .. "']" end 5 | } 6 | 7 | function List:new(table) 8 | local data = table 9 | for _,x in ipairs(data) do data[x] = true end 10 | return setmetatable({ _data = table }, list_mt) 11 | end 12 | 13 | function List:from_iter(iter) 14 | local data = {} 15 | for x in iter do 16 | table.insert(data, x) 17 | data[x] = true 18 | end 19 | return setmetatable({ _data = data }, list_mt) 20 | end 21 | 22 | function List:get() 23 | return rawget(self, "_data") 24 | end 25 | 26 | function List:size() return #self:get() end 27 | 28 | function List:is_empty() return self:size() == 0 end 29 | 30 | function List:contains(item) 31 | return self:get()[item] ~= nil 32 | end 33 | 34 | function List:contains_any(other) 35 | for _,x in ipairs(self:get()) do 36 | if other:contains(x) then return true end 37 | end 38 | return false 39 | end 40 | 41 | function List:group_by(grouping_func) 42 | local grouped_mt = { __index = function(t, k) return rawget(t,k) or rawset(t, k, {})[k] end } 43 | local grouped = setmetatable({}, grouped_mt) 44 | for _,x in ipairs(self:get()) do 45 | table.insert(grouped[grouping_func(x)], x) 46 | end 47 | return self:new(grouped) 48 | end 49 | 50 | function List:add(item) 51 | assert(self:get()[item] == nil, "Item already present!") 52 | table.insert(self:get(), item) 53 | self:get()[item] = true 54 | end 55 | 56 | function List:remove(item) 57 | local index = nil 58 | for idx,v in ipairs(self:get()) do 59 | if v == item then 60 | index = idx 61 | break 62 | end 63 | end 64 | if index then 65 | table.remove(self:get(), index) 66 | self:get()[item] = nil 67 | end 68 | end 69 | 70 | return List 71 | -------------------------------------------------------------------------------- /lua_utils/utils.lua: -------------------------------------------------------------------------------- 1 | local utils = {} 2 | 3 | function utils.get_extension(filename) 4 | return filename:match("%.([%a]+)$") 5 | end 6 | 7 | function utils.read_file(filename, line_parser) 8 | local fn_not_found = "ERROR: file %q was not found!" 9 | line_parser = line_parser or function(x) return x end 10 | local f, data = io.open(filename, 'r'), {} 11 | assert(f, fn_not_found:format(filename)) 12 | for line in f:lines("*l") do 13 | table.insert(data, line_parser(line)) 14 | end 15 | return data 16 | end 17 | 18 | function utils.open_file(filename, mode, callback) 19 | local f = io.open(filename, mode) 20 | if not f then 21 | return 22 | end 23 | local res = callback(f) 24 | f:close() 25 | return res 26 | end 27 | 28 | function utils.split(input, sep, is_regex) 29 | local splits, last_idx, plain = {}, 1, true 30 | local function add_substring(from, to) 31 | local split = input:sub(from,to) 32 | if #split > 0 then 33 | splits[#splits+1] = split 34 | end 35 | end 36 | if is_regex == true then 37 | plain = false 38 | end 39 | 40 | while true do 41 | local s,e = input:find(sep, last_idx, plain) 42 | if s == nil then 43 | break 44 | end 45 | add_substring(last_idx, s-1) 46 | last_idx = e+1 47 | end 48 | add_substring(last_idx, #input) 49 | return splits 50 | end 51 | 52 | function utils.defaultdict(func) 53 | local f = type(func) == 'function' and func or function() return func end 54 | local mt = { __index = function(t, idx) return rawget(t, idx) or rawset(t, idx, f())[idx] end } 55 | return setmetatable({}, mt) 56 | end 57 | 58 | function utils.table_to_set(t, in_place) 59 | local t_ = (in_place or true) and t or {} 60 | for i,v in ipairs(t) do 61 | assert(utils.is_numeric(v) == false, "Table t should not contain numeric values!") 62 | t_[v] = i 63 | end 64 | return t_ 65 | end 66 | 67 | function utils.path_exists(path) 68 | local f = io.open(path, 'r') 69 | if f then 70 | f:close() 71 | return true 72 | end 73 | return false 74 | end 75 | 76 | function utils.run_cmd(cmd) 77 | local output = {} 78 | local f = io.popen(cmd, 'r') 79 | for line in f:lines("*l") do 80 | table.insert(output, line) 81 | end 82 | f:close() 83 | return output 84 | end 85 | 86 | function utils.iterate_cmd(cmd) 87 | local output = {} 88 | local f = io.popen(cmd, 'r') 89 | for line in f:lines("*l") do 90 | table.insert(output, line) 91 | end 92 | f:close() 93 | return function() 94 | return table.remove(output, 1) 95 | end 96 | end 97 | 98 | function utils.strip_path(path) 99 | local stripped = string.match(path, "^.*/([^/]+)$") 100 | return stripped or path 101 | end 102 | 103 | function utils.dir_name(path) 104 | return utils.run_cmd(string.format("dirname %q", path))[1] 105 | end 106 | 107 | 108 | function utils.is_numeric_int(s) 109 | return string.match(s, "^%d+$") ~= nil 110 | end 111 | 112 | function utils.is_numeric(str) 113 | return string.match(str, "^-?[%d%.]+$") 114 | end 115 | 116 | function utils.to_set(list) 117 | local set = {} 118 | for _,v in pairs(list) do 119 | set[v] = true 120 | end 121 | return set 122 | end 123 | 124 | 125 | return utils 126 | -------------------------------------------------------------------------------- /main.lua: -------------------------------------------------------------------------------- 1 | local ButtonDialog = require("ui/widget/buttondialog") 2 | local CustomContextMenu = require("customcontextmenu") 3 | local DataStorage = require("datastorage") 4 | local DictQuickLookup = require("ui/widget/dictquicklookup") 5 | local InfoMessage = require("ui/widget/infomessage") 6 | local LuaSettings = require("luasettings") 7 | local MenuBuilder = require("menubuilder") 8 | local RadioButtonWidget = require("ui/widget/radiobuttonwidget") 9 | local Widget = require("ui/widget/widget") 10 | local UIManager = require("ui/uimanager") 11 | local logger = require("logger") 12 | local util = require("util") 13 | local _ = require("gettext") 14 | 15 | local lfs = require("libs/libkoreader-lfs") 16 | local AnkiConnect = require("ankiconnect") 17 | local AnkiNote = require("ankinote") 18 | local Configuration = require("anki_configuration") 19 | 20 | local AnkiWidget = Widget:extend { 21 | known_document_profiles = LuaSettings:open(DataStorage:getSettingsDir() .. "/anki_profiles.lua"), 22 | anki_note = nil, 23 | anki_connect = nil, 24 | } 25 | 26 | function AnkiWidget:show_profiles_widget(opts) 27 | local buttons = {} 28 | for name, _ in pairs(Configuration.profiles) do 29 | table.insert(buttons, { { text = name, provider = name, checked = Configuration:is_active(name) } }) 30 | end 31 | if #buttons == 0 then 32 | local msg = [[Failed to load profiles, there are none available, create a profile first. See the README on GitHub for more details.]] 33 | return UIManager:show(InfoMessage:new { text = msg, timeout = 4 }) 34 | end 35 | 36 | self.profile_change_widget = RadioButtonWidget:new{ 37 | title_text = opts.title_text, 38 | info_text = opts.info_text, 39 | cancel_text = "Cancel", 40 | ok_text = "Accept", 41 | width_factor = 0.9, 42 | radio_buttons = buttons, 43 | callback = function(radio) 44 | local profile = radio.provider:gsub(".lua$", "", 1) 45 | Configuration:load_profile(profile) 46 | self.profile_change_widget:onClose() 47 | local _, file_name = util.splitFilePathName(self.ui.document.file) 48 | self.known_document_profiles:saveSetting(file_name, profile) 49 | opts.cb() 50 | end, 51 | } 52 | UIManager:show(self.profile_change_widget) 53 | end 54 | 55 | function AnkiWidget:show_config_widget() 56 | local note_count = #self.anki_connect.local_notes 57 | local with_custom_tags_cb = function() 58 | self.current_note:add_tags(Configuration.custom_tags:get_value()) 59 | self.anki_connect:add_note(self.current_note) 60 | self.config_widget:onClose() 61 | end 62 | self.config_widget = ButtonDialog:new { 63 | buttons = { 64 | {{ text = ("Sync (%d) offline note(s)"):format(note_count), id = "sync", enabled = note_count > 0, callback = function() self.anki_connect:sync_offline_notes() end }}, 65 | {{ text = "Add with custom tags", id = "custom_tags", callback = with_custom_tags_cb }}, 66 | {{ 67 | text = "Add with custom context", 68 | id = "custom_context", 69 | enabled = self.current_note.contextual_lookup, 70 | callback = function() self:set_profile(function() return self:show_custom_context_widget() end) end 71 | }}, 72 | {{ 73 | text = "Delete latest note", 74 | id = "note_delete", 75 | enabled = self.anki_connect.latest_synced_note ~= nil, 76 | callback = function() 77 | self.anki_connect:delete_latest_note() 78 | self.config_widget:onClose() 79 | end 80 | }}, 81 | {{ 82 | text = "Change profile", 83 | id = "profile_change", 84 | callback = function() 85 | self:show_profiles_widget { 86 | title_text = "Change user profile", 87 | info_text = "Use a different profile", 88 | cb = function() end 89 | } 90 | end 91 | }} 92 | }, 93 | } 94 | UIManager:show(self.config_widget) 95 | end 96 | 97 | function AnkiWidget:show_custom_context_widget() 98 | local function on_save_cb() 99 | local m = self.context_menu 100 | self.current_note:set_custom_context(m.prev_s_cnt, m.prev_c_cnt, m.next_s_cnt, m.next_c_cnt) 101 | self.anki_connect:add_note(self.current_note) 102 | self.context_menu:onClose() 103 | self.config_widget:onClose() 104 | end 105 | self.context_menu = CustomContextMenu:new{ 106 | note = self.current_note, -- to extract context out of 107 | on_save_cb = on_save_cb, -- called when saving note with updated context 108 | } 109 | UIManager:show(self.context_menu) 110 | end 111 | 112 | -- [[ 113 | -- This function name is not chosen at random. There are 2 places where this function is called: 114 | -- - frontend/apps/filemanager/filemanagermenu.lua 115 | -- - frontend/apps/reader/modules/readermenu.lua 116 | -- These call the function `pcall(widget.addToMainMenu, widget, self.menu_items)` which lets other widgets add 117 | -- items to the dictionary menu 118 | -- ]] 119 | function AnkiWidget:addToMainMenu(menu_items) 120 | -- TODO an option to create a new profile (based on existing ones) would be cool 121 | local builder = MenuBuilder:new{ 122 | extensions = self.extensions, 123 | ui = self.ui 124 | } 125 | menu_items.anki_settings = { text = ("Anki Settings"), sub_item_table = builder:build() } 126 | end 127 | 128 | function AnkiWidget:load_extensions() 129 | self.extensions = {} -- contains filenames by numeric index, loaded modules by value 130 | local ext_directory = DataStorage:getFullDataDir() .. "/plugins/anki.koplugin/extensions/" 131 | 132 | for file in lfs.dir(ext_directory) do 133 | if file:match("^EXT_.*%.lua") then 134 | table.insert(self.extensions, file) 135 | local ext_module = assert(loadfile(ext_directory .. file))() 136 | self.extensions[file] = ext_module 137 | end 138 | end 139 | table.sort(self.extensions) 140 | end 141 | 142 | -- This function is called automatically for all tables extending from Widget 143 | function AnkiWidget:init() 144 | self:load_extensions() 145 | self.anki_connect = AnkiConnect:new { 146 | ui = self.ui 147 | } 148 | self.anki_note = AnkiNote:extend { 149 | ui = self.ui, 150 | ext_modules = self.extensions 151 | } 152 | 153 | -- this holds the latest note created by the user! 154 | self.current_note = nil 155 | 156 | self.ui.menu:registerToMainMenu(self) 157 | self:handle_events() 158 | end 159 | 160 | function AnkiWidget:extend_doc_settings(filepath, document_properties) 161 | local _, file = util.splitFilePathName(filepath) 162 | local file_pattern = "^%[([^%]]-)%]_(.-)_%[([^%]]-)%]%.[^%.]+" 163 | local f_author, f_title, f_extra = file:match(file_pattern) 164 | local file_properties = { 165 | title = f_title, 166 | author = f_author, 167 | description = f_extra, 168 | } 169 | local get_prop = function(property) 170 | local d_p, f_p = document_properties[property], file_properties[property] 171 | local d_len, f_len = d_p and #d_p or 0, f_p and #f_p or 0 172 | -- if our custom f_p match is more exact, pick that one 173 | -- e.g. for PDF the title is usually the full filename 174 | local f_p_more_precise = d_len == 0 or d_len > f_len and f_len ~= 0 175 | return f_p_more_precise and f_p or d_p 176 | end 177 | local metadata = { 178 | title = get_prop('display_title') or get_prop('title'), 179 | author = get_prop('author') or get_prop('authors'), 180 | description = get_prop('description'), 181 | current_page = function() return self.ui.view.state.page end, 182 | language = document_properties.language, 183 | pages = function() return document_properties.pages or self.ui.doc_settings:readSetting("doc_pages") end 184 | } 185 | local metadata_mt = { 186 | __index = function(t, k) return rawget(t, k) or "N/A" end 187 | } 188 | logger.dbg("AnkiWidget:extend_doc_settings#", filepath, document_properties, metadata) 189 | self.ui.document._anki_metadata = setmetatable(metadata, metadata_mt) 190 | end 191 | 192 | function AnkiWidget:set_profile(callback) 193 | local _, file_name = util.splitFilePathName(self.ui.document.file) 194 | local user_profile = self.known_document_profiles:readSetting(file_name) 195 | if user_profile and Configuration.profiles[user_profile] then 196 | local ok, err = pcall(Configuration.load_profile, Configuration, user_profile) 197 | if not ok then 198 | return UIManager:show(InfoMessage:new { text = ("Could not load profile %s: %s"):format(user_profile, err), timeout = 4 }) 199 | end 200 | return callback() 201 | end 202 | 203 | local info_text = "Choose the profile to link with this document." 204 | if user_profile then 205 | info_text = ("Document was associated with the non-existing profile '%s'.\nPlease pick a different profile to link with this document."):format(user_profile) 206 | end 207 | 208 | self:show_profiles_widget { 209 | title_text = "Set user profile", 210 | info_text = info_text, 211 | cb = function() 212 | callback() 213 | end 214 | } 215 | end 216 | 217 | function AnkiWidget:handle_events() 218 | -- these all return false so that the event goes up the chain, other widgets might wanna react to these events 219 | self.onCloseWidget = function() 220 | self.known_document_profiles:close() 221 | Configuration:save() 222 | end 223 | 224 | self.onSuspend = function() 225 | Configuration:save() 226 | end 227 | 228 | self.onNetworkConnected = function() 229 | self.anki_connect.wifi_connected = true 230 | end 231 | 232 | self.onNetworkDisconnected = function() 233 | self.anki_connect.wifi_connected = false 234 | end 235 | 236 | self.onReaderReady = function(obj, doc_settings) 237 | self.anki_connect:load_notes() 238 | -- Insert new button in the popup dictionary to allow adding anki cards 239 | -- TODO disable button if lookup was not contextual 240 | DictQuickLookup.tweak_buttons_func = function(popup_dict, buttons) 241 | self.add_to_anki_btn = { 242 | id = "add_to_anki", 243 | text = _("Add to Anki"), 244 | font_bold = true, 245 | callback = function() 246 | self:set_profile(function() 247 | self.current_note = self.anki_note:new(popup_dict) 248 | self.anki_connect:add_note(self.current_note) 249 | end) 250 | end, 251 | hold_callback = function() 252 | self:set_profile(function() 253 | self.current_note = self.anki_note:new(popup_dict) 254 | self:show_config_widget() 255 | end) 256 | end, 257 | } 258 | table.insert(buttons, 1, { self.add_to_anki_btn }) 259 | end 260 | local filepath = doc_settings.data.doc_path 261 | self:extend_doc_settings(filepath, self.ui.bookinfo:getDocProps(filepath, doc_settings.doc_props)) 262 | end 263 | 264 | self.onBookMetadataChanged = function(obj, updated_props) 265 | local filepath = updated_props.filepath 266 | self:extend_doc_settings(filepath, self.ui.bookinfo:getDocProps(filepath, updated_props.doc_props)) 267 | end 268 | end 269 | 270 | function AnkiWidget:onDictButtonsReady(popup_dict, buttons) 271 | if self.ui and not self.ui.document then 272 | return 273 | end 274 | self.add_to_anki_btn = { 275 | id = "add_to_anki", 276 | text = _("Add to Anki"), 277 | font_bold = true, 278 | callback = function() 279 | self:set_profile(function() 280 | self.current_note = self.anki_note:new(popup_dict) 281 | self.anki_connect:add_note(self.current_note) 282 | end) 283 | end, 284 | hold_callback = function() 285 | self:set_profile(function() 286 | self.current_note = self.anki_note:new(popup_dict) 287 | self:show_config_widget() 288 | end) 289 | end, 290 | } 291 | table.insert(buttons, 1, { self.add_to_anki_btn }) 292 | end 293 | 294 | return AnkiWidget 295 | -------------------------------------------------------------------------------- /menubuilder.lua: -------------------------------------------------------------------------------- 1 | local ConfirmBox = require("ui/widget/confirmbox") 2 | local UIManager = require("ui/uimanager") 3 | local InfoMessage = require("ui/widget/infomessage") 4 | local InputDialog = require("ui/widget/inputdialog") 5 | local MultiInputDialog = require("ui/widget/multiinputdialog") 6 | local util = require("util") 7 | local List = require("lua_utils.list") 8 | local config = require("anki_configuration") 9 | 10 | local general_settings = { "generic_settings", "General Settings" } 11 | local note_settings = { "note_settings", "Anki Note Settings" } 12 | local dictionary_settings = { "dictionary_settings", "Dictionary Settings" } 13 | 14 | -- 'raw' entries containing the strings displayed in the menu 15 | -- keys in the list should match the id of the underlying config option 16 | local menu_entries = { 17 | { 18 | id = "url", 19 | group = general_settings, 20 | name = "AnkiConnect URL", 21 | description = "The URL anki_connect is listening on.", 22 | }, 23 | { 24 | id = "api_key", 25 | group = general_settings, 26 | name = "AnkiConnect API key", 27 | description = "An optional API key to secure the connection.", 28 | }, 29 | { 30 | id = "deckName", 31 | group = general_settings, 32 | name = "Anki Deckname", 33 | description = "The name of the deck the new notes should be added to.", 34 | }, 35 | { 36 | id = "modelName", 37 | group = general_settings, 38 | name = "Anki Note Type", 39 | description = "The Anki note type our cards should use.", 40 | }, 41 | { 42 | id = "allow_dupes", 43 | group = general_settings, 44 | name = "Allow Duplicates", 45 | description = "Allow creation of duplicate notes", 46 | conf_type = "bool", 47 | }, 48 | { 49 | id = "dupe_scope", 50 | group = general_settings, 51 | name = "Duplicate Scope", 52 | description = "Anki Scope in which to look for duplicates", 53 | conf_type = "text", 54 | }, 55 | { 56 | id = "custom_tags", 57 | group = general_settings, 58 | name = "Custom Note Tags", 59 | description = "Provide custom tags to be added to a note.", 60 | conf_type = "list", 61 | }, 62 | { 63 | id = "word_field", 64 | group = note_settings, 65 | name = "Word Field", 66 | description = "Anki field for selected word.", 67 | }, 68 | { 69 | id = "context_field", 70 | group = note_settings, 71 | name = "Context Field", 72 | description = "Anki field for sentence selected word occured in.", 73 | }, 74 | { 75 | id = "translated_context_field", 76 | group = note_settings, 77 | name = "Translated Context Field", 78 | description = "Anki Field for the translation of the sentence the selected word occured in." 79 | }, 80 | { 81 | id = "def_field", 82 | group = note_settings, 83 | name = "Glossary Field", 84 | description = "Anki field for dictionary glossary.", 85 | }, 86 | { 87 | id = "meta_field", 88 | group = note_settings, 89 | name = "Metadata Field", 90 | description = "Anki field to store metadata about the current book.", 91 | }, 92 | { 93 | id = "audio_field", 94 | group = note_settings, 95 | name = "Forvo Audio Field", 96 | description = "Anki field to store Forvo audio in.", 97 | }, 98 | { 99 | id = "img_field", 100 | group = note_settings, 101 | name = "Image Field", 102 | description = "Anki field to store image in (used for CBZ only).", 103 | }, 104 | { 105 | id = "enabled_extensions", 106 | group = general_settings, 107 | name = "Extensions", 108 | description = "Custom scripts to modify created notes.", 109 | conf_type = "checklist", 110 | default_values = function(self) return self.extensions end, 111 | }, 112 | { 113 | id = "prev_sentence_count", 114 | group = note_settings, 115 | name = "Previous Sentence Count", 116 | description = "Amount of sentences to prepend to the word looked up.", 117 | }, 118 | { 119 | id = "next_sentence_count", 120 | group = note_settings, 121 | name = "Next Sentence Count", 122 | description = "Amount of sentences to append to the word looked up.", 123 | }, 124 | --[[ TODO: we may wanna move this to the extension and insert it back in the menu somehow 125 | { 126 | id = "dict_field_map", 127 | group = dictionary_settings, 128 | name = "Dictionary Map", 129 | description = "List of key/value pairs linking a dictionary with a field on the note type", 130 | conf_type = "map", 131 | default_values = function(menubuilder) return menubuilder.ui.dictionary.enabled_dict_names end, 132 | new_entry_value = "Note field to send the definition to", 133 | }, 134 | ]] 135 | } 136 | for i,x in ipairs(menu_entries) do menu_entries[x.id] = i end 137 | 138 | local MenuBuilder = {} 139 | local MenuConfigOpt = { 140 | user_conf = nil, -- the underlying ConfigOpt which this menu option configures 141 | menu_entry = nil, -- pretty name for display purposes 142 | conf_type = "text", -- default value for optional conf_type field 143 | } 144 | 145 | function MenuConfigOpt:new(o) 146 | local new_ = { idx = o.idx, enabled = o.enabled } -- idx is used to sort the entries so they are displayed in a consistent order 147 | for k,v in pairs(o.user_conf) do new_[k] = v end 148 | for k,v in pairs(o.menu_entry) do new_[k] = v end 149 | local function index(t, k) 150 | return rawget(t, k) or self[k] 151 | or o.user_conf[k] -- necessary to be able to call opt:get_value() 152 | or MenuBuilder[k] -- necessary to get access to ui (passed in via menubuilder) 153 | end 154 | return setmetatable(new_, { __index = index }) 155 | end 156 | 157 | local function build_single_dialog(title, input, hint, description, callback) 158 | local input_dialog -- saved first so we can reference it in the callbacks 159 | input_dialog = InputDialog:new { 160 | title = title, 161 | input = input, 162 | input_hint = hint, 163 | description = description, 164 | buttons = {{ 165 | { text = "Cancel", id = "cancel", callback = function() UIManager:close(input_dialog) end }, 166 | { text = "Save", id = "save", callback = function() callback(input_dialog) end }, 167 | }}, 168 | } 169 | return input_dialog 170 | end 171 | 172 | function MenuConfigOpt:build_single_dialog() 173 | local callback = function(dialog) 174 | self:update_value(dialog:getInputText()) 175 | UIManager:close(dialog) 176 | end 177 | local input_dialog = build_single_dialog(self.name, self:get_value_nodefault(), self.name, self.description, callback) 178 | UIManager:show(input_dialog) 179 | input_dialog:onShowKeyboard() 180 | end 181 | 182 | function MenuConfigOpt:build_multi_dialog() 183 | local fields = {} 184 | for k,v in pairs(self:get_value_nodefault() or {}) do 185 | table.insert(fields, { description = k, text = v }) 186 | end 187 | 188 | local multi_dialog 189 | multi_dialog = MultiInputDialog:new { 190 | title = self.name, 191 | description = self.description, 192 | fields = fields, 193 | buttons = {{ 194 | { text = "Cancel", id = "cancel", callback = function() UIManager:close(multi_dialog) end }, 195 | { text = "Save", id = "save", callback = function() 196 | local new = {} 197 | for idx,v in ipairs(multi_dialog:getFields()) do 198 | new[fields[idx].description] = v 199 | end 200 | self:update_value(new) 201 | UIManager:close(multi_dialog) 202 | end}, 203 | } 204 | }, 205 | } 206 | UIManager:show(multi_dialog) 207 | multi_dialog:onShowKeyboard() 208 | end 209 | 210 | function MenuConfigOpt:build_list_dialog() 211 | local callback = function(dialog) 212 | local new_tags = {} 213 | for tag in util.gsplit(dialog:getInputText(), ",") do 214 | table.insert(new_tags, tag) 215 | end 216 | self:update_value(new_tags) 217 | UIManager:close(dialog) 218 | end 219 | local description = self.description.."\nMultiple values can be listed, separated by a comma." 220 | local input_dialog = build_single_dialog(self.name,table.concat(self:get_value_nodefault() or {}, ","), self.name, description, callback) 221 | UIManager:show(input_dialog) 222 | input_dialog:onShowKeyboard() 223 | end 224 | 225 | function MenuConfigOpt:build_checklist() 226 | local menu_items = {} 227 | for _, list_item in ipairs(self:default_values()) do 228 | table.insert(menu_items, { 229 | text = list_item, 230 | checked_func = function() return List:new(self:get_value_nodefault() or {}):contains(list_item) end, 231 | hold_callback = function() 232 | UIManager:show(InfoMessage:new { text = self.extensions[list_item].description, timeout = nil }) 233 | end, 234 | callback = function() 235 | local l = List:new(self:get_value_nodefault() or {}) 236 | if l:contains(list_item) then 237 | l:remove(list_item) 238 | else 239 | l:add(list_item) 240 | end 241 | self:update_value(l:get()) 242 | end 243 | }) 244 | end 245 | return menu_items 246 | end 247 | 248 | function MenuConfigOpt:build_map_dialog() 249 | local function is_enabled(k) 250 | return (self:get_value_nodefault() or {})[k] ~= nil 251 | end 252 | -- called when enabling or updating a value in the map 253 | local function update_map_entry(entry_key) 254 | local new = self:get_value_nodefault() or {} 255 | local cb = function(dialog) 256 | new[entry_key] = dialog:getInputText() 257 | self:update_value(new) 258 | UIManager:close(dialog) 259 | end 260 | local input_dialog = build_single_dialog(entry_key, new[entry_key] or "", nil, self.new_entry_value, cb) 261 | UIManager:show(input_dialog) 262 | input_dialog:onShowKeyboard() 263 | end 264 | 265 | local sub_item_table = {} 266 | local values = self.default_values 267 | if type(values) == "function" then 268 | values = values(self) 269 | end 270 | for _,entry_key in ipairs(values) do 271 | local activate_menu = { 272 | text = "Activate", 273 | keep_menu_open = true, 274 | checked_func = function() return is_enabled(entry_key) end, 275 | callback = function() 276 | local new = self:get_value_nodefault() or {} 277 | if is_enabled(entry_key) then 278 | new[entry_key] = nil 279 | self:update_value(new) 280 | else 281 | -- this is hack to make the menu toggle update 282 | new[entry_key] = "" 283 | self:update_value(new) 284 | update_map_entry(entry_key) 285 | end 286 | end 287 | } 288 | local edit_menu = { 289 | text = "Edit", 290 | keep_menu_open = true, 291 | enabled_func = function() return is_enabled(entry_key) end, 292 | callback = function() return update_map_entry(entry_key) end, 293 | } 294 | local menu_item = { 295 | text = entry_key, 296 | checked_func = function() return is_enabled(entry_key) end, 297 | keep_menu_open = true, 298 | sub_item_table = { 299 | activate_menu, 300 | edit_menu, 301 | } 302 | } 303 | table.insert(sub_item_table, menu_item) 304 | end 305 | return sub_item_table 306 | end 307 | 308 | function MenuBuilder:new(opts) 309 | self.ui = opts.ui -- needed to get the enabled dictionaries 310 | self.extensions = opts.extensions 311 | return self 312 | end 313 | 314 | function MenuBuilder:build() 315 | local profiles = {} 316 | for name, p in pairs(config.profiles) do 317 | local menu_options = {} 318 | for _, setting in ipairs(config) do 319 | local user_conf = setting:copy { 320 | profile = p, 321 | value = p.data[setting.id] 322 | } 323 | local idx = menu_entries[setting.id] 324 | local entry = menu_entries[idx] 325 | if entry then 326 | table.insert(menu_options, MenuConfigOpt:new{ user_conf = user_conf, menu_entry = entry, idx = idx, enabled = p.data[setting.id] ~= nil }) 327 | end 328 | end 329 | table.sort(menu_options, function(a,b) return a.idx < b.idx end) 330 | 331 | -- contains data as expected to be passed along to main config widget 332 | local sub_item_table = {} 333 | local grouping_func = function(x) return x.group[2] end 334 | local group_order = { ["General Settings"] = 1, ["Anki Note Settings"] = 2, ["Dictionary Settings"] = 3 } 335 | for group, group_entries in pairs(List:new(menu_options):group_by(grouping_func):get()) do 336 | local menu_group = {} 337 | for _,opt in ipairs(group_entries) do 338 | table.insert(menu_group, self:convert_opt(opt)) 339 | end 340 | table.insert(sub_item_table, { text = group, sub_item_table = menu_group }) 341 | end 342 | table.sort(sub_item_table, function(a,b) return group_order[a.text] < group_order[b.text] end) 343 | table.insert(profiles, { text = name, sub_item_table = sub_item_table }) 344 | end 345 | return profiles 346 | end 347 | 348 | function MenuBuilder:convert_opt(opt) 349 | local sub_item_entry = { 350 | text = opt.name, 351 | keep_menu_open = true, 352 | --enabled_func = function() return opt.enabled end, 353 | hold_callback = function() 354 | -- no point in allowing deleting of stuff in the default profile 355 | if opt.profile.name == "default" then return end 356 | UIManager:show(ConfirmBox:new{ 357 | text = "Do you want to delete this setting from the current profile?", 358 | ok_callback = function() 359 | opt:delete() 360 | end 361 | }) 362 | end 363 | } 364 | if opt.conf_type == "text" then 365 | sub_item_entry['callback'] = function() return opt:build_single_dialog() end 366 | elseif opt.conf_type == "table" then 367 | sub_item_entry['callback'] = function() return opt:build_multi_dialog() end 368 | elseif opt.conf_type == "bool" then 369 | sub_item_entry['checked_func'] = function() return opt:get_value_nodefault() == true end 370 | sub_item_entry['callback'] = function() return opt:update_value(not opt:get_value_nodefault()) end 371 | elseif opt.conf_type == "list" then 372 | sub_item_entry['callback'] = function() return opt:build_list_dialog() end 373 | elseif opt.conf_type == "checklist" then 374 | sub_item_entry['sub_item_table'] = opt:build_checklist() 375 | elseif opt.conf_type == "map" then 376 | sub_item_entry['sub_item_table'] = opt:build_map_dialog() 377 | else -- TODO multitable 378 | sub_item_entry['enabled_func'] = function() return false end 379 | end 380 | return sub_item_entry 381 | end 382 | 383 | return MenuBuilder 384 | -------------------------------------------------------------------------------- /profiles/README.md: -------------------------------------------------------------------------------- 1 | # Profiles 2 | 3 | The plugin is configured via profiles. Each profile is a `.lua` file with a single table containing all user configurable settings. 4 | 5 | To use the plugin, copy the code snippet below and save it in a new file, this file can be named whatever you want, as long as it has a `.lua` suffix. 6 | 7 | It is also possible to define a default profile (this should be called `default.lua`) containing the entries that remain the same for all profiles. 8 | You can then define multiple other profiles (e.g. `en.lua`, `jp.lua`, etc.) which contain *only* the fields that differ. 9 | 10 | ```lua 11 | -- This file contains all the user configurable options 12 | -- Entries which aren't marked as REQUIRED can be ommitted completely 13 | local Config = { 14 | ---------------------------------------------- 15 | ---- [[ GENERAL CONFIGURATION OPTIONS ]] ----- 16 | ---------------------------------------------- 17 | -- This refers to the IP address of the PC ankiconnect is running on 18 | -- Remember to expose the port ankiconnect listens on so we can connect to it 19 | -- [REQUIRED] The ankiconnect settings also need to be updated to not only listen on the loopback address 20 | url = "http://localhost:8765", 21 | -- [REQUIRED] name of the anki deck 22 | deckName = "日本::3 - Mining Deck", 23 | -- [REQUIRED] note type of the notes that should be created 24 | modelName = "Japanese sentences", 25 | -- Each note created by the plugin will have the tag 'KOReader', it is possible to add other custom tags 26 | -- A card with custom tags can be created by pressing and holding the 'Add to Anki' button, which pops up a menu with some extra options. 27 | custom_tags = { "NEEDS_WORK" }, 28 | 29 | -- It is possible to toggle whether duplicate notes can be created. This can be of use if your note type contains the full sentence as first field (meaning this gets looked at for uniqueness) 30 | -- When multiple unknown words are present, it won't be possible to add both in this case, because the sentence would be the same. 31 | allow_dupes = false, 32 | -- The scope where ankiconnect will look to to find duplicates 33 | dupe_scope = "deck", 34 | -- api key - extra authentication supported by ankiconnect, see https://git.foosoft.net/alex/anki-connect#authentication 35 | -- this is totally optional and probably unnecessary, unless you expose anki-connect on the public network for some reason 36 | api_key = nil, 37 | 38 | 39 | ---------------------------------------------- 40 | --- [[ NOTE FIELD CONFIGURATION OPTIONS ]] --- 41 | ---------------------------------------------- 42 | -- [REQUIRED] The field name where the word which was looked up in a dictionary will be sent to. 43 | word_field = "VocabKanji", 44 | 45 | -- The field name where the sentence in which the word we looked up occurred will be sent to. 46 | context_field = "SentKanji", 47 | 48 | -- Translation of the context field 49 | translated_context_field = "SentEng", 50 | 51 | -- Amount of sentences which are prepended to the word looked up. Set this to 1 to complete the current sentence. 52 | prev_sentence_count = 1, 53 | 54 | -- Amount of sentences which are appended to the word looked up. Set this to 1 to complete the current sentence. 55 | next_sentence_count = 1, 56 | 57 | -- [REQUIRED] The field name where the dictionary definition will be sent to. 58 | def_field = "VocabDef", 59 | 60 | -- The field name where metadata (book source, page number, ...) will be sent to. 61 | -- This metadata is parsed from the EPUB's metadata, or from the filename 62 | meta_field = "Notes", 63 | 64 | -- The plugin can query Forvo for audio of the word you just looked up. 65 | -- The field name where the audio will be sent to. 66 | audio_field = "VocabAudio", 67 | 68 | -- list of extensions which should be enabled, by default they are all off 69 | -- an extension is turned on by listing its filename in the table below 70 | -- existing extensions are listed below, remove the leading -- to enable them 71 | enabled_extensions = { 72 | --"EXT_dict_edit.lua", 73 | --"EXT_dict_word_lookup.lua", 74 | --"EXT_multi_def.lua", 75 | --"EXT_pitch_accent.lua" 76 | } 77 | } 78 | return Config 79 | ``` 80 | --------------------------------------------------------------------------------