diff --git a/package.json b/package.json index 0870582..75f204e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "milo", "private": true, - "version": "0.1.6", + "version": "0.1.7", "type": "module", "scripts": { "dev": "vite", @@ -11,14 +11,17 @@ }, "dependencies": { "@tauri-apps/api": "^2.2.0", + "@tauri-apps/plugin-dialog": "^2.2.2", "@tauri-apps/plugin-notification": "~2", "autoprefixer": "^10.4.21", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.511.0", + "moment": "^2.30.1", "postcss": "^8.5.3", "react": "^18.2.0", "react-dom": "^18.2.0", + "recharts": "^2.15.3", "tailwind-merge": "^3.3.0", "tailwindcss": "3.4.17" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a8760c9..58fe0c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@tauri-apps/api': specifier: ^2.2.0 version: 2.2.0 + '@tauri-apps/plugin-dialog': + specifier: ^2.2.2 + version: 2.2.2 '@tauri-apps/plugin-notification': specifier: ~2 version: 2.2.1 @@ -26,6 +29,9 @@ importers: lucide-react: specifier: ^0.511.0 version: 0.511.0(react@18.3.1) + moment: + specifier: ^2.30.1 + version: 2.30.1 postcss: specifier: ^8.5.3 version: 8.5.3 @@ -35,6 +41,9 @@ importers: react-dom: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) + recharts: + specifier: ^2.15.3 + version: 2.15.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) tailwind-merge: specifier: ^3.3.0 version: 3.3.0 @@ -138,6 +147,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.27.4': + resolution: {integrity: sha512-t3yaEOuGu9NlIZ+hIeGbBjFtZT7j2cb2tg0fuaJKeGotchRjjLfrBA9Kwf8quhpP1EUuxModQg04q/mBwyg8uA==} + engines: {node: '>=6.9.0'} + '@babel/template@7.25.9': resolution: {integrity: sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==} engines: {node: '>=6.9.0'} @@ -494,6 +507,9 @@ packages: engines: {node: '>= 10'} hasBin: true + '@tauri-apps/plugin-dialog@2.2.2': + resolution: {integrity: sha512-Pm9qnXQq8ZVhAMFSEPwxvh+nWb2mk7LASVlNEHYaksHvcz8P6+ElR5U5dNL9Ofrm+uwhh1/gYKWswK8JJJAh6A==} + '@tauri-apps/plugin-notification@2.2.1': resolution: {integrity: sha512-QF8Zod6XDhxD6xkD5nU/BjbOpJ6+3gxGCrVULOdLpvMuMSN2Z2IdObV/qgnrEJk1UamUCF1ClQUqNCbk4zTJNQ==} @@ -509,6 +525,33 @@ packages: '@types/babel__traverse@7.20.6': resolution: {integrity: sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==} + '@types/d3-array@3.2.1': + resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.1.7': + resolution: {integrity: sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/estree@1.0.7': resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==} @@ -628,6 +671,50 @@ packages: csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.0: + resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + debug@4.4.0: resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} engines: {node: '>=6.0'} @@ -637,6 +724,9 @@ packages: supports-color: optional: true + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + detect-libc@2.0.4: resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==} engines: {node: '>=8'} @@ -647,6 +737,9 @@ packages: dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + dom-helpers@5.2.1: + resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -668,6 +761,13 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + + fast-equals@5.2.2: + resolution: {integrity: sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==} + engines: {node: '>=6.0.0'} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -718,6 +818,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + is-binary-path@2.1.0: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} @@ -836,6 +940,9 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -867,6 +974,9 @@ packages: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} + moment@2.30.1: + resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -971,6 +1081,9 @@ packages: resolution: {integrity: sha512-QSa9EBe+uwlGTFmHsPKokv3B/oEMQZxfqW0QqNCyhpa6mB1afzulwn8hihglqAb2pOw+BJgNlmXQ8la2VeHB7w==} engines: {node: ^10 || ^12 || >=14} + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -979,10 +1092,28 @@ packages: peerDependencies: react: ^18.3.1 + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react-refresh@0.14.2: resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} engines: {node: '>=0.10.0'} + react-smooth@4.0.4: + resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + react-transition-group@4.4.5: + resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} + peerDependencies: + react: '>=16.6.0' + react-dom: '>=16.6.0' + react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} @@ -994,6 +1125,16 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + recharts-scale@0.4.5: + resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==} + + recharts@2.15.3: + resolution: {integrity: sha512-EdOPzTwcFSuqtvkDoaM5ws/Km1+WTAO2eizL7rqiG0V2UVhTnz0m7J2i0CjVPUCdEkZImaWvXLbZDS2H5t6GFQ==} + engines: {node: '>=14'} + peerDependencies: + react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + resolve@1.22.10: resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==} engines: {node: '>= 0.4'} @@ -1074,6 +1215,9 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -1095,6 +1239,9 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + victory-vendor@36.9.2: + resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} + vite@5.4.19: resolution: {integrity: sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -1243,6 +1390,8 @@ snapshots: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.26.5 + '@babel/runtime@7.27.4': {} + '@babel/template@7.25.9': dependencies: '@babel/code-frame': 7.26.2 @@ -1481,6 +1630,10 @@ snapshots: '@tauri-apps/cli-win32-ia32-msvc': 2.2.4 '@tauri-apps/cli-win32-x64-msvc': 2.2.4 + '@tauri-apps/plugin-dialog@2.2.2': + dependencies: + '@tauri-apps/api': 2.2.0 + '@tauri-apps/plugin-notification@2.2.1': dependencies: '@tauri-apps/api': 2.2.0 @@ -1506,6 +1659,30 @@ snapshots: dependencies: '@babel/types': 7.26.5 + '@types/d3-array@3.2.1': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.1.7': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + '@types/estree@1.0.7': {} '@types/prop-types@15.7.14': {} @@ -1622,10 +1799,50 @@ snapshots: csstype@3.1.3: {} + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.0: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.0 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + debug@4.4.0: dependencies: ms: 2.1.3 + decimal.js-light@2.5.1: {} + detect-libc@2.0.4: optional: true @@ -1633,6 +1850,11 @@ snapshots: dlv@1.1.3: {} + dom-helpers@5.2.1: + dependencies: + '@babel/runtime': 7.27.4 + csstype: 3.1.3 + eastasianwidth@0.2.0: {} electron-to-chromium@1.5.80: {} @@ -1669,6 +1891,10 @@ snapshots: escalade@3.2.0: {} + eventemitter3@4.0.7: {} + + fast-equals@5.2.2: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -1722,6 +1948,8 @@ snapshots: dependencies: function-bind: 1.1.2 + internmap@2.0.3: {} + is-binary-path@2.1.0: dependencies: binary-extensions: 2.3.0 @@ -1806,6 +2034,8 @@ snapshots: lines-and-columns@1.2.4: {} + lodash@4.17.21: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -1833,6 +2063,8 @@ snapshots: minipass@7.1.2: {} + moment@2.30.1: {} + ms@2.1.3: {} mz@2.7.0: @@ -1915,6 +2147,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + queue-microtask@1.2.3: {} react-dom@18.3.1(react@18.3.1): @@ -1923,8 +2161,29 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 + react-is@16.13.1: {} + + react-is@18.3.1: {} + react-refresh@0.14.2: {} + react-smooth@4.0.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + fast-equals: 5.2.2 + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-transition-group: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + + react-transition-group@4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.27.4 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react@18.3.1: dependencies: loose-envify: 1.4.0 @@ -1937,6 +2196,23 @@ snapshots: dependencies: picomatch: 2.3.1 + recharts-scale@0.4.5: + dependencies: + decimal.js-light: 2.5.1 + + recharts@2.15.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + clsx: 2.1.1 + eventemitter3: 4.0.7 + lodash: 4.17.21 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-is: 18.3.1 + react-smooth: 4.0.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + recharts-scale: 0.4.5 + tiny-invariant: 1.3.3 + victory-vendor: 36.9.2 + resolve@1.22.10: dependencies: is-core-module: 2.16.1 @@ -2060,6 +2336,8 @@ snapshots: dependencies: any-promise: 1.3.0 + tiny-invariant@1.3.3: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -2076,6 +2354,23 @@ snapshots: util-deprecate@1.0.2: {} + victory-vendor@36.9.2: + dependencies: + '@types/d3-array': 3.2.1 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.7 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + vite@5.4.19(lightningcss@1.30.1): dependencies: esbuild: 0.21.5 diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e979337..95f5821 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "milo" -version = "0.1.6" +version = "0.1.7" description = "Milo - AI Text Transformation Tool" authors = ["you"] edition = "2021" @@ -20,6 +20,7 @@ tauri-plugin-shell = "2.0.0" tauri-plugin-clipboard-manager = "2" tauri-plugin-notification = "2" tauri-plugin-global-shortcut = "2" +tauri-plugin-dialog = "2" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" keyring = "2.0" @@ -30,6 +31,8 @@ anyhow = "1.0" window-shadows = "0.2" arboard = "3.2" dirs = "4.0" +chrono = { version = "0.4", features = ["serde"] } +jieba-rs = "0.7" # macOS-specific dependencies for native window manipulation [target.'cfg(target_os = "macos")'.dependencies] diff --git a/src-tauri/capabilities/deafult.json b/src-tauri/capabilities/deafult.json index 4b1e08e..e349ef0 100644 --- a/src-tauri/capabilities/deafult.json +++ b/src-tauri/capabilities/deafult.json @@ -15,6 +15,8 @@ "core:window:allow-set-focus", "core:window:allow-show", "core:window:allow-hide", + "dialog:allow-ask", + "dialog:default", "shell:default", { "identifier": "shell:allow-execute", diff --git a/src-tauri/src/core.rs b/src-tauri/src/core.rs new file mode 100644 index 0000000..34eceae --- /dev/null +++ b/src-tauri/src/core.rs @@ -0,0 +1,92 @@ +use tauri::Manager; +use tauri_plugin_notification::NotificationExt; +use arboard::Clipboard; + +use crate::transform::transform_text; +use crate::history::add_transformation_to_history; +use crate::api::get_api_key; + +// Helper function to clean text while preserving formatting +fn clean_text(text: &str) -> String { + text.lines() + .map(|line| line.trim()) + .collect::>() + .join("\n") +} + +// High-level function that handles clipboard transformation AND history tracking +#[tauri::command] +pub async fn transform_clipboard( + handle: tauri::AppHandle, + prompt_key: String, +) -> Result<(), String> { + // Get and clean clipboard content + let mut clipboard = Clipboard::new().map_err(|e| e.to_string())?; + let original_text = clipboard + .get_text() + .map_err(|e| format!("Failed to get clipboard text: {}", e))?; + let cleaned_original = clean_text(&original_text); + + // Get the state and prompt + let state = handle.state::(); + let settings = state.settings.lock().await; + let prompt = settings.custom_prompts.get(&prompt_key) + .ok_or_else(|| format!("Prompt not found for key: {}", prompt_key))? + .clone(); + + // Get API key and transform + let api_key = get_api_key().await + .map_err(|e| format!("Failed to get API key: {}", e))?; + + // Drop the lock before async operation + drop(settings); + + let transformed_text = transform_text(&cleaned_original, &prompt, &api_key).await?; + let cleaned_transformed = clean_text(&transformed_text); + + // Set transformed text back to clipboard + clipboard.set_text(&cleaned_transformed) + .map_err(|e| format!("Failed to set clipboard text: {}", e))?; + + // Store in history (this is the key addition!) + add_transformation_to_history( + prompt_key.clone(), + cleaned_original, + cleaned_transformed, + )?; + + // Send notification + handle.notification() + .builder() + .title("Milo") + .body(format!("Text transformed with {} tone!", prompt_key)) + .show() + .unwrap(); + + Ok(()) +} + +// Function that reads tone from settings and performs transform with history +#[tauri::command] +pub async fn transform_clip_with_setting(handle: tauri::AppHandle, is_shortcut: bool) -> Result<(), String> { + // Get the state and selected tone + let state = handle.state::(); + let settings = state.settings.lock().await; + + // If is_shortcut, check if shortcut is enabled + if is_shortcut && !settings.is_shortcut_enabled() { + return Ok(()); + } + + let tone_key = settings.selected_tone.clone() + .ok_or_else(|| "No tone selected".to_string())?; + + // Update selected tone in settings + settings.save()?; + + // Drop the lock before transformation + drop(settings); + + // Perform transformation (which now includes history tracking) + transform_clipboard(handle.clone(), tone_key).await +} \ No newline at end of file diff --git a/src-tauri/src/history.rs b/src-tauri/src/history.rs new file mode 100644 index 0000000..ac1d5cb --- /dev/null +++ b/src-tauri/src/history.rs @@ -0,0 +1,807 @@ +use anyhow::Result; +use dirs::config_dir; +use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, fs, path::PathBuf}; +use chrono::{DateTime, Utc, NaiveDate}; +use jieba_rs::Jieba; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct TransformationEntry { + pub tone_name: String, + pub original_text: String, + pub transformed_text: String, + pub timestamp: DateTime, + pub word_count: usize, + pub sentence_count: usize, + pub added_count: usize, + pub removed_count: usize, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct WordDiff { + pub word: String, + pub change_type: String, // "added", "removed", or "unchanged" + pub position: usize, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct TextDiff { + pub original_diff: Vec, + pub transformed_diff: Vec, + pub added_count: usize, + pub removed_count: usize, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct DayStats { + pub date: NaiveDate, + pub transformation_count: usize, + pub word_count: usize, + pub sentence_count: usize, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct TransformationHistory { + pub entries: Vec, + pub daily_stats: HashMap, // key: "YYYY-MM-DD" + pub max_entries: Option, +} + +impl Default for TransformationHistory { + fn default() -> Self { + Self { + entries: Vec::new(), + daily_stats: HashMap::new(), + max_entries: Some(1000), + } + } +} + +impl TransformationHistory { + pub fn load() -> Self { + fs::read_to_string(history_file_path()) + .ok() + .and_then(|contents| serde_json::from_str(&contents).ok()) + .unwrap_or_default() + } + + pub fn save(&self) -> Result<(), String> { + let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?; + fs::write(history_file_path(), json).map_err(|e| e.to_string()) + } + + pub fn add_entry(&mut self, entry: TransformationEntry) { + // Add to entries (most recent first) + self.entries.insert(0, entry.clone()); + + // Enforce max entries limit + if let Some(max) = self.max_entries { + if self.entries.len() > max { + self.entries.truncate(max); + } + } + + // Update daily stats + let date = entry.timestamp.date_naive(); + let date_key = date.format("%Y-%m-%d").to_string(); + + let day_stats = self.daily_stats.entry(date_key).or_insert(DayStats { + date, + transformation_count: 0, + word_count: 0, + sentence_count: 0, + }); + + day_stats.transformation_count += 1; + day_stats.word_count += entry.word_count; + day_stats.sentence_count += entry.sentence_count; + } + + pub fn get_recent_entries(&self, limit: usize) -> &[TransformationEntry] { + let end = self.entries.len().min(limit); + &self.entries[0..end] + } + + pub fn get_total_transformations(&self) -> usize { + self.entries.len() + } + + pub fn get_total_words_transformed(&self) -> usize { + self.entries.iter().map(|e| e.word_count).sum() + } + + pub fn get_total_sentences_transformed(&self) -> usize { + self.entries.iter().map(|e| e.sentence_count).sum() + } + + pub fn clear_history(&mut self) { + self.entries.clear(); + self.daily_stats.clear(); + } +} + +pub fn history_file_path() -> PathBuf { + let mut path = config_dir().unwrap_or_else(|| PathBuf::from(".")); + path.push("milo"); + fs::create_dir_all(&path).unwrap(); + path.push("transformation_history.json"); + path +} + +// Function to compute word-level diff between two texts +pub fn compute_word_diff(original: &str, transformed: &str) -> TextDiff { + // Helper function to tokenize text into words + let tokenize = |text: &str| -> Vec { + let jieba = Jieba::new(); + let has_chinese = text.chars().any(|c| { + matches!(c, '\u{4e00}'..='\u{9fff}') + }); + + if has_chinese { + jieba.cut(text, false).into_iter().map(|s| s.to_string()).collect() + } else { + text.split_whitespace().map(|s| s.to_string()).collect() + } + }; + + let original_words = tokenize(original); + let transformed_words = tokenize(transformed); + + // Compute LCS (Longest Common Subsequence) using dynamic programming + let lcs = compute_lcs(&original_words, &transformed_words); + + // Build diff for original text (mark deletions) + let mut original_diff = Vec::new(); + let mut lcs_index = 0; + + for (pos, word) in original_words.iter().enumerate() { + if lcs_index < lcs.len() && *word == lcs[lcs_index] { + // Word is unchanged + original_diff.push(WordDiff { + word: word.clone(), + change_type: "unchanged".to_string(), + position: pos, + }); + lcs_index += 1; + } else { + // Word was removed + original_diff.push(WordDiff { + word: word.clone(), + change_type: "removed".to_string(), + position: pos, + }); + } + } + + // Build diff for transformed text (mark additions) + let mut transformed_diff = Vec::new(); + let mut lcs_index = 0; + + for (pos, word) in transformed_words.iter().enumerate() { + if lcs_index < lcs.len() && *word == lcs[lcs_index] { + // Word is unchanged + transformed_diff.push(WordDiff { + word: word.clone(), + change_type: "unchanged".to_string(), + position: pos, + }); + lcs_index += 1; + } else { + // Word was added + transformed_diff.push(WordDiff { + word: word.clone(), + change_type: "added".to_string(), + position: pos, + }); + } + } + + // Count additions and removals + let added_count = transformed_diff.iter().filter(|d| d.change_type == "added").count(); + let removed_count = original_diff.iter().filter(|d| d.change_type == "removed").count(); + + TextDiff { + original_diff, + transformed_diff, + added_count, + removed_count, + } +} + +// Helper function to compute Longest Common Subsequence +fn compute_lcs(a: &[String], b: &[String]) -> Vec { + let m = a.len(); + let n = b.len(); + + // Create DP table + let mut dp = vec![vec![0; n + 1]; m + 1]; + + // Fill DP table + for i in 1..=m { + for j in 1..=n { + if a[i-1] == b[j-1] { + dp[i][j] = dp[i-1][j-1] + 1; + } else { + dp[i][j] = dp[i-1][j].max(dp[i][j-1]); + } + } + } + + // Backtrack to find LCS + let mut lcs = Vec::new(); + let mut i = m; + let mut j = n; + + while i > 0 && j > 0 { + if a[i-1] == b[j-1] { + lcs.push(a[i-1].clone()); + i -= 1; + j -= 1; + } else if dp[i-1][j] > dp[i][j-1] { + i -= 1; + } else { + j -= 1; + } + } + + lcs.reverse(); + lcs +} + +// Function to count sentences in text, supporting multiple languages and punctuation types +pub fn count_sentences(text: &str) -> usize { + if text.trim().is_empty() { + return 0; + } + + // Define sentence-ending punctuation for different languages + // Including both half-width and full-width (Chinese/Japanese) punctuation + let sentence_endings = [ + '.', '!', '?', // English half-width + '。', '!', '?', // Chinese/Japanese full-width + '…', '⋯', // Ellipsis + '‼', '⁇', '⁈', '⁉', // Special punctuation + ]; + + let mut sentence_count = 0; + let chars: Vec = text.chars().collect(); + + for (i, &ch) in chars.iter().enumerate() { + if sentence_endings.contains(&ch) { + sentence_count += 1; + + // Handle multiple consecutive punctuation (like "..." or "!!!") + // Skip the rest of consecutive similar punctuation + let mut j = i + 1; + while j < chars.len() && ( + chars[j] == ch || + chars[j].is_whitespace() || + sentence_endings.contains(&chars[j]) + ) { + j += 1; + } + } + } + + // If no sentence-ending punctuation found but text exists, count as 1 sentence + if sentence_count == 0 && !text.trim().is_empty() { + sentence_count = 1; + } + + sentence_count +} + +// Tauri Commands +#[tauri::command] +pub fn add_transformation_to_history( + tone_name: String, + original: String, + transformed: String, +) -> Result<(), String> { + let mut history = TransformationHistory::load(); + + // Calculate diff data immediately + let diff = compute_word_diff(&original, &transformed); + let word_count = diff.added_count + diff.removed_count; + let sentence_count = count_sentences(&transformed); + + let entry = TransformationEntry { + tone_name, + original_text: original, + transformed_text: transformed, + timestamp: Utc::now(), + word_count, + sentence_count, + added_count: diff.added_count, + removed_count: diff.removed_count, + }; + + history.add_entry(entry); + history.save()?; + + Ok(()) +} + +#[tauri::command] +pub fn get_transformation_history(limit: Option) -> Result, String> { + let history = TransformationHistory::load(); + let limit = limit.unwrap_or(50); + Ok(history.get_recent_entries(limit).to_vec()) +} + +#[tauri::command] +pub fn clear_transformation_history() -> Result<(), String> { + let mut history = TransformationHistory::load(); + history.clear_history(); + history.save() +} + +#[tauri::command] +pub fn delete_transformation_entry(index: usize) -> Result<(), String> { + let mut history = TransformationHistory::load(); + + if index >= history.entries.len() { + return Err("Entry index out of bounds".to_string()); + } + + // Remove the entry + let removed_entry = history.entries.remove(index); + + // Update daily stats + let date_key = removed_entry.timestamp.format("%Y-%m-%d").to_string(); + if let Some(day_stats) = history.daily_stats.get_mut(&date_key) { + day_stats.transformation_count = day_stats.transformation_count.saturating_sub(1); + day_stats.word_count = day_stats.word_count.saturating_sub(removed_entry.word_count); + day_stats.sentence_count = day_stats.sentence_count.saturating_sub(removed_entry.sentence_count); + + // Remove the day stats if no transformations left for that day + if day_stats.transformation_count == 0 { + history.daily_stats.remove(&date_key); + } + } + + history.save() +} + +#[tauri::command] +pub fn get_usage_stats() -> Result { + let history = TransformationHistory::load(); + + Ok(serde_json::json!({ + "total_transformations": history.get_total_transformations(), + "total_words_transformed": history.get_total_words_transformed(), + "total_sentences_transformed": history.get_total_sentences_transformed(), + "history_count": history.entries.len() + })) +} + +#[tauri::command] +pub fn get_daily_stats(days: Option) -> Result, String> { + let history = TransformationHistory::load(); + let days = days.unwrap_or(7); // Default to 7 days + + let today = Utc::now().date_naive(); + let mut stats = Vec::new(); + + // Generate stats for the last N days + for i in 0..days { + let date = today - chrono::Duration::days(i as i64); + let date_key = date.format("%Y-%m-%d").to_string(); + + let day_stats = history.daily_stats.get(&date_key) + .cloned() + .unwrap_or(DayStats { + date, + transformation_count: 0, + word_count: 0, + sentence_count: 0, + }); + + stats.push(day_stats); + } + + // Reverse to get chronological order (oldest first) + stats.reverse(); + Ok(stats) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{TimeZone, Utc}; + use std::fs; + + fn cleanup_test_files() { + let _ = fs::remove_file(history_file_path()); + } + + #[test] + fn test_transformation_entry_creation() { + let entry = TransformationEntry { + tone_name: "Improve Writing".to_string(), + original_text: "test text".to_string(), + transformed_text: "improved test text".to_string(), + timestamp: Utc::now(), + word_count: 3, + sentence_count: 1, + added_count: 0, + removed_count: 0, + }; + + assert_eq!(entry.tone_name, "Improve Writing"); + assert_eq!(entry.word_count, 3); + } + + #[test] + fn test_history_add_entry() { + cleanup_test_files(); + + let mut history = TransformationHistory::default(); + + let entry = TransformationEntry { + tone_name: "Test Tone".to_string(), + original_text: "original".to_string(), + transformed_text: "transformed".to_string(), + timestamp: Utc.with_ymd_and_hms(2024, 1, 15, 12, 0, 0).unwrap(), + word_count: 1, + sentence_count: 1, + added_count: 0, + removed_count: 0, + }; + + history.add_entry(entry); + + assert_eq!(history.entries.len(), 1); + assert_eq!(history.get_total_transformations(), 1); + assert_eq!(history.get_total_words_transformed(), 1); + + // Check daily stats + let date_key = "2024-01-15"; + assert!(history.daily_stats.contains_key(date_key)); + let day_stats = &history.daily_stats[date_key]; + assert_eq!(day_stats.transformation_count, 1); + assert_eq!(day_stats.word_count, 1); + + cleanup_test_files(); + } + + #[test] + fn test_history_multiple_entries_same_day() { + cleanup_test_files(); + + let mut history = TransformationHistory::default(); + let test_date = Utc.with_ymd_and_hms(2024, 1, 15, 12, 0, 0).unwrap(); + + // Add first entry + let entry1 = TransformationEntry { + tone_name: "Tone 1".to_string(), + original_text: "text 1".to_string(), + transformed_text: "transformed text 1".to_string(), + timestamp: test_date, + word_count: 3, + sentence_count: 1, + added_count: 0, + removed_count: 0, + }; + history.add_entry(entry1); + + // Add second entry same day + let entry2 = TransformationEntry { + tone_name: "Tone 2".to_string(), + original_text: "text 2".to_string(), + transformed_text: "transformed text 2".to_string(), + timestamp: test_date, + word_count: 2, + sentence_count: 1, + added_count: 0, + removed_count: 0, + }; + history.add_entry(entry2); + + assert_eq!(history.entries.len(), 2); + assert_eq!(history.get_total_transformations(), 2); + assert_eq!(history.get_total_words_transformed(), 5); + + // Check daily stats aggregation + let date_key = "2024-01-15"; + let day_stats = &history.daily_stats[date_key]; + assert_eq!(day_stats.transformation_count, 2); + assert_eq!(day_stats.word_count, 5); + + cleanup_test_files(); + } + + #[test] + fn test_history_max_entries_limit() { + cleanup_test_files(); + + let mut history = TransformationHistory::default(); + history.max_entries = Some(3); + + // Add 5 entries + for i in 0..5 { + let entry = TransformationEntry { + tone_name: format!("Tone {}", i), + original_text: format!("original {}", i), + transformed_text: format!("transformed {}", i), + timestamp: Utc::now(), + word_count: 1, + sentence_count: 1, + added_count: 0, + removed_count: 0, + }; + history.add_entry(entry); + } + + // Should only keep 3 entries (most recent) + assert_eq!(history.entries.len(), 3); + + // Check that most recent entries are kept + assert_eq!(history.entries[0].tone_name, "Tone 4"); + assert_eq!(history.entries[1].tone_name, "Tone 3"); + assert_eq!(history.entries[2].tone_name, "Tone 2"); + + cleanup_test_files(); + } + + #[test] + fn test_get_recent_entries() { + cleanup_test_files(); + + let mut history = TransformationHistory::default(); + + // Add some entries + for i in 0..5 { + let entry = TransformationEntry { + tone_name: format!("Tone {}", i), + original_text: format!("original {}", i), + transformed_text: format!("transformed {}", i), + timestamp: Utc::now(), + word_count: 1, + sentence_count: 1, + added_count: 0, + removed_count: 0, + }; + history.add_entry(entry); + } + + let recent = history.get_recent_entries(3); + assert_eq!(recent.len(), 3); + assert_eq!(recent[0].tone_name, "Tone 4"); // Most recent first + + cleanup_test_files(); + } + + #[test] + fn test_clear_history() { + cleanup_test_files(); + + let mut history = TransformationHistory::default(); + + // Add an entry + let entry = TransformationEntry { + tone_name: "Test".to_string(), + original_text: "test".to_string(), + transformed_text: "transformed".to_string(), + timestamp: Utc::now(), + word_count: 1, + sentence_count: 1, + added_count: 0, + removed_count: 0, + }; + history.add_entry(entry); + + assert_eq!(history.entries.len(), 1); + assert_eq!(history.daily_stats.len(), 1); + + history.clear_history(); + + assert_eq!(history.entries.len(), 0); + assert_eq!(history.daily_stats.len(), 0); + assert_eq!(history.get_total_transformations(), 0); + assert_eq!(history.get_total_words_transformed(), 0); + + cleanup_test_files(); + } + + #[test] + fn test_daily_stats_generation() { + cleanup_test_files(); + + let mut history = TransformationHistory::default(); + + // Add entries on different days + let day1 = Utc.with_ymd_and_hms(2024, 1, 15, 12, 0, 0).unwrap(); + let day2 = Utc.with_ymd_and_hms(2024, 1, 16, 12, 0, 0).unwrap(); + + let entry1 = TransformationEntry { + tone_name: "Day 1".to_string(), + original_text: "text".to_string(), + transformed_text: "day one text".to_string(), + timestamp: day1, + word_count: 3, + sentence_count: 1, + added_count: 0, + removed_count: 0, + }; + history.add_entry(entry1); + + let entry2 = TransformationEntry { + tone_name: "Day 2".to_string(), + original_text: "text".to_string(), + transformed_text: "day two text".to_string(), + timestamp: day2, + word_count: 2, + sentence_count: 1, + added_count: 0, + removed_count: 0, + }; + history.add_entry(entry2); + + assert_eq!(history.daily_stats.len(), 2); + + let day1_stats = &history.daily_stats["2024-01-15"]; + assert_eq!(day1_stats.transformation_count, 1); + assert_eq!(day1_stats.word_count, 3); + + let day2_stats = &history.daily_stats["2024-01-16"]; + assert_eq!(day2_stats.transformation_count, 1); + assert_eq!(day2_stats.word_count, 2); + + cleanup_test_files(); + } + + #[test] + fn test_save_and_load() { + cleanup_test_files(); + + // Create and save history + let mut history = TransformationHistory::default(); + let entry = TransformationEntry { + tone_name: "Test Save".to_string(), + original_text: "original".to_string(), + transformed_text: "transformed".to_string(), + timestamp: Utc.with_ymd_and_hms(2024, 1, 15, 12, 0, 0).unwrap(), + word_count: 1, + sentence_count: 1, + added_count: 0, + removed_count: 0, + }; + history.add_entry(entry); + + // Save to file + history.save().expect("Failed to save history"); + + // Load from file + let loaded_history = TransformationHistory::load(); + + assert_eq!(loaded_history.entries.len(), 1); + assert_eq!(loaded_history.entries[0].tone_name, "Test Save"); + assert_eq!(loaded_history.daily_stats.len(), 1); + + cleanup_test_files(); + } + + #[test] + fn test_compute_word_diff_simple() { + // Test the example from user: "I am a very tall guy." -> "I'm very tall." + let original = "I am a very tall guy."; + let transformed = "I'm very tall."; + let diff = compute_word_diff(original, transformed); + + println!("Diff result: added={}, removed={}", diff.added_count, diff.removed_count); + + // Should have: removed "am", "a", "guy." and added "I'm" + assert_eq!(diff.added_count, 1); // "I'm" + assert_eq!(diff.removed_count, 4); // "I", "am", "a", "guy." + + // Check that "I'm" is marked as added + let added_words: Vec<&str> = diff.transformed_diff + .iter() + .filter(|w| w.change_type == "added") + .map(|w| w.word.as_str()) + .collect(); + assert!(added_words.contains(&"I'm")); + + // Check that removed words are marked correctly + let removed_words: Vec<&str> = diff.original_diff + .iter() + .filter(|w| w.change_type == "removed") + .map(|w| w.word.as_str()) + .collect(); + assert!(removed_words.contains(&"I")); + assert!(removed_words.contains(&"am")); + assert!(removed_words.contains(&"a")); + assert!(removed_words.contains(&"guy.")); + } + + #[test] + fn test_compute_word_diff_additions_only() { + let original = "Hello world"; + let transformed = "Hello beautiful wonderful world"; + let diff = compute_word_diff(original, transformed); + + assert_eq!(diff.added_count, 2); // "beautiful", "wonderful" + assert_eq!(diff.removed_count, 0); + } + + #[test] + fn test_compute_word_diff_removals_only() { + let original = "Hello beautiful wonderful world"; + let transformed = "Hello world"; + let diff = compute_word_diff(original, transformed); + + assert_eq!(diff.added_count, 0); + assert_eq!(diff.removed_count, 2); // "beautiful", "wonderful" + } + + #[test] + fn test_compute_word_diff_identical() { + let original = "Hello world"; + let transformed = "Hello world"; + let diff = compute_word_diff(original, transformed); + + assert_eq!(diff.added_count, 0); + assert_eq!(diff.removed_count, 0); + } + + #[test] + fn test_compute_word_diff_chinese() { + let original = "我是一个学生"; + let transformed = "我是一个好学生"; + let diff = compute_word_diff(original, transformed); + + println!("Chinese diff: added={}, removed={}", diff.added_count, diff.removed_count); + assert_eq!(diff.added_count, 1); // "好" + assert_eq!(diff.removed_count, 0); + } + + #[test] + fn test_count_sentences_english() { + // Basic sentence counting + assert_eq!(count_sentences("Hello world."), 1); + assert_eq!(count_sentences("Hello world. How are you?"), 2); + assert_eq!(count_sentences("Hello world! How are you? Fine."), 3); + + // Multiple punctuation + assert_eq!(count_sentences("Hello world!!! How are you???"), 2); + assert_eq!(count_sentences("Hello world... How are you."), 2); + + // No punctuation (should count as 1) + assert_eq!(count_sentences("Hello world"), 1); + + // Empty string + assert_eq!(count_sentences(""), 0); + assert_eq!(count_sentences(" "), 0); + } + + #[test] + fn test_count_sentences_chinese() { + // Chinese punctuation (full-width) + assert_eq!(count_sentences("你好世界。"), 1); + assert_eq!(count_sentences("你好世界。你好吗?"), 2); + assert_eq!(count_sentences("你好世界!你好吗?很好。"), 3); + + // Mixed punctuation + assert_eq!(count_sentences("Hello世界。你好吗?"), 2); + + // No punctuation (should count as 1) + assert_eq!(count_sentences("你好世界"), 1); + } + + #[test] + fn test_count_sentences_mixed() { + // Mixed English and Chinese with various punctuation + assert_eq!(count_sentences("Hello 世界! 你好嗎?"), 2); + assert_eq!(count_sentences("Testing... 測試。"), 2); + assert_eq!(count_sentences("Hello! 你好! How are you? 你好嗎?"), 4); + } + + #[test] + fn test_count_sentences_special_punctuation() { + // Ellipsis and special punctuation + assert_eq!(count_sentences("Hello… World."), 2); + assert_eq!(count_sentences("Really⁉ Yes‼"), 2); + assert_eq!(count_sentences("Hello⋯ World?"), 2); + } +} \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 604402f..39d75b5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,8 @@ mod tray; mod api; mod shortcuts; mod system; +mod history; +mod core; use settings::Settings; use state::AppState; @@ -19,6 +21,7 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_notification::init()) + .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_global_shortcut::Builder::new() .with_handler(|app, shortcut, event| { println!("🎯 Shortcut handler triggered!"); @@ -30,7 +33,7 @@ pub fn run() { println!("⬇️ Shortcut PRESSED - triggering transform"); let app_handle = app.clone(); tauri::async_runtime::spawn(async move { - if let Err(e) = transform::transform_clip_with_setting(app_handle.clone(), true).await { + if let Err(e) = core::transform_clip_with_setting(app_handle.clone(), true).await { println!("❌ Transform error: {}", e); let _ = app_handle.notification() .builder() @@ -56,6 +59,9 @@ pub fn run() { #[cfg(desktop)] shortcuts::register_shortcuts(&app.handle())?; + #[cfg(target_os = "macos")]#[cfg(target_os = "macos")] + app.set_activation_policy(tauri::ActivationPolicy::Accessory); + Ok(()) }) .manage(app_state) @@ -65,10 +71,17 @@ pub fn run() { api::save_settings, api::get_settings, api::show_settings, - transform::transform_clipboard, + core::transform_clipboard, + core::transform_clip_with_setting, shortcuts::get_current_shortcut, shortcuts::update_shortcut, shortcuts::unregister_shortcut, + history::add_transformation_to_history, + history::get_transformation_history, + history::clear_transformation_history, + history::delete_transformation_entry, + history::get_usage_stats, + history::get_daily_stats, ]) .on_window_event(|_app, event| match event { tauri::WindowEvent::CloseRequested { api, .. } => { diff --git a/src-tauri/src/shortcuts.rs b/src-tauri/src/shortcuts.rs index 3401ac9..872e8b3 100644 --- a/src-tauri/src/shortcuts.rs +++ b/src-tauri/src/shortcuts.rs @@ -167,7 +167,6 @@ pub async fn get_current_shortcut(state: tauri::State<'_, AppState>) -> Result Result { let config = OpenAIConfig::new().with_api_key(api_key); @@ -45,75 +40,3 @@ pub async fn transform_text(text: &str, prompt: &str, api_key: &str) -> Result Err(format!("OpenAI API error: {}", e)) } } - -// Helper function to clean text while preserving formatting -fn clean_text(text: &str) -> String { - text.lines() - .map(|line| line.trim()) - .collect::>() - .join("\n") -} - -// Function that handles clipboard transformation without updating settings -#[tauri::command] -pub async fn transform_clipboard( - handle: tauri::AppHandle, - prompt_key: String, -) -> Result<(), String> { - // Get and clean clipboard content - let mut clipboard = Clipboard::new().map_err(|e| e.to_string())?; - let text = clipboard - .get_text() - .map_err(|e| format!("Failed to get clipboard text: {}", e))?; - let cleaned_text = clean_text(&text); - - // Get the state and prompt - let state = handle.state::(); - let settings = state.settings.lock().await; - let prompt = settings.custom_prompts.get(&prompt_key) - .ok_or_else(|| format!("Prompt not found for key: {}", prompt_key))?; - - // Get API key and transform - let api_key = get_api_key().await - .map_err(|e| format!("Failed to get API key: {}", e))?; - let transformed_text = transform_text(&cleaned_text, prompt, &api_key).await?; - - // Set transformed text back to clipboard - clipboard.set_text(clean_text(&transformed_text)) - .map_err(|e| format!("Failed to set clipboard text: {}", e))?; - - // Send notification - handle.notification() - .builder() - .title("Milo") - .body(format!("Text transformed with {} tone!", prompt_key)) - .show() - .unwrap(); - - Ok(()) -} - -// Function that reads tone from settings and performs transform -#[tauri::command] -pub async fn transform_clip_with_setting(handle: tauri::AppHandle, is_shortcut: bool) -> Result<(), String> { - // Get the state and selected tone - let state = handle.state::(); - let settings = state.settings.lock().await; - - // if is_shortcut, check if shortcut is enabled - if is_shortcut && !settings.is_shortcut_enabled() { - return Ok(()); - } - - let tone_key = settings.selected_tone.clone() - .ok_or_else(|| "No tone selected".to_string())?; - - // Update selected tone in settings - settings.save()?; - - // Drop the lock before transformation - drop(settings); - - // Perform transformation - transform_clipboard(handle.clone(), tone_key).await -} diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index d4f2b8f..2a3df34 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -7,12 +7,17 @@ use tauri::{ #[cfg(target_os = "macos")] use crate::system; +use crate::core; +use tauri::Emitter; pub fn create_tray_menu(app: &App) -> Result { println!("Creating tray menu..."); let menu = MenuBuilder::new(app) .text("transform", "Transform") + .separator() + .text("dashboard", "Dashboard") + .text("prompts", "Edit Tone Prompts") .text("settings", "Settings") .separator() .text("quit", "Quit") @@ -34,32 +39,17 @@ fn handle_menu_event(app: &AppHandle, event: tauri::menu::MenuEvent) { "quit" => { app.exit(0); } + "dashboard" => { + println!("Dashboard menu item clicked"); + show_window_and_navigate(app, "dashboard"); + } + "prompts" => { + println!("Edit Tone Prompts menu item clicked"); + show_window_and_navigate(app, "prompts"); + } "settings" => { println!("Settings menu item clicked"); - if let Some(window) = app.get_webview_window("main") { - // Show window first - let _ = window.show(); - - #[cfg(target_os = "macos")] - { - use tauri::UserAttentionType; - let _ = system::move_window_to_active_space(&window); - - // Request user attention to draw focus - let _ = window.request_user_attention(Some(UserAttentionType::Critical)); - } - - // Multiple focus attempts with increasing delays for desktop switching - std::thread::sleep(std::time::Duration::from_millis(200)); - let _ = window.set_focus(); - - std::thread::sleep(std::time::Duration::from_millis(100)); - let _ = window.set_focus(); - - // Final attempt with even longer delay - std::thread::sleep(std::time::Duration::from_millis(100)); - let _ = window.set_focus(); - } + show_window_and_navigate(app, "api"); } "transform" => { let state = app.state::(); @@ -68,7 +58,7 @@ fn handle_menu_event(app: &AppHandle, event: tauri::menu::MenuEvent) { println!("Starting transformation..."); let app_handle = app.clone(); tauri::async_runtime::spawn(async move { - if let Err(e) = crate::transform::transform_clip_with_setting(app_handle, false).await { + if let Err(e) = core::transform_clip_with_setting(app_handle, false).await { println!("Transform error: {}", e); } }); @@ -81,3 +71,33 @@ fn handle_menu_event(app: &AppHandle, event: tauri::menu::MenuEvent) { } } } + +fn show_window_and_navigate(app: &AppHandle, section: &str) { + if let Some(window) = app.get_webview_window("main") { + // Show window first + let _ = window.show(); + + #[cfg(target_os = "macos")] + { + use tauri::UserAttentionType; + let _ = system::move_window_to_active_space(&window); + + // Request user attention to draw focus + let _ = window.request_user_attention(Some(UserAttentionType::Critical)); + } + + // Multiple focus attempts with increasing delays for desktop switching + std::thread::sleep(std::time::Duration::from_millis(200)); + let _ = window.set_focus(); + + std::thread::sleep(std::time::Duration::from_millis(100)); + let _ = window.set_focus(); + + // Navigate to the specific section by emitting an event to all windows + let _ = app.emit("navigate-to-section", section); + + // Final focus attempt + std::thread::sleep(std::time::Duration::from_millis(100)); + let _ = window.set_focus(); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 71b54a8..4a0411d 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -19,7 +19,7 @@ }, "productName": "Milo", "mainBinaryName": "Milo", - "version": "0.1.6", + "version": "0.1.7", "identifier": "com.milo.dev", "plugins": {}, "app": { diff --git a/src/App.tsx b/src/App.tsx index 0dff923..4385120 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,10 +2,12 @@ import { useState, useEffect } from "react"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification'; -import { ApiSettings } from "./components/ApiSettings"; +import { Settings } from "./components/ApiSettings"; import { PromptSettings } from "./components/PromptSettings"; import { Sidebar } from "./components/Sidebar"; import { InfoPage } from "./components/InfoPage"; +import { History } from "./components/History"; +import { Dashboard } from "./components/Dashboard"; interface Settings { openai_model: string; @@ -57,7 +59,7 @@ function App() { if (!savedSettings.firstVisitComplete) { setActiveSection('info'); } else { - setActiveSection('prompts'); + setActiveSection('dashboard'); } setLoading(false); }) @@ -90,10 +92,18 @@ function App() { }); }); + // Listen for navigation events from tray + const unlistenNavigate = listen('navigate-to-section', (event) => { + const section = event.payload as string; + console.log('Navigation event received:', section); + setActiveSection(section); + }); + return () => { unlisten.then((fn) => fn()); unlistenNotification.then(fn => fn()); unlistenTransform.then(fn => fn()); + unlistenNavigate.then(fn => fn()); }; }, []); @@ -108,7 +118,11 @@ function App() { case 'prompts': return ; case 'api': - return ; + return ; + case 'history': + return ; + case 'dashboard': + return ; default: return ; } diff --git a/src/components/ApiSettings.tsx b/src/components/ApiSettings.tsx index d1b4428..b84b216 100644 --- a/src/components/ApiSettings.tsx +++ b/src/components/ApiSettings.tsx @@ -1,85 +1,257 @@ import { useState, useEffect } from "react"; import { invoke } from "@tauri-apps/api/core"; +import { ask, message } from '@tauri-apps/plugin-dialog'; +import { ShortcutItem } from "./ShortcutItem"; +import { useShortcutEditor } from "../hooks/useShortcutEditor"; +import { backendFormatToShortcut, shortcutToBackendFormat, Shortcut } from "../utils/keyboardUtils"; -export function ApiSettings() { +interface Settings { + openai_model: string; + custom_prompts: { + [key: string]: string; + }; + selected_tone?: string; + firstVisitComplete?: boolean; + shortcutEnabled?: boolean; +} + +export function Settings() { const [apiKey, setApiKey] = useState(""); - const [apiKeyStatus, setApiKeyStatus] = useState<{ message: string; type: 'success' | 'error' } | null>(null); + const [settings, setSettings] = useState({ + openai_model: "", + custom_prompts: {}, + firstVisitComplete: false, + shortcutEnabled: true + }); + const [shortcut, setShortcut] = useState([]); + const [shortcutEnabled, setShortcutEnabled] = useState(true); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); useEffect(() => { - invoke("get_api_key") - .then((key) => setApiKey(key)) - .catch(console.error); + loadSettings(); }, []); - const handleSaveApiKey = async () => { + const loadSettings = async () => { try { - await invoke("save_api_key", { key: apiKey }); - setApiKeyStatus({ message: "API key saved successfully!", type: 'success' }); - setTimeout(() => setApiKeyStatus(null), 3000); + setLoading(true); + + // Load API key + const savedApiKey = await invoke('get_api_key'); + setApiKey(savedApiKey || ''); + + // Load general settings + const savedSettings = await invoke("get_settings"); + setSettings(savedSettings); + setShortcutEnabled(savedSettings.shortcutEnabled ?? true); + + // Load current shortcut + const currentShortcut = await invoke("get_current_shortcut"); + const parsedShortcut = backendFormatToShortcut(currentShortcut); + setShortcut(parsedShortcut); } catch (error) { - console.error("Failed to save API key:", error); - setApiKeyStatus({ message: "Failed to save API key. Please try again.", type: 'error' }); - setTimeout(() => setApiKeyStatus(null), 3000); + console.error('Failed to load settings:', error); + } finally { + setLoading(false); } }; - return ( -
-
-
- - - -

OpenAI API Key

+ const saveApiKey = async () => { + try { + setSaving(true); + await invoke('save_api_key', { apiKey }); + await message('API key saved successfully!', { title: 'Success', kind: 'info' }); + } catch (error) { + console.error('Failed to save API key:', error); + await message('Failed to save API key. Please try again.', { title: 'Error', kind: 'error' }); + } finally { + setSaving(false); + } + }; + + const handleShortcutToggle = async (enabled: boolean) => { + try { + const updatedSettings = { + ...settings, + shortcutEnabled: enabled + }; + + await invoke("save_settings", { + settings: updatedSettings + }); + + setSettings(updatedSettings); + setShortcutEnabled(enabled); + } catch (error) { + console.error("Failed to save shortcut settings:", error); + } + }; + + const changeShortcut = async (newShortcut: Shortcut) => { + setShortcut(newShortcut); + try { + const backendFormat = shortcutToBackendFormat(newShortcut); + await invoke("update_shortcut", { shortcutKeys: backendFormat }); + } catch (error) { + console.error("Failed to update shortcut:", error); + } + }; + + const { + isEnabled, + isModalOpen, + currentKeys, + toggleEnabled, + openEditModal, + closeEditModal, + saveShortcut + } = useShortcutEditor(shortcutEnabled, changeShortcut, setShortcutEnabled); + + const onEditShortcut = async () => { + openEditModal(); + try { + await invoke("unregister_shortcut"); + } catch (error) { + console.error("Failed to unregister shortcut:", error); + } + }; + + const onCancelShortcut = async () => { + closeEditModal(); + try { + const backendFormat = shortcutToBackendFormat(shortcut); + await invoke("update_shortcut", { shortcutKeys: backendFormat }); + } catch (error) { + console.error("Failed to restore shortcut:", error); + } + }; + + const onSaveShortcut = async () => { + saveShortcut(); + }; + + const onToggleEnabled = async () => { + const newEnabled = !isEnabled; + await handleShortcutToggle(newEnabled); + toggleEnabled(); + }; + + const clearHistory = async () => { + const userConfirmed = await ask('Are you sure you want to clear all transformation history? This action cannot be undone.', { + title: 'Clear History', + kind: 'warning' + }); + + if (userConfirmed) { + try { + await invoke('clear_transformation_history'); + await message('History cleared successfully!', { title: 'Success', kind: 'info' }); + } catch (error) { + console.error('Failed to clear history:', error); + await message(`Failed to clear history: ${error}. Please try again.`, { title: 'Error', kind: 'error' }); + } + } + }; + + if (loading) { + return ( +
+
+
+
+ {[...Array(3)].map((_, i) => ( +
+ ))} +
-

- Enter your OpenAI API key to enable AI-powered text transformations -

+
+ ); + } + + return ( +
+
+

Settings

+

Configure your Milo preferences

-
-
- setApiKey(e.target.value)} - className="flex-1 h-9 px-3 border border-slate-200 rounded-md text-sm text-slate-800 transition-all duration-200 ease-in-out focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/10" - /> -
+
+ + {/* Shortcut Configuration Section */} +
+
+

Keyboard Shortcut

+

Configure the global shortcut to transform clipboard text

+
+ + {shortcut.length > 0 && ( +
+ +
+ )} +
+ + {/* Data Management Section */} +
+
+

Data Management

+

Manage your transformation history and data

+
- {/* Notification with smooth transitions */} -
-
- {apiKeyStatus && ( -
- - {apiKeyStatus.type === 'success' ? ( - - ) : ( - - )} - - {apiKeyStatus.message} -
- )} +
+
+
+

Clear History

+

Remove all transformation history permanently

+
+
diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx new file mode 100644 index 0000000..c20bddd --- /dev/null +++ b/src/components/Dashboard.tsx @@ -0,0 +1,188 @@ +import { useState, useEffect } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts'; + +interface DayStats { + date: string; + transformation_count: number; + word_count: number; + sentence_count: number; +} + +interface UsageStats { + total_transformations: number; + total_words_transformed: number; + total_sentences_transformed: number; + history_count: number; +} + +export function Dashboard() { + const [dailyStats, setDailyStats] = useState([]); + const [usageStats, setUsageStats] = useState(null); + const [loading, setLoading] = useState(true); + const [selectedPeriod, setSelectedPeriod] = useState<7 | 30>(7); + + useEffect(() => { + loadDashboardData(); + }, [selectedPeriod]); + + const loadDashboardData = async () => { + try { + setLoading(true); + + const [statsData, usageData] = await Promise.all([ + invoke('get_daily_stats', { days: selectedPeriod }), + invoke('get_usage_stats') + ]); + + setDailyStats(statsData); + setUsageStats(usageData); + } catch (error) { + console.error('Failed to load dashboard data:', error); + } finally { + setLoading(false); + } + }; + + if (loading) { + return ( +
+
+
+
+ {[...Array(3)].map((_, i) => ( +
+ ))} +
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+

Dashboard

+

Overview of your transformation activity

+
+ + {/* Usage Stats */} + {usageStats && ( +
+
+
{usageStats.total_transformations}
+
Transformations
+
+ +
+
{usageStats.total_words_transformed}
+
Words
+
+ +
+
{usageStats.total_sentences_transformed}
+
Sentences
+
+
+ )} + + {/* Daily Activity Chart */} +
+
+

Daily Activity

+
+ + +
+
+ + {dailyStats.length > 0 ? ( +
+ + ({ + ...day, + displayDate: new Date(day.date).toLocaleDateString('en-US', { + month: selectedPeriod === 30 ? 'numeric' : 'short', + day: 'numeric' + }) + }))} + margin={{ + top: 5, + right: 20, + left: 20, + bottom: 5, + }} + > + + + { + switch (name) { + case 'transformation_count': + return [value, 'Transformations']; + case 'word_count': + return [value, 'Words']; + case 'sentence_count': + return [value, 'Sentences']; + default: + return [value, name]; + } + }} + labelFormatter={(label) => `Date: ${label}`} + contentStyle={{ + backgroundColor: 'rgba(255, 255, 255, 0.95)', + border: '1px solid #e2e8f0', + borderRadius: '6px', + color: '#334155', + fontSize: '12px', + boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)' + }} + /> + + + +
+ ) : ( +
No activity data available
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/History.tsx b/src/components/History.tsx new file mode 100644 index 0000000..a2b17b6 --- /dev/null +++ b/src/components/History.tsx @@ -0,0 +1,361 @@ +import { useState, useEffect } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { ask, message } from '@tauri-apps/plugin-dialog'; +import moment from 'moment'; + +interface TransformationEntry { + tone_name: string; + original_text: string; + transformed_text: string; + timestamp: string; + word_count: number; + sentence_count: number; + added_count: number; + removed_count: number; +} + +interface WordDiff { + word: string; + change_type: 'added' | 'removed' | 'unchanged'; + position: number; +} + +interface TextDiff { + original_diff: WordDiff[]; + transformed_diff: WordDiff[]; + added_count: number; + removed_count: number; +} + +// Frontend word diff calculation +function tokenizeText(text: string): string[] { + return text.split(/\s+/).filter(word => word.length > 0); +} + +function computeLCS(a: string[], b: string[]): string[] { + const m = a.length; + const n = b.length; + + const dp: number[][] = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0)); + + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + if (a[i - 1] === b[j - 1]) { + dp[i][j] = dp[i - 1][j - 1] + 1; + } else { + dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); + } + } + } + + const lcs: string[] = []; + let i = m; + let j = n; + + while (i > 0 && j > 0) { + if (a[i - 1] === b[j - 1]) { + lcs.unshift(a[i - 1]); + i--; + j--; + } else if (dp[i - 1][j] > dp[i][j - 1]) { + i--; + } else { + j--; + } + } + + return lcs; +} + +function computeWordDiff(originalText: string, transformedText: string): TextDiff { + const originalWords = tokenizeText(originalText); + const transformedWords = tokenizeText(transformedText); + + const lcs = computeLCS(originalWords, transformedWords); + + const originalDiff: WordDiff[] = []; + let lcsIndex = 0; + + for (let pos = 0; pos < originalWords.length; pos++) { + const word = originalWords[pos]; + if (lcsIndex < lcs.length && word === lcs[lcsIndex]) { + originalDiff.push({ + word, + change_type: 'unchanged', + position: pos, + }); + lcsIndex++; + } else { + originalDiff.push({ + word, + change_type: 'removed', + position: pos, + }); + } + } + + const transformedDiff: WordDiff[] = []; + lcsIndex = 0; + + for (let pos = 0; pos < transformedWords.length; pos++) { + const word = transformedWords[pos]; + if (lcsIndex < lcs.length && word === lcs[lcsIndex]) { + transformedDiff.push({ + word, + change_type: 'unchanged', + position: pos, + }); + lcsIndex++; + } else { + transformedDiff.push({ + word, + change_type: 'added', + position: pos, + }); + } + } + + const addedCount = transformedDiff.filter(d => d.change_type === 'added').length; + const removedCount = originalDiff.filter(d => d.change_type === 'removed').length; + + return { + original_diff: originalDiff, + transformed_diff: transformedDiff, + added_count: addedCount, + removed_count: removedCount, + }; +} + +export function History() { + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [expandedEntry, setExpandedEntry] = useState(null); + const [diffData, setDiffData] = useState<{[key: number]: TextDiff}>({}); + + useEffect(() => { + loadHistoryData(); + }, []); + + const loadHistoryData = async () => { + try { + setLoading(true); + + const entriesData = await invoke('get_transformation_history', { limit: 50 }); + setEntries(entriesData); + } catch (error) { + console.error('Failed to load history data:', error); + } finally { + setLoading(false); + } + }; + + const deleteEntry = async (index: number) => { + const userConfirmed = await ask('Are you sure you want to delete this transformation?', { + title: 'Delete Entry', + kind: 'warning' + }); + + if (userConfirmed) { + try { + await invoke('delete_transformation_entry', { index }); + await loadHistoryData(); + await message('Entry deleted successfully!', { title: 'Success', kind: 'info' }); + } catch (error) { + console.error('Failed to delete entry:', error); + await message(`Failed to delete entry: ${error}`, { title: 'Error', kind: 'error' }); + } + } + }; + + const toggleDiffView = (index: number) => { + if (expandedEntry === index) { + setExpandedEntry(null); + } else { + setExpandedEntry(index); + // Calculate diff if not already cached + if (!diffData[index]) { + const entry = entries[index]; + const diff = computeWordDiff(entry.original_text, entry.transformed_text); + setDiffData(prev => ({ ...prev, [index]: diff })); + } + } + }; + + const renderDiffText = (words: WordDiff[], isTransformed: boolean) => { + return ( +
+ {words.map((wordDiff, index) => { + let className = ''; + + if (wordDiff.change_type === 'removed' && !isTransformed) { + className = 'bg-red-100 text-red-800 px-1 rounded'; + } else if (wordDiff.change_type === 'added' && isTransformed) { + className = 'bg-green-100 text-green-800 px-1 rounded'; + } else if (wordDiff.change_type === 'unchanged') { + className = 'text-slate-700'; + } + + return ( + + {wordDiff.word} + {index < words.length - 1 ? ' ' : ''} + + ); + })} +
+ ); + }; + + const formatTimeAgo = (dateString: string) => { + return moment(dateString).fromNow(); + }; + + if (loading) { + return ( +
+
+
+
+ {[...Array(5)].map((_, i) => ( +
+ ))} +
+
+
+ ); + } + + return ( + <> + +
+ {/* Header */} +
+
+

Transformation History

+

View your past text transformations

+
+
+ + {/* Recent Transformations */} +
+
+

Recent Transformations

+
+ + {entries.length > 0 ? ( +
+ {entries.map((entry, index) => ( +
toggleDiffView(index)}> +
+
+ + {entry.tone_name} + + {(entry.added_count > 0 || entry.removed_count > 0) && ( + + +{entry.added_count} + {' '} + -{entry.removed_count} + + )} + + {entry.sentence_count} sentences + +
+
+ + {formatTimeAgo(entry.timestamp)} + + +
+
+ +
+ {entry.transformed_text} +
+ + {expandedEntry === index && diffData[index] && ( +
+
+
+ Original + + -{diffData[index].removed_count} words + +
+
+ {renderDiffText(diffData[index].original_diff, false)} +
+
+ +
+
+ Transformed + + +{diffData[index].added_count} words + +
+
+ {renderDiffText(diffData[index].transformed_diff, true)} +
+
+ +
+

Legend:

+
+
+ removed + deleted words +
+
+ added + added words +
+
+
+
+ )} +
+ ))} +
+ ) : ( +
+
+ + + +
+

No transformation history yet

+

+ Start transforming text to see your history here +

+
+ )} +
+
+ + ); +} \ No newline at end of file diff --git a/src/components/InfoPage.tsx b/src/components/InfoPage.tsx index 27108ec..3bb8793 100644 --- a/src/components/InfoPage.tsx +++ b/src/components/InfoPage.tsx @@ -1,11 +1,8 @@ import { invoke } from "@tauri-apps/api/core"; import { useEffect, useState } from "react"; -import { ShortcutItem } from "./ShortcutItem"; -import { useShortcutEditor } from "../hooks/useShortcutEditor"; -import { backendFormatToShortcut, shortcutToBackendFormat, Shortcut } from "../utils/keyboardUtils"; +import { backendFormatToShortcut, Shortcut } from "../utils/keyboardUtils"; import miloLogo from "../assets/icon.png"; - export function InfoPage() { const [shortcut, setShortcut] = useState([]); const [shortcutEnabled, setShortcutEnabled] = useState(true); @@ -35,84 +32,17 @@ export function InfoPage() { loadSettings(); }, []); - const handleShortcutToggle = async (enabled: boolean) => { - try { - // Get current settings first - const currentSettings = await invoke("get_settings"); - - // Merge with new shortcut setting - const updatedSettings = { - ...currentSettings, - shortcut_enabled: enabled - }; - - // Save merged settings - await invoke("save_settings", { - settings: updatedSettings - }); - - setShortcutEnabled(enabled); - } catch (error) { - console.error("Failed to save settings:", error); - } - }; - - const changeShortcut = async (newShortcut: Shortcut) => { - console.log("🔄 Frontend: Changing shortcut to:", newShortcut); - setShortcut(newShortcut); - try { - const backendFormat = shortcutToBackendFormat(newShortcut); - console.log("📤 Frontend: Sending to backend:", backendFormat); - await invoke("update_shortcut", { shortcutKeys: backendFormat }); - console.log("✅ Frontend: Shortcut update successful"); - } catch (error) { - console.error("❌ Frontend: Failed to update shortcut:", error); - } - }; - - const { - isEnabled, - isModalOpen, - currentKeys, - toggleEnabled, - openEditModal, - closeEditModal, - saveShortcut - } = useShortcutEditor(shortcutEnabled, changeShortcut, setShortcutEnabled); - - const onEditShortcut = async () => { - console.log("🔄 Frontend: Starting shortcut edit"); - openEditModal(); - try { - await invoke("unregister_shortcut"); - console.log("✅ Frontend: Shortcut unregistered for editing"); - } catch (error) { - console.error("❌ Frontend: Failed to unregister shortcut:", error); - } - }; - - const onCancelShortcut = async () => { - console.log("🔄 Frontend: Canceling shortcut edit"); - closeEditModal(); - try { - const backendFormat = shortcutToBackendFormat(shortcut); - console.log("📤 Frontend: Restoring shortcut:", backendFormat); - await invoke("update_shortcut", { shortcutKeys: backendFormat }); - console.log("✅ Frontend: Shortcut restored"); - } catch (error) { - console.error("❌ Frontend: Failed to restore shortcut:", error); - } - }; - - const onSaveShortcut = async () => { - console.log("🔄 Frontend: Saving shortcut"); - saveShortcut(); - }; - - const onToggleEnabled = async () => { - const newEnabled = !isEnabled; - await handleShortcutToggle(newEnabled); - toggleEnabled(); + const renderShortcutKeys = (keys: Shortcut) => { + if (keys.length === 0) return "No shortcut set"; + + return keys.map((key, index) => ( + + + {key} + + {index < keys.length - 1 && +} + + )); }; return ( @@ -122,22 +52,26 @@ export function InfoPage() {

- Milo helps you improve your writing by transforming text in your clipboard. + Milo helps you improve your writing by transforming text in your clipboard. Simply copy any text and use the shortcut to transform it!

{shortcut.length > 0 && (
- +
+

Current Transform Shortcut:

+
+ {renderShortcutKeys(shortcut)} +
+

+ Status: + {shortcutEnabled ? "Enabled" : "Disabled"} + +

+

+ Configure shortcuts in Settings +

+
)}
diff --git a/src/components/PromptSettings.tsx b/src/components/PromptSettings.tsx index 8746818..b441c92 100644 --- a/src/components/PromptSettings.tsx +++ b/src/components/PromptSettings.tsx @@ -78,21 +78,18 @@ export function PromptSettings({ settings, setSettings }: PromptSettingsProps) { }; return ( -
+
{/* Dynamic header based on current view */} -
-
- - - -

+
+
+

{isFormOpen ? (editingTone ? `Edit ${editingTone.name}` : 'Add New Tone') : 'Custom Prompts' } -

+

-

+

{isFormOpen ? (editingTone ? 'Modify the prompt description for this tone' @@ -102,115 +99,118 @@ export function PromptSettings({ settings, setSettings }: PromptSettingsProps) {

- {isFormOpen ? ( -
- { - setIsFormOpen(false); - setEditingTone(null); - }} - /> -
- ) : ( -
- {/* Always show Improve Writing first */} - {settings.custom_prompts["Improve Writing"] && ( -
handleSelectTone("Improve Writing")} - > -
- Improve Writing -
- {settings.selected_tone === "Improve Writing" && ( - - - - )} -
-
-
{settings.custom_prompts["Improve Writing"]}
-
- )} - - {/* Show all other tones */} - {Object.entries(settings.custom_prompts) - .filter(([name]) => name !== "Improve Writing") - .map(([name, prompt]) => ( + {/* Content Section */} +
+ {isFormOpen ? ( +
+ { + setIsFormOpen(false); + setEditingTone(null); + }} + /> +
+ ) : ( +
+ {/* Always show Improve Writing first */} + {settings.custom_prompts["Improve Writing"] && (
handleSelectTone(name)} + onClick={() => handleSelectTone("Improve Writing")} >
- {name} + Improve Writing
- {settings.selected_tone === name && ( + {settings.selected_tone === "Improve Writing" && ( )} - -
{prompt}
+ settings.selected_tone === "Improve Writing" ? 'text-blue-600' : 'text-slate-600' + }`}>{settings.custom_prompts["Improve Writing"]}
- ))} -
- )} - - {!isFormOpen && ( - - )} + )} + + {/* Show all other tones */} + {Object.entries(settings.custom_prompts) + .filter(([name]) => name !== "Improve Writing") + .map(([name, prompt]) => ( +
handleSelectTone(name)} + > +
+ {name} +
+ {settings.selected_tone === name && ( + + + + )} + + +
+
+
{prompt}
+
+ ))} +
+ )} + + {!isFormOpen && ( + + )} +
); } diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index a858f6f..ceea62c 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -6,40 +6,73 @@ interface SidebarProps { export function Sidebar({ activeSection, onSectionChange }: SidebarProps) { return ( ); diff --git a/src/main.css b/src/main.css index a96f88b..28d6b9e 100644 --- a/src/main.css +++ b/src/main.css @@ -11,5 +11,18 @@ background-color: #F5F5F7; color: #1C1C1E; -webkit-font-smoothing: antialiased; + font-weight: 400; + } + + h1, h2, h3, h4, h5, h6 { + font-weight: 400; + } + + button { + font-weight: 400; + } + + label { + font-weight: 400; } } \ No newline at end of file