diff --git a/.flake8 b/.flake8 index f2b41384..ce02b1f3 100644 --- a/.flake8 +++ b/.flake8 @@ -1,5 +1,6 @@ +[flake8] max-line-length=100 -application_import_names=projectt +application_import_names=project ignore=P102,B311,W503,E226,S311,W504,F821 exclude=__pycache__, venv, .venv, tests import-order-style=pycharm diff --git a/.gitignore b/.gitignore index 894a44cc..2ed482f5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +# Project-specific files +project/*.ini +project/**/autosave.data + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] @@ -102,3 +106,10 @@ venv.bak/ # mypy .mypy_cache/ + +# IDEA workspace +.idea/ + +# compiled sphinx HTML documentation +docs/build +.vscode/settings.json diff --git a/Pipfile b/Pipfile index 72b70b6f..6e400524 100644 --- a/Pipfile +++ b/Pipfile @@ -5,11 +5,21 @@ verify_ssl = true [dev-packages] flake8 = "*" +pytest = "*" +sphinx = "*" [packages] +pyqt5 = "*" +flake8 = "*" +lxml = "*" +requests = "*" +thesaurus = "*" [requires] python_version = "3.7" [scripts] -lint = "python -m flake8" \ No newline at end of file +lint = "python -m flake8" +start = "python -m project" +test = "pytest project/tests/" +gendoc = "pydoc -g" diff --git a/Pipfile.lock b/Pipfile.lock index 79354a3c..b29635aa 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "a376db0bd471e38a7080cd854c46349b46922db98afeaf83d17b84923fbe9710" + "sha256": "8a1138130ef769617102c5d0548e339fcd02b844bee110972030c816003e0938" }, "pipfile-spec": 6, "requires": { @@ -15,8 +15,221 @@ } ] }, - "default": {}, + "default": { + "beautifulsoup4": { + "hashes": [ + "sha256:034740f6cb549b4e932ae1ab975581e6103ac8f942200a0e9759065984391858", + "sha256:945065979fb8529dd2f37dbb58f00b661bdbcbebf954f93b32fdf5263ef35348", + "sha256:ba6d5c59906a85ac23dadfe5c88deaf3e179ef565f4898671253e50a78680718" + ], + "version": "==4.7.1" + }, + "certifi": { + "hashes": [ + "sha256:47f9c83ef4c0c621eaef743f133f09fa8a74a9b75f037e8624f83bd1b6626cb7", + "sha256:993f830721089fef441cdfeb4b2c8c9df86f0c63239f06bd025a76a7daddb033" + ], + "version": "==2018.11.29" + }, + "chardet": { + "hashes": [ + "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", + "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" + ], + "version": "==3.0.4" + }, + "entrypoints": { + "hashes": [ + "sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19", + "sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451" + ], + "version": "==0.3" + }, + "flake8": { + "hashes": [ + "sha256:859996073f341f2670741b51ec1e67a01da142831aa1fdc6242dbf88dffbe661", + "sha256:a796a115208f5c03b18f332f7c11729812c8c3ded6c46319c59b53efd3819da8" + ], + "index": "pypi", + "version": "==3.7.7" + }, + "idna": { + "hashes": [ + "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", + "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c" + ], + "version": "==2.8" + }, + "lxml": { + "hashes": [ + "sha256:0358b9e9642bc7d39aac5cffe9884a99a5ca68e5e2c1b89e570ed60da9139908", + "sha256:091a359c4dafebbecd3959d9013f1b896b5371859165e4e50b01607a98d9e3e2", + "sha256:1998e4e60603c64bcc35af61b4331ab3af087457900d3980e18d190e17c3a697", + "sha256:2000b4088dee9a41f459fddaf6609bba48a435ce6374bb254c5ccdaa8928c5ba", + "sha256:2afb0064780d8aaf165875be5898c1866766e56175714fa5f9d055433e92d41d", + "sha256:2d8f1d9334a4e3ff176d096c14ded3100547d73440683567d85b8842a53180bb", + "sha256:2e38db22f6a3199fd63675e1b4bd795d676d906869047398f29f38ca55cb453a", + "sha256:3181f84649c1a1ca62b19ddf28436b1b2cb05ae6c7d2628f33872e713994c364", + "sha256:37462170dfd88af8431d04de6b236e6e9c06cda71e2ca26d88ef2332fd2a5237", + "sha256:3a9d8521c89bf6f2a929c3d12ad3ad7392c774c327ea809fd08a13be6b3bc05f", + "sha256:3d0bbd2e1a28b4429f24fd63a122a450ce9edb7a8063d070790092d7343a1aa4", + "sha256:483d60585ce3ee71929cea70949059f83850fa5e12deb9c094ed1c8c2ec73cbd", + "sha256:4888be27d5cba55ce94209baef5bcd7bbd7314a3d17021a5fc10000b3a5f737d", + "sha256:64b0d62e4209170a2a0c404c446ab83b941a0003e96604d2e4f4cb735f8a2254", + "sha256:68010900898fdf139ac08549c4dba8206c584070a960ffc530aebf0c6f2794ef", + "sha256:872ecb066de602a0099db98bd9e57f4cfc1d62f6093d94460c787737aa08f39e", + "sha256:88a32b03f2e4cd0e63f154cac76724709f40b3fc2f30139eb5d6f900521b44ed", + "sha256:b1dc7683da4e67ab2bebf266afa68098d681ae02ce570f0d1117312273d2b2ac", + "sha256:b29e27ce9371810250cb1528a771d047a9c7b0f79630dc7dc5815ff828f4273b", + "sha256:ce197559596370d985f1ce6b7051b52126849d8159040293bf8b98cb2b3e1f78", + "sha256:d45cf6daaf22584eff2175f48f82c4aa24d8e72a44913c5aff801819bb73d11f", + "sha256:e2ff9496322b2ce947ba4a7a5eb048158de9d6f3fe9efce29f1e8dd6878561e6", + "sha256:f7b979518ec1f294a41a707c007d54d0f3b3e1fd15d5b26b7e99b62b10d9a72e", + "sha256:f9c7268e9d16e34e50f8246c4f24cf7353764affd2bc971f0379514c246e3f6b", + "sha256:f9c839806089d79de588ee1dde2dae05dc1156d3355dfeb2b51fde84d9c960ad", + "sha256:ff962953e2389226adc4d355e34a98b0b800984399153c6678f2367b11b4d4b8" + ], + "index": "pypi", + "version": "==4.3.2" + }, + "mccabe": { + "hashes": [ + "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", + "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" + ], + "version": "==0.6.1" + }, + "pycodestyle": { + "hashes": [ + "sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56", + "sha256:e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c" + ], + "version": "==2.5.0" + }, + "pyflakes": { + "hashes": [ + "sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0", + "sha256:d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2" + ], + "version": "==2.1.1" + }, + "pyqt5": { + "hashes": [ + "sha256:1402f1613698ca64e3cec0ee27a60b5454e782c16fbd1bdee4a270a058947939", + "sha256:8872c78f204bf8b660164d6dfae87e1be5a9dbc3e20fd2823bd4e851b3647eba", + "sha256:912ead29f8ed86be178faeb2be83793fb633c11059ae54c3bd8e81c1e224e339", + "sha256:acbefac6c780f04709441aa7d8a147c0801f01f8db93f8f000e4a4391db27345" + ], + "index": "pypi", + "version": "==5.12" + }, + "pyqt5-sip": { + "hashes": [ + "sha256:04bd0bb8b6f8fa03c2dfbdfff0c8c9bfb3f46a21dd4cac73983dae93bf949523", + "sha256:058d450c26be92193605f4628ff690d77080f599ffe381a1029cea8eeb71ab8e", + "sha256:0b838ef8a55461785e78b4e347cf52ce228a5d4392c57e07cc46de51433dc8ac", + "sha256:40504f96ecb834e54491ead558589bfd773056dba7f2df76599a06fdd8ed1ead", + "sha256:49b2151bd0a0e439efc9d4c22c33a048d8e8ede5c7296851c221fa0988887edb", + "sha256:6540b510f9436fe2d65801af55ecbf8c43bdda47294e994ed3851403a93e4a8b", + "sha256:6b3063b12e700944172d57cdbeafb363229669af933f873d01c7a6d8a91c4c87", + "sha256:6b65d2b14084eb583bf4cf68b97ade295fabae5f5bf2aae0ab00ab30533f1c60", + "sha256:6ca796071b21761917ee486e57bfa2fc694580e65c462e4173cf849ed8fe201c", + "sha256:6d3013a6820ea614f46fdc73cc16dd57c36a0c74bcbd38bd0b9f2d46b6e6dd16", + "sha256:84f7401afdd5f31e961de75e9c6b1610849e8883fbe0ed675bbb7d7d97286347", + "sha256:bb81cfc4d35ca59f1c419b6abeb6ca6a726a63b712cf979f2b5ab24b81c36f49" + ], + "version": "==4.19.14" + }, + "requests": { + "hashes": [ + "sha256:502a824f31acdacb3a35b6690b5fbf0bc41d63a24a45c4004352b0242707598e", + "sha256:7bf2a778576d825600030a110f3c0e3e8edc51dfaafe1c146e39a2027784957b" + ], + "index": "pypi", + "version": "==2.21.0" + }, + "soupsieve": { + "hashes": [ + "sha256:afa56bf14907bb09403e5d15fbed6275caa4174d36b975226e3b67a3bb6e2c4b", + "sha256:eaed742b48b1f3e2d45ba6f79401b2ed5dc33b2123dfe216adb90d4bfa0ade26" + ], + "version": "==1.8" + }, + "thesaurus": { + "hashes": [ + "sha256:baba81d355f5e0ef40ee3b4dde7f7ab2f6d84ca380e37220010e3ef6bbb18a6e", + "sha256:e5fecdafb2534fbd006e2ba1fbeb29c7278473bf5c6c237fcd1c33349a9d9ef1" + ], + "index": "pypi", + "version": "==0.2.3" + }, + "urllib3": { + "hashes": [ + "sha256:61bf29cada3fc2fbefad4fdf059ea4bd1b4a86d2b6d15e1c7c0b582b9752fe39", + "sha256:de9529817c93f27c8ccbfead6985011db27bd0ddfcdb2d86f3f663385c6a9c22" + ], + "version": "==1.24.1" + } + }, "develop": { + "alabaster": { + "hashes": [ + "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359", + "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02" + ], + "version": "==0.7.12" + }, + "atomicwrites": { + "hashes": [ + "sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4", + "sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6" + ], + "version": "==1.3.0" + }, + "attrs": { + "hashes": [ + "sha256:10cbf6e27dbce8c30807caf056c8eb50917e0eaafe86347671b57254006c3e69", + "sha256:ca4be454458f9dec299268d472aaa5a11f67a4ff70093396e1ceae9c76cf4bbb" + ], + "version": "==18.2.0" + }, + "babel": { + "hashes": [ + "sha256:6778d85147d5d85345c14a26aada5e478ab04e39b078b0745ee6870c2b5cf669", + "sha256:8cba50f48c529ca3fa18cf81fa9403be176d374ac4d60738b839122dfaaa3d23" + ], + "version": "==2.6.0" + }, + "certifi": { + "hashes": [ + "sha256:47f9c83ef4c0c621eaef743f133f09fa8a74a9b75f037e8624f83bd1b6626cb7", + "sha256:993f830721089fef441cdfeb4b2c8c9df86f0c63239f06bd025a76a7daddb033" + ], + "version": "==2018.11.29" + }, + "chardet": { + "hashes": [ + "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", + "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" + ], + "version": "==3.0.4" + }, + "colorama": { + "hashes": [ + "sha256:05eed71e2e327246ad6b38c540c4a3117230b19679b875190486ddd2d721422d", + "sha256:f8ac84de7840f5b9c4e3347b3c1eaa50f7e49c2b07596221daec5edaabbd7c48" + ], + "markers": "sys_platform == 'win32'", + "version": "==0.4.1" + }, + "docutils": { + "hashes": [ + "sha256:02aec4bd92ab067f6ff27a38a38a41173bf01bed8f89157768c1573f53e474a6", + "sha256:51e64ef2ebfb29cae1faa133b3710143496eca21c530f3f71424d77687764274", + "sha256:7a4bd47eaf6596e1295ecb11361139febe29b084a87bf005bf899f9a42edc3c6" + ], + "version": "==0.14" + }, "entrypoints": { "hashes": [ "sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19", @@ -26,11 +239,65 @@ }, "flake8": { "hashes": [ - "sha256:6d8c66a65635d46d54de59b027a1dda40abbe2275b3164b634835ac9c13fd048", - "sha256:6eab21c6e34df2c05416faa40d0c59963008fff29b6f0ccfe8fa28152ab3e383" + "sha256:859996073f341f2670741b51ec1e67a01da142831aa1fdc6242dbf88dffbe661", + "sha256:a796a115208f5c03b18f332f7c11729812c8c3ded6c46319c59b53efd3819da8" ], "index": "pypi", - "version": "==3.7.6" + "version": "==3.7.7" + }, + "idna": { + "hashes": [ + "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", + "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c" + ], + "version": "==2.8" + }, + "imagesize": { + "hashes": [ + "sha256:3f349de3eb99145973fefb7dbe38554414e5c30abd0c8e4b970a7c9d09f3a1d8", + "sha256:f3832918bc3c66617f92e35f5d70729187676313caa60c187eb0f28b8fe5e3b5" + ], + "version": "==1.1.0" + }, + "jinja2": { + "hashes": [ + "sha256:74c935a1b8bb9a3947c50a54766a969d4846290e1e788ea44c1392163723c3bd", + "sha256:f84be1bb0040caca4cea721fcbbbbd61f9be9464ca236387158b0feea01914a4" + ], + "version": "==2.10" + }, + "markupsafe": { + "hashes": [ + "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", + "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", + "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", + "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", + "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", + "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", + "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", + "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", + "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", + "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", + "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", + "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", + "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", + "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", + "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", + "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", + "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", + "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", + "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", + "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", + "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", + "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", + "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", + "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", + "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", + "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", + "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", + "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7" + ], + "version": "==1.1.1" }, "mccabe": { "hashes": [ @@ -39,6 +306,35 @@ ], "version": "==0.6.1" }, + "more-itertools": { + "hashes": [ + "sha256:0125e8f60e9e031347105eb1682cef932f5e97d7b9a1a28d9bf00c22a5daef40", + "sha256:590044e3942351a1bdb1de960b739ff4ce277960f2425ad4509446dbace8d9d1" + ], + "markers": "python_version > '2.7'", + "version": "==6.0.0" + }, + "packaging": { + "hashes": [ + "sha256:0c98a5d0be38ed775798ece1b9727178c4469d9c3b4ada66e8e6b7849f8732af", + "sha256:9e1cbf8c12b1f1ce0bb5344b8d7ecf66a6f8a6e91bcb0c84593ed6d3ab5c4ab3" + ], + "version": "==19.0" + }, + "pluggy": { + "hashes": [ + "sha256:19ecf9ce9db2fce065a7a0586e07cfb4ac8614fe96edf628a264b1c70116cf8f", + "sha256:84d306a647cc805219916e62aab89caa97a33a1dd8c342e87a37f91073cd4746" + ], + "version": "==0.9.0" + }, + "py": { + "hashes": [ + "sha256:64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa", + "sha256:dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53" + ], + "version": "==1.8.0" + }, "pycodestyle": { "hashes": [ "sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56", @@ -48,10 +344,83 @@ }, "pyflakes": { "hashes": [ - "sha256:5e8c00e30c464c99e0b501dc160b13a14af7f27d4dffb529c556e30a159e231d", - "sha256:f277f9ca3e55de669fba45b7393a1449009cff5a37d1af10ebb76c52765269cd" + "sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0", + "sha256:d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2" + ], + "version": "==2.1.1" + }, + "pygments": { + "hashes": [ + "sha256:5ffada19f6203563680669ee7f53b64dabbeb100eb51b61996085e99c03b284a", + "sha256:e8218dd399a61674745138520d0d4cf2621d7e032439341bc3f647bff125818d" + ], + "version": "==2.3.1" + }, + "pyparsing": { + "hashes": [ + "sha256:66c9268862641abcac4a96ba74506e594c884e3f57690a696d21ad8210ed667a", + "sha256:f6c5ef0d7480ad048c054c37632c67fca55299990fff127850181659eea33fc3" + ], + "version": "==2.3.1" + }, + "pytest": { + "hashes": [ + "sha256:067a1d4bf827ffdd56ad21bd46674703fce77c5957f6c1eef731f6146bfcef1c", + "sha256:9687049d53695ad45cf5fdc7bbd51f0c49f1ea3ecfc4b7f3fde7501b541f17f4" + ], + "index": "pypi", + "version": "==4.3.0" + }, + "pytz": { + "hashes": [ + "sha256:32b0891edff07e28efe91284ed9c31e123d84bea3fd98e1f72be2508f43ef8d9", + "sha256:d5f05e487007e29e03409f9398d074e158d920d36eb82eaf66fb1136b0c5374c" + ], + "version": "==2018.9" + }, + "requests": { + "hashes": [ + "sha256:502a824f31acdacb3a35b6690b5fbf0bc41d63a24a45c4004352b0242707598e", + "sha256:7bf2a778576d825600030a110f3c0e3e8edc51dfaafe1c146e39a2027784957b" + ], + "index": "pypi", + "version": "==2.21.0" + }, + "six": { + "hashes": [ + "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", + "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73" + ], + "version": "==1.12.0" + }, + "snowballstemmer": { + "hashes": [ + "sha256:919f26a68b2c17a7634da993d91339e288964f93c274f1343e3bbbe2096e1128", + "sha256:9f3bcd3c401c3e862ec0ebe6d2c069ebc012ce142cce209c098ccb5b09136e89" + ], + "version": "==1.2.1" + }, + "sphinx": { + "hashes": [ + "sha256:b53904fa7cb4b06a39409a492b949193a1b68cc7241a1a8ce9974f86f0d24287", + "sha256:c1c00fc4f6e8b101a0d037065043460dffc2d507257f2f11acaed71fd2b0c83c" + ], + "index": "pypi", + "version": "==1.8.4" + }, + "sphinxcontrib-websupport": { + "hashes": [ + "sha256:68ca7ff70785cbe1e7bccc71a48b5b6d965d79ca50629606c7861a21b206d9dd", + "sha256:9de47f375baf1ea07cdb3436ff39d7a9c76042c10a769c52353ec46e4e8fc3b9" + ], + "version": "==1.1.0" + }, + "urllib3": { + "hashes": [ + "sha256:61bf29cada3fc2fbefad4fdf059ea4bd1b4a86d2b6d15e1c7c0b582b9752fe39", + "sha256:de9529817c93f27c8ccbfead6985011db27bd0ddfcdb2d86f3f663385c6a9c22" ], - "version": "==2.1.0" + "version": "==1.24.1" } } } diff --git a/README.md b/README.md index 697c2bf7..da701e46 100644 --- a/README.md +++ b/README.md @@ -29,17 +29,82 @@ You should be using [Pipenv](https://pipenv.readthedocs.io/en/latest/). Take a l * Running the application (assuming you use our project layout): `pipenv run start` # Project Information +##### Team Name -`# TODO` +Knowledgeable Kois +##### Members + +[BWACPro](https://github.com/BWACpro) + +[kaleidawave](https://github.com/kaleidawave) + +[Transfusion](https://github.com/Transfusion) 👑 ## Description -`# TODO` +### Theme +Clipboard Mangler + +### What does it do? +It's a clipboard manager in all its usefulness: + + - For when you `Ctrl-Z` but have no way to redo + - For when you need to quickly swap items in and out of the clipboard + - For when you need images and text to be quickly accessible at the same time + - ...etc + +However, it has a mind of its own... + +### Feature Breakdown +#### Core +* ~~Hooks into Ctrl-C, Ctrl-X and Ctrl-V~~ **done!** +* Able to select specific copied items and modify them + * Able to edit copied text + * ~~Able to add and remove copied items~~ **done!** + * ~~Able to rearrange copied items~~ **done!** + * ~~Able to delete specific items~~ **done!** +* ~~Clipboard should be saved after exiting the app~~ **done!** +* _(Keep entire clipboard history as a stack too?)_ +* _(Tray Logo, from which we can access settings?)_ +#### Crappifying +* Plugin-based architecture, each crappifier should be a class which applies its own transformation onto an image or text +* Ideas for text + * ~~Misspell words~~ **done!** + * Randomly transpose words around + * ~~Replace entire blocks of text with "funny" copypasta from APIs~~ **done!** +* Ideas for images + * Deep fry and load back into memory + * ~~Randomly rotate~~ **done!** + * Randomly add meme images to the front of the clipboard +* ~~Randomly select transformations to apply.~~ **done!** ## Setup & Installation -`# TODO` +Clone this repo, in a terminal run `pipenv install` then `pipenv run start` + +Unit tests are written using the [pytest](https://docs.pytest.org/en/latest/) framework. + +`pipenv run test` runs the available tests. ## How do I use this thing? -`# TODO` +As you copy and paste text or images, the copied content will be added to the scroll area of the main window. + +The numbers on the left, indicated by ③ in the infographic below indicate their position in the list of copied items. + +A selected item, ① in the infographic below, has a different background color compared to other items. After selection, it may be moved up or down the list, i.e. swapped with the item above or below it, as shown by ②. Remove removes the selected item. + +This may be useful if you want to organize your frequently accessed items at the top. Unchecking the `Always load top item into clipboard` option in the Settings menu will automatically copy the selected item into the system's clipboard. + +Saving the state of the clipboard is done automatically if the option in Settings is checked, or may be done manually in the File menu. + +![Screenshot of Clipboard Mangler on macOS Mojave](https://i.imgur.com/FbxCbjF.png) + +## Documentation + +Documentation is generated using [Sphinx](http://www.sphinx-doc.org/en/master/). To build: +```sh +cd docs +sphinx-apidoc -f -o source/ ../project/ +make html +``` diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 88c177c5..9e04de52 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -35,4 +35,7 @@ jobs: displayName: 'Install project dependencies' - script: pipenv run lint - displayName: 'Lint the project' \ No newline at end of file + displayName: 'Lint the project' + + - script: pipenv run test + displayName: 'Unit test the project' \ No newline at end of file diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 00000000..69fe55ec --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,19 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) \ No newline at end of file diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 00000000..543c6b13 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% + +:end +popd diff --git a/docs/source/ClipboardManager.rst b/docs/source/ClipboardManager.rst new file mode 100644 index 00000000..275e5db6 --- /dev/null +++ b/docs/source/ClipboardManager.rst @@ -0,0 +1,38 @@ +ClipboardManager package +======================== + +Submodules +---------- + +ClipboardManager.ClipboardManager module +---------------------------------------- + +.. automodule:: ClipboardManager.ClipboardManager + :members: + :undoc-members: + :show-inheritance: + +ClipboardManager.ClipboardObject module +--------------------------------------- + +.. automodule:: ClipboardManager.ClipboardObject + :members: + :undoc-members: + :show-inheritance: + +ClipboardManager.HistoryManager module +-------------------------------------- + +.. automodule:: ClipboardManager.HistoryManager + :members: + :undoc-members: + :show-inheritance: + + +Module contents +--------------- + +.. automodule:: ClipboardManager + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/PluginManager.rst b/docs/source/PluginManager.rst new file mode 100644 index 00000000..c14c5cc4 --- /dev/null +++ b/docs/source/PluginManager.rst @@ -0,0 +1,22 @@ +PluginManager package +===================== + +Submodules +---------- + +PluginManager.PluginManager module +---------------------------------- + +.. automodule:: PluginManager.PluginManager + :members: + :undoc-members: + :show-inheritance: + + +Module contents +--------------- + +.. automodule:: PluginManager + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/Stack.rst b/docs/source/Stack.rst new file mode 100644 index 00000000..a69825d5 --- /dev/null +++ b/docs/source/Stack.rst @@ -0,0 +1,22 @@ +Stack package +============= + +Submodules +---------- + +Stack.Stack module +------------------ + +.. automodule:: Stack.Stack + :members: + :undoc-members: + :show-inheritance: + + +Module contents +--------------- + +.. automodule:: Stack + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/Widgets.rst b/docs/source/Widgets.rst new file mode 100644 index 00000000..da02a540 --- /dev/null +++ b/docs/source/Widgets.rst @@ -0,0 +1,22 @@ +Widgets package +=============== + +Submodules +---------- + +Widgets.MainListWidget module +----------------------------- + +.. automodule:: Widgets.MainListWidget + :members: + :undoc-members: + :show-inheritance: + + +Module contents +--------------- + +.. automodule:: Widgets + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 00000000..90a4627f --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,182 @@ +# -*- coding: utf-8 -*- +# +# Configuration file for the Sphinx documentation builder. +# +# This file does only contain a selection of the most common options. For a +# full list see the documentation: +# http://www.sphinx-doc.org/en/master/config + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +import os +import sys +sys.path.insert(0, os.path.abspath('../../')) +sys.path.insert(1, os.path.abspath('../../project')) + + +# -- Project information ----------------------------------------------------- + +project = 'Knowledgeable Kois - CodeJam 4' +copyright = '2019, The Team' +author = 'The Team' + +# The short X.Y version +version = '' +# The full version, including alpha/beta/rc tags +release = '' + + +# -- General configuration --------------------------------------------------- + +# If your documentation needs a minimal Sphinx version, state it here. +# +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.coverage', +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# +# source_suffix = ['.rst', '.md'] +source_suffix = '.rst' + +# The master toctree document. +master_doc = 'index' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = None + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = [] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = None + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = 'alabaster' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +# +# html_theme_options = {} + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# Custom sidebar templates, must be a dictionary that maps document names +# to template names. +# +# The default sidebars (for documents that don't match any pattern) are +# defined by theme itself. Builtin themes are using these templates by +# default: ``['localtoc.html', 'relations.html', 'sourcelink.html', +# 'searchbox.html']``. +# +# html_sidebars = {} + + +# -- Options for HTMLHelp output --------------------------------------------- + +# Output file base name for HTML help builder. +htmlhelp_basename = 'KnowledgeableKois-CodeJam4doc' + + +# -- Options for LaTeX output ------------------------------------------------ + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', + + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + (master_doc, 'KnowledgeableKois-CodeJam4.tex', + 'Knowledgeable Kois - CodeJam 4 Documentation', + 'The Team', 'manual'), +] + + +# -- Options for manual page output ------------------------------------------ + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + (master_doc, 'knowledgeablekois-codejam4', + 'Knowledgeable Kois - CodeJam 4 Documentation', + [author], 1) +] + + +# -- Options for Texinfo output ---------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + (master_doc, 'KnowledgeableKois-CodeJam4', + 'Knowledgeable Kois - CodeJam 4 Documentation', + author, 'KnowledgeableKois-CodeJam4', 'One line description of project.', + 'Miscellaneous'), +] + + +# -- Options for Epub output ------------------------------------------------- + +# Bibliographic Dublin Core info. +epub_title = project + +# The unique identifier of the text. This can be a ISBN number +# or the project homepage. +# +# epub_identifier = '' + +# A unique identification for the text. +# +# epub_uid = '' + +# A list of files that should not be packed into the epub file. +epub_exclude_files = ['search.html'] + + +# -- Extension configuration ------------------------------------------------- diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 00000000..71b5a8f5 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,20 @@ +.. Knowledgeable Kois - CodeJam 4 documentation master file, created by + sphinx-quickstart on Fri Feb 22 19:29:14 2019. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to Knowledgeable Kois - CodeJam 4's documentation! +========================================================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/source/modules.rst b/docs/source/modules.rst new file mode 100644 index 00000000..ef9d38c3 --- /dev/null +++ b/docs/source/modules.rst @@ -0,0 +1,7 @@ +project +======= + +.. toctree:: + :maxdepth: 4 + + project diff --git a/docs/source/project.ClipboardManager.rst b/docs/source/project.ClipboardManager.rst new file mode 100644 index 00000000..bb210bac --- /dev/null +++ b/docs/source/project.ClipboardManager.rst @@ -0,0 +1,38 @@ +project.ClipboardManager package +================================ + +Submodules +---------- + +project.ClipboardManager.ClipboardManager module +------------------------------------------------ + +.. automodule:: project.ClipboardManager.ClipboardManager + :members: + :undoc-members: + :show-inheritance: + +project.ClipboardManager.ClipboardObject module +----------------------------------------------- + +.. automodule:: project.ClipboardManager.ClipboardObject + :members: + :undoc-members: + :show-inheritance: + +project.ClipboardManager.HistoryManager module +---------------------------------------------- + +.. automodule:: project.ClipboardManager.HistoryManager + :members: + :undoc-members: + :show-inheritance: + + +Module contents +--------------- + +.. automodule:: project.ClipboardManager + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/project.ConfigManager.rst b/docs/source/project.ConfigManager.rst new file mode 100644 index 00000000..66a6bfae --- /dev/null +++ b/docs/source/project.ConfigManager.rst @@ -0,0 +1,22 @@ +project.ConfigManager package +============================= + +Submodules +---------- + +project.ConfigManager.ConfigManager module +------------------------------------------ + +.. automodule:: project.ConfigManager.ConfigManager + :members: + :undoc-members: + :show-inheritance: + + +Module contents +--------------- + +.. automodule:: project.ConfigManager + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/project.PluginManager.rst b/docs/source/project.PluginManager.rst new file mode 100644 index 00000000..4cb043fb --- /dev/null +++ b/docs/source/project.PluginManager.rst @@ -0,0 +1,22 @@ +project.PluginManager package +============================= + +Submodules +---------- + +project.PluginManager.PluginManager module +------------------------------------------ + +.. automodule:: project.PluginManager.PluginManager + :members: + :undoc-members: + :show-inheritance: + + +Module contents +--------------- + +.. automodule:: project.PluginManager + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/project.Plugins.rst b/docs/source/project.Plugins.rst new file mode 100644 index 00000000..d544f449 --- /dev/null +++ b/docs/source/project.Plugins.rst @@ -0,0 +1,62 @@ +project.Plugins package +======================= + +Submodules +---------- + +project.Plugins.AbstractPlugin module +------------------------------------- + +.. automodule:: project.Plugins.AbstractPlugin + :members: + :undoc-members: + :show-inheritance: + +project.Plugins.ImageRotatePlugin module +---------------------------------------- + +.. automodule:: project.Plugins.ImageRotatePlugin + :members: + :undoc-members: + :show-inheritance: + +project.Plugins.QuotePlugin module +---------------------------------- + +.. automodule:: project.Plugins.QuotePlugin + :members: + :undoc-members: + :show-inheritance: + +project.Plugins.SpellingMistakesPlugin module +--------------------------------------------- + +.. automodule:: project.Plugins.SpellingMistakesPlugin + :members: + :undoc-members: + :show-inheritance: + +project.Plugins.SynonymPlugin module +------------------------------------ + +.. automodule:: project.Plugins.SynonymPlugin + :members: + :undoc-members: + :show-inheritance: + +project.Plugins.Systray module +------------------------------ + +.. automodule:: project.Plugins.Systray + :members: + :undoc-members: + :show-inheritance: + + +Module contents +--------------- + +.. automodule:: project.Plugins + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/project.Stack.rst b/docs/source/project.Stack.rst new file mode 100644 index 00000000..295b5432 --- /dev/null +++ b/docs/source/project.Stack.rst @@ -0,0 +1,22 @@ +project.Stack package +===================== + +Submodules +---------- + +project.Stack.Stack module +-------------------------- + +.. automodule:: project.Stack.Stack + :members: + :undoc-members: + :show-inheritance: + + +Module contents +--------------- + +.. automodule:: project.Stack + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/project.Widgets.rst b/docs/source/project.Widgets.rst new file mode 100644 index 00000000..359eaa4d --- /dev/null +++ b/docs/source/project.Widgets.rst @@ -0,0 +1,46 @@ +project.Widgets package +======================= + +Submodules +---------- + +project.Widgets.AddItemScreen module +------------------------------------ + +.. automodule:: project.Widgets.AddItemScreen + :members: + :undoc-members: + :show-inheritance: + +project.Widgets.MainListWidget module +------------------------------------- + +.. automodule:: project.Widgets.MainListWidget + :members: + :undoc-members: + :show-inheritance: + +project.Widgets.PluginsScreen module +------------------------------------ + +.. automodule:: project.Widgets.PluginsScreen + :members: + :undoc-members: + :show-inheritance: + +project.Widgets.SettingsScreen module +------------------------------------- + +.. automodule:: project.Widgets.SettingsScreen + :members: + :undoc-members: + :show-inheritance: + + +Module contents +--------------- + +.. automodule:: project.Widgets + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/project.rst b/docs/source/project.rst new file mode 100644 index 00000000..107c24ed --- /dev/null +++ b/docs/source/project.rst @@ -0,0 +1,35 @@ +project package +=============== + +Subpackages +----------- + +.. toctree:: + + project.ClipboardManager + project.ConfigManager + project.PluginManager + project.Plugins + project.Stack + project.Widgets + project.tests + +Submodules +---------- + +project.utils module +-------------------- + +.. automodule:: project.utils + :members: + :undoc-members: + :show-inheritance: + + +Module contents +--------------- + +.. automodule:: project + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/project.tests.rst b/docs/source/project.tests.rst new file mode 100644 index 00000000..420aac32 --- /dev/null +++ b/docs/source/project.tests.rst @@ -0,0 +1,22 @@ +project.tests package +===================== + +Submodules +---------- + +project.tests.test\_stack module +-------------------------------- + +.. automodule:: project.tests.test_stack + :members: + :undoc-members: + :show-inheritance: + + +Module contents +--------------- + +.. automodule:: project.tests + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/tests.rst b/docs/source/tests.rst new file mode 100644 index 00000000..ba78f1e3 --- /dev/null +++ b/docs/source/tests.rst @@ -0,0 +1,22 @@ +tests package +============= + +Submodules +---------- + +tests.test\_example module +-------------------------- + +.. automodule:: tests.test_example + :members: + :undoc-members: + :show-inheritance: + + +Module contents +--------------- + +.. automodule:: tests + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/utils.rst b/docs/source/utils.rst new file mode 100644 index 00000000..87aedafa --- /dev/null +++ b/docs/source/utils.rst @@ -0,0 +1,7 @@ +utils module +============ + +.. automodule:: utils + :members: + :undoc-members: + :show-inheritance: diff --git a/project/ClipboardManager/ClipboardManager.py b/project/ClipboardManager/ClipboardManager.py new file mode 100644 index 00000000..428471bd --- /dev/null +++ b/project/ClipboardManager/ClipboardManager.py @@ -0,0 +1,168 @@ +"""Class encapsulating clipboard events""" +import ast +import json +import logging +import traceback + +from PyQt5.Qt import QApplication # noqa: F401 +from PyQt5.QtCore import pyqtSignal, QObject, pyqtSlot, QByteArray, QBuffer, QIODevice +from PyQt5.QtGui import QImage, QPixmap + +from project.ClipboardManager.ClipboardObject import TextClipboardObject, ImageClipboardObject +from project.ConfigManager import ConfigManager +from project.PluginManager import PluginManager +from project.Stack import Stack + + +# https://stackoverflow.com/questions/36522809/ +# pyqt5-connection-doesnt-work-item-cannot-be-converted-to-pyqt5-qtcore-qobject + + +class ClipboardManager(QObject): + clipboard_changed_signal = pyqtSignal(Stack) + stack_changed_signal = pyqtSignal(Stack) + + def __init__(self): + super().__init__() + self._logger = logging.getLogger(self.__class__.__qualname__) + + self._plugin_manager = PluginManager.get_instance() + self._clipboard_state_callback = None + self._last_text = None + self._last_image = None + + self._clipboard_mutex = False + + QApplication.clipboard().dataChanged.connect(self._clipboard_changed) + + self.clipboard_stack = Stack() + + self.stack_changed_signal.connect(self._stack_changed) + + @pyqtSlot(Stack) + def _stack_changed(self): + """Slot to be called when the state of the stack changes + (usually on add, move, delete, or moving items around """ + self._clipboard_mutex = True + _config = ConfigManager.get_instance() + + # copy the top of the stack into the clipboard if the stack is not empty. + if self.clipboard_stack.items_count(): + if _config.auto_load_top: + item_to_load = self.clipboard_stack.peek() + else: + item_to_load = self.clipboard_stack.current_item + + if isinstance(item_to_load, TextClipboardObject): + self._last_text = item_to_load.text + self._logger.info("Current Stack Item: " + item_to_load.text) + QApplication.clipboard().setText(item_to_load.text) + elif isinstance(item_to_load, ImageClipboardObject): + self._last_image = item_to_load.pixmap + self._logger.info("Stack Changed Item: " + str(self._last_image)) + QApplication.clipboard().setPixmap(self._last_image) + + @pyqtSlot() + def _clipboard_changed(self): + """Slot to be called when the state of the system's clipboard changes + (mostly after copying)""" + self._logger.info("Clipboard Changed called") + if self._clipboard_mutex: + self._clipboard_mutex = False + return + + current_text = QApplication.clipboard().text() + current_image = QApplication.clipboard().pixmap() + + self._logger.info("Current Text:" + str(QApplication.clipboard().text())) + self._logger.info("Current Image Info:" + str(QApplication.clipboard().pixmap())) + + if current_text and (self._last_text is None or current_text != self._last_text): + self._plugin_manager.on_copy_text(current_text, self.clipboard_stack) + self.clipboard_changed_signal.emit(self.clipboard_stack) + + # If the image is not blank + if not current_image.toImage().isNull() \ + and (self._last_image is None + or current_image.toImage() != self._last_image.toImage()): + self._plugin_manager.on_copy_image(current_image, self.clipboard_stack) + self.clipboard_changed_signal.emit(self.clipboard_stack) + + self._last_image = current_image + self._last_text = current_text + + @pyqtSlot(int) + def set_selected_object(self, idx): + """Highlights a particular row in the main window""" + self._logger.info("set_selected_object called " + str(idx)) + if not 0 <= idx < self.clipboard_stack.items_count(): + raise Exception("Index is out of bounds") + + self.clipboard_stack.set_current_item(idx) + self._stack_changed() + + @pyqtSlot() + def remove_clipboard_item(self): + """Public function to remove a clipboard item""" + self._remove_clipboard_item(self.clipboard_stack.current_item_idx) + self.clipboard_changed_signal.emit(self.clipboard_stack) + + def move_selected_item_up(self): + """Moves the current item in the stack up by one""" + self.clipboard_stack.shift_current_item(Stack.SHIFT_DIRECTION.UP) + self.stack_changed_signal.emit(self.clipboard_stack) + + def move_selected_item_down(self): + """Moves the current item in the stack down by one""" + self.clipboard_stack.shift_current_item(Stack.SHIFT_DIRECTION.DOWN) + self.stack_changed_signal.emit(self.clipboard_stack) + + def _remove_clipboard_item(self, idx): + """Helper function to remove a item from the stack""" + if not self.clipboard_stack.items_count(): + return + + if not 0 <= idx < self.clipboard_stack.items_count(): + raise Exception("Index is out of bounds") + + self.clipboard_stack.pop(idx) + self.stack_changed_signal.emit(self.clipboard_stack) + + def save_state(self, location: str): + """Persists state of the stack to a JSON file""" + _list = [] + for item in self.clipboard_stack.items(): + if isinstance(item, TextClipboardObject): + _list.append({"type": "text", "value": item.text}) + elif isinstance(item, ImageClipboardObject): + _ba = QByteArray() + _buffer = QBuffer(_ba) + _buffer.open(QIODevice.WriteOnly) + item.pixmap.toImage().save(_buffer, 'PNG') + _base64_data = _ba.toBase64().data() + + _list.append({"type": "image", "value": str(_base64_data)}) + + with open(location, 'w') as outfile: + json.dump(_list, outfile) + + def load_state(self, location: str): + """Loads the state of the stack from a JSON file""" + try: + self.clipboard_stack.clear() + with open(location, 'r') as infile: + _stack = json.load(infile) + for saved_item in _stack: + + if saved_item['type'] == 'text': + self.clipboard_stack.push_item(TextClipboardObject(saved_item['value'])) + elif saved_item['type'] == 'image': + _ba = QByteArray.fromBase64(ast.literal_eval(saved_item['value'])) + _img = QImage.fromData(_ba, 'PNG') + _pixmap = QPixmap.fromImage(_img) + self.clipboard_stack.push_item(ImageClipboardObject(_pixmap)) + + self.stack_changed_signal.emit(self.clipboard_stack) + + except Exception: + traceback.print_exc() diff --git a/project/ClipboardManager/ClipboardObject.py b/project/ClipboardManager/ClipboardObject.py new file mode 100644 index 00000000..198f6a19 --- /dev/null +++ b/project/ClipboardManager/ClipboardObject.py @@ -0,0 +1,58 @@ +from abc import ABCMeta +import datetime + +from PyQt5.QtGui import QPixmap + + +class ClipboardObject(metaclass=ABCMeta): + + def __init__(self): + self._date = datetime.datetime.now() + + def date(self): + """Returns when this object was copied to clipboard""" + return self._date + + +class TextClipboardObject(ClipboardObject): + + def __eq__(self, other): + if isinstance(other, self.__class__): + return self.text == other.text + else: + return False + + def __ne__(self, other): + return not self.__eq__(other) + + def __init__(self, text): + super().__init__() + self.text = text + + def set_text(self, text: str): + self.text = text + + def __str__(self): + return self.text + + def __repr__(self): + return self.__str__() + + +class ImageClipboardObject(ClipboardObject): + + def __eq__(self, other): + if isinstance(other, self.__class__): + return self.pixmap.toImage() == other.pixmap.toImage() + else: + return False + + def __ne__(self, other): + return not self.__eq__(other) + + def __init__(self, pixmap: QPixmap): + super().__init__() + self.pixmap = pixmap + + def getImage(self): + return self.pixmap diff --git a/project/ClipboardManager/HistoryManager.py b/project/ClipboardManager/HistoryManager.py new file mode 100644 index 00000000..d9f27a49 --- /dev/null +++ b/project/ClipboardManager/HistoryManager.py @@ -0,0 +1,7 @@ +""" +.. module:: HistoryManager + :synopsis: Persists and loads the state of the clipboard \ + and history upon app close. + +.. moduleauthor:: TBD +""" diff --git a/project/ClipboardManager/__init__.py b/project/ClipboardManager/__init__.py new file mode 100644 index 00000000..009f2596 --- /dev/null +++ b/project/ClipboardManager/__init__.py @@ -0,0 +1,2 @@ + +from .ClipboardManager import ClipboardManager # noqa: F401 diff --git a/project/ConfigManager/ConfigManager.py b/project/ConfigManager/ConfigManager.py new file mode 100644 index 00000000..e9a19fcc --- /dev/null +++ b/project/ConfigManager/ConfigManager.py @@ -0,0 +1,105 @@ +import configparser +import json + +from project.utils import CONSTANTS + + +class ConfigManager: + """Basic settings settable from the settings menu""" + + __instance = None + + @staticmethod + def get_instance(): + """Singleton access method.""" + if ConfigManager.__instance is None: + ConfigManager() + return ConfigManager.__instance + + def __init__(self): + if ConfigManager.__instance is not None: + raise Exception("This class is a singleton. Please use get_instance().") + + ConfigManager.__instance = self + self._config = configparser.ConfigParser() + self._config.read(CONSTANTS['CONFIG_FILE_LOCATION']) + if not self._config.has_section('settings'): + self._init_config() + + def _init_config(self): + """Populates the config block with some default values.""" + self._config.add_section('settings') + self._config.add_section('plugin_settings') + + self._config.set('settings', 'persist_clipboard', 'true') + self._config.set('settings', 'auto_load_top', 'true') + + self._config.set('plugin_settings', 'chain_all_plugins', 'false') + self._config.set('plugin_settings', 'disabled_text_plugins', json.dumps([])) + self._config.set('plugin_settings', 'disabled_image_plugins', json.dumps([])) + + self.save() + + @property + def persist_clipboard(self): + return self._config.getboolean('settings', "persist_clipboard") + + @persist_clipboard.setter + def persist_clipboard(self, value: bool): + self._config['settings']["persist_clipboard"] = 'true' if value else 'false' + + @property + def auto_load_top(self): + return self._config.getboolean('settings', "auto_load_top") + + @auto_load_top.setter + def auto_load_top(self, value: bool): + self._config['settings']["auto_load_top"] = 'true' if value else 'false' + + @property + def chain_all_plugins(self): + return self._config.getboolean('plugin_settings', 'chain_all_plugins') + + @chain_all_plugins.setter + def chain_all_plugins(self, value: bool): + self._config['plugin_settings']['chain_all_plugins'] = 'true' if value else 'false' + + @property + def disabled_text_plugins(self): + return json.loads(self._config.get('plugin_settings', 'disabled_text_plugins')) + + @disabled_text_plugins.setter + def disabled_text_plugins(self, plugin_names: [str]): + self._config['plugin_settings']['disabled_text_plugins'] = json.dumps(plugin_names) + + def disable_text_plugin(self, text_plugin_name: str): + if text_plugin_name not in self.disabled_text_plugins: + self.disabled_text_plugins = self.disabled_text_plugins + [text_plugin_name] + + def enable_text_plugin(self, text_plugin_name): + _temp = self.disabled_text_plugins + if text_plugin_name in _temp: + _temp.remove(text_plugin_name) + self.disabled_text_plugins = _temp + + @property + def disabled_image_plugins(self): + return json.loads(self._config.get('plugin_settings', 'disabled_image_plugins')) + + @disabled_image_plugins.setter + def disabled_image_plugins(self, plugin_names: [str]): + self._config['plugin_settings']['disabled_image_plugins'] = json.dumps(plugin_names) + + def disable_image_plugin(self, image_plugin_name: str): + if image_plugin_name not in self.disabled_text_plugins: + self.disabled_image_plugins = self.disabled_image_plugins + [image_plugin_name] + + def enable_image_plugin(self, image_plugin_name): + _temp = self.disabled_image_plugins + if image_plugin_name in _temp: + _temp.remove(image_plugin_name) + self.disabled_image_plugins = _temp + + def save(self): + with open(CONSTANTS['CONFIG_FILE_LOCATION'], 'w') as file: + self._config.write(file) diff --git a/project/ConfigManager/__init__.py b/project/ConfigManager/__init__.py new file mode 100644 index 00000000..33083209 --- /dev/null +++ b/project/ConfigManager/__init__.py @@ -0,0 +1,2 @@ + +from .ConfigManager import ConfigManager # noqa: F401 diff --git a/project/PluginManager/PluginManager.py b/project/PluginManager/PluginManager.py new file mode 100644 index 00000000..4f538406 --- /dev/null +++ b/project/PluginManager/PluginManager.py @@ -0,0 +1,74 @@ +import logging +import random + +from PyQt5.QtGui import QPixmap + +from project.ClipboardManager.ClipboardObject import TextClipboardObject, ImageClipboardObject +from project.ConfigManager import ConfigManager +from project.Plugins import SpellingMistakesPlugin +from project.Plugins.ImageRotatePlugin import ImageRotatePlugin +from project.Plugins.QuotePlugin import QuotePlugin +from project.Plugins.SynonymPlugin import SynonymPlugin +from project.Stack import Stack + + +class PluginManager: + __instance = None + + @staticmethod + def get_instance(): + """Static access method.""" + if PluginManager.__instance is None: + PluginManager() + return PluginManager.__instance + + def __init__(self): + if PluginManager.__instance is not None: + raise Exception("This class is a singleton. Please use get_instance().") + + PluginManager.__instance = self + self._logger = logging.getLogger(self.__class__.__qualname__) + self.text_plugins = [] + self.image_plugins = [] + + self._config = ConfigManager.get_instance() + + # If this were a real application, would use importlib to dynamically import plugins + # within this folder. + self.text_plugins.append(SpellingMistakesPlugin()) + self.text_plugins.append(SynonymPlugin()) + self.text_plugins.append(QuotePlugin()) + + self.image_plugins.append(ImageRotatePlugin()) + + def on_copy_text(self, text_input: str, stack: Stack): + """Function that is called by the ClipboardManager upon text copy""" + + _enabled_plugins = list( + filter(lambda plugin: plugin.__class__.name() not in self._config.disabled_text_plugins, + self.text_plugins)) + + if not _enabled_plugins: + stack.push_item(TextClipboardObject(text_input)) + return + + _plugin = random.choice(list(_enabled_plugins)) + + self._logger.info("Passing " + text_input + " to plugins!") + self._logger.info("Randomly chose plugin " + _plugin.__class__.__qualname__) + _plugin.on_copy(text_input, stack) + + def on_copy_image(self, image_input: QPixmap, stack: Stack): + """Function that is called by the ClipboardManager upon image copy""" + + _enabled_plugins = list( + filter(lambda plugin: plugin.__class__.name()not in self._config.disabled_image_plugins, + self.image_plugins)) + + if not _enabled_plugins: + stack.push_item(ImageClipboardObject(image_input)) + return + + _plugin = random.choice(_enabled_plugins) + + _plugin.on_copy(image_input, stack) diff --git a/project/PluginManager/__init__.py b/project/PluginManager/__init__.py new file mode 100644 index 00000000..e2dc4c79 --- /dev/null +++ b/project/PluginManager/__init__.py @@ -0,0 +1 @@ +from .PluginManager import PluginManager # noqa: F401 diff --git a/project/Plugins/AbstractPlugin.py b/project/Plugins/AbstractPlugin.py new file mode 100644 index 00000000..3bc38c04 --- /dev/null +++ b/project/Plugins/AbstractPlugin.py @@ -0,0 +1,48 @@ +import logging +from abc import ABCMeta, abstractmethod +from enum import Enum + +from project import Stack + + +class PluginTypes(Enum): + TEXT = 'text' + IMAGE = 'image' + + +class AbstractPlugin(metaclass=ABCMeta): + """A plugin defines an operation to manipulate the item of a clipboard in some way.""" + + def __init__(self): + self._logger = logging.getLogger(self.__class__.name()) + + @staticmethod + def name() -> str: + pass + + @staticmethod + def description() -> str: + pass + + @abstractmethod + def onload(self): + pass + + @staticmethod + def getType(self) -> PluginTypes: + pass + + @abstractmethod + def unload(self): + pass + + @abstractmethod + def on_copy(self, copied_input: any, stack: Stack): + """Applies the function upon the input, + return value should be the same type as the input""" + pass + + @abstractmethod + def on_paste(self, stack: Stack): + """Manipulates the stack in some way; able to get the currently selected item""" + pass diff --git a/project/Plugins/ImageRotatePlugin.py b/project/Plugins/ImageRotatePlugin.py new file mode 100644 index 00000000..13913811 --- /dev/null +++ b/project/Plugins/ImageRotatePlugin.py @@ -0,0 +1,44 @@ +import random + +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QPixmap, QTransform + +from project import Stack +from project.ClipboardManager.ClipboardObject import ImageClipboardObject +from project.Plugins import AbstractPlugin + + +def _rotate_pixmap(pixmap: QPixmap): + """Helper function to rotate image in random multiples of 90 deg""" + + _transforms = [QTransform().rotate(0), + QTransform().rotate(90), + QTransform().rotate(180), + QTransform().rotate(270)] + + # pick a random transform + _transform = random.choice(_transforms) + return pixmap.transformed(_transform, Qt.SmoothTransformation) + + +class ImageRotatePlugin(AbstractPlugin): + + @staticmethod + def name() -> str: + return "ImageRotate" + + @staticmethod + def description() -> str: + return "Rotates your images so you have new ways of looking at them." + + def onload(self): + pass + + def unload(self): + pass + + def on_copy(self, copied_input: any, stack: Stack): + stack.push_item(ImageClipboardObject(_rotate_pixmap(copied_input))) + + def on_paste(self, stack: Stack): + return stack diff --git a/project/Plugins/QuotePlugin.py b/project/Plugins/QuotePlugin.py new file mode 100644 index 00000000..577267e2 --- /dev/null +++ b/project/Plugins/QuotePlugin.py @@ -0,0 +1,45 @@ +import requests +from lxml import html + +from project import Stack +from project.ClipboardManager.ClipboardObject import TextClipboardObject +from project.Plugins import AbstractPlugin + + +class QuotePlugin(AbstractPlugin): + + def _get_quote(self): + """Gets a quote from funnysentences.com API""" + try: + page = requests.get('https://funnysentences.com/sentence-generator/') + tree = html.fromstring(page.content) + quote = tree.xpath('//*[@id="sentencegen"]/text()') + return ''.join(quote) + except requests.exceptions.ConnectionError: + self._logger.info('Fetching quote from API failed') + return None + + @staticmethod + def name() -> str: + return "Quotes" + + @staticmethod + def description() -> str: + return "Adds a random quote to help you sound smarter." + + def onload(self): + pass + + def unload(self): + pass + + def on_copy(self, copied_input: any, stack: Stack): + self._logger.debug(QuotePlugin.name() + " called: " + copied_input) + # push the actual copied text first, then push the quote later + stack.push_item(TextClipboardObject(copied_input)) + _quote = self._get_quote() + if _quote is not None: + stack.push_item(TextClipboardObject(_quote)) + + def on_paste(self, stack: Stack): + return stack diff --git a/project/Plugins/SpellingMistakesPlugin.py b/project/Plugins/SpellingMistakesPlugin.py new file mode 100644 index 00000000..8429189e --- /dev/null +++ b/project/Plugins/SpellingMistakesPlugin.py @@ -0,0 +1,53 @@ +import string +from random import randint, choice + +from project import Stack +from project.ClipboardManager.ClipboardObject import TextClipboardObject +from project.Plugins import AbstractPlugin + + +def _random_spelling_mistakes(text): + text = text.split() + new_words = '' + for word in text: + if len(word) == 1 or len(word) == 2: + final = ''.join(word) + new_words = new_words + ' ' + final + else: + spot = randint(0, len(word) - 1) + if spot == 0: + '''Give it two chances to not be 0, I think its better + if its mostly the middle letters that get removed''' + spot = randint(0, len(word) - 1) + if spot == 0: + pass + final = ''.join(word) + final = final[0:spot] + choice(string.ascii_letters) + final[spot:] + new_words = new_words + ' ' + final + return new_words + + +class SpellingMistakesPlugin(AbstractPlugin): + + @staticmethod + def name() -> str: + return "SpellingMistakes" + + @staticmethod + def description() -> str: + return "To help you sound more natural when writing." + + def onload(self): + pass + + def unload(self): + pass + + def on_copy(self, copied_input: any, stack: Stack): + self._logger.debug(SpellingMistakesPlugin.name() + " called: " + copied_input) + # stack.push_item(copied_input) + stack.push_item(TextClipboardObject(_random_spelling_mistakes(copied_input))) + self._logger.info("Stack size" + str(stack.items_count())) + + def on_paste(self, stack: Stack): + return stack diff --git a/project/Plugins/SynonymPlugin.py b/project/Plugins/SynonymPlugin.py new file mode 100644 index 00000000..ef3f5abf --- /dev/null +++ b/project/Plugins/SynonymPlugin.py @@ -0,0 +1,60 @@ +import time +from random import randint + +import thesaurus + +from project import Stack +from project.ClipboardManager.ClipboardObject import TextClipboardObject +from project.Plugins import AbstractPlugin + + +class SynonymPlugin(AbstractPlugin): + + def _synonym(self, text): + skip_word = False + start = time.time() + text = text.split() + new_words = '' + for word in text: + if skip_word: + new_words = new_words + ' ' + word + skip_word = False + else: + skip_word = True + try: + w = thesaurus.Word(word) + w.synonyms('all') + _synonyms = w.synonyms() + text = _synonyms[randint(0, len(_synonyms) - 1)] + final = ''.join(text) + new_words = new_words + ' ' + final + except thesaurus.exceptions.MisspellingError: + new_words = new_words + ' ' + word + except thesaurus.exceptions.WordNotFoundError: + new_words = new_words + ' ' + word + except TypeError: + new_words = new_words + ' ' + word + end = time.time() + self._logger.info('processing time: ' + str(end - start)) + return new_words + + @staticmethod + def name() -> str: + return "Synonyms" + + @staticmethod + def description() -> str: + return "Picks up the thesaurus for you so you don't have to." + + def onload(self): + pass + + def unload(self): + pass + + def on_copy(self, copied_input: any, stack: Stack): + stack.push_item(TextClipboardObject(self._synonym(copied_input))) + self._logger.info("Stack size" + str(stack.items_count())) + + def on_paste(self, stack: Stack): + return stack diff --git a/project/Plugins/Systray.py b/project/Plugins/Systray.py new file mode 100644 index 00000000..540226f3 --- /dev/null +++ b/project/Plugins/Systray.py @@ -0,0 +1,17 @@ +import sys # noqa: F401 +from PyQt5 import QtWidgets, QtGui +from project.utils import CONSTANTS + + +class SystemTrayIcon(QtWidgets.QSystemTrayIcon): + """ Class which contains logic for a system tray interactive icon""" + + icon = CONSTANTS["ICON_LOCATION"] + name = CONSTANTS["NAME"] + + def __init__(self, parent=None): + QtWidgets.QSystemTrayIcon.__init__(self, QtGui.QIcon(self.icon), parent) + menu = QtWidgets.QMenu(parent) + showAction = menu.addAction("Show Clipboard") # noqa: F841 + self.setContextMenu(menu) + self.setToolTip(self.name) diff --git a/project/Plugins/__init__.py b/project/Plugins/__init__.py new file mode 100644 index 00000000..b1c1bd8e --- /dev/null +++ b/project/Plugins/__init__.py @@ -0,0 +1,2 @@ +from .AbstractPlugin import AbstractPlugin # noqa: F401 +from .SpellingMistakesPlugin import SpellingMistakesPlugin # noqa: F401 diff --git a/project/Stack/Stack.py b/project/Stack/Stack.py new file mode 100644 index 00000000..697fbc30 --- /dev/null +++ b/project/Stack/Stack.py @@ -0,0 +1,96 @@ +""" +.. module:: Stack + :synopsis: Class encapsulating the behavior of the stack. + +.. moduleauthor:: Bryan Kok +""" +import enum + + +class Stack: + """ A collection of strings/other objects """ + + # if it is a collection please consider adding magic + # methods for this (e.g. "__len__", "__iter__",...) + + class SHIFT_DIRECTION(enum.Enum): + UP = 1 + DOWN = 2 + + def __init__(self, cur_stack_pointer=None): + # Stack is backwards??? + self._stack = [] + + if cur_stack_pointer: + self._stack_pointer = cur_stack_pointer + else: + # By default the stack pointer is at the top of the stack (-1 ??) + self._stack_pointer = len(self._stack) - 1 + + # this should be __iter__ + def items(self): + return self._stack + + def items_count(self): + return len(self._stack) + + # this should be __setitem__ + def set_current_item(self, idx): + # What does idx relate to + if not 0 <= idx < len(self._stack): + raise Exception("Index is out of bounds") + + self._stack_pointer = idx + + @property + def current_item_idx(self): + return self._stack_pointer + + @property + def current_item(self): + return self._stack[self._stack_pointer] if self._stack else None + + def shift_current_item(self, shift_direction: SHIFT_DIRECTION): + """Shifts the current item pointed to by the stack pointer up or down""" + + if self._stack_pointer <= 0 and shift_direction == Stack.SHIFT_DIRECTION.DOWN or \ + self._stack_pointer == self.items_count() - 1 \ + and shift_direction == Stack.SHIFT_DIRECTION.UP: + return + + _temp = self._stack[self._stack_pointer] + if shift_direction == Stack.SHIFT_DIRECTION.UP: + self._stack[self._stack_pointer] = self._stack[self._stack_pointer + 1] + self._stack[self._stack_pointer + 1] = _temp + self._stack_pointer += 1 + + elif shift_direction == Stack.SHIFT_DIRECTION.DOWN: + self._stack[self._stack_pointer] = self._stack[self._stack_pointer - 1] + self._stack[self._stack_pointer - 1] = _temp + self._stack_pointer -= 1 + + def swap_items(self, idx, target_idx): + _temp = self._stack[idx] + self._stack[idx] = self._stack[target_idx] + self._stack[target_idx] = _temp + + def push_item(self, item): + self._stack.append(item) + if self.items_count() == 1: + self.set_current_item(0) + + self._stack_pointer = self.items_count() - 1 + + # this should be __getitem__ + def peek(self): + if not self._stack: + return None + return self._stack[-1] # if we reverse stack at start we won't have to do this + + def pop(self, index=-1): + self._stack_pointer = max(0, self._stack_pointer - 1) + + return self._stack.pop(index) + + def clear(self): + self._stack = [] diff --git a/project/Stack/__init__.py b/project/Stack/__init__.py new file mode 100644 index 00000000..8a92fdf4 --- /dev/null +++ b/project/Stack/__init__.py @@ -0,0 +1,2 @@ + +from .Stack import Stack # noqa: F401 diff --git a/project/Widgets/AddItemScreen.py b/project/Widgets/AddItemScreen.py new file mode 100644 index 00000000..6f9600e9 --- /dev/null +++ b/project/Widgets/AddItemScreen.py @@ -0,0 +1,9 @@ +from PyQt5.QtWidgets import QMainWindow + + +class AddItemScreen(QMainWindow): + """Screen that allows users to manually add some ext """ + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Add Item") diff --git a/project/Widgets/MainListWidget.py b/project/Widgets/MainListWidget.py new file mode 100644 index 00000000..473faeca --- /dev/null +++ b/project/Widgets/MainListWidget.py @@ -0,0 +1,84 @@ +""" +.. module:: MainListView + :synopsis: The scrollable area in the main user interface, \ + with helper functions to add or remove objects + +.. moduleauthor:: TBD +""" +from PyQt5.QtWidgets import QListWidget, QWidget, QHBoxLayout, QLabel, QVBoxLayout, QSizePolicy + +from project.ClipboardManager.ClipboardObject import TextClipboardObject, ImageClipboardObject + + +class MainListWidget(QListWidget): + def __init__(self): + super().__init__() + # self.addItem("Sample Item") + + +# https://www.pythoncentral.io/pyside-pyqt-tutorial-the-qlistwidget/ +# Or it can be created with the list as a parent, then automatically added to the list +# Or... https://stackoverflow.com/questions/25187444/pyqt-qlistwidget-custom-items +class TextListWidgetItem(QWidget): + """A row in the scrollview of the actual widget.""" + + def __init__(self, index: int, obj: TextClipboardObject, parent=None): + super(TextListWidgetItem, self).__init__(parent) + + self._main_hbox_layout = QHBoxLayout() + + self._right_section = QWidget() + self._right_vbox_layout = QVBoxLayout() + + self._text_area = QLabel() + self._text_area.setText(obj.text) + + self._date_label = QLabel() + self._date_label.setText(obj.date().strftime("%Y-%m-%d %H:%M:%S")) + + self._right_vbox_layout.addWidget(self._text_area) + self._right_vbox_layout.addWidget(self._date_label) + + self._right_section.setLayout(self._right_vbox_layout) + self._right_section.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + + self._index_label = QLabel() + self._index_label.setText(str(index)) + + self._main_hbox_layout.addWidget(self._index_label) + self._main_hbox_layout.addWidget(self._right_section) + + self.setLayout(self._main_hbox_layout) + + +class ImageListWidgetItem(QWidget): + def __init__(self, index: int, obj: ImageClipboardObject, parent=None): + super().__init__(parent) + + self._main_hbox_layout = QHBoxLayout() + + self._right_section = QWidget() + self._right_vbox_layout = QVBoxLayout() + + # https://stackoverflow.com/questions/2286864/how-can-i-add-a-picture-to-a-qwidget-in-pyqt4 + # works with pixmaps too + self._image_area = QLabel() + self._image_area.setPixmap(obj.pixmap) + + self._date_label = QLabel() + self._date_label.setText(obj.date().strftime("%Y-%m-%d %H:%M:%S")) + + # Date should be at the bottom. + self._right_vbox_layout.addWidget(self._image_area) + self._right_vbox_layout.addWidget(self._date_label) + + self._right_section.setLayout(self._right_vbox_layout) + self._right_section.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + + self._index_label = QLabel() + self._index_label.setText(str(index)) + + self._main_hbox_layout.addWidget(self._index_label) + self._main_hbox_layout.addWidget(self._right_section) + + self.setLayout(self._main_hbox_layout) diff --git a/project/Widgets/PluginsScreen.py b/project/Widgets/PluginsScreen.py new file mode 100644 index 00000000..13f2a2b1 --- /dev/null +++ b/project/Widgets/PluginsScreen.py @@ -0,0 +1,105 @@ +import logging + +from PyQt5.QtWidgets import QMainWindow, QWidget, QVBoxLayout, \ + QGroupBox, QCheckBox, QHBoxLayout, QPushButton, QLabel + +from project.ConfigManager import ConfigManager +from project.PluginManager import PluginManager + + +class PluginsScreen(QMainWindow): + """Enable and disable some of our enhancers""" + + def _save_clicked(self): + _config_mgr = ConfigManager.get_instance() + _config_mgr.save() + self._dirty = False + self._save_btn.setDisabled(True) + + def _text_plugin_checkbox_clicked(self, checked, name): + self._dirty = True + self._save_btn.setDisabled(False) + # self._logger.info(name + ': ' + str(checked)) + _config_mgr = ConfigManager.get_instance() + if checked: + _config_mgr.enable_text_plugin(name) + else: + _config_mgr.disable_text_plugin(name) + + def _image_plugin_checkbox_clicked(self, checked, name): + self._dirty = True + self._save_btn.setDisabled(False) + _config_mgr = ConfigManager.get_instance() + if checked: + _config_mgr.enable_image_plugin(name) + else: + _config_mgr.disable_image_plugin(name) + + def __init__(self, parent=None): + super().__init__(parent) + + self._logger = logging.getLogger(self.__class__.__qualname__) + + self._plugin_manager = PluginManager.get_instance() + + self.setWindowTitle("Plugins") + + _config = ConfigManager.get_instance() + self._dirty = False + + self._central_widget_layout = QVBoxLayout() + self._central_widget = QWidget(self) + + self._text_plugins_group_box = QGroupBox("Text Enhancers") + self._image_plugins_group_box = QGroupBox("Image Enhancers") + + _vbox1 = QVBoxLayout() + + for plugin in self._plugin_manager.text_plugins: + + _checkbox = QCheckBox(plugin.__class__.name(), self) + + if plugin.__class__.name() not in _config.disabled_text_plugins: + _checkbox.setChecked(True) + + # https://stackoverflow.com/questions/19837486/python-lambda-in-a-loop + _checkbox.toggled.connect(lambda checked, name=plugin.__class__.name(): + self._text_plugin_checkbox_clicked(checked, name)) + + _vbox1.addWidget(_checkbox) + _vbox1.addWidget(QLabel(plugin.__class__.description())) + + self._text_plugins_group_box.setLayout(_vbox1) + + _vbox2 = QVBoxLayout() + + for plugin in self._plugin_manager.image_plugins: + _checkbox = QCheckBox(plugin.__class__.name(), self) + + if plugin.__class__.name() not in _config.disabled_image_plugins: + _checkbox.setChecked(True) + + _checkbox.toggled.connect(lambda checked, name=plugin.__class__.name(): + self._image_plugin_checkbox_clicked(checked, name)) + + _vbox2.addWidget(_checkbox) + _vbox2.addWidget(QLabel(plugin.__class__.description())) + + self._image_plugins_group_box.setLayout(_vbox2) + + self._central_widget_layout.addWidget(self._text_plugins_group_box) + self._central_widget_layout.addWidget(self._image_plugins_group_box) + + # Bottom save button + self._bottom_save_widget = QWidget() + self._bottom_save_layout = QHBoxLayout() + self._bottom_save_layout.addStretch(1) + self._save_btn = QPushButton("Save") + self._save_btn.clicked.connect(self._save_clicked) + self._bottom_save_layout.addWidget(self._save_btn) + + self._bottom_save_widget.setLayout(self._bottom_save_layout) + self._central_widget_layout.addWidget(self._bottom_save_widget) + + self._central_widget.setLayout(self._central_widget_layout) + self.setCentralWidget(self._central_widget) diff --git a/project/Widgets/SettingsScreen.py b/project/Widgets/SettingsScreen.py new file mode 100644 index 00000000..0638102a --- /dev/null +++ b/project/Widgets/SettingsScreen.py @@ -0,0 +1,79 @@ +from PyQt5.QtWidgets import QMainWindow, QVBoxLayout, QWidget, QCheckBox, QHBoxLayout, QPushButton + +from project.ConfigManager import ConfigManager + + +class SettingsScreen(QMainWindow): + + def _persist_clipboard_checkbox_clicked(self): + _config_mgr = ConfigManager.get_instance() + _config_mgr.persist_clipboard = not _config_mgr.persist_clipboard + self._dirty = True + self._save_btn.setDisabled(False) + + def _delete_after_paste_checkbox_clicked(self): + _config_mgr = ConfigManager.get_instance() + _config_mgr.delete_after_paste = not _config_mgr.delete_after_paste + self._dirty = True + self._save_btn.setDisabled(False) + + def _auto_load_top_checkbox_clicked(self): + _config_mgr = ConfigManager.get_instance() + _config_mgr.auto_load_top = not _config_mgr.auto_load_top + self._dirty = True + self._save_btn.setDisabled(False) + + def _save_clicked(self): + _config_mgr = ConfigManager.get_instance() + _config_mgr.save() + self._dirty = False + self._save_btn.setDisabled(True) + + def __init__(self, parent=None): + super(SettingsScreen, self).__init__(parent) + + self.setWindowTitle('Settings') + _config_mgr = ConfigManager.get_instance() + + self._dirty = False # Changed settings but not saved + + self._central_widget_layout = QVBoxLayout() + self._central_widget = QWidget(self) + + # Checkboxes begin here + self._persist_clipboard_checkbox = \ + QCheckBox("Persist clipboard upon app quit", self) + + # Can't capture key events outside app easily in a cross-platform way/security risk + # self._delete_after_paste_checkbox = \ + # QCheckBox("Delete selected clipboard item after paste", self) + self._auto_load_top_checkbox = \ + QCheckBox("Always load top item into clipboard", self) + + self._persist_clipboard_checkbox.setChecked(_config_mgr.persist_clipboard) + # self._delete_after_paste_checkbox.setChecked(_config_mgr.delete_after_paste) + self._auto_load_top_checkbox.setChecked(_config_mgr.auto_load_top) + + self._persist_clipboard_checkbox.toggled.connect(self._persist_clipboard_checkbox_clicked) + # self._delete_after_paste_checkbox.toggled.connect(self._delete_after_paste_checkbox_clicked) + self._auto_load_top_checkbox.toggled.connect(self._auto_load_top_checkbox_clicked) + + self._central_widget_layout.addWidget(self._persist_clipboard_checkbox) + # self._central_widget_layout.addWidget(self._delete_after_paste_checkbox) + self._central_widget_layout.addWidget(self._auto_load_top_checkbox) + + # Bottom save button + self._bottom_save_widget = QWidget() + self._bottom_save_layout = QHBoxLayout() + self._bottom_save_layout.addStretch(1) + self._save_btn = QPushButton("Save") + self._save_btn.clicked.connect(self._save_clicked) + self._bottom_save_layout.addWidget(self._save_btn) + + self._bottom_save_widget.setLayout(self._bottom_save_layout) + self._central_widget_layout.addWidget(self._bottom_save_widget) + + self._central_widget.setLayout(self._central_widget_layout) + self.setCentralWidget(self._central_widget) + + # self.setFixedSize(self.size()) diff --git a/project/Widgets/__init__.py b/project/Widgets/__init__.py new file mode 100644 index 00000000..9450ee82 --- /dev/null +++ b/project/Widgets/__init__.py @@ -0,0 +1 @@ +from .MainListWidget import MainListWidget, TextListWidgetItem # noqa: F401 diff --git a/project/__init__.py b/project/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/project/__main__.py b/project/__main__.py index e69de29b..618f6e06 100644 --- a/project/__main__.py +++ b/project/__main__.py @@ -0,0 +1,280 @@ +import logging +import os +import sys +from pathlib import Path + +from PyQt5 import QtWidgets +from PyQt5.QtCore import pyqtSlot, pyqtSignal +from PyQt5.QtGui import QIcon +from PyQt5.QtGui import QKeySequence +from PyQt5.QtWidgets import QMainWindow, QApplication, \ + QHBoxLayout, QVBoxLayout, QWidget, QListWidgetItem, QFileDialog + +from project import ClipboardManager +from project.ClipboardManager.ClipboardObject import TextClipboardObject, ImageClipboardObject +from project.ConfigManager import ConfigManager +from project.Plugins.Systray import SystemTrayIcon +from project.Stack import Stack +from project.Widgets import MainListWidget, TextListWidgetItem +from project.Widgets.MainListWidget import ImageListWidgetItem +from project.Widgets.PluginsScreen import PluginsScreen +from project.Widgets.SettingsScreen import SettingsScreen +from project.utils import CONSTANTS + + +class ActionBar(QWidget): + """ A bar which contains the controls for adding to this list of clipboard items""" + + def __init__(self): + super().__init__() + _horizontal_layout = QHBoxLayout(self) + + # self._add_btn = QtWidgets.QPushButton("Add") + # can we not do this in the constructor? + # self._add_btn.setObjectName(MainWindow.ADD_BUTTON_NAME) + + self._remove_btn = QtWidgets.QPushButton("Remove") + # self._remove_btn.setGeometry(QtCore.QRect(50, 3, 51, 20)) + # self._remove_btn.clicked.connect() + self._remove_btn.setObjectName(MainWindow.REMOVE_BUTTON_NAME) + + # self._edit_btn = QtWidgets.QPushButton("Edit") + # self._edit_btn.setGeometry(QtCore.QRect(100, 3, 51, 20)) + # self._edit_btn.setObjectName(MainWindow.EDIT_BUTTON_NAME) + # self._edit_btn.clicked.connect(self._start_editor) + + self._move_up_btn = QtWidgets.QPushButton("Move Up") + self._move_up_btn.setObjectName(MainWindow.MOVE_UP_BUTTON_NAME) + + self._move_down_btn = QtWidgets.QPushButton("Move Down") + self._move_down_btn.setObjectName(MainWindow.MOVE_DOWN_BUTTON_NAME) + + # _horizontal_layout.addWidget(self._add_btn) + _horizontal_layout.addWidget(self._remove_btn) + # _horizontal_layout.addWidget(self._edit_btn) + _horizontal_layout.addWidget(self._move_up_btn) + _horizontal_layout.addWidget(self._move_down_btn) + + _horizontal_layout.addStretch(1) + + def _start_editor(text): + Editor.Editor.start() + + +class MainWindow(QMainWindow): + add_btn_signal = pyqtSignal() + remove_btn_signal = pyqtSignal() + + move_up_btn_signal = pyqtSignal() + move_down_btn_signal = pyqtSignal() + + item_selected = pyqtSignal(int) + + """ MainWindow """ + # strange constants: + CENTRAL_WIDGET_NAME = 'central_widget' + ADD_BUTTON_NAME = 'add_btn' + REMOVE_BUTTON_NAME = 'remove_btn' + EDIT_BUTTON_NAME = 'edit_btn' + MOVE_UP_BUTTON_NAME = 'move_up_btn' + MOVE_DOWN_BUTTON_NAME = 'move_down_btn' + + def __init__(self, clipboard_manager: ClipboardManager): + """ Initialises new MainWindow class """ + super().__init__() + self._logger = logging.getLogger(self.__class__.__qualname__) + + self._central_widget_layout = QVBoxLayout() + self._central_widget = QtWidgets.QWidget(self) + self._clipboard_manager = clipboard_mgr + + # self._clipboard_manager.bind_clipboard_state_callback(self._render_clipboard_stack) + self._clipboard_manager.clipboard_changed_signal.connect(self._render_clipboard_stack) + self._clipboard_manager.stack_changed_signal.connect(self._render_clipboard_stack) + + self.remove_btn_signal.connect(self._clipboard_manager.remove_clipboard_item) + self.move_up_btn_signal.connect(self._clipboard_manager.move_selected_item_up) + self.move_down_btn_signal.connect(self._clipboard_manager.move_selected_item_down) + + self.item_selected.connect(self._clipboard_manager.set_selected_object) + # self._main_list_widget.itemClicked.connect(self._set_selected_object) + + self._settings_screen = SettingsScreen(self) + + self._plugins_screen = PluginsScreen(self) + + self._init_ui() + + @pyqtSlot(int) + def _set_current_row(self, idx): + # if no item is selected, idx will be -1 + if idx > -1: + self.item_selected.emit(self._clipboard_manager + .clipboard_stack.items_count() - max(0, idx) - 1) + + # @pyqtSlot(QListWidgetItem) + # def _set_selected_object(self, list_item: QListWidgetItem): + # self.item_selected.emit(self._main_list_widget.currentRow()) + + def _init_ui(self): + self.setWindowTitle(CONSTANTS['NAME']) + # self.setGeometry(self.left, self.top, self.width, self.height) + self.setupUi() + self.show() + + def _show_settings_window(self): + self._settings_screen.show() + + def _show_plugins_window(self): + self._plugins_screen.show() + + @pyqtSlot(Stack) + def _render_clipboard_stack(self, clipboard_stack: Stack): + self._main_list_widget.clear() + # newest items should be at the top + for (idx, clipboard_object) in enumerate(reversed(clipboard_stack.items())): + if isinstance(clipboard_object, TextClipboardObject): + _item = QListWidgetItem(self._main_list_widget) + _custom_item = TextListWidgetItem(clipboard_stack.items_count() + - idx - 1, clipboard_object) + _item.setSizeHint(_custom_item.sizeHint()) + + self._main_list_widget.addItem(_item) + self._main_list_widget.setItemWidget(_item, _custom_item) + + elif isinstance(clipboard_object, ImageClipboardObject): + _item = QListWidgetItem(self._main_list_widget) + _custom_item = ImageListWidgetItem(clipboard_stack.items_count() - idx - 1, + clipboard_object) + _item.setSizeHint(_custom_item.sizeHint()) + self._main_list_widget.addItem(_item) + self._main_list_widget.setItemWidget(_item, _custom_item) + + self._main_list_widget.setCurrentRow(self._clipboard_manager.clipboard_stack.items_count() + - self._clipboard_manager.clipboard_stack + .current_item_idx - 1) + + def setupUi(self): + + self._central_widget.setObjectName(MainWindow.CENTRAL_WIDGET_NAME) + + self._action_bar = ActionBar() + self._action_bar._remove_btn.clicked.connect(self.remove_btn_signal) + self._action_bar._move_up_btn.clicked.connect(self.move_up_btn_signal) + self._action_bar._move_down_btn.clicked.connect(self.move_down_btn_signal) + + self._central_widget_layout.addWidget(self._action_bar) + + self._main_list_widget = MainListWidget() + self._main_list_widget.currentRowChanged.connect(self._set_current_row) + + self._central_widget_layout.addWidget(self._main_list_widget) + + # Menu Bar + self.menubar = self.menuBar() + + # File Menu Bar + self.fileMenu = QtWidgets.QMenu(self.menubar) + self.fileMenu.setObjectName("fileMenu") + self.fileMenu.setTitle("File") + + # Save: + self.saveOption = QtWidgets.QAction(self) + self.saveOption.setObjectName("saveOption") + self.saveOption.setText("Save current clipboard") + self.saveOption.triggered.connect(self._save) + + self.fileMenu.addAction(self.saveOption) + + # Load: + self.loadOption = QtWidgets.QAction(self) + self.loadOption.setObjectName("loadOption") + self.loadOption.setText("Load existing clipboard") + self.loadOption.triggered.connect(self._load) + + self.fileMenu.addAction(self.loadOption) + + self.menubar.addAction(self.fileMenu.menuAction()) + + # Settings Menu Bar + self.settingsMenu = QtWidgets.QMenu(self.menubar) + self.settingsMenu.setObjectName("settingsMenu") + self.settingsMenu.setTitle("Settings") + + # General Settings + self.actionSettings = QtWidgets.QAction(self) + self.actionSettings.setObjectName("actionSettings") + self.actionSettings.setText("General") + self.actionSettings.triggered.connect(self._show_settings_window) + + self.settingsMenu.addAction(self.actionSettings) + + # Plugin Settings + self.pluginSettings = QtWidgets.QAction(self) + self.pluginSettings.setObjectName("actionPlugins") + self.pluginSettings.setText('Plugins') + self.pluginSettings.triggered.connect(self._show_plugins_window) + + self.settingsMenu.addAction(self.pluginSettings) + + self.menubar.addAction(self.settingsMenu.menuAction()) + + self._central_widget.setLayout(self._central_widget_layout) + self.setCentralWidget(self._central_widget) + + # https://stackoverflow.com/questions/2462401/problem-in-understanding-connectslotsbyname-in-pyqt + # Better to use new-style decorator @QtCore.pyqtSlot() + # QtCore.QMetaObject.connectSlotsByName(self) + + self.show() + + _config = ConfigManager.get_instance() + + # check whether the autosave file is present and auto persist checked, if so, try to load it + _autosave_path_location = Path('./' + CONSTANTS['AUTOSAVE_DATA_FILE']).resolve() + print(_autosave_path_location) + if _config.persist_clipboard and os.path.isfile(_autosave_path_location): + self._clipboard_manager.load_state(_autosave_path_location) + # print(self.clipboard_stack.items_count()) + + def closeEvent(self, event): + """Fires on window close""" + _config = ConfigManager.get_instance() + if _config.persist_clipboard: + self._clipboard_manager.save_state(CONSTANTS['AUTOSAVE_DATA_FILE']) + + def keyPressEvent(self, event): + self._logger.info("Key press detected") + if event.matches(QKeySequence.Paste): + self._logger.info("Detected paste") + + def _load(self): + """ Function to load stack""" + filename, _ = QFileDialog.getOpenFileName(self, 'Load Clipboard State', filter='*.json') + if filename: + self._clipboard_manager.load_state(filename) + + def _save(self): + """ Function to save stack""" + filename, _ = QFileDialog.getSaveFileName(self, 'Save Clipboard State', filter='*.json') + if filename: + self._clipboard_manager.save_state(filename) + + +if __name__ == '__main__': + logging.basicConfig(level=logging.INFO) + logger = logging.getLogger(__name__) + logger.info("App Started") + app = QApplication(sys.argv) + + app.setWindowIcon(QIcon(CONSTANTS["ICON_LOCATION"])) + + clipboard_mgr = ClipboardManager.ClipboardManager() + main_window = MainWindow(clipboard_mgr) + + # Creates and starts systray icon + w = QtWidgets.QDesktopWidget() + systray = SystemTrayIcon(w) + systray.show() + + sys.exit(app.exec_()) diff --git a/project/icon.ico b/project/icon.ico new file mode 100644 index 00000000..eb0b77d7 Binary files /dev/null and b/project/icon.ico differ diff --git a/project/tests/__init__.py b/project/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/project/tests/test_stack.py b/project/tests/test_stack.py new file mode 100644 index 00000000..5cd7dab5 --- /dev/null +++ b/project/tests/test_stack.py @@ -0,0 +1,73 @@ +""" +.. module:: test_stack.py + :synopsis: Unit tests for the stack, the core of this app. We should have started testing earlier, eh? + +.. moduleauthor:: Bryan Kok +""" +import pytest + +from project.Stack import Stack + + +@pytest.fixture(scope='function') +def clean_stack(): + return Stack() + + +def test_clear(clean_stack): + """Ensure that clear empties the stack.""" + _stack = clean_stack + for i in range(5): + _stack.push_item(i) + + assert (_stack.items_count() == 5) + + _stack.clear() + assert (_stack.items_count() == 0) + + +def test_push_items(clean_stack): + """Ensure that the stack retains pushed items""" + + _stack = clean_stack + _list = [1, 2, 'a', 'b', 'c'] + for _item in _list: + _stack.push_item(_item) + + assert (_stack.items_count() == 5) + assert (_stack.items() == _list) + _stack.clear() + + +def test_shift_item_down(clean_stack): + """Shifting an item down should not affect order of other items in stack""" + + _stack = clean_stack + _list = [1, 2, 'a', 'b', 'c', 3, 4, 5] + for _item in _list: + _stack.push_item(_item) + + _stack.set_current_item(3) # b + _stack.shift_current_item(Stack.SHIFT_DIRECTION.DOWN) + assert (_stack.items() == [1, 2, 'b', 'a', 'c', 3, 4, 5]) + + +def test_shift_item_up(clean_stack): + """Shifting an item up should not affect order of other items in stack""" + + _stack = clean_stack + _list = [1, 2, 'a', 'b', 'c', 3, 4, 5] + for _item in _list: + _stack.push_item(_item) + + _stack.set_current_item(3) # b + _stack.shift_current_item(Stack.SHIFT_DIRECTION.UP) + assert (_stack.items() == [1, 2, 'a', 'c', 'b', 3, 4, 5]) + + +def test_peek(clean_stack): + """The top of the stack should always be returned by peek""" + _stack = clean_stack + for _item in [1, 2, 3]: + _stack.push_item(_item) + assert (_stack.peek() == _item) diff --git a/project/utils.py b/project/utils.py new file mode 100644 index 00000000..915511e8 --- /dev/null +++ b/project/utils.py @@ -0,0 +1,16 @@ +""" +.. module:: utils + :synopsis: File containing constants and static functions. + +.. moduleauthor:: Bryan Kok +""" + + +CONSTANTS = { + 'NAME': 'Clipboard Mangler', + 'NUMOFOBJECTS': 0, + 'HISTORY_MAX_ITEMS': 10, + 'AUTOSAVE_DATA_FILE': 'project/autosave.data', + 'CONFIG_FILE_LOCATION': 'project/config.ini', + 'ICON_LOCATION': "project\\icon.ico" +}