diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml index 2ee12209c..3ecf47dd8 100644 --- a/.idea/dataSources.xml +++ b/.idea/dataSources.xml @@ -1,7 +1,7 @@ - + sqlite.xerial true org.sqlite.JDBC diff --git a/package-lock.json b/package-lock.json index 331341b20..1f7ab50ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,9 +17,9 @@ "dev": true }, "@babel/runtime": { - "version": "7.9.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.9.2.tgz", - "integrity": "sha512-NE2DtOdufG7R5vnfQUTehdTfNycfUANEtCa9PssN9O/xmTzP4E08UI797ixaei6hBEVL9BI/PsdJS5x7mWoB9Q==", + "version": "7.9.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.9.6.tgz", + "integrity": "sha512-64AF1xY3OAkFHqOb9s4jpgk1Mm5vDZ4L3acHvAml+53nO1XbXLuDodsVpO4OIUsmemlUHMxNdYMNJmsvOwLrvQ==", "requires": { "regenerator-runtime": "^0.13.4" } @@ -198,26 +198,24 @@ } }, "@jimp/bmp": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@jimp/bmp/-/bmp-0.10.3.tgz", - "integrity": "sha512-keMOc5woiDmONXsB/6aXLR4Z5Q+v8lFq3EY2rcj2FmstbDMhRuGbmcBxlEgOqfRjwvtf/wOtJ3Of37oAWtVfLg==", + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@jimp/bmp/-/bmp-0.12.1.tgz", + "integrity": "sha512-t16IamuBMv4GiGa1VAMzsgrVKVANxXG81wXECzbikOUkUv7pKJ2vHZDgkLBEsZQ9sAvFCneM1+yoSRpuENrfVQ==", "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.3", - "bmp-js": "^0.1.0", - "core-js": "^3.4.1" + "@jimp/utils": "^0.12.1", + "bmp-js": "^0.1.0" } }, "@jimp/core": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@jimp/core/-/core-0.10.3.tgz", - "integrity": "sha512-Gd5IpL3U2bFIO57Fh/OA3HCpWm4uW/pU01E75rI03BXfTdz3T+J7TwvyG1XaqsQ7/DSlS99GXtLQPlfFIe28UA==", + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@jimp/core/-/core-0.12.1.tgz", + "integrity": "sha512-mWfjExYEjHxBal+1gPesGChOQBSpxO7WUQkrO9KM7orboitOdQ15G5UA75ce7XVZ+5t+FQPOLmVkVZzzTQSEJA==", "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.3", + "@jimp/utils": "^0.12.1", "any-base": "^1.1.0", "buffer": "^5.2.0", - "core-js": "^3.4.1", "exif-parser": "^0.1.12", "file-type": "^9.0.0", "load-bmfont": "^1.3.1", @@ -235,323 +233,294 @@ } }, "@jimp/custom": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@jimp/custom/-/custom-0.10.3.tgz", - "integrity": "sha512-nZmSI+jwTi5IRyNLbKSXQovoeqsw+D0Jn0SxW08wYQvdkiWA8bTlDQFgQ7HVwCAKBm8oKkDB/ZEo9qvHJ+1gAQ==", + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@jimp/custom/-/custom-0.12.1.tgz", + "integrity": "sha512-bVClp8FEJ/11GFTKeRTrfH7NgUWvVO5/tQzO/68aOwMIhbz9BOYQGh533K9+mSy29VjZJo8jxZ0C9ZwYHuFwfA==", "requires": { "@babel/runtime": "^7.7.2", - "@jimp/core": "^0.10.3", - "core-js": "^3.4.1" + "@jimp/core": "^0.12.1" } }, "@jimp/gif": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@jimp/gif/-/gif-0.10.3.tgz", - "integrity": "sha512-vjlRodSfz1CrUvvrnUuD/DsLK1GHB/yDZXHthVdZu23zYJIW7/WrIiD1IgQ5wOMV7NocfrvPn2iqUfBP81/WWA==", + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@jimp/gif/-/gif-0.12.1.tgz", + "integrity": "sha512-cGn/AcvMGUGcqR6ByClGSnrja4AYmTwsGVXTQ1+EmfAdTiy6ztGgZCTDpZ/tq4SpdHXwm9wDHez7damKhTrH0g==", "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.3", - "core-js": "^3.4.1", + "@jimp/utils": "^0.12.1", "omggif": "^1.0.9" } }, "@jimp/jpeg": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@jimp/jpeg/-/jpeg-0.10.3.tgz", - "integrity": "sha512-AAANwgUZOt6f6P7LZxY9lyJ9xclqutYJlsxt3JbriXUGJgrrFAIkcKcqv1nObgmQASSAQKYaMV9KdHjMlWFKlQ==", + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@jimp/jpeg/-/jpeg-0.12.1.tgz", + "integrity": "sha512-UoCUHbKLj2CDCETd7LrJnmK/ExDsSfJXmc1pKkfgomvepjXogdl2KTHf141wL6D+9CfSD2VBWQLC5TvjMvcr9A==", "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.3", - "core-js": "^3.4.1", - "jpeg-js": "^0.3.4" + "@jimp/utils": "^0.12.1", + "jpeg-js": "^0.4.0" } }, "@jimp/plugin-blit": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@jimp/plugin-blit/-/plugin-blit-0.10.3.tgz", - "integrity": "sha512-5zlKlCfx4JWw9qUVC7GI4DzXyxDWyFvgZLaoGFoT00mlXlN75SarlDwc9iZ/2e2kp4bJWxz3cGgG4G/WXrbg3Q==", + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-blit/-/plugin-blit-0.12.1.tgz", + "integrity": "sha512-VRBB6bx6EpQuaH0WX8ytlGNqUQcmuxXBbzL3e+cD0W6MluYibzQy089okvXcyUS72Q+qpSMmUDCVr3pDqLAsSA==", "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.3", - "core-js": "^3.4.1" + "@jimp/utils": "^0.12.1" } }, "@jimp/plugin-blur": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@jimp/plugin-blur/-/plugin-blur-0.10.3.tgz", - "integrity": "sha512-cTOK3rjh1Yjh23jSfA6EHCHjsPJDEGLC8K2y9gM7dnTUK1y9NNmkFS23uHpyjgsWFIoH9oRh2SpEs3INjCpZhQ==", + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-blur/-/plugin-blur-0.12.1.tgz", + "integrity": "sha512-rTFY0yrwVJFNgNsAlYGn2GYCRLVEcPQ6cqAuhNylXuR/7oH3Acul+ZWafeKtvN8D8uMlth/6VP74gruXvwffZw==", "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.3", - "core-js": "^3.4.1" + "@jimp/utils": "^0.12.1" } }, "@jimp/plugin-circle": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@jimp/plugin-circle/-/plugin-circle-0.10.3.tgz", - "integrity": "sha512-51GAPIVelqAcfuUpaM5JWJ0iWl4vEjNXB7p4P7SX5udugK5bxXUjO6KA2qgWmdpHuCKtoNgkzWU9fNSuYp7tCA==", + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-circle/-/plugin-circle-0.12.1.tgz", + "integrity": "sha512-+/OiBDjby7RBbQoDX8ZsqJRr1PaGPdTaaKUVGAsrE7KCNO9ODYNFAizB9lpidXkGgJ4Wx5R4mJy21i22oY/a4Q==", "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.3", - "core-js": "^3.4.1" + "@jimp/utils": "^0.12.1" } }, "@jimp/plugin-color": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@jimp/plugin-color/-/plugin-color-0.10.3.tgz", - "integrity": "sha512-RgeHUElmlTH7vpI4WyQrz6u59spiKfVQbsG/XUzfWGamFSixa24ZDwX/yV/Ts+eNaz7pZeIuv533qmKPvw2ujg==", + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-color/-/plugin-color-0.12.1.tgz", + "integrity": "sha512-xlnK/msWN4uZ+Bu7+UrCs9oMzTSA9QE0jWFnF3h0aBsD8t1LGxozkckHe8nHtC/y/sxIa8BGKSfkiaW+r6FbnA==", "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.3", - "core-js": "^3.4.1", + "@jimp/utils": "^0.12.1", "tinycolor2": "^1.4.1" } }, "@jimp/plugin-contain": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@jimp/plugin-contain/-/plugin-contain-0.10.3.tgz", - "integrity": "sha512-bYJKW9dqzcB0Ihc6u7jSyKa3juStzbLs2LFr6fu8TzA2WkMS/R8h+ddkiO36+F9ILTWHP0CIA3HFe5OdOGcigw==", + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-contain/-/plugin-contain-0.12.1.tgz", + "integrity": "sha512-WZ/D6G0jhnBh2bkBh610PEh/caGhAUIAxYLsQsfSSlOxPsDhbj3S6hMbFKRgnDvf0hsd5zTIA0j1B0UG4kh18A==", "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.3", - "core-js": "^3.4.1" + "@jimp/utils": "^0.12.1" } }, "@jimp/plugin-cover": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@jimp/plugin-cover/-/plugin-cover-0.10.3.tgz", - "integrity": "sha512-pOxu0cM0BRPzdV468n4dMocJXoMbTnARDY/EpC3ZW15SpMuc/dr1KhWQHgoQX5kVW1Wt8zgqREAJJCQ5KuPKDA==", + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-cover/-/plugin-cover-0.12.1.tgz", + "integrity": "sha512-ddWwTQO40GcabJ2UwUYCeuNxnjV4rBTiLprnjGMqAJCzdz3q3Sp20FkRf+H+E22k2v2LHss8dIOFOF4i6ycr9Q==", "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.3", - "core-js": "^3.4.1" + "@jimp/utils": "^0.12.1" } }, "@jimp/plugin-crop": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@jimp/plugin-crop/-/plugin-crop-0.10.3.tgz", - "integrity": "sha512-nB7HgOjjl9PgdHr076xZ3Sr6qHYzeBYBs9qvs3tfEEUeYMNnvzgCCGtUl6eMakazZFCMk3mhKmcB9zQuHFOvkg==", + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-crop/-/plugin-crop-0.12.1.tgz", + "integrity": "sha512-CKjVkrNO8FDZKYVpMireQW4SgKBSOdF+Ip/1sWssHHe77+jGEKqOjhYju+VhT3dZJ3+75rJNI9II7Kethp+rTw==", "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.3", - "core-js": "^3.4.1" + "@jimp/utils": "^0.12.1" } }, "@jimp/plugin-displace": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@jimp/plugin-displace/-/plugin-displace-0.10.3.tgz", - "integrity": "sha512-8t3fVKCH5IVqI4lewe4lFFjpxxr69SQCz5/tlpDLQZsrNScNJivHdQ09zljTrVTCSgeCqQJIKgH2Q7Sk/pAZ0w==", + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-displace/-/plugin-displace-0.12.1.tgz", + "integrity": "sha512-MQAw2iuf1/bVJ6P95WWTLA+WBjvIZ7TeGBerkvBaTK8oWdj+NSLNRIYOIoyPbZ7DTL8f1SN4Vd6KD6BZaoWrwg==", "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.3", - "core-js": "^3.4.1" + "@jimp/utils": "^0.12.1" } }, "@jimp/plugin-dither": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@jimp/plugin-dither/-/plugin-dither-0.10.3.tgz", - "integrity": "sha512-JCX/oNSnEg1kGQ8ffZ66bEgQOLCY3Rn+lrd6v1jjLy/mn9YVZTMsxLtGCXpiCDC2wG/KTmi4862ysmP9do9dAQ==", + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-dither/-/plugin-dither-0.12.1.tgz", + "integrity": "sha512-mCrBHdx2ViTLJDLcrobqGLlGhZF/Mq41bURWlElQ2ArvrQ3/xR52We9DNDfC08oQ2JVb6q3v1GnCCdn0KNojGQ==", "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.3", - "core-js": "^3.4.1" + "@jimp/utils": "^0.12.1" } }, "@jimp/plugin-fisheye": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@jimp/plugin-fisheye/-/plugin-fisheye-0.10.3.tgz", - "integrity": "sha512-RRZb1wqe+xdocGcFtj2xHU7sF7xmEZmIa6BmrfSchjyA2b32TGPWKnP3qyj7p6LWEsXn+19hRYbjfyzyebPElQ==", + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-fisheye/-/plugin-fisheye-0.12.1.tgz", + "integrity": "sha512-CHvYSXtHNplzkkYzB44tENPDmvfUHiYCnAETTY+Hx58kZ0w8ERZ+OiLhUmiBcvH/QHm/US1iiNjgGUAfeQX6dg==", "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.3", - "core-js": "^3.4.1" + "@jimp/utils": "^0.12.1" } }, "@jimp/plugin-flip": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@jimp/plugin-flip/-/plugin-flip-0.10.3.tgz", - "integrity": "sha512-0epbi8XEzp0wmSjoW9IB0iMu0yNF17aZOxLdURCN3Zr+8nWPs5VNIMqSVa1Y62GSyiMDpVpKF/ITiXre+EqrPg==", + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-flip/-/plugin-flip-0.12.1.tgz", + "integrity": "sha512-xi+Yayrnln8A/C9E3yQBExjxwBSeCkt/ZQg1CxLgszVyX/3Zo8+nkV8MJYpkTpj8LCZGTOKlsE05mxu/a3lbJQ==", "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.3", - "core-js": "^3.4.1" + "@jimp/utils": "^0.12.1" } }, "@jimp/plugin-gaussian": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@jimp/plugin-gaussian/-/plugin-gaussian-0.10.3.tgz", - "integrity": "sha512-25eHlFbHUDnMMGpgRBBeQ2AMI4wsqCg46sue0KklI+c2BaZ+dGXmJA5uT8RTOrt64/K9Wz5E+2n7eBnny4dfpQ==", + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-gaussian/-/plugin-gaussian-0.12.1.tgz", + "integrity": "sha512-7O6eKlhL37hsLfV6WAX1Cvce7vOqSwL1oWbBveC1agutDlrtvcTh1s2mQ4Pde654hCJu55mq1Ur10+ote5j3qw==", "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.3", - "core-js": "^3.4.1" + "@jimp/utils": "^0.12.1" } }, "@jimp/plugin-invert": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@jimp/plugin-invert/-/plugin-invert-0.10.3.tgz", - "integrity": "sha512-effYSApWY/FbtlzqsKXlTLkgloKUiHBKjkQnqh5RL4oQxh/33j6aX+HFdDyQKtsXb8CMd4xd7wyiD2YYabTa0g==", + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-invert/-/plugin-invert-0.12.1.tgz", + "integrity": "sha512-JTAs7A1Erbxwl+7ph7tgcb2PZ4WzB+3nb2WbfiWU8iCrKj17mMDSc5soaCCycn8wfwqvgB1vhRfGpseOLWxsuQ==", "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.3", - "core-js": "^3.4.1" + "@jimp/utils": "^0.12.1" } }, "@jimp/plugin-mask": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@jimp/plugin-mask/-/plugin-mask-0.10.3.tgz", - "integrity": "sha512-twrg8q8TIhM9Z6Jcu9/5f+OCAPaECb0eKrrbbIajJqJ3bCUlj5zbfgIhiQIzjPJ6KjpnFPSqHQfHkU1Vvk/nVw==", + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-mask/-/plugin-mask-0.12.1.tgz", + "integrity": "sha512-bnDdY0RO/x5Mhqoy+056SN1wEj++sD4muAKqLD2CIT8Zq5M/0TA4hkdf/+lwFy3H2C0YTK39PSE9xyb4jPX3kA==", "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.3", - "core-js": "^3.4.1" + "@jimp/utils": "^0.12.1" } }, "@jimp/plugin-normalize": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@jimp/plugin-normalize/-/plugin-normalize-0.10.3.tgz", - "integrity": "sha512-xkb5eZI/mMlbwKkDN79+1/t/+DBo8bBXZUMsT4gkFgMRKNRZ6NQPxlv1d3QpRzlocsl6UMxrHnhgnXdLAcgrXw==", + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-normalize/-/plugin-normalize-0.12.1.tgz", + "integrity": "sha512-4kSaI4JLM/PNjHwbnAHgyh51V5IlPfPxYvsZyZ1US32pebWtocxSMaSuOaJUg7OGSkwSDBv81UR2h5D+Dz1b5A==", "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.3", - "core-js": "^3.4.1" + "@jimp/utils": "^0.12.1" } }, "@jimp/plugin-print": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@jimp/plugin-print/-/plugin-print-0.10.3.tgz", - "integrity": "sha512-wjRiI6yjXsAgMe6kVjizP+RgleUCLkH256dskjoNvJzmzbEfO7xQw9g6M02VET+emnbY0CO83IkrGm2q43VRyg==", + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-print/-/plugin-print-0.12.1.tgz", + "integrity": "sha512-T0lNS3qU9SwCHOEz7AGrdp50+gqiWGZibOL3350/X/dqoFs1EvGDjKVeWncsGCyLlpfd7M/AibHZgu8Fx2bWng==", "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.3", - "core-js": "^3.4.1", + "@jimp/utils": "^0.12.1", "load-bmfont": "^1.4.0" } }, "@jimp/plugin-resize": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-0.10.3.tgz", - "integrity": "sha512-rf8YmEB1d7Sg+g4LpqF0Mp+dfXfb6JFJkwlAIWPUOR7lGsPWALavEwTW91c0etEdnp0+JB9AFpy6zqq7Lwkq6w==", + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-0.12.1.tgz", + "integrity": "sha512-sbNn4tdBGcgGlPt9XFxCuDl4ZOoxa8/Re8nAikyxYhRss2Dqz91ARbBQxOf1vlUGeicQMsjEuWbPQAogTSJRug==", "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.3", - "core-js": "^3.4.1" + "@jimp/utils": "^0.12.1" } }, "@jimp/plugin-rotate": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@jimp/plugin-rotate/-/plugin-rotate-0.10.3.tgz", - "integrity": "sha512-YXLlRjm18fkW9MOHUaVAxWjvgZM851ofOipytz5FyKp4KZWDLk+dZK1JNmVmK7MyVmAzZ5jsgSLhIgj+GgN0Eg==", + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-rotate/-/plugin-rotate-0.12.1.tgz", + "integrity": "sha512-RYkLzwG2ervG6hHy8iepbIVeWdT1kz4Qz044eloqo6c66MK0KAqp228YI8+CAKm0joQnVDC/A0FgRIj/K8uyAw==", "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.3", - "core-js": "^3.4.1" + "@jimp/utils": "^0.12.1" } }, "@jimp/plugin-scale": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@jimp/plugin-scale/-/plugin-scale-0.10.3.tgz", - "integrity": "sha512-5DXD7x7WVcX1gUgnlFXQa8F+Q3ThRYwJm+aesgrYvDOY+xzRoRSdQvhmdd4JEEue3lyX44DvBSgCIHPtGcEPaw==", + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-scale/-/plugin-scale-0.12.1.tgz", + "integrity": "sha512-zjNVI1fUj+ywfG78T1ZU33g9a5sk4rhEQkkhtny8koAscnVsDN2YaZEKoFli54kqaWh5kSS5DDL7a/9pEfXnFQ==", "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.3", - "core-js": "^3.4.1" + "@jimp/utils": "^0.12.1" } }, "@jimp/plugin-shadow": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@jimp/plugin-shadow/-/plugin-shadow-0.10.3.tgz", - "integrity": "sha512-/nkFXpt2zVcdP4ETdkAUL0fSzyrC5ZFxdcphbYBodqD7fXNqChS/Un1eD4xCXWEpW8cnG9dixZgQgStjywH0Mg==", + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-shadow/-/plugin-shadow-0.12.1.tgz", + "integrity": "sha512-Z82IwvunXWQ2jXegd3W3TYUXpfJcEvNbHodr7Z+oVnwhM1OoQ5QC6RSRQwsj2qXIhbGffQjH8eguHgEgAV+u5w==", "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.3", - "core-js": "^3.4.1" + "@jimp/utils": "^0.12.1" } }, "@jimp/plugin-threshold": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@jimp/plugin-threshold/-/plugin-threshold-0.10.3.tgz", - "integrity": "sha512-Dzh0Yq2wXP2SOnxcbbiyA4LJ2luwrdf1MghNIt9H+NX7B+IWw/N8qA2GuSm9n4BPGSLluuhdAWJqHcTiREriVA==", + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-threshold/-/plugin-threshold-0.12.1.tgz", + "integrity": "sha512-PFezt5fSk0q+xKvdpuv0eLggy2I7EgYotrK8TRZOT0jimuYFXPF0Z514c6szumoW5kEsRz04L1HkPT1FqI97Yg==", "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.3", - "core-js": "^3.4.1" + "@jimp/utils": "^0.12.1" } }, "@jimp/plugins": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@jimp/plugins/-/plugins-0.10.3.tgz", - "integrity": "sha512-jTT3/7hOScf0EIKiAXmxwayHhryhc1wWuIe3FrchjDjr9wgIGNN2a7XwCgPl3fML17DXK1x8EzDneCdh261bkw==", + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@jimp/plugins/-/plugins-0.12.1.tgz", + "integrity": "sha512-7+Yp29T6BbYo+Oqnc+m7A5AH+O+Oy5xnxvxlfmsp48+SuwEZ4akJp13Gu2PSmRlylENzR7MlWOxzhas5ERNlIg==", "requires": { "@babel/runtime": "^7.7.2", - "@jimp/plugin-blit": "^0.10.3", - "@jimp/plugin-blur": "^0.10.3", - "@jimp/plugin-circle": "^0.10.3", - "@jimp/plugin-color": "^0.10.3", - "@jimp/plugin-contain": "^0.10.3", - "@jimp/plugin-cover": "^0.10.3", - "@jimp/plugin-crop": "^0.10.3", - "@jimp/plugin-displace": "^0.10.3", - "@jimp/plugin-dither": "^0.10.3", - "@jimp/plugin-fisheye": "^0.10.3", - "@jimp/plugin-flip": "^0.10.3", - "@jimp/plugin-gaussian": "^0.10.3", - "@jimp/plugin-invert": "^0.10.3", - "@jimp/plugin-mask": "^0.10.3", - "@jimp/plugin-normalize": "^0.10.3", - "@jimp/plugin-print": "^0.10.3", - "@jimp/plugin-resize": "^0.10.3", - "@jimp/plugin-rotate": "^0.10.3", - "@jimp/plugin-scale": "^0.10.3", - "@jimp/plugin-shadow": "^0.10.3", - "@jimp/plugin-threshold": "^0.10.3", - "core-js": "^3.4.1", + "@jimp/plugin-blit": "^0.12.1", + "@jimp/plugin-blur": "^0.12.1", + "@jimp/plugin-circle": "^0.12.1", + "@jimp/plugin-color": "^0.12.1", + "@jimp/plugin-contain": "^0.12.1", + "@jimp/plugin-cover": "^0.12.1", + "@jimp/plugin-crop": "^0.12.1", + "@jimp/plugin-displace": "^0.12.1", + "@jimp/plugin-dither": "^0.12.1", + "@jimp/plugin-fisheye": "^0.12.1", + "@jimp/plugin-flip": "^0.12.1", + "@jimp/plugin-gaussian": "^0.12.1", + "@jimp/plugin-invert": "^0.12.1", + "@jimp/plugin-mask": "^0.12.1", + "@jimp/plugin-normalize": "^0.12.1", + "@jimp/plugin-print": "^0.12.1", + "@jimp/plugin-resize": "^0.12.1", + "@jimp/plugin-rotate": "^0.12.1", + "@jimp/plugin-scale": "^0.12.1", + "@jimp/plugin-shadow": "^0.12.1", + "@jimp/plugin-threshold": "^0.12.1", "timm": "^1.6.1" } }, "@jimp/png": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@jimp/png/-/png-0.10.3.tgz", - "integrity": "sha512-YKqk/dkl+nGZxSYIDQrqhmaP8tC3IK8H7dFPnnzFVvbhDnyYunqBZZO3SaZUKTichClRw8k/CjBhbc+hifSGWg==", + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@jimp/png/-/png-0.12.1.tgz", + "integrity": "sha512-tOUSJMJzcMAN82F9/Q20IToquIVWzvOe/7NIpVQJn6m+Lq6TtVmd7d8gdcna9AEFm2FIza5lhq2Kta6Xj0KXhQ==", "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.3", - "core-js": "^3.4.1", + "@jimp/utils": "^0.12.1", "pngjs": "^3.3.3" } }, "@jimp/tiff": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@jimp/tiff/-/tiff-0.10.3.tgz", - "integrity": "sha512-7EsJzZ5Y/EtinkBGuwX3Bi4S+zgbKouxjt9c82VJTRJOQgLWsE/RHqcyRCOQBhHAZ9QexYmDz34medfLKdoX0g==", + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@jimp/tiff/-/tiff-0.12.1.tgz", + "integrity": "sha512-bzWDgv3202TKhaBGzV9OFF0PVQWEb4194h9kv5js348SSnbCusz/tzTE1EwKrnbDZThZPgTB1ryKs7D+Q9Mhmg==", "requires": { "@babel/runtime": "^7.7.2", - "core-js": "^3.4.1", "utif": "^2.0.1" } }, "@jimp/types": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@jimp/types/-/types-0.10.3.tgz", - "integrity": "sha512-XGmBakiHZqseSWr/puGN+CHzx0IKBSpsKlmEmsNV96HKDiP6eu8NSnwdGCEq2mmIHe0JNcg1hqg59hpwtQ7Tiw==", + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@jimp/types/-/types-0.12.1.tgz", + "integrity": "sha512-hg5OKXpWWeKGuDrfibrjWWhr7hqb7f552wqnPWSLQpVrdWgjH+hpOv6cOzdo9bsU78qGTelZJPxr0ERRoc+MhQ==", "requires": { "@babel/runtime": "^7.7.2", - "@jimp/bmp": "^0.10.3", - "@jimp/gif": "^0.10.3", - "@jimp/jpeg": "^0.10.3", - "@jimp/png": "^0.10.3", - "@jimp/tiff": "^0.10.3", - "core-js": "^3.4.1", + "@jimp/bmp": "^0.12.1", + "@jimp/gif": "^0.12.1", + "@jimp/jpeg": "^0.12.1", + "@jimp/png": "^0.12.1", + "@jimp/tiff": "^0.12.1", "timm": "^1.6.1" } }, "@jimp/utils": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@jimp/utils/-/utils-0.10.3.tgz", - "integrity": "sha512-VcSlQhkil4ReYmg1KkN+WqHyYfZ2XfZxDsKAHSfST1GEz/RQHxKZbX+KhFKtKflnL0F4e6DlNQj3vznMNXCR2w==", + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@jimp/utils/-/utils-0.12.1.tgz", + "integrity": "sha512-EjPkDQOzV/oZfbolEUgFT6SE++PtCccVBvjuACkttyCfl0P2jnpR49SwstyVLc2u8AwBAZEHHAw9lPYaMjtbXQ==", "requires": { "@babel/runtime": "^7.7.2", - "core-js": "^3.4.1", "regenerator-runtime": "^0.13.3" } }, @@ -619,9 +588,9 @@ "integrity": "sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g==" }, "@types/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-UoOfVEzAUpeSPmjm7h1uk5MH6KZma2z2O7a75onTGjnNvAvMVrPzPL/vBbT65iIGHWj6rokwfmYcmxmlSf2uwg==", + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-8.1.1.tgz", + "integrity": "sha512-TcUlBem321DFQzBNuz8p0CLLKp0VvF/XH9E4KHNmgwyp4E3AfgI5cjiIVZWlbfThBop2qxFIh4+LeY6hVWWZ2w==", "dev": true, "requires": { "@types/node": "*" @@ -648,9 +617,9 @@ "integrity": "sha512-KPYGmfD0/b1eXurQ59fXD1GBzhSQfz6/lKBxkaHX9dKTzjXbK68Zt7yGUxUsCS1jeTy/8aL+d9JEr+S54mpkWQ==" }, "@types/yargs": { - "version": "15.0.4", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.4.tgz", - "integrity": "sha512-9T1auFmbPZoxHz0enUFlUuKRy3it01R+hlggyVUMtnCTQRunsQYifnSGb8hET4Xo8yiC0o0r1paW3ud5+rbURg==", + "version": "15.0.5", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.5.tgz", + "integrity": "sha512-Dk/IDOPtOgubt/IaevIUbTgV7doaKkoorvOyYM2CMwuDyP89bekI7H4xLIwunNYiK9jhCkmc6pUrJk3cj2AB9w==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -1192,27 +1161,27 @@ "integrity": "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==" }, "app-builder-bin": { - "version": "3.5.8", - "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-3.5.8.tgz", - "integrity": "sha512-ni3q7QTfQNWHNWuyn5x3FZu6GnQZv+TFnfgk5++svqleKEhHGqS1mIaKsh7x5pBX6NFXU3/+ktk98wA/AW4EXw==", + "version": "3.5.9", + "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-3.5.9.tgz", + "integrity": "sha512-NSjtqZ3x2kYiDp3Qezsgukx/AUzKPr3Xgf9by4cYt05ILWGAptepeeu0Uv+7MO+41o6ujhLixTou8979JGg2Kg==", "dev": true }, "app-builder-lib": { - "version": "22.6.0", - "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-22.6.0.tgz", - "integrity": "sha512-ky2aLYy92U+Gh6dKq/e8/bNmCotp6/GMhnX8tDZPv9detLg9WuBnWWi1ktBPlpbl1DREusy+TIh+9rgvfduQoA==", + "version": "22.6.1", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-22.6.1.tgz", + "integrity": "sha512-ENL7r+H7IBfDb4faeLASgndsXrAT7AV7m7yJjcpbFDXYma6an7ZWGFIvR0HJrsfiC5TIB8kdLJ/aMSImrrSi/Q==", "dev": true, "requires": { "7zip-bin": "~5.0.3", "@develar/schema-utils": "~2.6.5", "async-exit-hook": "^2.0.1", "bluebird-lst": "^1.0.9", - "builder-util": "22.6.0", + "builder-util": "22.6.1", "builder-util-runtime": "8.7.0", "chromium-pickle-js": "^0.2.0", "debug": "^4.1.1", "ejs": "^3.1.2", - "electron-publish": "22.6.0", + "electron-publish": "22.6.1", "fs-extra": "^9.0.0", "hosted-git-info": "^3.0.4", "is-ci": "^2.0.0", @@ -1263,7 +1232,7 @@ "dependencies": { "file-type": { "version": "3.9.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-3.9.0.tgz", + "resolved": "http://registry.npmjs.org/file-type/-/file-type-3.9.0.tgz", "integrity": "sha1-JXoHg4TR24CHvESdEH1SpSZyuek=" } } @@ -1539,7 +1508,7 @@ }, "uuid": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-2.0.3.tgz", + "resolved": "http://registry.npmjs.org/uuid/-/uuid-2.0.3.tgz", "integrity": "sha1-Z+LoY3lyFVMN/zGOW/nc6/1Hsho=" } } @@ -1573,7 +1542,7 @@ "dependencies": { "semver": { "version": "4.3.6", - "resolved": "https://registry.npmjs.org/semver/-/semver-4.3.6.tgz", + "resolved": "http://registry.npmjs.org/semver/-/semver-4.3.6.tgz", "integrity": "sha1-MAvG4OhjdPe6YQaLWx7NV/xlMto=" } } @@ -1593,7 +1562,7 @@ }, "bl": { "version": "1.2.2", - "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.2.tgz", + "resolved": "http://registry.npmjs.org/bl/-/bl-1.2.2.tgz", "integrity": "sha512-e8tQYnZodmebYDWGH7KMRvtzKXaJHx3BbilrgZCfvyLUYdKpK1t5PSPmpkny/SgiTSCnjfLW7v5rlONXVFkQEA==", "requires": { "readable-stream": "^2.3.5", @@ -1853,26 +1822,26 @@ "dependencies": { "file-type": { "version": "3.9.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-3.9.0.tgz", + "resolved": "http://registry.npmjs.org/file-type/-/file-type-3.9.0.tgz", "integrity": "sha1-JXoHg4TR24CHvESdEH1SpSZyuek=" }, "uuid": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-2.0.3.tgz", + "resolved": "http://registry.npmjs.org/uuid/-/uuid-2.0.3.tgz", "integrity": "sha1-Z+LoY3lyFVMN/zGOW/nc6/1Hsho=" } } }, "builder-util": { - "version": "22.6.0", - "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-22.6.0.tgz", - "integrity": "sha512-jgdES2ExJYkuXC3DEaGAjFctKNA81C4QDy8zdoc+rqdSqheTizuDNtZg02uMFklmUES4V4fggmqds+Y7wraqng==", + "version": "22.6.1", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-22.6.1.tgz", + "integrity": "sha512-A9cF+bSHqRTSKIUHEyE92Tl0Uh12N7yZRH9bccIL3gRUwtp6ulF28LsjNIWTSQ1clZo2M895cT5PCrKzjPQFVg==", "dev": true, "requires": { "7zip-bin": "~5.0.3", "@types/debug": "^4.1.5", "@types/fs-extra": "^8.1.0", - "app-builder-bin": "3.5.8", + "app-builder-bin": "3.5.9", "bluebird-lst": "^1.0.9", "builder-util-runtime": "8.7.0", "chalk": "^4.0.0", @@ -1920,16 +1889,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, - "source-map-support": { - "version": "0.5.19", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.19.tgz", - "integrity": "sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==", - "dev": true, - "requires": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, "stat-mode": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", @@ -1973,7 +1932,7 @@ }, "readable-stream": { "version": "1.1.14", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", + "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", "integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=", "requires": { "core-util-is": "~1.0.0", @@ -2148,7 +2107,7 @@ }, "chalk": { "version": "1.1.3", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "resolved": "http://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", "requires": { "ansi-styles": "^2.2.1", @@ -2218,9 +2177,9 @@ } }, "cli-spinners": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.2.0.tgz", - "integrity": "sha512-tgU3fKwzYjiLEQgPMD9Jt+JjHVL9kW93FiIMX/l7rivvOD4/LL0Mf7gda3+4U2KJBloybwgj5KEoQgGRioMiKQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.3.0.tgz", + "integrity": "sha512-Xs2Hf2nzrvJMFKimOR7YR0QwZ8fc0u98kdtwN1eNAZzNQgH3vK2pXzff6GJtKh7S5hoJ87ECiAiZFS2fb5Ii2w==", "dev": true }, "cli-table3": { @@ -2465,7 +2424,7 @@ }, "commander": { "version": "2.8.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.8.1.tgz", + "resolved": "http://registry.npmjs.org/commander/-/commander-2.8.1.tgz", "integrity": "sha1-Br42f+v9oMMwqh4qBy09yXYkJdQ=", "requires": { "graceful-readlink": ">= 1.0.0" @@ -2644,7 +2603,9 @@ "core-js": { "version": "3.4.4", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.4.4.tgz", - "integrity": "sha512-vKea1DrcLA80Hrfc7AQgfoGvEaByfR5mH08t+zuWOWY94TFBmabdEL56mUbcijvadG9RxsXR2gUUFrfj4/iTcA==" + "integrity": "sha512-vKea1DrcLA80Hrfc7AQgfoGvEaByfR5mH08t+zuWOWY94TFBmabdEL56mUbcijvadG9RxsXR2gUUFrfj4/iTcA==", + "dev": true, + "optional": true }, "core-util-is": { "version": "1.0.2", @@ -2832,9 +2793,9 @@ "integrity": "sha1-QGXiATz5+5Ft39gu+1Bq1MZ2kGI=" }, "dayjs": { - "version": "1.8.26", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.8.26.tgz", - "integrity": "sha512-KqtAuIfdNfZR5sJY1Dixr2Is4ZvcCqhb0dZpCOt5dGEFiMzoIbjkTSzUb4QKTCsP+WNpGwUjAFIZrnZvUxxkhw==" + "version": "1.8.27", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.8.27.tgz", + "integrity": "sha512-Jpa2acjWIeOkg8KURUHICk0EqnEFSSF5eMEscsOgyJ92ZukXwmpmRkPSUka7KHSfbj5eKH30ieosYip+ky9emQ==" }, "debug": { "version": "4.1.1", @@ -3128,7 +3089,7 @@ }, "readable-stream": { "version": "1.1.14", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", + "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", "integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=", "requires": { "core-util-is": "~1.0.0", @@ -3153,13 +3114,13 @@ } }, "dmg-builder": { - "version": "22.6.0", - "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-22.6.0.tgz", - "integrity": "sha512-rJxuGhHIpcuDGBtWZMM8aLxkbZNgYO2MO5dUerDIBXebhX1K8DA23iz/uZ8ahcRNgWEv57b8GDqJbXKEfr5T0A==", + "version": "22.6.1", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-22.6.1.tgz", + "integrity": "sha512-jUTN0acP15puzevtQASj7QEPgUGpedWSuSnOwR/++JbeYRTwU2oro09h/KZnaeMcxgxjdmT3tYLJeY1XUfPbRg==", "dev": true, "requires": { - "app-builder-lib": "22.6.0", - "builder-util": "22.6.0", + "app-builder-lib": "22.6.1", + "builder-util": "22.6.1", "fs-extra": "^9.0.0", "iconv-lite": "^0.5.1", "js-yaml": "^3.13.1", @@ -3337,9 +3298,9 @@ "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" }, "ejs": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.2.tgz", - "integrity": "sha512-zFuywxrAWtX5Mk2KAuoJNkXXbfezpNA0v7i+YC971QORguPekpjpAgeOv99YWSdKXwj7JxI2QAWDeDkE8fWtXw==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.3.tgz", + "integrity": "sha512-wmtrUGyfSC23GC/B1SMv2ogAUgbQEtDmTIhfqielrG5ExIM9TP4UoYdi90jLF1aTcsWCJNEO0UrgKzP0y3nTSg==", "requires": { "jake": "^10.6.1" } @@ -3356,18 +3317,18 @@ } }, "electron-builder": { - "version": "22.6.0", - "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-22.6.0.tgz", - "integrity": "sha512-aLHlB6DTfjJ3MI4AUIFeWnwIozNgNlbOk2c2sTHxB10cAKp0dBVSPZ7xF5NK0uwDhElvRzJQubnHtJD6zKg42Q==", + "version": "22.6.1", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-22.6.1.tgz", + "integrity": "sha512-3/VNg9GfXKHM53TilFtfF1+bsAR8THK1XHgeqCpsiequa02J9jTPc/DhpCUKQPkrs6/EIGxP7uboop7XYoew0Q==", "dev": true, "requires": { - "@types/yargs": "^15.0.4", - "app-builder-lib": "22.6.0", + "@types/yargs": "^15.0.5", + "app-builder-lib": "22.6.1", "bluebird-lst": "^1.0.9", - "builder-util": "22.6.0", + "builder-util": "22.6.1", "builder-util-runtime": "8.7.0", "chalk": "^4.0.0", - "dmg-builder": "22.6.0", + "dmg-builder": "22.6.1", "fs-extra": "^9.0.0", "is-ci": "^2.0.0", "lazy-val": "^1.0.4", @@ -3424,9 +3385,9 @@ } }, "electron-debug": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/electron-debug/-/electron-debug-3.0.1.tgz", - "integrity": "sha512-fo3mtDM4Bxxm3DW1I+XcJKfQlUlns4QGWyWGs8OrXK1bBZ2X9HeqYMntYBx78MYRcGY5S/ualuG4GhCnPlaZEA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/electron-debug/-/electron-debug-3.1.0.tgz", + "integrity": "sha512-SWEqLj4MgfV3tGuO5eBLQ5/Nr6M+KPxsnE0bUJZvQebGJus6RAcdmvd7L+l0Ji31h2mmrN23l2tHFtCa2FvurA==", "requires": { "electron-is-dev": "^1.1.0", "electron-localshortcut": "^3.1.0" @@ -3607,29 +3568,19 @@ "integrity": "sha1-UJ5RDCala1Xhf4Y6SwThEYRqsns=" }, "electron-is-dev": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/electron-is-dev/-/electron-is-dev-1.1.0.tgz", - "integrity": "sha512-Z1qA/1oHNowGtSBIcWk0pcLEqYT/j+13xUw/MYOrBUOL4X7VN0i0KCTf5SqyvMPmW5pSPKbo28wkxMxzZ20YnQ==" + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/electron-is-dev/-/electron-is-dev-1.2.0.tgz", + "integrity": "sha512-R1oD5gMBPS7PVU8gJwH6CtT0e6VSoD0+SzSnYpNm+dBkcijgA+K7VAMHDfnRq/lkKPZArpzplTW6jfiMYosdzw==" }, "electron-localshortcut": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/electron-localshortcut/-/electron-localshortcut-3.1.0.tgz", - "integrity": "sha512-MgL/j5jdjW7iA0R6cI7S045B0GlKXWM1FjjujVPjlrmyXRa6yH0bGSaIAfxXAF9tpJm3pLEiQzerYHkRh9JG/A==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/electron-localshortcut/-/electron-localshortcut-3.2.1.tgz", + "integrity": "sha512-DWvhKv36GsdXKnaFFhEiK8kZZA+24/yFLgtTwJJHc7AFgDjNRIBJZ/jq62Y/dWv9E4ypYwrVWN2bVrCYw1uv7Q==", "requires": { - "debug": "^2.6.8", + "debug": "^4.0.1", "electron-is-accelerator": "^0.1.0", - "keyboardevent-from-electron-accelerator": "^1.1.0", + "keyboardevent-from-electron-accelerator": "^2.0.0", "keyboardevents-areequal": "^0.2.1" - }, - "dependencies": { - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "requires": { - "ms": "2.0.0" - } - } } }, "electron-notarize": { @@ -3734,19 +3685,19 @@ } }, "electron-publish": { - "version": "22.6.0", - "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-22.6.0.tgz", - "integrity": "sha512-+v05SBf9qR7Os5au+fifloNHy5QxHQkUGudBj68YaTb43Pn37UkwRxSc49Lf13s4wW32ohM45g8BOVInPJEdnA==", + "version": "22.6.1", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-22.6.1.tgz", + "integrity": "sha512-/MkS47ospdSfAFW5Jp52OzYou14HhGJpZ51uAc3GJ5rCfACeqpimC/n1ajRLE3hcXxTWfd3t9MCuClq5jrUO5w==", "dev": true, "requires": { "@types/fs-extra": "^8.1.0", "bluebird-lst": "^1.0.9", - "builder-util": "22.6.0", + "builder-util": "22.6.1", "builder-util-runtime": "8.7.0", "chalk": "^4.0.0", "fs-extra": "^9.0.0", "lazy-val": "^1.0.4", - "mime": "^2.4.4" + "mime": "^2.4.5" }, "dependencies": { "ansi-styles": { @@ -3802,9 +3753,9 @@ } }, "electron-rebuild": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/electron-rebuild/-/electron-rebuild-1.10.1.tgz", - "integrity": "sha512-KSqp0Xiu7CCvKL2aEdPp/vNe2Rr11vaO8eM/wq9gQJTY02UjtAJ3l7WLV7Mf8oR+UJReJO8SWOWs/FozqK8ggA==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/electron-rebuild/-/electron-rebuild-1.11.0.tgz", + "integrity": "sha512-cn6AqZBQBVtaEyj5jZW1/LOezZZ22PA1HvhEP7asvYPJ8PDF4i4UFt9be4i9T7xJKiSiomXvY5Fd+dSq3FXZxA==", "dev": true, "requires": { "colors": "^1.3.3", @@ -3877,9 +3828,9 @@ } }, "yargs": { - "version": "14.2.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-14.2.2.tgz", - "integrity": "sha512-/4ld+4VV5RnrynMhPZJ/ZpOCGSCeghMykZ3BhdFBDa9Wy/RH6uEGNWDJog+aUlq+9OM1CFTgtYRW5Is1Po9NOA==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-14.2.3.tgz", + "integrity": "sha512-ZbotRWhF+lkjijC/VhmOT9wSgyBQ7+zr13+YLkhfsSiTriYsMzkTUFP18pFhWwBeMa5gUc1MzbhrO6/VB7c9Xg==", "dev": true, "requires": { "cliui": "^5.0.0", @@ -3892,13 +3843,13 @@ "string-width": "^3.0.0", "which-module": "^2.0.0", "y18n": "^4.0.0", - "yargs-parser": "^15.0.0" + "yargs-parser": "^15.0.1" } }, "yargs-parser": { - "version": "15.0.0", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-15.0.0.tgz", - "integrity": "sha512-xLTUnCMc4JhxrPEPUYD5IBR1mWCK/aT6+RJ/K29JY2y1vD+FhtgKK0AXRWvI262q3QSffAQuTouFIKUuHX89wQ==", + "version": "15.0.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-15.0.1.tgz", + "integrity": "sha512-0OAMV2mAZQrs3FkNpDQcBk1x5HXb8X4twADss4S0Iuk+2dGnLOE/fRHrsYm542GduMveyA77OF4wrNJuanRCWw==", "dev": true, "requires": { "camelcase": "^5.0.0", @@ -4448,9 +4399,9 @@ } }, "file-type": { - "version": "14.3.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-14.3.0.tgz", - "integrity": "sha512-s71v6jMkbfwVdj87csLeNpL5K93mv4lN+lzgzifoICtPHhnXokDwBa3jrzfg+z6FK872iYJ0vS0i74v8XmoFDA==", + "version": "14.5.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-14.5.0.tgz", + "integrity": "sha512-hIxIT/8DPClkKbC+IEoZvcQ5aBhsivh4aWzLMvmkp9Uabzey7gFNNPmTOwp8O/b2DkJ8a4FkFMkyFzkyRVsJXg==", "requires": { "readable-web-to-node-stream": "^2.0.0", "strtok3": "^6.0.0", @@ -4957,7 +4908,7 @@ }, "get-stream": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", + "resolved": "http://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=" }, "getpass": { @@ -5221,7 +5172,7 @@ }, "got": { "version": "5.7.1", - "resolved": "https://registry.npmjs.org/got/-/got-5.7.1.tgz", + "resolved": "http://registry.npmjs.org/got/-/got-5.7.1.tgz", "integrity": "sha1-X4FjWmHkplifGAVp6k44FoClHzU=", "requires": { "create-error-class": "^3.0.1", @@ -5869,7 +5820,7 @@ }, "into-stream": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/into-stream/-/into-stream-3.1.0.tgz", + "resolved": "http://registry.npmjs.org/into-stream/-/into-stream-3.1.0.tgz", "integrity": "sha1-lvsKk2wSur1v8XUqF9BWFqvQlMY=", "requires": { "from2": "^2.1.1", @@ -6021,7 +5972,7 @@ }, "is-obj": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", + "resolved": "http://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", "integrity": "sha1-PkcprB9f3gJc19g6iW2rn09n2w8=" }, "is-object": { @@ -6114,9 +6065,12 @@ "integrity": "sha1-1LVcafUYhvm2XHDWwmItN+KfSP4=" }, "is-wsl": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.1.1.tgz", - "integrity": "sha512-umZHcSrwlDHo2TGMXv0DZ8dIUGunZ2Iv68YZnrmCiBPkZ4aaOhtv7pXJKeki9k3qJ3RJr0cDyitcl5wEH3AYog==" + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "requires": { + "is-docker": "^2.0.0" + } }, "is-yarn-global": { "version": "0.3.0", @@ -6216,6 +6170,38 @@ } } }, + "jasmine": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/jasmine/-/jasmine-3.5.0.tgz", + "integrity": "sha512-DYypSryORqzsGoMazemIHUfMkXM7I7easFaxAvNM3Mr6Xz3Fy36TupTrAOxZWN8MVKEU5xECv22J4tUQf3uBzQ==", + "dev": true, + "requires": { + "glob": "^7.1.4", + "jasmine-core": "~3.5.0" + }, + "dependencies": { + "glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + } + } + }, + "jasmine-core": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-3.5.0.tgz", + "integrity": "sha512-nCeAiw37MIMA9w9IXso7bRaLl+c/ef3wnxsoSAlYrzS+Ot0zTG6nU8G/cIfGkqpkjX2wNaIW9RFG0TwIFnG6bA==", + "dev": true + }, "jest-worker": { "version": "25.5.0", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-25.5.0.tgz", @@ -6244,22 +6230,21 @@ } }, "jimp": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/jimp/-/jimp-0.10.3.tgz", - "integrity": "sha512-meVWmDMtyUG5uYjFkmzu0zBgnCvvxwWNi27c4cg55vWNVC9ES4Lcwb+ogx+uBBQE3Q+dLKjXaLl0JVW+nUNwbQ==", + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/jimp/-/jimp-0.12.1.tgz", + "integrity": "sha512-0soPJif+yjmzmOF+4cF2hyhxUWWpXpQntsm2joJXFFoRcQiPzsG4dbLKYqYPT3Fc6PjZ8MaLtCkDqqckVSfmRw==", "requires": { "@babel/runtime": "^7.7.2", - "@jimp/custom": "^0.10.3", - "@jimp/plugins": "^0.10.3", - "@jimp/types": "^0.10.3", - "core-js": "^3.4.1", + "@jimp/custom": "^0.12.1", + "@jimp/plugins": "^0.12.1", + "@jimp/types": "^0.12.1", "regenerator-runtime": "^0.13.3" } }, "jpeg-js": { - "version": "0.3.7", - "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.3.7.tgz", - "integrity": "sha512-9IXdWudL61npZjvLuVe/ktHiA41iE8qFyLB+4VDTblEsWBzeg8WQTlktdUK4CdncUqtUgUg0bbOmTE2bKBKaBQ==" + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.0.tgz", + "integrity": "sha512-960VHmtN1vTpasX/1LupLohdP5odwAT7oK/VSm6mW0M58LbrBnowLAPWAZhWGhDAGjzbMnPXZxzB/QYgBwkN0w==" }, "js-yaml": { "version": "3.13.1", @@ -6512,9 +6497,9 @@ "integrity": "sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ==" }, "keyboardevent-from-electron-accelerator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/keyboardevent-from-electron-accelerator/-/keyboardevent-from-electron-accelerator-1.1.0.tgz", - "integrity": "sha512-VDC4vKWGrR3VgIKCE4CsXnvObGgP8C2idnTKEMUkuEuvDGE1GEBX9FtNdJzrD00iQlhI3xFxRaeItsUmlERVng==" + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/keyboardevent-from-electron-accelerator/-/keyboardevent-from-electron-accelerator-2.0.0.tgz", + "integrity": "sha512-iQcmNA0M4ETMNi0kG/q0h/43wZk7rMeKYrXP7sqKIJbHkTU8Koowgzv+ieR/vWJbOwxx5nDC3UnudZ0aLSu4VA==" }, "keyboardevents-areequal": { "version": "0.2.2", @@ -6621,7 +6606,7 @@ }, "load-json-file": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz", + "resolved": "http://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz", "integrity": "sha1-lWkFcI1YtLq0wiYbBPWfMcmTdMA=", "requires": { "graceful-fs": "^4.1.2", @@ -7130,7 +7115,7 @@ }, "minimist": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "resolved": "http://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=" }, "minipass": { @@ -7230,7 +7215,7 @@ }, "mkdirp": { "version": "0.5.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "resolved": "http://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", "requires": { "minimist": "0.0.8" @@ -7238,7 +7223,7 @@ "dependencies": { "minimist": { "version": "0.0.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "resolved": "http://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=" } } @@ -7432,7 +7417,7 @@ }, "get-stream": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", + "resolved": "http://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=" }, "got": { @@ -7468,7 +7453,7 @@ }, "p-cancelable": { "version": "0.4.1", - "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-0.4.1.tgz", + "resolved": "http://registry.npmjs.org/p-cancelable/-/p-cancelable-0.4.1.tgz", "integrity": "sha512-HNa1A8LvB1kie7cERyy21VNeHb2CWJJYqyyC2o3klWFfMGlFmWv2Z7sFgZH8ZiaYL95ydToKTFVXgMV/Os0bBQ==" }, "p-event": { @@ -7592,7 +7577,7 @@ "dependencies": { "file-type": { "version": "3.9.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-3.9.0.tgz", + "resolved": "http://registry.npmjs.org/file-type/-/file-type-3.9.0.tgz", "integrity": "sha1-JXoHg4TR24CHvESdEH1SpSZyuek=" } } @@ -7617,7 +7602,7 @@ "dependencies": { "get-stream": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", + "resolved": "http://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=" }, "pify": { @@ -7674,7 +7659,7 @@ }, "get-stream": { "version": "2.3.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-2.3.1.tgz", + "resolved": "http://registry.npmjs.org/get-stream/-/get-stream-2.3.1.tgz", "integrity": "sha1-Xzj5PzRgCWZu4BUKBUFn+Rvdld4=", "requires": { "object-assign": "^4.0.1", @@ -7704,7 +7689,7 @@ "dependencies": { "get-stream": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", + "resolved": "http://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=" } } @@ -7744,7 +7729,7 @@ }, "pify": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "resolved": "http://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=" }, "prepend-http": { @@ -7849,7 +7834,7 @@ }, "readable-stream": { "version": "1.1.14", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", + "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", "integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=", "requires": { "core-util-is": "~1.0.0", @@ -7923,9 +7908,9 @@ "integrity": "sha512-0L9FvHG3nfnnmaEQPjT9xhfN4ISk0A8/2j4M37Np4mcDesJjHgEUfgPhdCyZuFI954tjokaIj/A3NdpFNdEh4Q==" }, "node-abi": { - "version": "2.16.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-2.16.0.tgz", - "integrity": "sha512-+sa0XNlWDA6T+bDLmkCUYn6W5k5W6BPRL6mqzSCs6H/xUgtl4D5x2fORKDzopKiU6wsyn/+wXlRXwXeSp+mtoA==", + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-2.17.0.tgz", + "integrity": "sha512-dFRAA0ACk/aBo0TIXQMEWMLUTyWYYT8OBYIzLmEUrQTElGRjxDCvyBZIsDL0QA7QCaj9PrawhOmTEdsuLY4uOQ==", "requires": { "semver": "^5.4.1" }, @@ -8227,13 +8212,13 @@ }, "onetime": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz", + "resolved": "http://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz", "integrity": "sha1-ofeDj4MUxRbwXs78vEzP4EtO14k=" }, "open": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/open/-/open-7.0.3.tgz", - "integrity": "sha512-sP2ru2v0P290WFfv49Ap8MF6PkzGNnGlAwHweB4WR4mr5d2d0woiCluUeJ218w7/+PmoBy9JmYgD5A4mLcWOFA==", + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/open/-/open-7.0.4.tgz", + "integrity": "sha512-brSA+/yq+b08Hsr4c8fsEW2CRzk1BmfN3SAK/5VCHQ9bdoZJ4qa/+AfR0xHjlbbZUyPkUHs1b8x1RqdyZdkVqQ==", "requires": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" @@ -8379,7 +8364,7 @@ }, "p-is-promise": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-1.1.0.tgz", + "resolved": "http://registry.npmjs.org/p-is-promise/-/p-is-promise-1.1.0.tgz", "integrity": "sha1-nJRWmJ6fZYgBewQ01WCXZ1w9oF4=" }, "p-limit": { @@ -8860,7 +8845,7 @@ }, "get-stream": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", + "resolved": "http://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=" } } @@ -9144,7 +9129,7 @@ "dependencies": { "file-type": { "version": "3.9.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-3.9.0.tgz", + "resolved": "http://registry.npmjs.org/file-type/-/file-type-3.9.0.tgz", "integrity": "sha1-JXoHg4TR24CHvESdEH1SpSZyuek=" } } @@ -9169,7 +9154,7 @@ "dependencies": { "get-stream": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", + "resolved": "http://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=" }, "pify": { @@ -9207,7 +9192,7 @@ }, "get-stream": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", + "resolved": "http://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=" } } @@ -9259,7 +9244,7 @@ }, "get-stream": { "version": "2.3.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-2.3.1.tgz", + "resolved": "http://registry.npmjs.org/get-stream/-/get-stream-2.3.1.tgz", "integrity": "sha1-Xzj5PzRgCWZu4BUKBUFn+Rvdld4=", "requires": { "object-assign": "^4.0.1", @@ -9289,7 +9274,7 @@ "dependencies": { "get-stream": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", + "resolved": "http://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=" } } @@ -9477,7 +9462,7 @@ }, "query-string": { "version": "5.1.1", - "resolved": "https://registry.npmjs.org/query-string/-/query-string-5.1.1.tgz", + "resolved": "http://registry.npmjs.org/query-string/-/query-string-5.1.1.tgz", "integrity": "sha512-gjWOsm2SoGlgLEdAGt7a6slVOk9mGiXmPFMqrEhLQ68rhQuBnpfs3+EmlvqKyxnCo9/PPlF+9MtY02S1aFg+Jw==", "requires": { "decode-uri-component": "^0.2.0", @@ -9552,9 +9537,9 @@ } }, "rcedit": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/rcedit/-/rcedit-2.1.1.tgz", - "integrity": "sha512-N1JyXxHD2zpqqW4A77RNK1d/M+tyed9JkvL/lnUI5cf4igF/8B9FNLFCtDUhGrk2GWEPxC+RF0WXWWB3I8QC7w==" + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/rcedit/-/rcedit-2.2.0.tgz", + "integrity": "sha512-dhFtYmQS+V8qQIANyX6zDK+sO50ayDePKApi46ZPK8I6QeyyTDD6LManMa7a3p3c9mLM4zi9QBP41pfhQ9p7Sg==" }, "read-all-stream": { "version": "3.1.0", @@ -9616,7 +9601,7 @@ }, "readable-stream": { "version": "2.3.6", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", "requires": { "core-util-is": "~1.0.0", @@ -9929,9 +9914,9 @@ } }, "rxjs": { - "version": "6.5.4", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.4.tgz", - "integrity": "sha512-naMQXcgEo3csAEGvw/NydRA0fuS2nDZJiw1YUWFKU7aPPAPGZEsD4Iimit96qwCieH6y614MCLYwdkrWx7z/7Q==", + "version": "6.5.5", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.5.tgz", + "integrity": "sha512-WfQI+1gohdf0Dai/Bbmk5L5ItH5tYqm3ki2c5GdWhKjalzjg93N3avFjVStyZZz+A2Em+ZxKH5bNghw9UeylGQ==", "dev": true, "requires": { "tslib": "^1.9.0" @@ -10343,9 +10328,9 @@ "dev": true }, "sqlite": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/sqlite/-/sqlite-4.0.7.tgz", - "integrity": "sha512-1bBO+me3gXRfqwRR3K9aNDoSbTkQ87o6fSjj/BE2gSHHsK3qIDR+LoFZHgZ6kSPdFBoLTsy5/w/+8PBBaK+lvg==" + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/sqlite/-/sqlite-4.0.9.tgz", + "integrity": "sha512-vB6Xzn5S5XxMfmyO0ErKjuP5jEQ0z+oFXFC4zXC0s12NMULLETUTb6+PST8sZ7/2HR4KLk4Jsj5yeXkCvogYxg==" }, "sqlite3": { "version": "4.1.1", @@ -10484,7 +10469,7 @@ }, "strip-ansi": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "resolved": "http://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", "requires": { "ansi-regex": "^2.0.0" @@ -10509,7 +10494,7 @@ }, "strip-dirs": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/strip-dirs/-/strip-dirs-1.1.1.tgz", + "resolved": "http://registry.npmjs.org/strip-dirs/-/strip-dirs-1.1.1.tgz", "integrity": "sha1-lgu9EoeETzl1pFWKoQOoJV4kVqA=", "requires": { "chalk": "^1.0.0", @@ -10767,7 +10752,7 @@ }, "through": { "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "resolved": "http://registry.npmjs.org/through/-/through-2.3.8.tgz", "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=" }, "through2": { @@ -10786,7 +10771,7 @@ }, "readable-stream": { "version": "1.0.34", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", + "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", "integrity": "sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=", "requires": { "core-util-is": "~1.0.0", @@ -11929,9 +11914,9 @@ } }, "ws": { - "version": "7.2.5", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.2.5.tgz", - "integrity": "sha512-C34cIU4+DB2vMyAbmEKossWq2ZQDr6QEyuuCzWrM9zfw1sGc0mYiJ0UnG9zzNykt49C2Fi34hvr2vssFQRS6EA==" + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.3.0.tgz", + "integrity": "sha512-iFtXzngZVXPGgpTlP1rBqsUK82p9tKqsWRPg5L56egiljujJT3vGAYnHANvFxBieXrTFavhzhxW52jnaWV+w2w==" }, "x-xss-protection": { "version": "1.3.0", diff --git a/package.json b/package.json index f794709d8..e59aed25f 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "build-backend-docs": "./node_modules/.bin/jsdoc -c jsdoc-conf.json -d ./docs/backend_api src/entities/*.js src/services/backend_script_api.js", "build-frontend-docs": "./node_modules/.bin/jsdoc -c jsdoc-conf.json -d ./docs/frontend_api src/public/app/entities/*.js src/public/app/services/frontend_script_api.js src/public/app/widgets/collapsible_widget.js", "build-docs": "npm run build-backend-docs && npm run build-frontend-docs", - "webpack": "npx webpack -c webpack-desktop.config.js && npx webpack -c webpack-mobile.config.js && npx webpack -c webpack-setup.config.js" + "webpack": "npx webpack -c webpack-desktop.config.js && npx webpack -c webpack-mobile.config.js && npx webpack -c webpack-setup.config.js", + "test": "jasmine" }, "dependencies": { "async-mutex": "0.2.2", @@ -28,16 +29,16 @@ "commonmark": "0.29.1", "cookie-parser": "1.4.5", "csurf": "1.11.0", - "dayjs": "1.8.26", + "dayjs": "1.8.27", "debug": "4.1.1", - "ejs": "3.1.2", - "electron-debug": "3.0.1", + "ejs": "3.1.3", + "electron-debug": "3.1.0", "electron-dl": "3.0.0", "electron-find": "1.0.6", "electron-window-state": "5.0.3", "express": "4.17.1", "express-session": "1.17.1", - "file-type": "14.3.0", + "file-type": "14.5.0", "fs-extra": "9.0.0", "helmet": "3.22.0", "html": "1.0.0", @@ -51,14 +52,14 @@ "imagemin-pngquant": "8.0.0", "ini": "1.3.5", "is-svg": "4.2.1", - "jimp": "0.10.3", + "jimp": "0.12.1", "mime-types": "2.1.27", "multer": "1.4.2", - "node-abi": "2.16.0", - "open": "7.0.3", + "node-abi": "2.17.0", + "open": "7.0.4", "portscanner": "2.2.0", "rand-token": "1.0.1", - "rcedit": "2.1.1", + "rcedit": "2.2.0", "rimraf": "3.0.2", "sanitize-filename": "1.6.3", "sax": "1.2.4", @@ -66,22 +67,23 @@ "serve-favicon": "2.5.0", "session-file-store": "1.4.0", "simple-node-logger": "18.12.24", - "sqlite": "4.0.7", + "sqlite": "4.0.9", "sqlite3": "4.1.1", "string-similarity": "4.0.1", "tar-stream": "2.1.2", "turndown": "6.0.0", "turndown-plugin-gfm": "1.0.2", "unescape": "1.0.1", - "ws": "7.2.5", + "ws": "7.3.0", "yauzl": "^2.10.0", "yazl": "^2.5.1" }, "devDependencies": { "electron": "9.0.0", - "electron-builder": "22.6.0", + "electron-builder": "22.6.1", "electron-packager": "14.2.1", - "electron-rebuild": "1.10.1", + "electron-rebuild": "1.11.0", + "jasmine": "^3.5.0", "jsdoc": "3.6.4", "lorem-ipsum": "2.0.3", "webpack": "5.0.0-beta.16", diff --git a/spec/lexer.spec.js b/spec/lexer.spec.js new file mode 100644 index 000000000..2d366e763 --- /dev/null +++ b/spec/lexer.spec.js @@ -0,0 +1,71 @@ +const lexer = require('../src/services/search/lexer'); + +describe("Lexer fulltext", () => { + it("simple lexing", () => { + expect(lexer("hello world").fulltextTokens) + .toEqual(["hello", "world"]); + }); + + it("use quotes to keep words together", () => { + expect(lexer("'hello world' my friend").fulltextTokens) + .toEqual(["hello world", "my", "friend"]); + + expect(lexer('"hello world" my friend').fulltextTokens) + .toEqual(["hello world", "my", "friend"]); + + expect(lexer('`hello world` my friend').fulltextTokens) + .toEqual(["hello world", "my", "friend"]); + }); + + it("you can use different quotes and other special characters inside quotes", () => { + expect(lexer("'i can use \" or ` or #~=*' without problem").fulltextTokens) + .toEqual(["i can use \" or ` or #~=*", "without", "problem"]); + }); + + it("if quote is not ended then it's just one long token", () => { + expect(lexer("'unfinished quote").fulltextTokens) + .toEqual(["unfinished quote"]); + }); + + it("parenthesis and symbols in fulltext section are just normal characters", () => { + expect(lexer("what's u=p ").fulltextTokens) + .toEqual(["what's", "u=p", ""]); + }); + + it("escaping special characters", () => { + expect(lexer("hello \\#\\~\\'").fulltextTokens) + .toEqual(["hello", "#~'"]); + }); +}); + +describe("Lexer expression", () => { + it("simple attribute existence", () => { + expect(lexer("#label ~relation").expressionTokens) + .toEqual(["#label", "~relation"]); + }); + + it("simple label operators", () => { + expect(lexer("#label*=*text").expressionTokens) + .toEqual(["#label", "*=*", "text"]); + }); + + it("spaces in attribute names and values", () => { + expect(lexer(`#'long label'="hello o' world" ~'long relation'`).expressionTokens) + .toEqual(["#long label", "=", "hello o' world", "~long relation"]); + }); + + it("complex expressions with and, or and parenthesis", () => { + expect(lexer(`# (#label=text OR #second=text) AND ~relation`).expressionTokens) + .toEqual(["#", "(", "#label", "=", "text", "or", "#second", "=", "text", ")", "and", "~relation"]); + }); + + it("dot separated properties", () => { + expect(lexer(`# ~author.title = 'Hugh Howey' AND note.'book title' = 'Silo'`).expressionTokens) + .toEqual(["#", "~author", ".", "title", "=", "hugh howey", "and", "note", ".", "book title", "=", "silo"]); + }); + + it("negation of sub-expression", () => { + expect(lexer(`# not(#capital) and note.noteId != "root"`).expressionTokens) + .toEqual(["#", "not", "(", "#capital", ")", "and", "note", ".", "noteid", "!=", "root"]); + }); +}); diff --git a/spec/note_cache_mocking.js b/spec/note_cache_mocking.js new file mode 100644 index 000000000..e96252dfd --- /dev/null +++ b/spec/note_cache_mocking.js @@ -0,0 +1,70 @@ +const Note = require('../src/services/note_cache/entities/note'); +const Branch = require('../src/services/note_cache/entities/branch'); +const Attribute = require('../src/services/note_cache/entities/attribute'); +const noteCache = require('../src/services/note_cache/note_cache'); +const randtoken = require('rand-token').generator({source: 'crypto'}); + +/** @return {Note} */ +function findNoteByTitle(searchResults, title) { + return searchResults + .map(sr => noteCache.notes[sr.noteId]) + .find(note => note.title === title); +} + +class NoteBuilder { + constructor(note) { + this.note = note; + } + + label(name, value = '', isInheritable = false) { + new Attribute(noteCache, { + attributeId: id(), + noteId: this.note.noteId, + type: 'label', + isInheritable, + name, + value + }); + + return this; + } + + relation(name, targetNote) { + new Attribute(noteCache, { + attributeId: id(), + noteId: this.note.noteId, + type: 'relation', + name, + value: targetNote.noteId + }); + + return this; + } + + child(childNoteBuilder, prefix = "") { + new Branch(noteCache, { + branchId: id(), + noteId: childNoteBuilder.note.noteId, + parentNoteId: this.note.noteId, + prefix + }); + + return this; + } +} + +function id() { + return randtoken.generate(10); +} + +function note(title) { + const note = new Note(noteCache, {noteId: id(), title}); + + return new NoteBuilder(note); +} + +module.exports = { + NoteBuilder, + findNoteByTitle, + note +}; diff --git a/spec/parens.spec.js b/spec/parens.spec.js new file mode 100644 index 000000000..c55896e44 --- /dev/null +++ b/spec/parens.spec.js @@ -0,0 +1,21 @@ +const parens = require('../src/services/search/parens'); + +describe("Parens handler", () => { + it("handles parens", () => { + expect(parens(["(", "hello", ")", "and", "(", "(", "pick", "one", ")", "and", "another", ")"])) + .toEqual([ + [ + "hello" + ], + "and", + [ + [ + "pick", + "one" + ], + "and", + "another" + ] + ]); + }); +}); diff --git a/spec/parser.spec.js b/spec/parser.spec.js new file mode 100644 index 000000000..b5ac8d8ee --- /dev/null +++ b/spec/parser.spec.js @@ -0,0 +1,136 @@ +const ParsingContext = require("../src/services/search/parsing_context"); +const parser = require('../src/services/search/parser'); + +describe("Parser", () => { + it("fulltext parser without content", () => { + const rootExp = parser({ + fulltextTokens: ["hello", "hi"], + expressionTokens: [], + parsingContext: new ParsingContext({includeNoteContent: false}) + }); + + expect(rootExp.constructor.name).toEqual("NoteCacheFulltextExp"); + expect(rootExp.tokens).toEqual(["hello", "hi"]); + }); + + it("fulltext parser with content", () => { + const rootExp = parser({ + fulltextTokens: ["hello", "hi"], + expressionTokens: [], + parsingContext: new ParsingContext({includeNoteContent: true}) + }); + + expect(rootExp.constructor.name).toEqual("OrExp"); + const [firstSub, secondSub] = rootExp.subExpressions; + + expect(firstSub.constructor.name).toEqual("NoteCacheFulltextExp"); + expect(firstSub.tokens).toEqual(["hello", "hi"]); + + expect(secondSub.constructor.name).toEqual("NoteContentFulltextExp"); + expect(secondSub.tokens).toEqual(["hello", "hi"]); + }); + + it("simple label comparison", () => { + const rootExp = parser({ + fulltextTokens: [], + expressionTokens: ["#mylabel", "=", "text"], + parsingContext: new ParsingContext() + }); + + expect(rootExp.constructor.name).toEqual("LabelComparisonExp"); + expect(rootExp.attributeType).toEqual("label"); + expect(rootExp.attributeName).toEqual("mylabel"); + expect(rootExp.comparator).toBeTruthy(); + }); + + it("simple label AND", () => { + const rootExp = parser({ + fulltextTokens: [], + expressionTokens: ["#first", "=", "text", "and", "#second", "=", "text"], + parsingContext: new ParsingContext(true) + }); + + expect(rootExp.constructor.name).toEqual("AndExp"); + const [firstSub, secondSub] = rootExp.subExpressions; + + expect(firstSub.constructor.name).toEqual("LabelComparisonExp"); + expect(firstSub.attributeName).toEqual("first"); + + expect(secondSub.constructor.name).toEqual("LabelComparisonExp"); + expect(secondSub.attributeName).toEqual("second"); + }); + + it("simple label AND without explicit AND", () => { + const rootExp = parser({ + fulltextTokens: [], + expressionTokens: ["#first", "=", "text", "#second", "=", "text"], + parsingContext: new ParsingContext() + }); + + expect(rootExp.constructor.name).toEqual("AndExp"); + const [firstSub, secondSub] = rootExp.subExpressions; + + expect(firstSub.constructor.name).toEqual("LabelComparisonExp"); + expect(firstSub.attributeName).toEqual("first"); + + expect(secondSub.constructor.name).toEqual("LabelComparisonExp"); + expect(secondSub.attributeName).toEqual("second"); + }); + + it("simple label OR", () => { + const rootExp = parser({ + fulltextTokens: [], + expressionTokens: ["#first", "=", "text", "or", "#second", "=", "text"], + parsingContext: new ParsingContext() + }); + + expect(rootExp.constructor.name).toEqual("OrExp"); + const [firstSub, secondSub] = rootExp.subExpressions; + + expect(firstSub.constructor.name).toEqual("LabelComparisonExp"); + expect(firstSub.attributeName).toEqual("first"); + + expect(secondSub.constructor.name).toEqual("LabelComparisonExp"); + expect(secondSub.attributeName).toEqual("second"); + }); + + it("fulltext and simple label", () => { + const rootExp = parser({ + fulltextTokens: ["hello"], + expressionTokens: ["#mylabel", "=", "text"], + parsingContext: new ParsingContext() + }); + + expect(rootExp.constructor.name).toEqual("AndExp"); + const [firstSub, secondSub] = rootExp.subExpressions; + + expect(firstSub.constructor.name).toEqual("NoteCacheFulltextExp"); + expect(firstSub.tokens).toEqual(["hello"]); + + expect(secondSub.constructor.name).toEqual("LabelComparisonExp"); + expect(secondSub.attributeName).toEqual("mylabel"); + }); + + it("label sub-expression", () => { + const rootExp = parser({ + fulltextTokens: [], + expressionTokens: ["#first", "=", "text", "or", ["#second", "=", "text", "and", "#third", "=", "text"]], + parsingContext: new ParsingContext() + }); + + expect(rootExp.constructor.name).toEqual("OrExp"); + const [firstSub, secondSub] = rootExp.subExpressions; + + expect(firstSub.constructor.name).toEqual("LabelComparisonExp"); + expect(firstSub.attributeName).toEqual("first"); + + expect(secondSub.constructor.name).toEqual("AndExp"); + const [firstSubSub, secondSubSub] = secondSub.subExpressions; + + expect(firstSubSub.constructor.name).toEqual("LabelComparisonExp"); + expect(firstSubSub.attributeName).toEqual("second"); + + expect(secondSubSub.constructor.name).toEqual("LabelComparisonExp"); + expect(secondSubSub.attributeName).toEqual("third"); + }); +}); diff --git a/spec/search.spec.js b/spec/search.spec.js new file mode 100644 index 000000000..35ec670d8 --- /dev/null +++ b/spec/search.spec.js @@ -0,0 +1,532 @@ +const searchService = require('../src/services/search/search'); +const Note = require('../src/services/note_cache/entities/note'); +const Branch = require('../src/services/note_cache/entities/branch'); +const Attribute = require('../src/services/note_cache/entities/attribute'); +const ParsingContext = require('../src/services/search/parsing_context'); +const dateUtils = require('../src/services/date_utils'); +const noteCache = require('../src/services/note_cache/note_cache'); +const {NoteBuilder, findNoteByTitle, note} = require('./note_cache_mocking'); + +describe("Search", () => { + let rootNote; + + beforeEach(() => { + noteCache.reset(); + + rootNote = new NoteBuilder(new Note(noteCache, {noteId: 'root', title: 'root'})); + new Branch(noteCache, {branchId: 'root', noteId: 'root', parentNoteId: 'none'}); + }); + + it("simple path match", async () => { + rootNote + .child(note("Europe") + .child(note("Austria")) + ); + + const parsingContext = new ParsingContext(); + const searchResults = await searchService.findNotesWithQuery('europe austria', parsingContext); + + expect(searchResults.length).toEqual(1); + expect(findNoteByTitle(searchResults, "Austria")).toBeTruthy(); + }); + + it("only end leafs are results", async () => { + rootNote + .child(note("Europe") + .child(note("Austria")) + ); + + const parsingContext = new ParsingContext(); + const searchResults = await searchService.findNotesWithQuery('europe', parsingContext); + + expect(searchResults.length).toEqual(1); + expect(findNoteByTitle(searchResults, "Europe")).toBeTruthy(); + }); + + it("only end leafs are results", async () => { + rootNote + .child(note("Europe") + .child(note("Austria") + .label('capital', 'Vienna')) + ); + + const parsingContext = new ParsingContext(); + + const searchResults = await searchService.findNotesWithQuery('Vienna', parsingContext); + expect(searchResults.length).toEqual(1); + expect(findNoteByTitle(searchResults, "Austria")).toBeTruthy(); + }); + + it("label comparison with short syntax", async () => { + rootNote + .child(note("Europe") + .child(note("Austria") + .label('capital', 'Vienna')) + .child(note("Czech Republic") + .label('capital', 'Prague')) + ); + + const parsingContext = new ParsingContext(); + + let searchResults = await searchService.findNotesWithQuery('#capital=Vienna', parsingContext); + expect(searchResults.length).toEqual(1); + expect(findNoteByTitle(searchResults, "Austria")).toBeTruthy(); + }); + + it("label comparison with full syntax", async () => { + rootNote + .child(note("Europe") + .child(note("Austria") + .label('capital', 'Vienna')) + .child(note("Czech Republic") + .label('capital', 'Prague')) + ); + + const parsingContext = new ParsingContext(); + + let searchResults = await searchService.findNotesWithQuery('# note.labels.capital=Prague', parsingContext); + expect(searchResults.length).toEqual(1); + expect(findNoteByTitle(searchResults, "Czech Republic")).toBeTruthy(); + }); + + it("numeric label comparison", async () => { + rootNote + .child(note("Europe") + .label('country', '', true) + .child(note("Austria") + .label('population', '8859000')) + .child(note("Czech Republic") + .label('population', '10650000')) + ); + + const parsingContext = new ParsingContext(); + + const searchResults = await searchService.findNotesWithQuery('#country #population >= 10000000', parsingContext); + expect(searchResults.length).toEqual(1); + expect(findNoteByTitle(searchResults, "Czech Republic")).toBeTruthy(); + }); + + it("numeric label comparison fallback to string comparison", async () => { + // dates should not be coerced into numbers which would then give wrong numbers + + rootNote + .child(note("Europe") + .label('country', '', true) + .child(note("Austria") + .label('established', '1955-07-27')) + .child(note("Czech Republic") + .label('established', '1993-01-01')) + .child(note("Hungary") + .label('established', '1920-06-04')) + ); + + const parsingContext = new ParsingContext(); + + let searchResults = await searchService.findNotesWithQuery('#established <= 1955-01-01', parsingContext); + expect(searchResults.length).toEqual(1); + expect(findNoteByTitle(searchResults, "Hungary")).toBeTruthy(); + + searchResults = await searchService.findNotesWithQuery('#established > 1955-01-01', parsingContext); + expect(searchResults.length).toEqual(2); + expect(findNoteByTitle(searchResults, "Austria")).toBeTruthy(); + expect(findNoteByTitle(searchResults, "Czech Republic")).toBeTruthy(); + }); + + it("smart date comparisons", async () => { + // dates should not be coerced into numbers which would then give wrong numbers + + rootNote + .child(note("My note") + .label('year', new Date().getFullYear().toString()) + .label('month', dateUtils.localNowDate().substr(0, 7)) + .label('date', dateUtils.localNowDate()) + .label('dateTime', dateUtils.localNowDateTime()) + ); + + const parsingContext = new ParsingContext(); + + async function test(query, expectedResultCount) { + const searchResults = await searchService.findNotesWithQuery(query, parsingContext); + expect(searchResults.length).toEqual(expectedResultCount); + + if (expectedResultCount === 1) { + expect(findNoteByTitle(searchResults, "My note")).toBeTruthy(); + } + } + + await test("#year = YEAR", 1); + await test("#year >= YEAR", 1); + await test("#year <= YEAR", 1); + await test("#year < YEAR+1", 1); + await test("#year > YEAR+1", 0); + + await test("#month = MONTH", 1); + + await test("#date = TODAY", 1); + await test("#date > TODAY", 0); + await test("#date > TODAY-1", 1); + await test("#date < TODAY+1", 1); + await test("#date < 'TODAY + 1'", 1); + + await test("#dateTime <= NOW+10", 1); + await test("#dateTime < NOW-10", 0); + await test("#dateTime >= NOW-10", 1); + await test("#dateTime < NOW-10", 0); + }); + + it("logical or", async () => { + rootNote + .child(note("Europe") + .label('country', '', true) + .child(note("Austria") + .label('languageFamily', 'germanic')) + .child(note("Czech Republic") + .label('languageFamily', 'slavic')) + .child(note("Hungary") + .label('languageFamily', 'finnougric')) + ); + + const parsingContext = new ParsingContext(); + + const searchResults = await searchService.findNotesWithQuery('#languageFamily = slavic OR #languageFamily = germanic', parsingContext); + expect(searchResults.length).toEqual(2); + expect(findNoteByTitle(searchResults, "Czech Republic")).toBeTruthy(); + expect(findNoteByTitle(searchResults, "Austria")).toBeTruthy(); + }); + + it("fuzzy attribute search", async () => { + rootNote + .child(note("Europe") + .label('country', '', true) + .child(note("Austria") + .label('languageFamily', 'germanic')) + .child(note("Czech Republic") + .label('languageFamily', 'slavic'))); + + let parsingContext = new ParsingContext({fuzzyAttributeSearch: false}); + + let searchResults = await searchService.findNotesWithQuery('#language', parsingContext); + expect(searchResults.length).toEqual(0); + + searchResults = await searchService.findNotesWithQuery('#languageFamily=ger', parsingContext); + expect(searchResults.length).toEqual(0); + + parsingContext = new ParsingContext({fuzzyAttributeSearch: true}); + + searchResults = await searchService.findNotesWithQuery('#language', parsingContext); + expect(searchResults.length).toEqual(2); + + searchResults = await searchService.findNotesWithQuery('#languageFamily=ger', parsingContext); + expect(searchResults.length).toEqual(1); + expect(findNoteByTitle(searchResults, "Austria")).toBeTruthy(); + }); + + it("filter by note property", async () => { + rootNote + .child(note("Europe") + .child(note("Austria")) + .child(note("Czech Republic"))); + + const parsingContext = new ParsingContext(); + + const searchResults = await searchService.findNotesWithQuery('# note.title =* czech', parsingContext); + expect(searchResults.length).toEqual(1); + expect(findNoteByTitle(searchResults, "Czech Republic")).toBeTruthy(); + }); + + it("filter by note's parent", async () => { + rootNote + .child(note("Europe") + .child(note("Austria")) + .child(note("Czech Republic") + .child(note("Prague"))) + ) + .child(note("Asia") + .child(note('Taiwan'))); + + const parsingContext = new ParsingContext(); + + let searchResults = await searchService.findNotesWithQuery('# note.parents.title = Europe', parsingContext); + expect(searchResults.length).toEqual(2); + expect(findNoteByTitle(searchResults, "Austria")).toBeTruthy(); + expect(findNoteByTitle(searchResults, "Czech Republic")).toBeTruthy(); + + searchResults = await searchService.findNotesWithQuery('# note.parents.title = Asia', parsingContext); + expect(searchResults.length).toEqual(1); + expect(findNoteByTitle(searchResults, "Taiwan")).toBeTruthy(); + + searchResults = await searchService.findNotesWithQuery('# note.parents.parents.title = Europe', parsingContext); + expect(searchResults.length).toEqual(1); + expect(findNoteByTitle(searchResults, "Prague")).toBeTruthy(); + }); + + it("filter by note's ancestor", async () => { + rootNote + .child(note("Europe") + .child(note("Austria")) + .child(note("Czech Republic") + .child(note("Prague").label('city'))) + ) + .child(note("Asia") + .child(note('Taiwan') + .child(note('Taipei').label('city'))) + ); + + const parsingContext = new ParsingContext(); + + let searchResults = await searchService.findNotesWithQuery('#city AND note.ancestors.title = Europe', parsingContext); + expect(searchResults.length).toEqual(1); + expect(findNoteByTitle(searchResults, "Prague")).toBeTruthy(); + + searchResults = await searchService.findNotesWithQuery('#city AND note.ancestors.title = Asia', parsingContext); + expect(searchResults.length).toEqual(1); + expect(findNoteByTitle(searchResults, "Taipei")).toBeTruthy(); + }); + + it("filter by note's child", async () => { + rootNote + .child(note("Europe") + .child(note("Austria") + .child(note("Vienna"))) + .child(note("Czech Republic") + .child(note("Prague")))) + .child(note("Oceania") + .child(note('Australia'))); + + const parsingContext = new ParsingContext(); + + let searchResults = await searchService.findNotesWithQuery('# note.children.title =* Aust', parsingContext); + expect(searchResults.length).toEqual(2); + expect(findNoteByTitle(searchResults, "Europe")).toBeTruthy(); + expect(findNoteByTitle(searchResults, "Oceania")).toBeTruthy(); + + searchResults = await searchService.findNotesWithQuery('# note.children.title =* Aust AND note.children.title *= republic', parsingContext); + expect(searchResults.length).toEqual(1); + expect(findNoteByTitle(searchResults, "Europe")).toBeTruthy(); + + searchResults = await searchService.findNotesWithQuery('# note.children.children.title = Prague', parsingContext); + expect(searchResults.length).toEqual(1); + expect(findNoteByTitle(searchResults, "Europe")).toBeTruthy(); + }); + + it("filter by relation's note properties using short syntax", async () => { + const austria = note("Austria"); + const portugal = note("Portugal"); + + rootNote + .child(note("Europe") + .child(austria) + .child(note("Czech Republic") + .relation('neighbor', austria.note)) + .child(portugal) + .child(note("Spain") + .relation('neighbor', portugal.note)) + ); + + const parsingContext = new ParsingContext(); + + let searchResults = await searchService.findNotesWithQuery('# ~neighbor.title = Austria', parsingContext); + expect(searchResults.length).toEqual(1); + expect(findNoteByTitle(searchResults, "Czech Republic")).toBeTruthy(); + + searchResults = await searchService.findNotesWithQuery('# ~neighbor.title = Portugal', parsingContext); + expect(searchResults.length).toEqual(1); + expect(findNoteByTitle(searchResults, "Spain")).toBeTruthy(); + }); + + it("filter by relation's note properties using long syntax", async () => { + const austria = note("Austria"); + const portugal = note("Portugal"); + + rootNote + .child(note("Europe") + .child(austria) + .child(note("Czech Republic") + .relation('neighbor', austria.note)) + .child(portugal) + .child(note("Spain") + .relation('neighbor', portugal.note)) + ); + + const parsingContext = new ParsingContext(); + + const searchResults = await searchService.findNotesWithQuery('# note.relations.neighbor.title = Austria', parsingContext); + expect(searchResults.length).toEqual(1); + expect(findNoteByTitle(searchResults, "Czech Republic")).toBeTruthy(); + }); + + it("filter by multiple level relation", async () => { + const austria = note("Austria"); + const slovakia = note("Slovakia"); + const italy = note("Italy"); + const ukraine = note("Ukraine"); + + rootNote + .child(note("Europe") + .child(austria + .relation('neighbor', italy.note) + .relation('neighbor', slovakia.note)) + .child(note("Czech Republic") + .relation('neighbor', austria.note) + .relation('neighbor', slovakia.note)) + .child(slovakia + .relation('neighbor', ukraine.note)) + .child(ukraine) + ); + + const parsingContext = new ParsingContext(); + + let searchResults = await searchService.findNotesWithQuery('# note.relations.neighbor.relations.neighbor.title = Italy', parsingContext); + expect(searchResults.length).toEqual(1); + expect(findNoteByTitle(searchResults, "Czech Republic")).toBeTruthy(); + + searchResults = await searchService.findNotesWithQuery('# note.relations.neighbor.relations.neighbor.title = Ukraine', parsingContext); + expect(searchResults.length).toEqual(2); + expect(findNoteByTitle(searchResults, "Czech Republic")).toBeTruthy(); + expect(findNoteByTitle(searchResults, "Austria")).toBeTruthy(); + }); + + it("test note properties", async () => { + const austria = note("Austria"); + + austria.relation('myself', austria.note); + austria.label('capital', 'Vienna'); + austria.label('population', '8859000'); + + rootNote + .child(note("Asia")) + .child(note("Europe") + .child(austria + .child(note("Vienna")) + .child(note("Sebastian Kurz")) + ) + ) + .child(note("Mozart") + .child(austria)); + + austria.note.type = 'text'; + austria.note.mime = 'text/html'; + austria.note.isProtected = false; + austria.note.dateCreated = '2020-05-14 12:11:42.001+0200'; + austria.note.dateModified = '2020-05-14 13:11:42.001+0200'; + austria.note.utcDateCreated = '2020-05-14 10:11:42.001Z'; + austria.note.utcDateModified = '2020-05-14 11:11:42.001Z'; + austria.note.contentLength = 1001; + + const parsingContext = new ParsingContext(); + + async function test(propertyName, value, expectedResultCount) { + const searchResults = await searchService.findNotesWithQuery(`# note.${propertyName} = ${value}`, parsingContext); + expect(searchResults.length).toEqual(expectedResultCount); + + if (expectedResultCount === 1) { + expect(findNoteByTitle(searchResults, "Austria")).toBeTruthy(); + } + } + + await test("type", "text", 1); + await test("type", "code", 0); + + await test("mime", "text/html", 1); + await test("mime", "application/json", 0); + + await test("isProtected", "false", 7); + await test("isProtected", "true", 0); + + await test("dateCreated", "'2020-05-14 12:11:42.001+0200'", 1); + await test("dateCreated", "wrong", 0); + + await test("dateModified", "'2020-05-14 13:11:42.001+0200'", 1); + await test("dateModified", "wrong", 0); + + await test("utcDateCreated", "'2020-05-14 10:11:42.001Z'", 1); + await test("utcDateCreated", "wrong", 0); + + await test("utcDateModified", "'2020-05-14 11:11:42.001Z'", 1); + await test("utcDateModified", "wrong", 0); + + await test("contentLength", "1001", 1); + await test("contentLength", "10010", 0); + + await test("parentCount", "2", 1); + await test("parentCount", "3", 0); + + await test("childrenCount", "2", 1); + await test("childrenCount", "10", 0); + + await test("attributeCount", "3", 1); + await test("attributeCount", "4", 0); + + await test("labelCount", "2", 1); + await test("labelCount", "3", 0); + + await test("relationCount", "1", 1); + await test("relationCount", "2", 0); + }); + + it("test order by", async () => { + const italy = note("Italy").label("capital", "Rome"); + const slovakia = note("Slovakia").label("capital", "Bratislava"); + const austria = note("Austria").label("capital", "Vienna"); + const ukraine = note("Ukraine").label("capital", "Kiev"); + + rootNote + .child(note("Europe") + .child(ukraine) + .child(slovakia) + .child(austria) + .child(italy)); + + const parsingContext = new ParsingContext(); + + let searchResults = await searchService.findNotesWithQuery('# note.parents.title = Europe orderBy note.title', parsingContext); + expect(searchResults.length).toEqual(4); + expect(noteCache.notes[searchResults[0].noteId].title).toEqual("Austria"); + expect(noteCache.notes[searchResults[1].noteId].title).toEqual("Italy"); + expect(noteCache.notes[searchResults[2].noteId].title).toEqual("Slovakia"); + expect(noteCache.notes[searchResults[3].noteId].title).toEqual("Ukraine"); + + searchResults = await searchService.findNotesWithQuery('# note.parents.title = Europe orderBy note.labels.capital', parsingContext); + expect(searchResults.length).toEqual(4); + expect(noteCache.notes[searchResults[0].noteId].title).toEqual("Slovakia"); + expect(noteCache.notes[searchResults[1].noteId].title).toEqual("Ukraine"); + expect(noteCache.notes[searchResults[2].noteId].title).toEqual("Italy"); + expect(noteCache.notes[searchResults[3].noteId].title).toEqual("Austria"); + + searchResults = await searchService.findNotesWithQuery('# note.parents.title = Europe orderBy note.labels.capital DESC', parsingContext); + expect(searchResults.length).toEqual(4); + expect(noteCache.notes[searchResults[0].noteId].title).toEqual("Austria"); + expect(noteCache.notes[searchResults[1].noteId].title).toEqual("Italy"); + expect(noteCache.notes[searchResults[2].noteId].title).toEqual("Ukraine"); + expect(noteCache.notes[searchResults[3].noteId].title).toEqual("Slovakia"); + + searchResults = await searchService.findNotesWithQuery('# note.parents.title = Europe orderBy note.labels.capital DESC limit 2', parsingContext); + expect(searchResults.length).toEqual(2); + expect(noteCache.notes[searchResults[0].noteId].title).toEqual("Austria"); + expect(noteCache.notes[searchResults[1].noteId].title).toEqual("Italy"); + + searchResults = await searchService.findNotesWithQuery('# note.parents.title = Europe orderBy #capital DESC limit 0', parsingContext); + expect(searchResults.length).toEqual(0); + + searchResults = await searchService.findNotesWithQuery('# note.parents.title = Europe orderBy #capital DESC limit 1000', parsingContext); + expect(searchResults.length).toEqual(4); + }); + + it("test not(...)", async () => { + const italy = note("Italy").label("capital", "Rome"); + const slovakia = note("Slovakia").label("capital", "Bratislava"); + + rootNote + .child(note("Europe") + .child(slovakia) + .child(italy)); + + const parsingContext = new ParsingContext(); + + let searchResults = await searchService.findNotesWithQuery('# not(#capital) and note.noteId != root', parsingContext); + expect(searchResults.length).toEqual(1); + expect(noteCache.notes[searchResults[0].noteId].title).toEqual("Europe"); + }); + + // FIXME: test what happens when we order without any filter criteria +}); diff --git a/spec/support/jasmine.json b/spec/support/jasmine.json new file mode 100644 index 000000000..370fc4464 --- /dev/null +++ b/spec/support/jasmine.json @@ -0,0 +1,11 @@ +{ + "spec_dir": "spec", + "spec_files": [ + "**/*[sS]pec.js" + ], + "helpers": [ + "helpers/**/*.js" + ], + "stopSpecOnExpectationFailure": false, + "random": true +} diff --git a/spec/value_extractor.spec.js b/spec/value_extractor.spec.js new file mode 100644 index 000000000..40a9e193e --- /dev/null +++ b/spec/value_extractor.spec.js @@ -0,0 +1,86 @@ +const {NoteBuilder, findNoteByTitle, note} = require('./note_cache_mocking'); +const ValueExtractor = require('../src/services/search/value_extractor'); +const noteCache = require('../src/services/note_cache/note_cache'); + +describe("Value extractor", () => { + beforeEach(() => { + noteCache.reset(); + }); + + it("simple title extraction", async () => { + const europe = note("Europe").note; + + const valueExtractor = new ValueExtractor(["note", "title"]); + + expect(valueExtractor.validate()).toBeFalsy(); + expect(valueExtractor.extract(europe)).toEqual("Europe"); + }); + + it("label extraction", async () => { + const austria = note("Austria") + .label("Capital", "Vienna") + .note; + + let valueExtractor = new ValueExtractor(["note", "labels", "capital"]); + + expect(valueExtractor.validate()).toBeFalsy(); + expect(valueExtractor.extract(austria)).toEqual("vienna"); + + valueExtractor = new ValueExtractor(["#capital"]); + + expect(valueExtractor.validate()).toBeFalsy(); + expect(valueExtractor.extract(austria)).toEqual("vienna"); + }); + + it("parent/child property extraction", async () => { + const vienna = note("Vienna"); + const europe = note("Europe") + .child(note("Austria") + .child(vienna)); + + let valueExtractor = new ValueExtractor(["note", "children", "children", "title"]); + + expect(valueExtractor.validate()).toBeFalsy(); + expect(valueExtractor.extract(europe.note)).toEqual("Vienna"); + + valueExtractor = new ValueExtractor(["note", "parents", "parents", "title"]); + + expect(valueExtractor.validate()).toBeFalsy(); + expect(valueExtractor.extract(vienna.note)).toEqual("Europe"); + }); + + it("extract through relation", async () => { + const czechRepublic = note("Czech Republic").label("capital", "Prague"); + const slovakia = note("Slovakia").label("capital", "Bratislava"); + const austria = note("Austria") + .relation('neighbor', czechRepublic.note) + .relation('neighbor', slovakia.note); + + let valueExtractor = new ValueExtractor(["note", "relations", "neighbor", "labels", "capital"]); + + expect(valueExtractor.validate()).toBeFalsy(); + expect(valueExtractor.extract(austria.note)).toEqual("prague"); + + valueExtractor = new ValueExtractor(["~neighbor", "labels", "capital"]); + + expect(valueExtractor.validate()).toBeFalsy(); + expect(valueExtractor.extract(austria.note)).toEqual("prague"); + }); +}); + +describe("Invalid value extractor property path", () => { + it('each path must start with "note" (or label/relation)', + () => expect(new ValueExtractor(["neighbor"]).validate()).toBeTruthy()); + + it("extra path element after terminal label", + () => expect(new ValueExtractor(["~neighbor", "labels", "capital", "noteId"]).validate()).toBeTruthy()); + + it("extra path element after terminal title", + () => expect(new ValueExtractor(["note", "title", "isProtected"]).validate()).toBeTruthy()); + + it("relation name and note property is missing", + () => expect(new ValueExtractor(["note", "relations"]).validate()).toBeTruthy()); + + it("relation is specified but target note property is not specified", + () => expect(new ValueExtractor(["note", "relations", "myrel"]).validate()).toBeTruthy()); +}); diff --git a/src/app.js b/src/app.js index 3ef1a7c0f..5fa590378 100644 --- a/src/app.js +++ b/src/app.js @@ -10,8 +10,11 @@ const FileStore = require('session-file-store')(session); const os = require('os'); const sessionSecret = require('./services/session_secret'); const cls = require('./services/cls'); +const dataDir = require('./services/data_dir'); require('./entities/entity_constructor'); require('./services/handlers'); +require('./services/hoisted_note_loader'); +require('./services/note_cache/note_cache_loader'); const app = express(); @@ -56,7 +59,7 @@ const sessionParser = session({ }, store: new FileStore({ ttl: 30 * 24 * 3600, - path: os.tmpdir() + '/trilium-sessions' + path: dataDir.TRILIUM_DATA_DIR + '/sessions' }) }); app.use(sessionParser); @@ -120,4 +123,4 @@ require('./services/scheduler'); module.exports = { app, sessionParser -}; \ No newline at end of file +}; diff --git a/src/entities/attribute.js b/src/entities/attribute.js index 0800d6252..14b7c8a87 100644 --- a/src/entities/attribute.js +++ b/src/entities/attribute.js @@ -8,13 +8,13 @@ const sql = require('../services/sql'); /** * Attribute is key value pair owned by a note. * - * @property {string} attributeId - * @property {string} noteId - * @property {string} type - * @property {string} name + * @property {string} attributeId - immutable + * @property {string} noteId - immutable + * @property {string} type - immutable + * @property {string} name - immutable * @property {string} value * @property {int} position - * @property {boolean} isInheritable + * @property {boolean} isInheritable - immutable * @property {boolean} isDeleted * @property {string|null} deleteId - ID identifying delete transaction * @property {string} utcDateCreated @@ -108,14 +108,14 @@ class Attribute extends Entity { delete pojo.__note; } - createClone(type, name, value) { + createClone(type, name, value, isInheritable) { return new Attribute({ noteId: this.noteId, type: type, name: name, value: value, position: this.position, - isInheritable: this.isInheritable, + isInheritable: isInheritable, isDeleted: false, utcDateCreated: this.utcDateCreated, utcDateModified: this.utcDateModified diff --git a/src/entities/branch.js b/src/entities/branch.js index 7950526dc..060a275cd 100644 --- a/src/entities/branch.js +++ b/src/entities/branch.js @@ -9,9 +9,9 @@ const sql = require('../services/sql'); * Branch represents note's placement in the tree - it's essentially pair of noteId and parentNoteId. * Each note can have multiple (at least one) branches, meaning it can be placed into multiple places in the tree. * - * @property {string} branchId - primary key - * @property {string} noteId - * @property {string} parentNoteId + * @property {string} branchId - primary key, immutable + * @property {string} noteId - immutable + * @property {string} parentNoteId - immutable * @property {int} notePosition * @property {string} prefix * @property {boolean} isExpanded @@ -77,4 +77,4 @@ class Branch extends Entity { } } -module.exports = Branch; \ No newline at end of file +module.exports = Branch; diff --git a/src/public/app/dialogs/add_link.js b/src/public/app/dialogs/add_link.js index 43f67df85..47270c164 100644 --- a/src/public/app/dialogs/add_link.js +++ b/src/public/app/dialogs/add_link.js @@ -75,7 +75,7 @@ function updateTitleFormGroupVisibility() { } $form.on('submit', () => { - const notePath = $autoComplete.getSelectedPath(); + const notePath = $autoComplete.getSelectedNotePath(); if (notePath) { $dialog.modal('hide'); @@ -89,4 +89,4 @@ $form.on('submit', () => { } return false; -}); \ No newline at end of file +}); diff --git a/src/public/app/dialogs/attributes.js b/src/public/app/dialogs/attributes.js index 2cf844420..e4264114c 100644 --- a/src/public/app/dialogs/attributes.js +++ b/src/public/app/dialogs/attributes.js @@ -269,7 +269,7 @@ function initKoPlugins() { init: function (element, valueAccessor, allBindings, viewModel, bindingContext) { noteAutocompleteService.initNoteAutocomplete($(element)); - $(element).setSelectedPath(bindingContext.$data.selectedPath); + $(element).setSelectedNotePath(bindingContext.$data.selectedPath); $(element).on('autocomplete:selected', function (event, suggestion, dataset) { bindingContext.$data.selectedPath = $(element).val().trim() ? suggestion.path : ''; diff --git a/src/public/app/dialogs/clone_to.js b/src/public/app/dialogs/clone_to.js index eab144aac..14e231aae 100644 --- a/src/public/app/dialogs/clone_to.js +++ b/src/public/app/dialogs/clone_to.js @@ -52,7 +52,7 @@ async function cloneNotesTo(notePath) { } $form.on('submit', () => { - const notePath = $noteAutoComplete.getSelectedPath(); + const notePath = $noteAutoComplete.getSelectedNotePath(); if (notePath) { $dialog.modal('hide'); @@ -64,4 +64,4 @@ $form.on('submit', () => { } return false; -}); \ No newline at end of file +}); diff --git a/src/public/app/dialogs/include_note.js b/src/public/app/dialogs/include_note.js index 71d3cb220..1aa2b149e 100644 --- a/src/public/app/dialogs/include_note.js +++ b/src/public/app/dialogs/include_note.js @@ -38,7 +38,7 @@ async function includeNote(notePath) { } $form.on('submit', () => { - const notePath = $autoComplete.getSelectedPath(); + const notePath = $autoComplete.getSelectedNotePath(); if (notePath) { $dialog.modal('hide'); @@ -50,4 +50,4 @@ $form.on('submit', () => { } return false; -}); \ No newline at end of file +}); diff --git a/src/public/app/dialogs/move_to.js b/src/public/app/dialogs/move_to.js index 4dbc6bcca..afd9ceb5f 100644 --- a/src/public/app/dialogs/move_to.js +++ b/src/public/app/dialogs/move_to.js @@ -41,7 +41,7 @@ async function moveNotesTo(parentNoteId) { } $form.on('submit', () => { - const notePath = $noteAutoComplete.getSelectedPath(); + const notePath = $noteAutoComplete.getSelectedNotePath(); if (notePath) { $dialog.modal('hide'); @@ -55,4 +55,4 @@ $form.on('submit', () => { } return false; -}); \ No newline at end of file +}); diff --git a/src/public/app/services/app_context.js b/src/public/app/services/app_context.js index dd31f6da5..6a06ea9f8 100644 --- a/src/public/app/services/app_context.js +++ b/src/public/app/services/app_context.js @@ -11,6 +11,7 @@ import Component from "../widgets/component.js"; import keyboardActionsService from "./keyboard_actions.js"; import MobileScreenSwitcherExecutor from "../widgets/mobile_widgets/mobile_screen_switcher.js"; import MainTreeExecutors from "./main_tree_executors.js"; +import protectedSessionHolder from "./protected_session_holder.js"; class AppContext extends Component { constructor(isMainWindow) { @@ -111,6 +112,8 @@ const appContext = new AppContext(window.glob.isMainWindow); // we should save all outstanding changes before the page/app is closed $(window).on('beforeunload', () => { + protectedSessionHolder.resetSessionCookie(); + appContext.triggerEvent('beforeUnload'); }); diff --git a/src/public/app/services/note_autocomplete.js b/src/public/app/services/note_autocomplete.js index 4f6b6a575..c9ae9be8d 100644 --- a/src/public/app/services/note_autocomplete.js +++ b/src/public/app/services/note_autocomplete.js @@ -3,7 +3,7 @@ import appContext from "./app_context.js"; import utils from './utils.js'; // this key needs to have this value so it's hit by the tooltip -const SELECTED_PATH_KEY = "data-note-path"; +const SELECTED_NOTE_PATH_KEY = "data-note-path"; async function autocompleteSource(term, cb) { const result = await server.get('autocomplete' @@ -12,8 +12,8 @@ async function autocompleteSource(term, cb) { if (result.length === 0) { result.push({ - pathTitle: "No results", - path: "" + notePathTitle: "No results", + notePath: "" }); } @@ -25,7 +25,7 @@ function clearText($el) { return; } - $el.setSelectedPath(""); + $el.setSelectedNotePath(""); $el.autocomplete("val", "").trigger('change'); } @@ -34,7 +34,7 @@ function showRecentNotes($el) { return; } - $el.setSelectedPath(""); + $el.setSelectedNotePath(""); $el.autocomplete("val", ""); $el.trigger('focus'); } @@ -91,10 +91,10 @@ function initNoteAutocomplete($el, options) { }, [ { source: autocompleteSource, - displayKey: 'pathTitle', + displayKey: 'notePathTitle', templates: { suggestion: function(suggestion) { - return suggestion.highlightedTitle; + return suggestion.highlightedNotePathTitle; } }, // we can't cache identical searches because notes can be created / renamed, new recent notes can be added @@ -102,7 +102,7 @@ function initNoteAutocomplete($el, options) { } ]); - $el.on('autocomplete:selected', (event, suggestion) => $el.setSelectedPath(suggestion.path)); + $el.on('autocomplete:selected', (event, suggestion) => $el.setSelectedNotePath(suggestion.notePath)); $el.on('autocomplete:closed', () => { if (!$el.val().trim()) { clearText($el); @@ -113,24 +113,24 @@ function initNoteAutocomplete($el, options) { } function init() { - $.fn.getSelectedPath = function () { + $.fn.getSelectedNotePath = function () { if (!$(this).val().trim()) { return ""; } else { - return $(this).attr(SELECTED_PATH_KEY); + return $(this).attr(SELECTED_NOTE_PATH_KEY); } }; - $.fn.setSelectedPath = function (path) { - path = path || ""; + $.fn.setSelectedNotePath = function (notePath) { + notePath = notePath || ""; - $(this).attr(SELECTED_PATH_KEY, path); + $(this).attr(SELECTED_NOTE_PATH_KEY, notePath); $(this) .closest(".input-group") .find(".go-to-selected-note-button") - .toggleClass("disabled", !path.trim()) - .attr(SELECTED_PATH_KEY, path); // we also set attr here so tooltip can be displayed + .toggleClass("disabled", !notePath.trim()) + .attr(SELECTED_NOTE_PATH_KEY, notePath); // we also set attr here so tooltip can be displayed }; } @@ -139,4 +139,4 @@ export default { initNoteAutocomplete, showRecentNotes, init -} \ No newline at end of file +} diff --git a/src/public/app/services/protected_session_holder.js b/src/public/app/services/protected_session_holder.js index 7f0dc2a7d..fc041b51e 100644 --- a/src/public/app/services/protected_session_holder.js +++ b/src/public/app/services/protected_session_holder.js @@ -12,15 +12,19 @@ setInterval(() => { resetProtectedSession(); } -}, 5000); +}, 10000); function setProtectedSessionId(id) { // using session cookie so that it disappears after browser/tab is closed utils.setSessionCookie(PROTECTED_SESSION_ID_KEY, id); } -function resetProtectedSession() { +function resetSessionCookie() { utils.setSessionCookie(PROTECTED_SESSION_ID_KEY, null); +} + +function resetProtectedSession() { + resetSessionCookie(); // most secure solution - guarantees nothing remained in memory // since this expires because user doesn't use the app, it shouldn't be disruptive @@ -47,8 +51,9 @@ function touchProtectedSessionIfNecessary(note) { export default { setProtectedSessionId, + resetSessionCookie, resetProtectedSession, isProtectedSessionAvailable, touchProtectedSession, touchProtectedSessionIfNecessary -}; \ No newline at end of file +}; diff --git a/src/public/app/services/utils.js b/src/public/app/services/utils.js index 0fa8e9642..504f5ae45 100644 --- a/src/public/app/services/utils.js +++ b/src/public/app/services/utils.js @@ -187,7 +187,7 @@ function setCookie(name, value) { } function setSessionCookie(name, value) { - document.cookie = name + "=" + (value || "") + ";"; + document.cookie = name + "=" + (value || "") + "; SameSite=Strict"; } function getCookie(name) { @@ -356,4 +356,4 @@ export default { copySelectionToClipboard, isCKEditorInitialized, dynamicRequire -}; \ No newline at end of file +}; diff --git a/src/public/app/widgets/promoted_attributes.js b/src/public/app/widgets/promoted_attributes.js index 58e025d07..859ca2291 100644 --- a/src/public/app/widgets/promoted_attributes.js +++ b/src/public/app/widgets/promoted_attributes.js @@ -200,7 +200,7 @@ export default class PromotedAttributesWidget extends TabAwareWidget { this.promotedAttributeChanged(event); }); - $input.setSelectedPath(valueAttr.value); + $input.setSelectedNotePath(valueAttr.value); } else { ws.logError("Unknown attribute type=" + valueAttr.type); @@ -250,7 +250,7 @@ export default class PromotedAttributesWidget extends TabAwareWidget { value = $attr.is(':checked') ? "true" : "false"; } else if ($attr.prop("attribute-type") === "relation") { - const selectedPath = $attr.getSelectedPath(); + const selectedPath = $attr.getSelectedNotePath(); value = selectedPath ? treeService.getNoteIdFromNotePath(selectedPath) : ""; } diff --git a/src/public/app/widgets/search_results.js b/src/public/app/widgets/search_results.js index 88aa434a9..0a5f1031a 100644 --- a/src/public/app/widgets/search_results.js +++ b/src/public/app/widgets/search_results.js @@ -48,8 +48,8 @@ export default class SearchResultsWidget extends BasicWidget { for (const result of results) { const link = $('', { href: 'javascript:', - text: result.title - }).attr('data-action', 'note').attr('data-note-path', result.path); + text: result.notePathTitle + }).attr('data-action', 'note').attr('data-note-path', result.notePath); const $result = $('
  • ').append(link); @@ -60,4 +60,4 @@ export default class SearchResultsWidget extends BasicWidget { searchFlowEndedEvent() { this.$searchResults.hide(); } -} \ No newline at end of file +} diff --git a/src/public/app/widgets/title_bar_buttons.js b/src/public/app/widgets/title_bar_buttons.js index 7c3084353..6be2e2dae 100644 --- a/src/public/app/widgets/title_bar_buttons.js +++ b/src/public/app/widgets/title_bar_buttons.js @@ -17,6 +17,10 @@ const TPL = ` padding-left: 10px; padding-right: 10px; } + + .title-bar-buttons button:hover { + background-color: var(--accented-background-color) !important; + } @@ -62,4 +66,4 @@ export default class TitleBarButtonsWidget extends BasicWidget { return this.$widget; } -} \ No newline at end of file +} diff --git a/src/routes/api/attributes.js b/src/routes/api/attributes.js index 131e7a77b..abab2ab9e 100644 --- a/src/routes/api/attributes.js +++ b/src/routes/api/attributes.js @@ -98,10 +98,11 @@ async function updateNoteAttributes(req) { if (attribute.type !== attributeEntity.type || attribute.name !== attributeEntity.name - || (attribute.type === 'relation' && attribute.value !== attributeEntity.value)) { + || (attribute.type === 'relation' && attribute.value !== attributeEntity.value) + || attribute.isInheritable !== attributeEntity.isInheritable) { if (attribute.type !== 'relation' || !!attribute.value.trim()) { - const newAttribute = attributeEntity.createClone(attribute.type, attribute.name, attribute.value); + const newAttribute = attributeEntity.createClone(attribute.type, attribute.name, attribute.value, attribute.isInheritable); await newAttribute.save(); } diff --git a/src/routes/api/autocomplete.js b/src/routes/api/autocomplete.js index 307746b6c..28dfaf164 100644 --- a/src/routes/api/autocomplete.js +++ b/src/routes/api/autocomplete.js @@ -1,6 +1,7 @@ "use strict"; -const noteCacheService = require('../../services/note_cache'); +const noteCacheService = require('../../services/note_cache/note_cache_service'); +const searchService = require('../../services/search/search'); const repository = require('../../services/repository'); const log = require('../../services/log'); const utils = require('../../services/utils'); @@ -18,7 +19,7 @@ async function getAutocomplete(req) { results = await getRecentNotes(activeNoteId); } else { - results = await noteCacheService.findNotes(query); + results = await searchService.searchNotesForAutocomplete(query); } const msTaken = Date.now() - timestampStarted; @@ -57,14 +58,13 @@ async function getRecentNotes(activeNoteId) { const title = noteCacheService.getNoteTitleForPath(rn.notePath.split('/')); return { - path: rn.notePath, - pathTitle: title, - highlightedTitle: title, - noteTitle: noteCacheService.getNoteTitleFromPath(rn.notePath) + notePath: rn.notePath, + notePathTitle: title, + highlightedNotePathTitle: utils.escapeHtml(title) }; }); } module.exports = { getAutocomplete -}; \ No newline at end of file +}; diff --git a/src/routes/api/clipper.js b/src/routes/api/clipper.js index 6ad2efea4..38473fe06 100644 --- a/src/routes/api/clipper.js +++ b/src/routes/api/clipper.js @@ -109,11 +109,17 @@ async function addImagesToNote(images, note, content) { const {note: imageNote, url} = await imageService.saveImage(note.noteId, buffer, filename, true); + await new Attribute({ + noteId: imageNote.noteId, + type: 'label', + name: 'hideInAutocomplete' + }).save(); + await new Attribute({ noteId: note.noteId, type: 'relation', - value: imageNote.noteId, - name: 'imageLink' + name: 'imageLink', + value: imageNote.noteId }).save(); console.log(`Replacing ${imageId} with ${url}`); @@ -155,4 +161,4 @@ module.exports = { addClipping, openNote, handshake -}; \ No newline at end of file +}; diff --git a/src/routes/api/import.js b/src/routes/api/import.js index 7237450d3..f0529e888 100644 --- a/src/routes/api/import.js +++ b/src/routes/api/import.js @@ -8,7 +8,7 @@ const zipImportService = require('../../services/import/zip'); const singleImportService = require('../../services/import/single'); const cls = require('../../services/cls'); const path = require('path'); -const noteCacheService = require('../../services/note_cache'); +const noteCacheService = require('../../services/note_cache/note_cache.js'); const log = require('../../services/log'); const TaskContext = require('../../services/task_context.js'); @@ -85,4 +85,4 @@ async function importToBranch(req) { module.exports = { importToBranch -}; \ No newline at end of file +}; diff --git a/src/routes/api/note_revisions.js b/src/routes/api/note_revisions.js index 7f325f725..dff91b4e6 100644 --- a/src/routes/api/note_revisions.js +++ b/src/routes/api/note_revisions.js @@ -1,7 +1,7 @@ "use strict"; const repository = require('../../services/repository'); -const noteCacheService = require('../../services/note_cache'); +const noteCacheService = require('../../services/note_cache/note_cache.js'); const protectedSessionService = require('../../services/protected_session'); const noteRevisionService = require('../../services/note_revisions'); const utils = require('../../services/utils'); diff --git a/src/routes/api/recent_changes.js b/src/routes/api/recent_changes.js index a4049072a..536e8a861 100644 --- a/src/routes/api/recent_changes.js +++ b/src/routes/api/recent_changes.js @@ -3,7 +3,7 @@ const sql = require('../../services/sql'); const protectedSessionService = require('../../services/protected_session'); const noteService = require('../../services/notes'); -const noteCacheService = require('../../services/note_cache'); +const noteCacheService = require('../../services/note_cache/note_cache.js'); async function getRecentChanges(req) { const {ancestorNoteId} = req.params; @@ -102,4 +102,4 @@ async function getRecentChanges(req) { module.exports = { getRecentChanges -}; \ No newline at end of file +}; diff --git a/src/routes/api/search.js b/src/routes/api/search.js index b88989fb2..1bbbfbc40 100644 --- a/src/routes/api/search.js +++ b/src/routes/api/search.js @@ -1,18 +1,18 @@ "use strict"; const repository = require('../../services/repository'); -const noteCacheService = require('../../services/note_cache'); +const noteCacheService = require('../../services/note_cache/note_cache.js'); const log = require('../../services/log'); const scriptService = require('../../services/script'); -const searchService = require('../../services/search'); +const searchService = require('../../services/search/search'); async function searchNotes(req) { - const noteIds = await searchService.searchForNoteIds(req.params.searchString); + const notePaths = await searchService.searchNotes(req.params.searchString); try { return { success: true, - results: noteIds.map(noteCacheService.getNotePath).filter(res => !!res) + results: notePaths } } catch { @@ -110,4 +110,4 @@ async function searchFromRelation(note, relationName) { module.exports = { searchNotes, searchFromNote -}; \ No newline at end of file +}; diff --git a/src/routes/api/similar_notes.js b/src/routes/api/similar_notes.js index 3403833f8..c04dc29f1 100644 --- a/src/routes/api/similar_notes.js +++ b/src/routes/api/similar_notes.js @@ -1,6 +1,6 @@ "use strict"; -const noteCacheService = require('../../services/note_cache'); +const noteCacheService = require('../../services/note_cache/note_cache_service'); const repository = require('../../services/repository'); async function getSimilarNotes(req) { @@ -12,7 +12,7 @@ async function getSimilarNotes(req) { return [404, `Note ${noteId} not found.`]; } - const results = await noteCacheService.findSimilarNotes(note.title); + const results = await noteCacheService.findSimilarNotes(noteId); return results .filter(note => note.noteId !== noteId); @@ -20,4 +20,4 @@ async function getSimilarNotes(req) { module.exports = { getSimilarNotes -}; \ No newline at end of file +}; diff --git a/src/services/hoisted_note.js b/src/services/hoisted_note.js index dc9536bc9..2a45cd322 100644 --- a/src/services/hoisted_note.js +++ b/src/services/hoisted_note.js @@ -1,19 +1,6 @@ -const optionService = require('./options'); -const sqlInit = require('./sql_init'); -const eventService = require('./events'); - let hoistedNoteId = 'root'; -eventService.subscribe(eventService.ENTITY_CHANGED, async ({entityName, entity}) => { - if (entityName === 'options' && entity.name === 'hoistedNoteId') { - hoistedNoteId = entity.value; - } -}); - -sqlInit.dbReady.then(async () => { - hoistedNoteId = await optionService.getOption('hoistedNoteId'); -}); - module.exports = { - getHoistedNoteId: () => hoistedNoteId -}; \ No newline at end of file + getHoistedNoteId: () => hoistedNoteId, + setHoistedNoteId(noteId) { hoistedNoteId = noteId; } +}; diff --git a/src/services/hoisted_note_loader.js b/src/services/hoisted_note_loader.js new file mode 100644 index 000000000..fb7b6df12 --- /dev/null +++ b/src/services/hoisted_note_loader.js @@ -0,0 +1,14 @@ +const optionService = require('./options'); +const sqlInit = require('./sql_init'); +const eventService = require('./events'); +const hoistedNote = require('./hoisted_note'); + +eventService.subscribe(eventService.ENTITY_CHANGED, async ({entityName, entity}) => { + if (entityName === 'options' && entity.name === 'hoistedNoteId') { + hoistedNote.setHoistedNoteId(entity.value); + } +}); + +sqlInit.dbReady.then(async () => { + hoistedNote.setHoistedNoteId(await optionService.getOption('hoistedNoteId')); +}); diff --git a/src/services/note_cache.js b/src/services/note_cache.js deleted file mode 100644 index 0012d2545..000000000 --- a/src/services/note_cache.js +++ /dev/null @@ -1,559 +0,0 @@ -const sql = require('./sql'); -const sqlInit = require('./sql_init'); -const eventService = require('./events'); -const repository = require('./repository'); -const protectedSessionService = require('./protected_session'); -const utils = require('./utils'); -const hoistedNoteService = require('./hoisted_note'); -const stringSimilarity = require('string-similarity'); - -let loaded = false; -let loadedPromiseResolve; -/** Is resolved after the initial load */ -let loadedPromise = new Promise(res => loadedPromiseResolve = res); - -let noteTitles = {}; -let protectedNoteTitles = {}; -let noteIds; -let childParentToBranchId = {}; -const childToParent = {}; -let archived = {}; - -// key is 'childNoteId-parentNoteId' as a replacement for branchId which we don't use here -let prefixes = {}; - -async function load() { - noteTitles = await sql.getMap(`SELECT noteId, title FROM notes WHERE isDeleted = 0 AND isProtected = 0`); - noteIds = Object.keys(noteTitles); - - prefixes = await sql.getMap(` - SELECT noteId || '-' || parentNoteId, prefix - FROM branches - WHERE isDeleted = 0 AND prefix IS NOT NULL AND prefix != ''`); - - const branches = await sql.getRows(`SELECT branchId, noteId, parentNoteId FROM branches WHERE isDeleted = 0`); - - for (const rel of branches) { - childToParent[rel.noteId] = childToParent[rel.noteId] || []; - childToParent[rel.noteId].push(rel.parentNoteId); - childParentToBranchId[`${rel.noteId}-${rel.parentNoteId}`] = rel.branchId; - } - - archived = await sql.getMap(`SELECT noteId, isInheritable FROM attributes WHERE isDeleted = 0 AND type = 'label' AND name = 'archived'`); - - if (protectedSessionService.isProtectedSessionAvailable()) { - await loadProtectedNotes(); - } - - for (const noteId in childToParent) { - resortChildToParent(noteId); - } - - loaded = true; - loadedPromiseResolve(); -} - -async function loadProtectedNotes() { - protectedNoteTitles = await sql.getMap(`SELECT noteId, title FROM notes WHERE isDeleted = 0 AND isProtected = 1`); - - for (const noteId in protectedNoteTitles) { - protectedNoteTitles[noteId] = protectedSessionService.decryptString(protectedNoteTitles[noteId]); - } -} - -function highlightResults(results, allTokens) { - // we remove < signs because they can cause trouble in matching and overwriting existing highlighted chunks - // which would make the resulting HTML string invalid. - // { and } are used for marking and tag (to avoid matches on single 'b' character) - allTokens = allTokens.map(token => token.replace('/[<\{\}]/g', '')); - - // sort by the longest so we first highlight longest matches - allTokens.sort((a, b) => a.length > b.length ? -1 : 1); - - for (const result of results) { - result.highlightedTitle = result.pathTitle; - } - - for (const token of allTokens) { - const tokenRegex = new RegExp("(" + utils.escapeRegExp(token) + ")", "gi"); - - for (const result of results) { - result.highlightedTitle = result.highlightedTitle.replace(tokenRegex, "{$1}"); - } - } - - for (const result of results) { - result.highlightedTitle = result.highlightedTitle - .replace(/{/g, "") - .replace(/}/g, ""); - } -} - -async function findNotes(query) { - if (!noteTitles || !query.length) { - return []; - } - - const allTokens = query - .trim() // necessary because even with .split() trailing spaces are tokens which causes havoc - .toLowerCase() - .split(/[ -]/) - .filter(token => token !== '/'); // '/' is used as separator - - const tokens = allTokens.slice(); - let results = []; - - let noteIds = Object.keys(noteTitles); - - if (protectedSessionService.isProtectedSessionAvailable()) { - noteIds = [...new Set(noteIds.concat(Object.keys(protectedNoteTitles)))]; - } - - for (const noteId of noteIds) { - // autocomplete should be able to find notes by their noteIds as well (only leafs) - if (noteId === query) { - search(noteId, [], [], results); - continue; - } - - // for leaf note it doesn't matter if "archived" label is inheritable or not - if (noteId in archived) { - continue; - } - - const parents = childToParent[noteId]; - if (!parents) { - continue; - } - - for (const parentNoteId of parents) { - // for parent note archived needs to be inheritable - if (archived[parentNoteId] === 1) { - continue; - } - - const title = getNoteTitle(noteId, parentNoteId).toLowerCase(); - const foundTokens = []; - - for (const token of tokens) { - if (title.includes(token)) { - foundTokens.push(token); - } - } - - if (foundTokens.length > 0) { - const remainingTokens = tokens.filter(token => !foundTokens.includes(token)); - - search(parentNoteId, remainingTokens, [noteId], results); - } - } - } - - if (hoistedNoteService.getHoistedNoteId() !== 'root') { - results = results.filter(res => res.pathArray.includes(hoistedNoteService.getHoistedNoteId())); - } - - // sort results by depth of the note. This is based on the assumption that more important results - // are closer to the note root. - results.sort((a, b) => { - if (a.pathArray.length === b.pathArray.length) { - return a.title < b.title ? -1 : 1; - } - - return a.pathArray.length < b.pathArray.length ? -1 : 1; - }); - - const apiResults = results.slice(0, 200).map(res => { - const notePath = res.pathArray.join('/'); - - return { - noteId: res.noteId, - branchId: res.branchId, - path: notePath, - pathTitle: res.titleArray.join(' / '), - noteTitle: getNoteTitleFromPath(notePath) - }; - }); - - highlightResults(apiResults, allTokens); - - return apiResults; -} - -function search(noteId, tokens, path, results) { - if (tokens.length === 0) { - const retPath = getSomePath(noteId, path); - - if (retPath && !isNotePathArchived(retPath)) { - const thisNoteId = retPath[retPath.length - 1]; - const thisParentNoteId = retPath[retPath.length - 2]; - - results.push({ - noteId: thisNoteId, - branchId: childParentToBranchId[`${thisNoteId}-${thisParentNoteId}`], - pathArray: retPath, - titleArray: getNoteTitleArrayForPath(retPath) - }); - } - - return; - } - - const parents = childToParent[noteId]; - if (!parents || noteId === 'root') { - return; - } - - for (const parentNoteId of parents) { - // archived must be inheritable - if (archived[parentNoteId] === 1) { - continue; - } - - const title = getNoteTitle(noteId, parentNoteId).toLowerCase(); - const foundTokens = []; - - for (const token of tokens) { - if (title.includes(token)) { - foundTokens.push(token); - } - } - - if (foundTokens.length > 0) { - const remainingTokens = tokens.filter(token => !foundTokens.includes(token)); - - search(parentNoteId, remainingTokens, path.concat([noteId]), results); - } - else { - search(parentNoteId, tokens, path.concat([noteId]), results); - } - } -} - -function isNotePathArchived(notePath) { - // if the note is archived directly - if (archived[notePath[notePath.length - 1]] !== undefined) { - return true; - } - - for (let i = 0; i < notePath.length - 1; i++) { - // this is going through parents so archived must be inheritable - if (archived[notePath[i]] === 1) { - return true; - } - } - - return false; -} - -/** - * This assumes that note is available. "archived" note means that there isn't a single non-archived note-path - * leading to this note. - * - * @param noteId - */ -function isArchived(noteId) { - const notePath = getSomePath(noteId); - - return isNotePathArchived(notePath); -} - -/** - * @param {string} noteId - * @param {string} ancestorNoteId - * @return {boolean} - true if given noteId has ancestorNoteId in any of its paths (even archived) - */ -function isInAncestor(noteId, ancestorNoteId) { - if (ancestorNoteId === 'root' || ancestorNoteId === noteId) { - return true; - } - - for (const parentNoteId of childToParent[noteId] || []) { - if (isInAncestor(parentNoteId, ancestorNoteId)) { - return true; - } - } - - return false; -} - -function getNoteTitleFromPath(notePath) { - const pathArr = notePath.split("/"); - - if (pathArr.length === 1) { - return getNoteTitle(pathArr[0], 'root'); - } - else { - return getNoteTitle(pathArr[pathArr.length - 1], pathArr[pathArr.length - 2]); - } -} - -function getNoteTitle(noteId, parentNoteId) { - const prefix = prefixes[noteId + '-' + parentNoteId]; - - let title = noteTitles[noteId]; - - if (!title) { - if (protectedSessionService.isProtectedSessionAvailable()) { - title = protectedNoteTitles[noteId]; - } - else { - title = '[protected]'; - } - } - - return (prefix ? (prefix + ' - ') : '') + title; -} - -function getNoteTitleArrayForPath(path) { - const titles = []; - - if (path[0] === hoistedNoteService.getHoistedNoteId() && path.length === 1) { - return [ getNoteTitle(hoistedNoteService.getHoistedNoteId()) ]; - } - - let parentNoteId = 'root'; - let hoistedNotePassed = false; - - for (const noteId of path) { - // start collecting path segment titles only after hoisted note - if (hoistedNotePassed) { - const title = getNoteTitle(noteId, parentNoteId); - - titles.push(title); - } - - if (noteId === hoistedNoteService.getHoistedNoteId()) { - hoistedNotePassed = true; - } - - parentNoteId = noteId; - } - - return titles; -} - -function getNoteTitleForPath(path) { - const titles = getNoteTitleArrayForPath(path); - - return titles.join(' / '); -} - -/** - * Returns notePath for noteId from cache. Note hoisting is respected. - * Archived notes are also returned, but non-archived paths are preferred if available - * - this means that archived paths is returned only if there's no non-archived path - * - you can check whether returned path is archived using isArchived() - */ -function getSomePath(noteId, path = []) { - if (noteId === 'root') { - path.push(noteId); - path.reverse(); - - if (!path.includes(hoistedNoteService.getHoistedNoteId())) { - return false; - } - - return path; - } - - const parents = childToParent[noteId]; - if (!parents || parents.length === 0) { - return false; - } - - for (const parentNoteId of parents) { - const retPath = getSomePath(parentNoteId, path.concat([noteId])); - - if (retPath) { - return retPath; - } - } - - return false; -} - -function getNotePath(noteId) { - const retPath = getSomePath(noteId); - - if (retPath) { - const noteTitle = getNoteTitleForPath(retPath); - const parentNoteId = childToParent[noteId][0]; - - return { - noteId: noteId, - branchId: childParentToBranchId[`${noteId}-${parentNoteId}`], - title: noteTitle, - notePath: retPath, - path: retPath.join('/') - }; - } -} - -function evaluateSimilarity(text1, text2, noteId, results) { - let coeff = stringSimilarity.compareTwoStrings(text1, text2); - - if (coeff > 0.4) { - const notePath = getSomePath(noteId); - - // this takes care of note hoisting - if (!notePath) { - return; - } - - if (isNotePathArchived(notePath)) { - coeff -= 0.2; // archived penalization - } - - results.push({coeff, notePath, noteId}); - } -} - -/** - * Point of this is to break up long running sync process to avoid blocking - * see https://snyk.io/blog/nodejs-how-even-quick-async-functions-can-block-the-event-loop-starve-io/ - */ -function setImmediatePromise() { - return new Promise((resolve) => { - setTimeout(() => resolve(), 0); - }); -} - -async function evaluateSimilarityDict(title, dict, results) { - let i = 0; - - for (const noteId in dict) { - evaluateSimilarity(title, dict[noteId], noteId, results); - - i++; - - if (i % 200 === 0) { - await setImmediatePromise(); - } - } -} - -async function findSimilarNotes(title) { - const results = []; - - await evaluateSimilarityDict(title, noteTitles, results); - - if (protectedSessionService.isProtectedSessionAvailable()) { - await evaluateSimilarityDict(title, protectedNoteTitles, results); - } - - results.sort((a, b) => a.coeff > b.coeff ? -1 : 1); - - return results.length > 50 ? results.slice(0, 50) : results; -} - -eventService.subscribe([eventService.ENTITY_CHANGED, eventService.ENTITY_DELETED, eventService.ENTITY_SYNCED], async ({entityName, entity}) => { - // note that entity can also be just POJO without methods if coming from sync - - if (!loaded) { - return; - } - - if (entityName === 'notes') { - const note = entity; - - if (note.isDeleted) { - delete noteTitles[note.noteId]; - delete childToParent[note.noteId]; - } - else { - if (note.isProtected) { - // we can assume we have protected session since we managed to update - // removing from the maps is important when switching between protected & unprotected - protectedNoteTitles[note.noteId] = note.title; - delete noteTitles[note.noteId]; - } - else { - noteTitles[note.noteId] = note.title; - delete protectedNoteTitles[note.noteId]; - } - } - } - else if (entityName === 'branches') { - const branch = entity; - - if (branch.isDeleted) { - if (branch.noteId in childToParent) { - childToParent[branch.noteId] = childToParent[branch.noteId].filter(noteId => noteId !== branch.parentNoteId); - } - - delete prefixes[branch.noteId + '-' + branch.parentNoteId]; - delete childParentToBranchId[branch.noteId + '-' + branch.parentNoteId]; - } - else { - if (branch.prefix) { - prefixes[branch.noteId + '-' + branch.parentNoteId] = branch.prefix; - } - - childToParent[branch.noteId] = childToParent[branch.noteId] || []; - - if (!childToParent[branch.noteId].includes(branch.parentNoteId)) { - childToParent[branch.noteId].push(branch.parentNoteId); - } - - resortChildToParent(branch.noteId); - - childParentToBranchId[branch.noteId + '-' + branch.parentNoteId] = branch.branchId; - } - } - else if (entityName === 'attributes') { - const attribute = entity; - - if (attribute.type === 'label' && attribute.name === 'archived') { - // we're not using label object directly, since there might be other non-deleted archived label - const archivedLabel = await repository.getEntity(`SELECT * FROM attributes WHERE isDeleted = 0 AND type = 'label' - AND name = 'archived' AND noteId = ?`, [attribute.noteId]); - - if (archivedLabel) { - archived[attribute.noteId] = archivedLabel.isInheritable ? 1 : 0; - } - else { - delete archived[attribute.noteId]; - } - } - } -}); - -// will sort the childs so that non-archived are first and archived at the end -// this is done so that non-archived paths are always explored as first when searching for note path -function resortChildToParent(noteId) { - if (!(noteId in childToParent)) { - return; - } - - childToParent[noteId].sort((a, b) => archived[a] === 1 ? 1 : -1); -} - -/** - * @param noteId - * @returns {boolean} - true if note exists (is not deleted) and is available in current note hoisting - */ -function isAvailable(noteId) { - const notePath = getNotePath(noteId); - - return !!notePath; -} - -eventService.subscribe(eventService.ENTER_PROTECTED_SESSION, () => { - loadedPromise.then(() => loadProtectedNotes()); -}); - -sqlInit.dbReady.then(() => utils.stopWatch("Note cache load", load)); - -module.exports = { - loadedPromise, - findNotes, - getNotePath, - getNoteTitleForPath, - getNoteTitleFromPath, - isAvailable, - isArchived, - isInAncestor, - load, - findSimilarNotes -}; \ No newline at end of file diff --git a/src/services/note_cache/entities/attribute.js b/src/services/note_cache/entities/attribute.js new file mode 100644 index 000000000..73ae80e86 --- /dev/null +++ b/src/services/note_cache/entities/attribute.js @@ -0,0 +1,50 @@ +"use strict"; + +class Attribute { + constructor(noteCache, row) { + /** @param {NoteCache} */ + this.noteCache = noteCache; + /** @param {string} */ + this.attributeId = row.attributeId; + /** @param {string} */ + this.noteId = row.noteId; + /** @param {string} */ + this.type = row.type; + /** @param {string} */ + this.name = row.name.toLowerCase(); + /** @param {string} */ + this.value = row.type === 'label'? row.value.toLowerCase() : row.value; + /** @param {boolean} */ + this.isInheritable = !!row.isInheritable; + + this.noteCache.attributes[this.attributeId] = this; + this.noteCache.notes[this.noteId].ownedAttributes.push(this); + + const key = `${this.type}-${this.name}`; + this.noteCache.attributeIndex[key] = this.noteCache.attributeIndex[key] || []; + this.noteCache.attributeIndex[key].push(this); + + const targetNote = this.targetNote; + + if (targetNote) { + targetNote.targetRelations.push(this); + } + } + + get isAffectingSubtree() { + return this.isInheritable + || (this.type === 'relation' && this.name === 'template'); + } + + get note() { + return this.noteCache.notes[this.noteId]; + } + + get targetNote() { + if (this.type === 'relation') { + return this.noteCache.notes[this.value]; + } + } +} + +module.exports = Attribute; diff --git a/src/services/note_cache/entities/branch.js b/src/services/note_cache/entities/branch.js new file mode 100644 index 000000000..dfff5a393 --- /dev/null +++ b/src/services/note_cache/entities/branch.js @@ -0,0 +1,49 @@ +"use strict"; + +class Branch { + constructor(noteCache, row) { + /** @param {NoteCache} */ + this.noteCache = noteCache; + /** @param {string} */ + this.branchId = row.branchId; + /** @param {string} */ + this.noteId = row.noteId; + /** @param {string} */ + this.parentNoteId = row.parentNoteId; + /** @param {string} */ + this.prefix = row.prefix; + + if (this.branchId === 'root') { + return; + } + + const childNote = this.noteCache.notes[this.noteId]; + const parentNote = this.parentNote; + + if (!childNote) { + console.log(`Cannot find child note ${this.noteId} of a branch ${this.branchId}`); + return; + } + + childNote.parents.push(parentNote); + childNote.parentBranches.push(this); + + parentNote.children.push(childNote); + + this.noteCache.branches[this.branchId] = this; + this.noteCache.childParentToBranch[`${this.noteId}-${this.parentNoteId}`] = this; + } + + /** @return {Note} */ + get parentNote() { + const note = this.noteCache.notes[this.parentNoteId]; + + if (!note) { + console.log(`Cannot find note ${this.parentNoteId}`); + } + + return note; + } +} + +module.exports = Branch; diff --git a/src/services/note_cache/entities/note.js b/src/services/note_cache/entities/note.js new file mode 100644 index 000000000..409589263 --- /dev/null +++ b/src/services/note_cache/entities/note.js @@ -0,0 +1,327 @@ +"use strict"; + +const protectedSessionService = require('../../protected_session'); + +class Note { + constructor(noteCache, row) { + /** @param {NoteCache} */ + this.noteCache = noteCache; + /** @param {string} */ + this.noteId = row.noteId; + /** @param {string} */ + this.title = row.title; + /** @param {string} */ + this.type = row.type; + /** @param {string} */ + this.mime = row.mime; + /** @param {number} */ + this.contentLength = row.contentLength; + /** @param {string} */ + this.dateCreated = row.dateCreated; + /** @param {string} */ + this.dateModified = row.dateModified; + /** @param {string} */ + this.utcDateCreated = row.utcDateCreated; + /** @param {string} */ + this.utcDateModified = row.utcDateModified; + /** @param {boolean} */ + this.isProtected = !!row.isProtected; + /** @param {boolean} */ + this.isDecrypted = !row.isProtected || !!row.isContentAvailable; + /** @param {Branch[]} */ + this.parentBranches = []; + /** @param {Note[]} */ + this.parents = []; + /** @param {Note[]} */ + this.children = []; + /** @param {Attribute[]} */ + this.ownedAttributes = []; + + /** @param {Attribute[]|null} */ + this.attributeCache = null; + /** @param {Attribute[]|null} */ + this.inheritableAttributeCache = null; + + /** @param {Attribute[]} */ + this.targetRelations = []; + + /** @param {string|null} */ + this.flatTextCache = null; + + this.noteCache.notes[this.noteId] = this; + + if (protectedSessionService.isProtectedSessionAvailable()) { + this.decrypt(); + } + + /** @param {Note[]|null} */ + this.ancestorCache = null; + } + + /** @return {Attribute[]} */ + get attributes() { + if (!this.attributeCache) { + const parentAttributes = this.ownedAttributes.slice(); + + if (this.noteId !== 'root') { + for (const parentNote of this.parents) { + parentAttributes.push(...parentNote.inheritableAttributes); + } + } + + const templateAttributes = []; + + for (const ownedAttr of parentAttributes) { // parentAttributes so we process also inherited templates + if (ownedAttr.type === 'relation' && ownedAttr.name === 'template') { + const templateNote = this.noteCache.notes[ownedAttr.value]; + + if (templateNote) { + templateAttributes.push(...templateNote.attributes); + } + } + } + + this.attributeCache = parentAttributes.concat(templateAttributes); + this.inheritableAttributeCache = []; + + for (const attr of this.attributeCache) { + if (attr.isInheritable) { + this.inheritableAttributeCache.push(attr); + } + } + } + + return this.attributeCache; + } + + /** @return {Attribute[]} */ + get inheritableAttributes() { + if (!this.inheritableAttributeCache) { + this.attributes; // will refresh also this.inheritableAttributeCache + } + + return this.inheritableAttributeCache; + } + + hasAttribute(type, name) { + return this.attributes.find(attr => attr.type === type && attr.name === name); + } + + getLabelValue(name) { + const label = this.attributes.find(attr => attr.type === 'label' && attr.name === name); + + return label ? label.value : null; + } + + getRelationTarget(name) { + const relation = this.attributes.find(attr => attr.type === 'relation' && attr.name === name); + + return relation ? relation.targetNote : null; + } + + get isArchived() { + return this.hasAttribute('label', 'archived'); + } + + get isHideInAutocompleteOrArchived() { + return this.attributes.find(attr => + attr.type === 'label' + && ["archived", "hideInAutocomplete"].includes(attr.name)); + } + + get hasInheritableOwnedArchivedLabel() { + return !!this.ownedAttributes.find(attr => attr.type === 'label' && attr.name === 'archived' && attr.isInheritable); + } + + // will sort the parents so that non-archived are first and archived at the end + // this is done so that non-archived paths are always explored as first when searching for note path + resortParents() { + this.parents.sort((a, b) => a.hasInheritableOwnedArchivedLabel ? 1 : -1); + } + + /** + * @return {string} - returns flattened textual representation of note, prefixes and attributes usable for searching + */ + get flatText() { + if (!this.flatTextCache) { + if (this.isHideInAutocompleteOrArchived) { + this.flatTextCache = " "; // can't be empty + return this.flatTextCache; + } + + this.flatTextCache = ''; + + for (const branch of this.parentBranches) { + if (branch.prefix) { + this.flatTextCache += branch.prefix + ' - '; + } + } + + this.flatTextCache += this.title; + + for (const attr of this.attributes) { + // it's best to use space as separator since spaces are filtered from the search string by the tokenization into words + this.flatTextCache += (attr.type === 'label' ? '#' : '@') + attr.name; + + if (attr.value) { + this.flatTextCache += '=' + attr.value; + } + } + + this.flatTextCache = this.flatTextCache.toLowerCase(); + } + + return this.flatTextCache; + } + + invalidateThisCache() { + this.flatTextCache = null; + + this.attributeCache = null; + this.inheritableAttributeCache = null; + this.ancestorCache = null; + } + + invalidateSubtreeCaches() { + this.invalidateThisCache(); + + for (const childNote of this.children) { + childNote.invalidateSubtreeCaches(); + } + + for (const targetRelation of this.targetRelations) { + if (targetRelation.name === 'template') { + const note = targetRelation.note; + + if (note) { + note.invalidateSubtreeCaches(); + } + } + } + } + + invalidateSubtreeFlatText() { + this.flatTextCache = null; + + for (const childNote of this.children) { + childNote.invalidateSubtreeFlatText(); + } + + for (const targetRelation of this.targetRelations) { + if (targetRelation.name === 'template') { + const note = targetRelation.note; + + if (note) { + note.invalidateSubtreeFlatText(); + } + } + } + } + + get isTemplate() { + return !!this.targetRelations.find(rel => rel.name === 'template'); + } + + /** @return {Note[]} */ + get subtreeNotesIncludingTemplated() { + const arr = [[this]]; + + for (const childNote of this.children) { + arr.push(childNote.subtreeNotesIncludingTemplated); + } + + for (const targetRelation of this.targetRelations) { + if (targetRelation.name === 'template') { + const note = targetRelation.note; + + if (note) { + arr.push(note.subtreeNotesIncludingTemplated); + } + } + } + + return arr.flat(); + } + + /** @return {Note[]} */ + get subtreeNotes() { + const arr = [[this]]; + + for (const childNote of this.children) { + arr.push(childNote.subtreeNotes); + } + + return arr.flat(); + } + + get parentCount() { + return this.parents.length; + } + + get childrenCount() { + return this.children.length; + } + + get labelCount() { + return this.attributes.filter(attr => attr.type === 'label').length; + } + + get relationCount() { + return this.attributes.filter(attr => attr.type === 'relation').length; + } + + get attributeCount() { + return this.attributes.length; + } + + get ancestors() { + if (!this.ancestorCache) { + const noteIds = new Set(); + this.ancestorCache = []; + + for (const parent of this.parents) { + if (!noteIds.has(parent.noteId)) { + this.ancestorCache.push(parent); + noteIds.add(parent.noteId); + } + + for (const ancestorNote of parent.ancestors) { + if (!noteIds.has(ancestorNote.noteId)) { + this.ancestorCache.push(ancestorNote); + noteIds.add(ancestorNote.noteId); + } + } + } + } + + return this.ancestorCache; + } + + /** @return {Note[]} - returns only notes which are templated, does not include their subtrees + * in effect returns notes which are influenced by note's non-inheritable attributes */ + get templatedNotes() { + const arr = [this]; + + for (const targetRelation of this.targetRelations) { + if (targetRelation.name === 'template') { + const note = targetRelation.note; + + if (note) { + arr.push(note); + } + } + } + + return arr; + } + + decrypt() { + if (this.isProtected && !this.isDecrypted && protectedSessionService.isProtectedSessionAvailable()) { + this.title = protectedSessionService.decryptString(note.title); + + this.isDecrypted = true; + } + } +} + +module.exports = Note; diff --git a/src/services/note_cache/note_cache.js b/src/services/note_cache/note_cache.js new file mode 100644 index 000000000..c517563d6 --- /dev/null +++ b/src/services/note_cache/note_cache.js @@ -0,0 +1,61 @@ +"use strict"; + +const Note = require('./entities/note'); +const Branch = require('./entities/branch'); +const Attribute = require('./entities/attribute'); + +class NoteCache { + constructor() { + this.reset(); + } + + reset() { + /** @type {Object.} */ + this.notes = []; + /** @type {Object.} */ + this.branches = []; + /** @type {Object.} */ + this.childParentToBranch = {}; + /** @type {Object.} */ + this.attributes = []; + /** @type {Object.} Points from attribute type-name to list of attributes them */ + this.attributeIndex = {}; + + this.loaded = false; + this.loadedResolve = null; + this.loadedPromise = new Promise(res => {this.loadedResolve = res;}); + } + + /** @return {Attribute[]} */ + findAttributes(type, name) { + return this.attributeIndex[`${type}-${name}`] || []; + } + + /** @return {Attribute[]} */ + findAttributesWithPrefix(type, name) { + const resArr = []; + const key = `${type}-${name}`; + + for (const idx in this.attributeIndex) { + if (idx.startsWith(key)) { + resArr.push(this.attributeIndex[idx]); + } + } + + return resArr.flat(); + } + + decryptProtectedNotes() { + for (const note of Object.values(this.notes)) { + note.decrypt(); + } + } + + getBranch(childNoteId, parentNoteId) { + return this.childParentToBranch[`${childNoteId}-${parentNoteId}`]; + } +} + +const noteCache = new NoteCache(); + +module.exports = noteCache; diff --git a/src/services/note_cache/note_cache_loader.js b/src/services/note_cache/note_cache_loader.js new file mode 100644 index 000000000..e2104d984 --- /dev/null +++ b/src/services/note_cache/note_cache_loader.js @@ -0,0 +1,155 @@ +"use strict"; + +const sql = require('../sql.js'); +const sqlInit = require('../sql_init.js'); +const eventService = require('../events.js'); +const noteCache = require('./note_cache'); +const Note = require('./entities/note'); +const Branch = require('./entities/branch'); +const Attribute = require('./entities/attribute'); + +async function load() { + await sqlInit.dbReady; + + noteCache.reset(); + + (await sql.getRows(`SELECT noteId, title, type, mime, isProtected, dateCreated, dateModified, utcDateCreated, utcDateModified, contentLength FROM notes WHERE isDeleted = 0`, [])) + .map(row => new Note(noteCache, row)); + + (await sql.getRows(`SELECT branchId, noteId, parentNoteId, prefix FROM branches WHERE isDeleted = 0`, [])) + .map(row => new Branch(noteCache, row)); + + (await sql.getRows(`SELECT attributeId, noteId, type, name, value, isInheritable FROM attributes WHERE isDeleted = 0`, [])).map(row => new Attribute(noteCache, row)); + + noteCache.loaded = true; + noteCache.loadedResolve(); +} + +eventService.subscribe([eventService.ENTITY_CHANGED, eventService.ENTITY_DELETED, eventService.ENTITY_SYNCED], async ({entityName, entity}) => { + // note that entity can also be just POJO without methods if coming from sync + + if (!noteCache.loaded) { + return; + } + + if (entityName === 'notes') { + const {noteId} = entity; + + if (entity.isDeleted) { + delete noteCache.notes[noteId]; + } + else if (noteId in noteCache.notes) { + const note = noteCache.notes[noteId]; + + // we can assume we have protected session since we managed to update + note.title = entity.title; + note.isProtected = entity.isProtected; + note.isDecrypted = !entity.isProtected || !!entity.isContentAvailable; + note.flatTextCache = null; + + note.decrypt(); + } + else { + const note = new Note(entity); + noteCache.notes[noteId] = note; + + note.decrypt(); + } + } + else if (entityName === 'branches') { + const {branchId, noteId, parentNoteId} = entity; + const childNote = noteCache.notes[noteId]; + + if (entity.isDeleted) { + if (childNote) { + childNote.parents = childNote.parents.filter(parent => parent.noteId !== parentNoteId); + childNote.parentBranches = childNote.parentBranches.filter(branch => branch.branchId !== branchId); + + if (childNote.parents.length > 0) { + childNote.invalidateSubtreeCaches(); + } + } + + const parentNote = noteCache.notes[parentNoteId]; + + if (parentNote) { + parentNote.children = parentNote.children.filter(child => child.noteId !== noteId); + } + + delete noteCache.childParentToBranch[`${noteId}-${parentNoteId}`]; + delete noteCache.branches[branchId]; + } + else if (branchId in noteCache.branches) { + // only relevant thing which can change in a branch is prefix + noteCache.branches[branchId].prefix = entity.prefix; + + if (childNote) { + childNote.flatTextCache = null; + } + } + else { + noteCache.branches[branchId] = new Branch(entity); + + if (childNote) { + childNote.resortParents(); + } + } + } + else if (entityName === 'attributes') { + const {attributeId, noteId} = entity; + const note = noteCache.notes[noteId]; + const attr = noteCache.attributes[attributeId]; + + if (entity.isDeleted) { + if (note && attr) { + // first invalidate and only then remove the attribute (otherwise invalidation wouldn't be complete) + if (attr.isAffectingSubtree || note.isTemplate) { + note.invalidateSubtreeCaches(); + } + + note.ownedAttributes = note.ownedAttributes.filter(attr => attr.attributeId !== attributeId); + + const targetNote = attr.targetNote; + + if (targetNote) { + targetNote.targetRelations = targetNote.targetRelations.filter(rel => rel.attributeId !== attributeId); + } + } + + delete noteCache.attributes[attributeId]; + delete noteCache.attributeIndex[`${attr.type}-${attr.name}`]; + } + else if (attributeId in noteCache.attributes) { + const attr = noteCache.attributes[attributeId]; + + // attr name and isInheritable are immutable + attr.value = entity.value; + + if (attr.isAffectingSubtree || note.isTemplate) { + note.invalidateSubtreeFlatText(); + } + else { + note.flatTextCache = null; + } + } + else { + const attr = new Attribute(entity); + noteCache.attributes[attributeId] = attr; + + if (note) { + if (attr.isAffectingSubtree || note.isTemplate) { + note.invalidateSubtreeCaches(); + } + else { + note.invalidateThisCache(); + } + } + } + } +}); + +eventService.subscribe(eventService.ENTER_PROTECTED_SESSION, () => { + noteCache.loadedPromise.then(() => noteCache.decryptProtectedNotes()); +}); + +load(); diff --git a/src/services/note_cache/note_cache_service.js b/src/services/note_cache/note_cache_service.js new file mode 100644 index 000000000..8007c9cb3 --- /dev/null +++ b/src/services/note_cache/note_cache_service.js @@ -0,0 +1,241 @@ +"use strict"; + +const noteCache = require('./note_cache'); +const hoistedNoteService = require('../hoisted_note'); +const stringSimilarity = require('string-similarity'); + +function isNotePathArchived(notePath) { + const noteId = notePath[notePath.length - 1]; + const note = noteCache.notes[noteId]; + + if (note.isArchived) { + return true; + } + + for (let i = 0; i < notePath.length - 1; i++) { + const note = noteCache.notes[notePath[i]]; + + // this is going through parents so archived must be inheritable + if (note.hasInheritableOwnedArchivedLabel) { + return true; + } + } + + return false; +} + +/** + * This assumes that note is available. "archived" note means that there isn't a single non-archived note-path + * leading to this note. + * + * @param noteId + */ +function isArchived(noteId) { + const notePath = getSomePath(noteId); + + return isNotePathArchived(notePath); +} + +/** + * @param {string} noteId + * @param {string} ancestorNoteId + * @return {boolean} - true if given noteId has ancestorNoteId in any of its paths (even archived) + */ +function isInAncestor(noteId, ancestorNoteId) { + if (ancestorNoteId === 'root' || ancestorNoteId === noteId) { + return true; + } + + const note = noteCache.notes[noteId]; + + for (const parentNote of note.parents) { + if (isInAncestor(parentNote.noteId, ancestorNoteId)) { + return true; + } + } + + return false; +} + +function getNoteTitle(childNoteId, parentNoteId) { + const childNote = noteCache.notes[childNoteId]; + const parentNote = noteCache.notes[parentNoteId]; + + let title; + + if (childNote.isProtected) { + title = protectedSessionService.isProtectedSessionAvailable() ? childNote.title : '[protected]'; + } + else { + title = childNote.title; + } + + const branch = parentNote ? noteCache.getBranch(childNote.noteId, parentNote.noteId) : null; + + return ((branch && branch.prefix) ? `${branch.prefix} - ` : '') + title; +} + +function getNoteTitleArrayForPath(notePathArray) { + const titles = []; + + if (notePathArray[0] === hoistedNoteService.getHoistedNoteId() && notePathArray.length === 1) { + return [ getNoteTitle(hoistedNoteService.getHoistedNoteId()) ]; + } + + let parentNoteId = 'root'; + let hoistedNotePassed = false; + + for (const noteId of notePathArray) { + // start collecting path segment titles only after hoisted note + if (hoistedNotePassed) { + const title = getNoteTitle(noteId, parentNoteId); + + titles.push(title); + } + + if (noteId === hoistedNoteService.getHoistedNoteId()) { + hoistedNotePassed = true; + } + + parentNoteId = noteId; + } + + return titles; +} + +function getNoteTitleForPath(notePathArray) { + const titles = getNoteTitleArrayForPath(notePathArray); + + return titles.join(' / '); +} + +/** + * Returns notePath for noteId from cache. Note hoisting is respected. + * Archived notes are also returned, but non-archived paths are preferred if available + * - this means that archived paths is returned only if there's no non-archived path + * - you can check whether returned path is archived using isArchived() + */ +function getSomePath(note, path = []) { + if (note.noteId === 'root') { + path.push(note.noteId); + path.reverse(); + + if (!path.includes(hoistedNoteService.getHoistedNoteId())) { + return false; + } + + return path; + } + + const parents = note.parents; + if (parents.length === 0) { + return false; + } + + for (const parentNote of parents) { + const retPath = getSomePath(parentNote, path.concat([note.noteId])); + + if (retPath) { + return retPath; + } + } + + return false; +} + +function getNotePath(noteId) { + const note = noteCache.notes[noteId]; + const retPath = getSomePath(note); + + if (retPath) { + const noteTitle = getNoteTitleForPath(retPath); + const parentNote = note.parents[0]; + + return { + noteId: noteId, + branchId: getBranch(noteId, parentNote.noteId).branchId, + title: noteTitle, + notePath: retPath, + path: retPath.join('/') + }; + } +} + +function evaluateSimilarity(sourceNote, candidateNote, results) { + let coeff = stringSimilarity.compareTwoStrings(sourceNote.flatText, candidateNote.flatText); + + if (coeff > 0.4) { + const notePath = getSomePath(candidateNote); + + // this takes care of note hoisting + if (!notePath) { + return; + } + + if (isNotePathArchived(notePath)) { + coeff -= 0.2; // archived penalization + } + + results.push({coeff, notePath, noteId: candidateNote.noteId}); + } +} + +/** + * Point of this is to break up long running sync process to avoid blocking + * see https://snyk.io/blog/nodejs-how-even-quick-async-functions-can-block-the-event-loop-starve-io/ + */ +function setImmediatePromise() { + return new Promise((resolve) => { + setTimeout(() => resolve(), 0); + }); +} + +async function findSimilarNotes(noteId) { + const results = []; + let i = 0; + + const origNote = noteCache.notes[noteId]; + + if (!origNote) { + return []; + } + + for (const note of Object.values(noteCache.notes)) { + if (note.isProtected && !note.isDecrypted) { + continue; + } + + evaluateSimilarity(origNote, note, results); + + i++; + + if (i % 200 === 0) { + await setImmediatePromise(); + } + } + + results.sort((a, b) => a.coeff > b.coeff ? -1 : 1); + + return results.length > 50 ? results.slice(0, 50) : results; +} + +/** + * @param noteId + * @returns {boolean} - true if note exists (is not deleted) and is available in current note hoisting + */ +function isAvailable(noteId) { + const notePath = getNotePath(noteId); + + return !!notePath; +} + +module.exports = { + getSomePath, + getNotePath, + getNoteTitle, + getNoteTitleForPath, + isAvailable, + isArchived, + isInAncestor, + findSimilarNotes +}; diff --git a/src/services/search.js b/src/services/search.js index c62c1dbda..2efa62984 100644 --- a/src/services/search.js +++ b/src/services/search.js @@ -1,9 +1,24 @@ +"use strict"; + +/** + * Missing things from the OLD search: + * - orderBy + * - limit + * - in - replaced with note.ancestors + * - content in attribute search + * - not - pherhaps not necessary + * + * other potential additions: + * - targetRelations - either named or not + * - any relation without name + */ + const repository = require('./repository'); const sql = require('./sql'); const log = require('./log'); -const parseFilters = require('./parse_filters'); +const parseFilters = require('./search/parse_filters.js'); const buildSearchQuery = require('./build_search_query'); -const noteCacheService = require('./note_cache'); +const noteCacheService = require('./note_cache/note_cache.js'); async function searchForNotes(searchString) { const noteIds = await searchForNoteIds(searchString); @@ -71,4 +86,4 @@ async function searchForNoteIds(searchString) { module.exports = { searchForNotes, searchForNoteIds -}; \ No newline at end of file +}; diff --git a/src/services/search/comparator_builder.js b/src/services/search/comparator_builder.js new file mode 100644 index 000000000..ef589aacf --- /dev/null +++ b/src/services/search/comparator_builder.js @@ -0,0 +1,77 @@ +const dayjs = require("dayjs"); + +const stringComparators = { + "=": comparedValue => (val => val === comparedValue), + "!=": comparedValue => (val => val !== comparedValue), + ">": comparedValue => (val => val > comparedValue), + ">=": comparedValue => (val => val >= comparedValue), + "<": comparedValue => (val => val < comparedValue), + "<=": comparedValue => (val => val <= comparedValue), + "*=": comparedValue => (val => val.endsWith(comparedValue)), + "=*": comparedValue => (val => val.startsWith(comparedValue)), + "*=*": comparedValue => (val => val.includes(comparedValue)), +}; + +const numericComparators = { + ">": comparedValue => (val => parseFloat(val) > comparedValue), + ">=": comparedValue => (val => parseFloat(val) >= comparedValue), + "<": comparedValue => (val => parseFloat(val) < comparedValue), + "<=": comparedValue => (val => parseFloat(val) <= comparedValue) +}; + +const smartValueRegex = /^(NOW|TODAY|WEEK|MONTH|YEAR) *([+\-] *\d+)?$/i; + +function calculateSmartValue(v) { + const match = smartValueRegex.exec(v); + if (match === null) { + return v; + } + + const keyword = match[1].toUpperCase(); + const num = match[2] ? parseInt(match[2].replace(/ /g, "")) : 0; // can contain spaces between sign and digits + + let format, date; + + if (keyword === 'NOW') { + date = dayjs().add(num, 'second'); + format = "YYYY-MM-DD HH:mm:ss"; + } + else if (keyword === 'TODAY') { + date = dayjs().add(num, 'day'); + format = "YYYY-MM-DD"; + } + else if (keyword === 'WEEK') { + // FIXME: this will always use sunday as start of the week + date = dayjs().startOf('week').add(7 * num, 'day'); + format = "YYYY-MM-DD"; + } + else if (keyword === 'MONTH') { + date = dayjs().add(num, 'month'); + format = "YYYY-MM"; + } + else if (keyword === 'YEAR') { + date = dayjs().add(num, 'year'); + format = "YYYY"; + } + else { + throw new Error("Unrecognized keyword: " + keyword); + } + + return date.format(format); +} + +function buildComparator(operator, comparedValue) { + comparedValue = comparedValue.toLowerCase(); + + comparedValue = calculateSmartValue(comparedValue); + + if (operator in numericComparators && !isNaN(comparedValue)) { + return numericComparators[operator](parseFloat(comparedValue)); + } + + if (operator in stringComparators) { + return stringComparators[operator](comparedValue); + } +} + +module.exports = buildComparator; diff --git a/src/services/search/expressions/and.js b/src/services/search/expressions/and.js new file mode 100644 index 000000000..9d0237c2e --- /dev/null +++ b/src/services/search/expressions/and.js @@ -0,0 +1,30 @@ +"use strict"; + +const Expression = require('./expression'); + +class AndExp extends Expression { + static of(subExpressions) { + subExpressions = subExpressions.filter(exp => !!exp); + + if (subExpressions.length === 1) { + return subExpressions[0]; + } else if (subExpressions.length > 0) { + return new AndExp(subExpressions); + } + } + + constructor(subExpressions) { + super(); + this.subExpressions = subExpressions; + } + + async execute(inputNoteSet, searchContext) { + for (const subExpression of this.subExpressions) { + inputNoteSet = await subExpression.execute(inputNoteSet, searchContext); + } + + return inputNoteSet; + } +} + +module.exports = AndExp; diff --git a/src/services/search/expressions/attribute_exists.js b/src/services/search/expressions/attribute_exists.js new file mode 100644 index 000000000..b368ab92a --- /dev/null +++ b/src/services/search/expressions/attribute_exists.js @@ -0,0 +1,43 @@ +"use strict"; + +const NoteSet = require('../note_set'); +const noteCache = require('../../note_cache/note_cache'); +const Expression = require('./expression'); + +class AttributeExistsExp extends Expression { + constructor(attributeType, attributeName, prefixMatch) { + super(); + + this.attributeType = attributeType; + this.attributeName = attributeName; + this.prefixMatch = prefixMatch; + } + + execute(inputNoteSet) { + const attrs = this.prefixMatch + ? noteCache.findAttributesWithPrefix(this.attributeType, this.attributeName) + : noteCache.findAttributes(this.attributeType, this.attributeName); + + const resultNoteSet = new NoteSet(); + + for (const attr of attrs) { + const note = attr.note; + + if (inputNoteSet.hasNoteId(note.noteId)) { + if (attr.isInheritable) { + resultNoteSet.addAll(note.subtreeNotesIncludingTemplated); + } + else if (note.isTemplate) { + resultNoteSet.addAll(note.templatedNotes); + } + else { + resultNoteSet.add(note); + } + } + } + + return resultNoteSet; + } +} + +module.exports = AttributeExistsExp; diff --git a/src/services/search/expressions/child_of.js b/src/services/search/expressions/child_of.js new file mode 100644 index 000000000..a98b0030c --- /dev/null +++ b/src/services/search/expressions/child_of.js @@ -0,0 +1,36 @@ +"use strict"; + +const Expression = require('./expression'); +const NoteSet = require('../note_set'); + +class ChildOfExp extends Expression { + constructor(subExpression) { + super(); + + this.subExpression = subExpression; + } + + execute(inputNoteSet, searchContext) { + const subInputNoteSet = new NoteSet(); + + for (const note of inputNoteSet.notes) { + subInputNoteSet.addAll(note.parents); + } + + const subResNoteSet = this.subExpression.execute(subInputNoteSet, searchContext); + + const resNoteSet = new NoteSet(); + + for (const parentNote of subResNoteSet.notes) { + for (const childNote of parentNote.children) { + if (inputNoteSet.hasNote(childNote)) { + resNoteSet.add(childNote); + } + } + } + + return resNoteSet; + } +} + +module.exports = ChildOfExp; diff --git a/src/services/search/expressions/descendant_of.js b/src/services/search/expressions/descendant_of.js new file mode 100644 index 000000000..51e01748a --- /dev/null +++ b/src/services/search/expressions/descendant_of.js @@ -0,0 +1,28 @@ +"use strict"; + +const Expression = require('./expression'); +const NoteSet = require('../note_set'); +const noteCache = require('../../note_cache/note_cache'); + +class DescendantOfExp extends Expression { + constructor(subExpression) { + super(); + + this.subExpression = subExpression; + } + + execute(inputNoteSet, searchContext) { + const subInputNoteSet = new NoteSet(Object.values(noteCache.notes)); + const subResNoteSet = this.subExpression.execute(subInputNoteSet, searchContext); + + const subTreeNoteSet = new NoteSet(); + + for (const note of subResNoteSet.notes) { + subTreeNoteSet.addAll(note.subtreeNotes); + } + + return inputNoteSet.intersection(subTreeNoteSet); + } +} + +module.exports = DescendantOfExp; diff --git a/src/services/search/expressions/expression.js b/src/services/search/expressions/expression.js new file mode 100644 index 000000000..41192cbb4 --- /dev/null +++ b/src/services/search/expressions/expression.js @@ -0,0 +1,12 @@ +"use strict"; + +class Expression { + /** + * @param {NoteSet} inputNoteSet + * @param {object} searchContext + * @return {NoteSet} + */ + execute(inputNoteSet, searchContext) {} +} + +module.exports = Expression; diff --git a/src/services/search/expressions/label_comparison.js b/src/services/search/expressions/label_comparison.js new file mode 100644 index 000000000..143c41b6e --- /dev/null +++ b/src/services/search/expressions/label_comparison.js @@ -0,0 +1,40 @@ +"use strict"; + +const Expression = require('./expression'); +const NoteSet = require('../note_set'); +const noteCache = require('../../note_cache/note_cache'); + +class LabelComparisonExp extends Expression { + constructor(attributeType, attributeName, comparator) { + super(); + + this.attributeType = attributeType; + this.attributeName = attributeName; + this.comparator = comparator; + } + + execute(inputNoteSet) { + const attrs = noteCache.findAttributes(this.attributeType, this.attributeName); + const resultNoteSet = new NoteSet(); + + for (const attr of attrs) { + const note = attr.note; + + if (inputNoteSet.hasNoteId(note.noteId) && this.comparator(attr.value)) { + if (attr.isInheritable) { + resultNoteSet.addAll(note.subtreeNotesIncludingTemplated); + } + else if (note.isTemplate) { + resultNoteSet.addAll(note.templatedNotes); + } + else { + resultNoteSet.add(note); + } + } + } + + return resultNoteSet; + } +} + +module.exports = LabelComparisonExp; diff --git a/src/services/search/expressions/not.js b/src/services/search/expressions/not.js new file mode 100644 index 000000000..a24d3c2c1 --- /dev/null +++ b/src/services/search/expressions/not.js @@ -0,0 +1,19 @@ +"use strict"; + +const Expression = require('./expression'); + +class NotExp extends Expression { + constructor(subExpression) { + super(); + + this.subExpression = subExpression; + } + + execute(inputNoteSet, searchContext) { + const subNoteSet = this.subExpression.execute(inputNoteSet, searchContext); + + return inputNoteSet.minus(subNoteSet); + } +} + +module.exports = NotExp; diff --git a/src/services/search/expressions/note_cache_fulltext.js b/src/services/search/expressions/note_cache_fulltext.js new file mode 100644 index 000000000..eedc3e279 --- /dev/null +++ b/src/services/search/expressions/note_cache_fulltext.js @@ -0,0 +1,137 @@ +"use strict"; + +const Expression = require('./expression'); +const NoteSet = require('../note_set'); +const noteCache = require('../../note_cache/note_cache'); + +class NoteCacheFulltextExp extends Expression { + constructor(tokens) { + super(); + + this.tokens = tokens; + } + + execute(inputNoteSet, searchContext) { + // has deps on SQL which breaks unit test so needs to be dynamically required + const noteCacheService = require('../../note_cache/note_cache_service'); + const resultNoteSet = new NoteSet(); + + function searchDownThePath(note, tokens, path) { + if (tokens.length === 0) { + const retPath = noteCacheService.getSomePath(note, path); + + if (retPath) { + const noteId = retPath[retPath.length - 1]; + searchContext.noteIdToNotePath[noteId] = retPath; + + resultNoteSet.add(noteCache.notes[noteId]); + } + + return; + } + + if (!note.parents.length === 0 || note.noteId === 'root') { + return; + } + + const foundAttrTokens = []; + + for (const attribute of note.ownedAttributes) { + for (const token of tokens) { + if (attribute.name.toLowerCase().includes(token) + || attribute.value.toLowerCase().includes(token)) { + foundAttrTokens.push(token); + } + } + } + + for (const parentNote of note.parents) { + const title = noteCacheService.getNoteTitle(note.noteId, parentNote.noteId).toLowerCase(); + const foundTokens = foundAttrTokens.slice(); + + for (const token of tokens) { + if (title.includes(token)) { + foundTokens.push(token); + } + } + + if (foundTokens.length > 0) { + const remainingTokens = tokens.filter(token => !foundTokens.includes(token)); + + searchDownThePath(parentNote, remainingTokens, path.concat([note.noteId])); + } + else { + searchDownThePath(parentNote, tokens, path.concat([note.noteId])); + } + } + } + + const candidateNotes = this.getCandidateNotes(inputNoteSet); + + for (const note of candidateNotes) { + // autocomplete should be able to find notes by their noteIds as well (only leafs) + if (this.tokens.length === 1 && note.noteId === this.tokens[0]) { + searchDownThePath(note, [], []); + continue; + } + + // for leaf note it doesn't matter if "archived" label is inheritable or not + if (note.isArchived) { + continue; + } + + const foundAttrTokens = []; + + for (const attribute of note.ownedAttributes) { + for (const token of this.tokens) { + if (attribute.name.toLowerCase().includes(token) + || attribute.value.toLowerCase().includes(token)) { + foundAttrTokens.push(token); + } + } + } + + for (const parentNote of note.parents) { + const title = noteCacheService.getNoteTitle(note.noteId, parentNote.noteId).toLowerCase(); + const foundTokens = foundAttrTokens.slice(); + + for (const token of this.tokens) { + if (title.includes(token)) { + foundTokens.push(token); + } + } + + if (foundTokens.length > 0) { + const remainingTokens = this.tokens.filter(token => !foundTokens.includes(token)); + + searchDownThePath(parentNote, remainingTokens, [note.noteId]); + } + } + } + + return resultNoteSet; + } + + /** + * Returns noteIds which have at least one matching tokens + * + * @param {NoteSet} noteSet + * @return {String[]} + */ + getCandidateNotes(noteSet) { + const candidateNotes = []; + + for (const note of noteSet.notes) { + for (const token of this.tokens) { + if (note.flatText.includes(token)) { + candidateNotes.push(note); + break; + } + } + } + + return candidateNotes; + } +} + +module.exports = NoteCacheFulltextExp; diff --git a/src/services/search/expressions/note_content_fulltext.js b/src/services/search/expressions/note_content_fulltext.js new file mode 100644 index 000000000..6c8b9dc34 --- /dev/null +++ b/src/services/search/expressions/note_content_fulltext.js @@ -0,0 +1,40 @@ +"use strict"; + +const Expression = require('./expression'); +const NoteSet = require('../note_set'); +const noteCache = require('../../note_cache/note_cache'); +const utils = require('../../utils'); + +class NoteContentFulltextExp extends Expression { + constructor(operator, tokens) { + super(); + + this.likePrefix = ["*=*", "*="].includes(operator) ? "%" : ""; + this.likeSuffix = ["*=*", "=*"].includes(operator) ? "%" : ""; + + this.tokens = tokens; + } + + async execute(inputNoteSet) { + const resultNoteSet = new NoteSet(); + const wheres = this.tokens.map(token => "note_contents.content LIKE " + utils.prepareSqlForLike(this.likePrefix, token, this.likeSuffix)); + + const sql = require('../../sql'); + + const noteIds = await sql.getColumn(` + SELECT notes.noteId + FROM notes + JOIN note_contents ON notes.noteId = note_contents.noteId + WHERE isDeleted = 0 AND isProtected = 0 AND ${wheres.join(' AND ')}`); + + for (const noteId of noteIds) { + if (inputNoteSet.hasNoteId(noteId) && noteId in noteCache.notes) { + resultNoteSet.add(noteCache.notes[noteId]); + } + } + + return resultNoteSet; + } +} + +module.exports = NoteContentFulltextExp; diff --git a/src/services/search/expressions/or.js b/src/services/search/expressions/or.js new file mode 100644 index 000000000..63586e0cc --- /dev/null +++ b/src/services/search/expressions/or.js @@ -0,0 +1,35 @@ +"use strict"; + +const Expression = require('./expression'); +const NoteSet = require('../note_set'); + +class OrExp extends Expression { + static of(subExpressions) { + subExpressions = subExpressions.filter(exp => !!exp); + + if (subExpressions.length === 1) { + return subExpressions[0]; + } + else if (subExpressions.length > 0) { + return new OrExp(subExpressions); + } + } + + constructor(subExpressions) { + super(); + + this.subExpressions = subExpressions; + } + + async execute(inputNoteSet, searchContext) { + const resultNoteSet = new NoteSet(); + + for (const subExpression of this.subExpressions) { + resultNoteSet.mergeIn(await subExpression.execute(inputNoteSet, searchContext)); + } + + return resultNoteSet; + } +} + +module.exports = OrExp; diff --git a/src/services/search/expressions/order_by_and_limit.js b/src/services/search/expressions/order_by_and_limit.js new file mode 100644 index 000000000..3a91484c5 --- /dev/null +++ b/src/services/search/expressions/order_by_and_limit.js @@ -0,0 +1,58 @@ +"use strict"; + +const Expression = require('./expression'); +const NoteSet = require('../note_set'); + +class OrderByAndLimitExp extends Expression { + constructor(orderDefinitions, limit) { + super(); + + this.orderDefinitions = orderDefinitions; + + for (const od of this.orderDefinitions) { + od.smaller = od.direction === "asc" ? -1 : 1; + od.larger = od.direction === "asc" ? 1 : -1; + } + + this.limit = limit; + + /** @type {Expression} */ + this.subExpression = null; // it's expected to be set after construction + } + + execute(inputNoteSet, searchContext) { + let {notes} = this.subExpression.execute(inputNoteSet, searchContext); + + notes.sort((a, b) => { + for (const {valueExtractor, smaller, larger} of this.orderDefinitions) { + let valA = valueExtractor.extract(a); + let valB = valueExtractor.extract(b); + + if (!isNaN(valA) && !isNaN(valB)) { + valA = parseFloat(valA); + valB = parseFloat(valB); + } + + if (valA < valB) { + return smaller; + } else if (valA > valB) { + return larger; + } + // else go to next order definition + } + + return 0; + }); + + if (this.limit >= 0) { + notes = notes.slice(0, this.limit); + } + + const noteSet = new NoteSet(notes); + noteSet.sorted = true; + + return noteSet; + } +} + +module.exports = OrderByAndLimitExp; diff --git a/src/services/search/expressions/parent_of.js b/src/services/search/expressions/parent_of.js new file mode 100644 index 000000000..e7924feb7 --- /dev/null +++ b/src/services/search/expressions/parent_of.js @@ -0,0 +1,36 @@ +"use strict"; + +const Expression = require('./expression'); +const NoteSet = require('../note_set'); + +class ParentOfExp extends Expression { + constructor(subExpression) { + super(); + + this.subExpression = subExpression; + } + + execute(inputNoteSet, searchContext) { + const subInputNoteSet = new NoteSet(); + + for (const note of inputNoteSet.notes) { + subInputNoteSet.addAll(note.children); + } + + const subResNoteSet = this.subExpression.execute(subInputNoteSet, searchContext); + + const resNoteSet = new NoteSet(); + + for (const childNote of subResNoteSet.notes) { + for (const parentNote of childNote.parents) { + if (inputNoteSet.hasNote(parentNote)) { + resNoteSet.add(parentNote); + } + } + } + + return resNoteSet; + } +} + +module.exports = ParentOfExp; diff --git a/src/services/search/expressions/property_comparison.js b/src/services/search/expressions/property_comparison.js new file mode 100644 index 000000000..7bd681bd8 --- /dev/null +++ b/src/services/search/expressions/property_comparison.js @@ -0,0 +1,63 @@ +"use strict"; + +const Expression = require('./expression'); +const NoteSet = require('../note_set'); + +/** + * Search string is lower cased for case insensitive comparison. But when retrieving properties + * we need case sensitive form so we have this translation object. + */ +const PROP_MAPPING = { + "noteid": "noteId", + "title": "title", + "type": "type", + "mime": "mime", + "isprotected": "isProtected", + "isarhived": "isArchived", + "datecreated": "dateCreated", + "datemodified": "dateModified", + "utcdatecreated": "utcDateCreated", + "utcdatemodified": "utcDateModified", + "contentlength": "contentLength", + "parentcount": "parentCount", + "childrencount": "childrenCount", + "attributecount": "attributeCount", + "labelcount": "labelCount", + "relationcount": "relationCount" +}; + +class PropertyComparisonExp extends Expression { + static isProperty(name) { + return name in PROP_MAPPING; + } + + constructor(propertyName, comparator) { + super(); + + this.propertyName = PROP_MAPPING[propertyName]; + this.comparator = comparator; + } + + execute(inputNoteSet, searchContext) { + const resNoteSet = new NoteSet(); + + for (const note of inputNoteSet.notes) { + let value = note[this.propertyName]; + + if (value !== undefined && value !== null && typeof value !== 'string') { + value = value.toString(); + } + + if (value) { + value = value.toLowerCase(); + } + if (this.comparator(value)) { + resNoteSet.add(note); + } + } + + return resNoteSet; + } +} + +module.exports = PropertyComparisonExp; diff --git a/src/services/search/expressions/relation_where.js b/src/services/search/expressions/relation_where.js new file mode 100644 index 000000000..f873762e0 --- /dev/null +++ b/src/services/search/expressions/relation_where.js @@ -0,0 +1,41 @@ +"use strict"; + +const Expression = require('./expression'); +const NoteSet = require('../note_set'); +const noteCache = require('../../note_cache/note_cache'); + +class RelationWhereExp extends Expression { + constructor(relationName, subExpression) { + super(); + + this.relationName = relationName; + this.subExpression = subExpression; + } + + execute(inputNoteSet, searchContext) { + const candidateNoteSet = new NoteSet(); + + for (const attr of noteCache.findAttributes('relation', this.relationName)) { + const note = attr.note; + + if (inputNoteSet.hasNoteId(note.noteId)) { + const subInputNoteSet = new NoteSet([attr.targetNote]); + const subResNoteSet = this.subExpression.execute(subInputNoteSet, searchContext); + + if (subResNoteSet.hasNote(attr.targetNote)) { + if (attr.isInheritable) { + candidateNoteSet.addAll(note.subtreeNotesIncludingTemplated); + } else if (note.isTemplate) { + candidateNoteSet.addAll(note.templatedNotes); + } else { + candidateNoteSet.add(note); + } + } + } + } + + return candidateNoteSet.intersection(inputNoteSet); + } +} + +module.exports = RelationWhereExp; diff --git a/src/services/search/lexer.js b/src/services/search/lexer.js new file mode 100644 index 000000000..635d0d031 --- /dev/null +++ b/src/services/search/lexer.js @@ -0,0 +1,115 @@ +function lexer(str) { + str = str.toLowerCase(); + + const fulltextTokens = []; + const expressionTokens = []; + + let quotes = false; + let fulltextEnded = false; + let currentWord = ''; + + function isOperatorSymbol(chr) { + return ['=', '*', '>', '<', '!'].includes(chr); + } + + function previusOperatorSymbol() { + if (currentWord.length === 0) { + return false; + } + else { + return isOperatorSymbol(currentWord[currentWord.length - 1]); + } + } + + function finishWord() { + if (currentWord === '') { + return; + } + + if (fulltextEnded) { + expressionTokens.push(currentWord); + } else { + fulltextTokens.push(currentWord); + } + + currentWord = ''; + } + + for (let i = 0; i < str.length; i++) { + const chr = str[i]; + + if (chr === '\\') { + if ((i + 1) < str.length) { + i++; + + currentWord += str[i]; + } + else { + currentWord += chr; + } + + continue; + } + else if (['"', "'", '`'].includes(chr)) { + if (!quotes) { + if (currentWord.length === 0 || fulltextEnded) { + if (previusOperatorSymbol()) { + finishWord(); + } + + quotes = chr; + } + else { + // quote inside a word does not have special meening and does not break word + // e.g. d'Artagnan is kept as a single token + currentWord += chr; + } + } + else if (quotes === chr) { + quotes = false; + + finishWord(); + } + else { + // it's a quote but within other kind of quotes so it's valid as a literal character + currentWord += chr; + } + continue; + } + else if (!quotes) { + if (currentWord.length === 0 && (chr === '#' || chr === '~')) { + fulltextEnded = true; + currentWord = chr; + + continue; + } + else if (chr === ' ') { + finishWord(); + continue; + } + else if (fulltextEnded && ['(', ')', '.'].includes(chr)) { + finishWord(); + currentWord += chr; + finishWord(); + continue; + } + else if (fulltextEnded && previusOperatorSymbol() !== isOperatorSymbol(chr)) { + finishWord(); + + currentWord += chr; + continue; + } + } + + currentWord += chr; + } + + finishWord(); + + return { + fulltextTokens, + expressionTokens + } +} + +module.exports = lexer; diff --git a/src/services/search/note_set.js b/src/services/search/note_set.js new file mode 100644 index 000000000..3f8dc9dcf --- /dev/null +++ b/src/services/search/note_set.js @@ -0,0 +1,61 @@ +"use strict"; + +class NoteSet { + constructor(notes = []) { + /** @type {Note[]} */ + this.notes = notes; + /** @type {boolean} */ + this.sorted = false; + } + + add(note) { + if (!this.hasNote(note)) { + this.notes.push(note); + } + } + + addAll(notes) { + for (const note of notes) { + this.add(note); + } + } + + hasNote(note) { + return this.hasNoteId(note.noteId); + } + + hasNoteId(noteId) { + // TODO: optimize + return !!this.notes.find(note => note.noteId === noteId); + } + + mergeIn(anotherNoteSet) { + this.notes = this.notes.concat(anotherNoteSet.notes); + } + + minus(anotherNoteSet) { + const newNoteSet = new NoteSet(); + + for (const note of this.notes) { + if (!anotherNoteSet.hasNoteId(note.noteId)) { + newNoteSet.add(note); + } + } + + return newNoteSet; + } + + intersection(anotherNoteSet) { + const newNoteSet = new NoteSet(); + + for (const note of this.notes) { + if (anotherNoteSet.hasNote(note)) { + newNoteSet.add(note); + } + } + + return newNoteSet; + } +} + +module.exports = NoteSet; diff --git a/src/services/search/parens.js b/src/services/search/parens.js new file mode 100644 index 000000000..ba5fdc7c1 --- /dev/null +++ b/src/services/search/parens.js @@ -0,0 +1,43 @@ +/** + * This will create a recursive object from list of tokens - tokens between parenthesis are grouped in a single array + */ +function parens(tokens) { + if (tokens.length === 0) { + return []; + } + + while (true) { + const leftIdx = tokens.findIndex(token => token === '('); + + if (leftIdx === -1) { + return tokens; + } + + let rightIdx; + let parensLevel = 0 + + for (rightIdx = leftIdx; rightIdx < tokens.length; rightIdx++) { + if (tokens[rightIdx] === ')') { + parensLevel--; + + if (parensLevel === 0) { + break; + } + } else if (tokens[rightIdx] === '(') { + parensLevel++; + } + } + + if (rightIdx >= tokens.length) { + throw new Error("Did not find matching right parenthesis."); + } + + tokens = [ + ...tokens.slice(0, leftIdx), + parens(tokens.slice(leftIdx + 1, rightIdx)), + ...tokens.slice(rightIdx + 1) + ]; + } +} + +module.exports = parens; diff --git a/src/services/parse_filters.js b/src/services/search/parse_filters.js similarity index 92% rename from src/services/parse_filters.js rename to src/services/search/parse_filters.js index e21b67c09..472a938d0 100644 --- a/src/services/parse_filters.js +++ b/src/services/search/parse_filters.js @@ -1,4 +1,9 @@ const dayjs = require("dayjs"); +const AndExp = require('./expressions/and'); +const OrExp = require('./expressions/or'); +const NotExp = require('./expressions/not'); +const NoteCacheFulltextExp = require('./expressions/note_cache_fulltext'); +const NoteContentFulltextExp = require('./expressions/note_content_fulltext'); const filterRegex = /(\b(AND|OR)\s+)?@(!?)([\p{L}\p{Number}_]+|"[^"]+")\s*((=|!=|<|<=|>|>=|!?\*=|!?=\*|!?\*=\*)\s*([^\s=*"]+|"[^"]+"))?/igu; const smartValueRegex = /^(NOW|TODAY|WEEK|MONTH|YEAR) *([+\-] *\d+)?$/i; diff --git a/src/services/search/parser.js b/src/services/search/parser.js new file mode 100644 index 000000000..64102b6b5 --- /dev/null +++ b/src/services/search/parser.js @@ -0,0 +1,305 @@ +"use strict"; + +const AndExp = require('./expressions/and'); +const OrExp = require('./expressions/or'); +const NotExp = require('./expressions/not'); +const ChildOfExp = require('./expressions/child_of'); +const DescendantOfExp = require('./expressions/descendant_of'); +const ParentOfExp = require('./expressions/parent_of'); +const RelationWhereExp = require('./expressions/relation_where'); +const PropertyComparisonExp = require('./expressions/property_comparison'); +const AttributeExistsExp = require('./expressions/attribute_exists'); +const LabelComparisonExp = require('./expressions/label_comparison'); +const NoteCacheFulltextExp = require('./expressions/note_cache_fulltext'); +const NoteContentFulltextExp = require('./expressions/note_content_fulltext'); +const OrderByAndLimitExp = require('./expressions/order_by_and_limit'); +const comparatorBuilder = require('./comparator_builder'); +const ValueExtractor = require('./value_extractor'); + +function getFulltext(tokens, parsingContext) { + parsingContext.highlightedTokens.push(...tokens); + + if (tokens.length === 0) { + return null; + } + else if (parsingContext.includeNoteContent) { + return new OrExp([ + new NoteCacheFulltextExp(tokens), + new NoteContentFulltextExp('*=*', tokens) + ]); + } + else { + return new NoteCacheFulltextExp(tokens); + } +} + +function isOperator(str) { + return str.match(/^[=<>*]+$/); +} + +function getExpression(tokens, parsingContext, level = 0) { + if (tokens.length === 0) { + return null; + } + + const expressions = []; + let op = null; + + let i; + + function parseNoteProperty() { + if (tokens[i] !== '.') { + parsingContext.addError('Expected "." to separate field path'); + return; + } + + i++; + + if (tokens[i] === 'content') { + i += 1; + + const operator = tokens[i]; + + if (!isOperator(operator)) { + parsingContext.addError(`After content expected operator, but got "${tokens[i]}"`); + return; + } + + i++; + + return new NoteContentFulltextExp(operator, [tokens[i]]); + } + + if (tokens[i] === 'parents') { + i += 1; + + return new ChildOfExp(parseNoteProperty()); + } + + if (tokens[i] === 'children') { + i += 1; + + return new ParentOfExp(parseNoteProperty()); + } + + if (tokens[i] === 'ancestors') { + i += 1; + + return new DescendantOfExp(parseNoteProperty()); + } + + if (tokens[i] === 'labels') { + if (tokens[i + 1] !== '.') { + parsingContext.addError(`Expected "." to separate field path, god "${tokens[i + 1]}"`); + return; + } + + i += 2; + + return parseLabel(tokens[i]); + } + + if (tokens[i] === 'relations') { + if (tokens[i + 1] !== '.') { + parsingContext.addError(`Expected "." to separate field path, god "${tokens[i + 1]}"`); + return; + } + + i += 2; + + return parseRelation(tokens[i]); + } + + if (PropertyComparisonExp.isProperty(tokens[i])) { + const propertyName = tokens[i]; + const operator = tokens[i + 1]; + const comparedValue = tokens[i + 2]; + const comparator = comparatorBuilder(operator, comparedValue); + + if (!comparator) { + parsingContext.addError(`Can't find operator '${operator}'`); + return; + } + + i += 2; + + return new PropertyComparisonExp(propertyName, comparator); + } + + parsingContext.addError(`Unrecognized note property "${tokens[i]}"`); + } + + function parseLabel(labelName) { + parsingContext.highlightedTokens.push(labelName); + + if (i < tokens.length - 2 && isOperator(tokens[i + 1])) { + let operator = tokens[i + 1]; + const comparedValue = tokens[i + 2]; + + parsingContext.highlightedTokens.push(comparedValue); + + if (parsingContext.fuzzyAttributeSearch && operator === '=') { + operator = '*=*'; + } + + const comparator = comparatorBuilder(operator, comparedValue); + + if (!comparator) { + parsingContext.addError(`Can't find operator '${operator}'`); + } else { + i += 2; + + return new LabelComparisonExp('label', labelName, comparator); + } + } else { + return new AttributeExistsExp('label', labelName, parsingContext.fuzzyAttributeSearch); + } + } + + function parseRelation(relationName) { + parsingContext.highlightedTokens.push(relationName); + + if (i < tokens.length - 2 && tokens[i + 1] === '.') { + i += 1; + + return new RelationWhereExp(relationName, parseNoteProperty()); + } else { + return new AttributeExistsExp('relation', relationName, parsingContext.fuzzyAttributeSearch); + } + } + + function parseOrderByAndLimit() { + const orderDefinitions = []; + let limit; + + if (tokens[i] === 'orderby') { + do { + const propertyPath = []; + let direction = "asc"; + + do { + i++; + + propertyPath.push(tokens[i]); + + i++; + } while (tokens[i] === '.'); + + if (["asc", "desc"].includes(tokens[i])) { + direction = tokens[i]; + i++; + } + + const valueExtractor = new ValueExtractor(propertyPath); + + if (valueExtractor.validate()) { + parsingContext.addError(valueExtractor.validate()); + } + + orderDefinitions.push({ + valueExtractor, + direction + }); + } while (tokens[i] === ','); + } + + if (tokens[i] === 'limit') { + limit = parseInt(tokens[i + 1]); + } + + return new OrderByAndLimitExp(orderDefinitions, limit); + } + + function getAggregateExpression() { + if (op === null || op === 'and') { + return AndExp.of(expressions); + } + else if (op === 'or') { + return OrExp.of(expressions); + } + } + + for (i = 0; i < tokens.length; i++) { + const token = tokens[i]; + + if (token === '#' || token === '~') { + continue; + } + + if (Array.isArray(token)) { + expressions.push(getExpression(token, parsingContext, level++)); + } + else if (token.startsWith('#')) { + const labelName = token.substr(1); + + expressions.push(parseLabel(labelName)); + } + else if (token.startsWith('~')) { + const relationName = token.substr(1); + + expressions.push(parseRelation(relationName)); + } + else if (['orderby', 'limit'].includes(token)) { + if (level !== 0) { + parsingContext.addError('orderBy can appear only on the top expression level'); + continue; + } + + const exp = parseOrderByAndLimit(); + + if (!exp) { + continue; + } + + exp.subExpression = getAggregateExpression(); + + return exp; + } + else if (token === 'not') { + i += 1; + + if (!Array.isArray(tokens[i])) { + parsingContext.addError(`not keyword should be followed by sub-expression in parenthesis, got ${tokens[i]} instead`); + continue; + } + + expressions.push(new NotExp(getExpression(tokens[i], parsingContext, level++))); + } + else if (token === 'note') { + i++; + + expressions.push(parseNoteProperty(tokens)); + + continue; + } + else if (['and', 'or'].includes(token)) { + if (!op) { + op = token; + } + else if (op !== token) { + parsingContext.addError('Mixed usage of AND/OR - always use parenthesis to group AND/OR expressions.'); + } + } + else if (isOperator(token)) { + parsingContext.addError(`Misplaced or incomplete expression "${token}"`); + } + else { + parsingContext.addError(`Unrecognized expression "${token}"`); + } + + if (!op && expressions.length > 1) { + op = 'and'; + } + } + + return getAggregateExpression(); +} + +function parse({fulltextTokens, expressionTokens, parsingContext}) { + return AndExp.of([ + getFulltext(fulltextTokens, parsingContext), + getExpression(expressionTokens, parsingContext) + ]); +} + +module.exports = parse; diff --git a/src/services/search/parsing_context.js b/src/services/search/parsing_context.js new file mode 100644 index 000000000..58ab77d39 --- /dev/null +++ b/src/services/search/parsing_context.js @@ -0,0 +1,20 @@ +"use strict"; + +class ParsingContext { + constructor(params = {}) { + this.includeNoteContent = !!params.includeNoteContent; + this.fuzzyAttributeSearch = !!params.fuzzyAttributeSearch; + this.highlightedTokens = []; + this.error = null; + } + + addError(error) { + // we record only the first error, subsequent ones are usually consequence of the first + if (!this.error) { + this.error = error; + console.log(this.error); + } + } +} + +module.exports = ParsingContext; diff --git a/src/services/search/search.js b/src/services/search/search.js new file mode 100644 index 000000000..1e7437b58 --- /dev/null +++ b/src/services/search/search.js @@ -0,0 +1,179 @@ +"use strict"; + +const lexer = require('./lexer'); +const parens = require('./parens'); +const parser = require('./parser'); +const NoteSet = require("./note_set"); +const SearchResult = require("./search_result"); +const ParsingContext = require("./parsing_context"); +const noteCache = require('../note_cache/note_cache'); +const noteCacheService = require('../note_cache/note_cache_service'); +const hoistedNoteService = require('../hoisted_note'); +const utils = require('../utils'); + +/** + * @param {Expression} expression + * @return {Promise} + */ +async function findNotesWithExpression(expression) { + const hoistedNote = noteCache.notes[hoistedNoteService.getHoistedNoteId()]; + const allNotes = (hoistedNote && hoistedNote.noteId !== 'root') + ? hoistedNote.subtreeNotes + : Object.values(noteCache.notes); + + const allNoteSet = new NoteSet(allNotes); + + const searchContext = { + noteIdToNotePath: {} + }; + + const noteSet = await expression.execute(allNoteSet, searchContext); + + let searchResults = noteSet.notes + .map(note => searchContext.noteIdToNotePath[note.noteId] || noteCacheService.getSomePath(note)) + .filter(notePathArray => notePathArray.includes(hoistedNoteService.getHoistedNoteId())) + .map(notePathArray => new SearchResult(notePathArray)); + + if (!noteSet.sorted) { + // sort results by depth of the note. This is based on the assumption that more important results + // are closer to the note root. + searchResults.sort((a, b) => { + if (a.notePathArray.length === b.notePathArray.length) { + return a.notePathTitle < b.notePathTitle ? -1 : 1; + } + + return a.notePathArray.length < b.notePathArray.length ? -1 : 1; + }); + } + + return searchResults; +} + +function parseQueryToExpression(query, parsingContext) { + const {fulltextTokens, expressionTokens} = lexer(query); + const structuredExpressionTokens = parens(expressionTokens); + + const expression = parser({ + fulltextTokens, + expressionTokens: structuredExpressionTokens, + parsingContext + }); + + return expression; +} + +/** + * @param {string} query + * @param {ParsingContext} parsingContext + * @return {Promise} + */ +async function findNotesWithQuery(query, parsingContext) { + const expression = parseQueryToExpression(query, parsingContext); + + if (!expression) { + return []; + } + + return await findNotesWithExpression(expression); +} + +async function searchNotes(query) { + if (!query.trim().length) { + return []; + } + + const parsingContext = new ParsingContext({ + includeNoteContent: true, + fuzzyAttributeSearch: false + }); + + let searchResults = await findNotesWithQuery(query, parsingContext); + + searchResults = searchResults.slice(0, 200); + + return searchResults; +} + +async function searchNotesForAutocomplete(query) { + if (!query.trim().length) { + return []; + } + + const parsingContext = new ParsingContext({ + includeNoteContent: false, + fuzzyAttributeSearch: true + }); + + let searchResults = await findNotesWithQuery(query, parsingContext); + + searchResults = searchResults.slice(0, 200); + + highlightSearchResults(searchResults, parsingContext.highlightedTokens); + + return searchResults.map(result => { + return { + notePath: result.notePath, + notePathTitle: result.notePathTitle, + highlightedNotePathTitle: result.highlightedNotePathTitle + } + }); +} + +function highlightSearchResults(searchResults, highlightedTokens) { + // we remove < signs because they can cause trouble in matching and overwriting existing highlighted chunks + // which would make the resulting HTML string invalid. + // { and } are used for marking and tag (to avoid matches on single 'b' character) + highlightedTokens = highlightedTokens.map(token => token.replace('/[<\{\}]/g', '')); + + // sort by the longest so we first highlight longest matches + highlightedTokens.sort((a, b) => a.length > b.length ? -1 : 1); + + for (const result of searchResults) { + const note = noteCache.notes[result.noteId]; + + result.highlightedNotePathTitle = result.notePathTitle; + + for (const attr of note.attributes) { + if (highlightedTokens.find(token => attr.name.includes(token) || attr.value.includes(token))) { + result.highlightedNotePathTitle += ` ${formatAttribute(attr)}`; + } + } + } + + for (const token of highlightedTokens) { + const tokenRegex = new RegExp("(" + utils.escapeRegExp(token) + ")", "gi"); + + for (const result of searchResults) { + result.highlightedNotePathTitle = result.highlightedNotePathTitle.replace(tokenRegex, "{$1}"); + } + } + + for (const result of searchResults) { + result.highlightedNotePathTitle = result.highlightedNotePathTitle + .replace(/{/g, "") + .replace(/}/g, ""); + } +} + +function formatAttribute(attr) { + if (attr.type === 'relation') { + return '@' + utils.escapeHtml(attr.name) + "=…"; + } + else if (attr.type === 'label') { + let label = '#' + utils.escapeHtml(attr.name); + + if (attr.value) { + const val = /[^\w_-]/.test(attr.value) ? '"' + attr.value + '"' : attr.value; + + label += '=' + utils.escapeHtml(val); + } + + return label; + } +} + +module.exports = { + searchNotes, + searchNotesForAutocomplete, + findNotesWithQuery +}; diff --git a/src/services/search/search_result.js b/src/services/search/search_result.js new file mode 100644 index 000000000..83c395b10 --- /dev/null +++ b/src/services/search/search_result.js @@ -0,0 +1,20 @@ +"use strict"; + +const noteCacheService = require('../note_cache/note_cache_service'); + +class SearchResult { + constructor(notePathArray) { + this.notePathArray = notePathArray; + this.notePathTitle = noteCacheService.getNoteTitleForPath(notePathArray); + } + + get notePath() { + return this.notePathArray.join('/'); + } + + get noteId() { + return this.notePathArray[this.notePathArray.length - 1]; + } +} + +module.exports = SearchResult; diff --git a/src/services/search/value_extractor.js b/src/services/search/value_extractor.js new file mode 100644 index 000000000..ea1bb38d8 --- /dev/null +++ b/src/services/search/value_extractor.js @@ -0,0 +1,110 @@ +"use strict"; + +/** + * Search string is lower cased for case insensitive comparison. But when retrieving properties + * we need case sensitive form so we have this translation object. + */ +const PROP_MAPPING = { + "noteid": "noteId", + "title": "title", + "type": "type", + "mime": "mime", + "isprotected": "isProtected", + "isarhived": "isArchived", + "datecreated": "dateCreated", + "datemodified": "dateModified", + "utcdatecreated": "utcDateCreated", + "utcdatemodified": "utcDateModified", + "contentlength": "contentLength", + "parentcount": "parentCount", + "childrencount": "childrenCount", + "attributecount": "attributeCount", + "labelcount": "labelCount", + "relationcount": "relationCount" +}; + +class ValueExtractor { + constructor(propertyPath) { + this.propertyPath = propertyPath.map(pathEl => pathEl.toLowerCase()); + + if (this.propertyPath[0].startsWith('#')) { + this.propertyPath = ['note', 'labels', this.propertyPath[0].substr(1), ...this.propertyPath.slice( 1, this.propertyPath.length)]; + } + else if (this.propertyPath[0].startsWith('~')) { + this.propertyPath = ['note', 'relations', this.propertyPath[0].substr(1), ...this.propertyPath.slice( 1, this.propertyPath.length)]; + } + } + + validate() { + if (this.propertyPath[0] !== 'note') { + return `property specifier must start with 'note', but starts with '${this.propertyPath[0]}'`; + } + + for (let i = 1; i < this.propertyPath.length; i++) { + const pathEl = this.propertyPath[i]; + + if (pathEl === 'labels') { + if (i !== this.propertyPath.length - 2) { + return `label is a terminal property specifier and must be at the end`; + } + + i++; + } + else if (pathEl === 'relations') { + if (i >= this.propertyPath.length - 2) { + return `relation name or property name is missing`; + } + + i++; + } + else if (pathEl in PROP_MAPPING) { + if (i !== this.propertyPath.length - 1) { + return `${pathEl} is a terminal property specifier and must be at the end`; + } + } + else if (!["parents", "children"].includes(pathEl)) { + return `Unrecognized property specifier ${pathEl}`; + } + } + } + + extract(note) { + let cursor = note; + + let i; + + const cur = () => this.propertyPath[i]; + + for (i = 0; i < this.propertyPath.length; i++) { + if (!cursor) { + return cursor; + } + + if (cur() === 'labels') { + i++; + + return cursor.getLabelValue(cur()); + } + + if (cur() === 'relations') { + i++; + + cursor = cursor.getRelationTarget(cur()); + } + else if (cur() === 'parents') { + cursor = cursor.parents[0]; + } + else if (cur() === 'children') { + cursor = cursor.children[0]; + } + else if (cur() in PROP_MAPPING) { + return cursor[PROP_MAPPING[cur()]]; + } + else { + // FIXME + } + } + } +} + +module.exports = ValueExtractor; diff --git a/src/services/sql_init.js b/src/services/sql_init.js index eaf0aaf23..6a50492e3 100644 --- a/src/services/sql_init.js +++ b/src/services/sql_init.js @@ -197,4 +197,4 @@ module.exports = { createInitialDatabase, createDatabaseForSync, dbInitialized -}; \ No newline at end of file +}; diff --git a/src/services/tree.js b/src/services/tree.js index 410d565fc..4f6c4bffe 100644 --- a/src/services/tree.js +++ b/src/services/tree.js @@ -5,7 +5,7 @@ const repository = require('./repository'); const Branch = require('../entities/branch'); const syncTableService = require('./sync_table'); const protectedSessionService = require('./protected_session'); -const noteCacheService = require('./note_cache'); +const noteCacheService = require('./note_cache/note_cache.js'); async function getNotes(noteIds) { // we return also deleted notes which have been specifically asked for @@ -197,4 +197,4 @@ module.exports = { validateParentChild, sortNotesAlphabetically, setNoteToParent -}; \ No newline at end of file +}; diff --git a/src/views/desktop.ejs b/src/views/desktop.ejs index 837e8d8d4..8f5560d1e 100644 --- a/src/views/desktop.ejs +++ b/src/views/desktop.ejs @@ -8,6 +8,11 @@ + +
    @@ -78,6 +83,10 @@ + +