diff --git a/bun.lock b/bun.lock index 98df3f2..37220be 100644 --- a/bun.lock +++ b/bun.lock @@ -38,8 +38,10 @@ "devDependencies": { "@capacitor/assets": "^3.0.5", "@eslint/js": "^9.39.1", + "@happy-dom/global-registrator": "^20.9.0", "@napi-rs/canvas": "^0.1.90", "@tailwindcss/vite": "^4.1.18", + "@testing-library/react": "^16.3.2", "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", @@ -96,6 +98,8 @@ "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], + "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], "@babel/traverse": ["@babel/traverse@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/types": "^7.28.6", "debug": "^4.3.1" } }, "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg=="], @@ -206,6 +210,8 @@ "@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="], + "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.9.0", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.9.0" } }, "sha512-lBW6/m5BIFl3pMuWPNN0lIOYw9LMCmPfix53ExS3FBi4E+NELEljQ3xH6aAV9IYiQRfn9YIIgzzMrD0vIcD7tw=="], + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], @@ -514,6 +520,10 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="], + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], + + "@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="], + "@trapezedev/gradle-parse": ["@trapezedev/gradle-parse@7.1.3", "", {}, "sha512-WQVF5pEJ5o/mUyvfGTG9nBKx9Te/ilKM3r2IT69GlbaooItT5ao7RyF1MUTBNjHLPk/xpGUY3c6PyVnjDlz0Vw=="], "@trapezedev/project": ["@trapezedev/project@7.1.3", "", { "dependencies": { "@ionic/utils-fs": "^3.1.5", "@ionic/utils-subprocess": "^2.1.8", "@prettier/plugin-xml": "^2.2.0", "@trapezedev/gradle-parse": "7.1.3", "@xmldom/xmldom": "^0.7.5", "conventional-changelog": "^3.1.4", "cross-spawn": "^7.0.3", "diff": "^5.1.0", "env-paths": "^3.0.0", "gradle-to-js": "^2.0.0", "ini": "^2.0.0", "kleur": "^4.1.5", "lodash": "^4.17.21", "mergexml": "^1.2.3", "plist": "^3.0.4", "prettier": "^2.7.1", "prompts": "^2.4.2", "replace": "^1.1.0", "tempy": "^1.0.1", "tmp": "^0.2.1", "ts-node": "^10.2.1", "xcode": "^3.0.1", "xml-js": "^1.6.11", "xpath": "^0.0.32", "yargs": "^17.2.1" } }, "sha512-GANh8Ey73MechZrryfJoILY9hBnWqzS6AdB53zuWBCBbaiImyblXT41fWdN6pB2f5+cNI2FAUxGfVhl+LeEVbQ=="], @@ -544,6 +554,8 @@ "@turnkey/webauthn-stamper": ["@turnkey/webauthn-stamper@0.6.0", "", { "dependencies": { "sha256-uint8array": "^0.10.7" } }, "sha512-jdN17QEnn7RBykEOhtKIialWmDjnDAH8DzbyITwn8jsKcwT1TBNYge89hTUTjbdsDLBAqQw8cHujPdy0RaAqvw=="], + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], @@ -574,6 +586,10 @@ "@types/web-push": ["@types/web-push@3.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ=="], + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.53.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.53.1", "@typescript-eslint/type-utils": "8.53.1", "@typescript-eslint/utils": "8.53.1", "@typescript-eslint/visitor-keys": "8.53.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.53.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-cFYYFZ+oQFi6hUnBTbLRXfTJiaQtYE3t4O692agbBl+2Zy+eqSKWtPjhPXJu1G7j4RLjKgeJPDdq3EqOwmX5Ag=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.53.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.53.1", "@typescript-eslint/types": "8.53.1", "@typescript-eslint/typescript-estree": "8.53.1", "@typescript-eslint/visitor-keys": "8.53.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg=="], @@ -682,6 +698,8 @@ "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + "array-ify": ["array-ify@1.0.0", "", {}, "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng=="], "array-union": ["array-union@2.1.0", "", {}, "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw=="], @@ -950,6 +968,8 @@ "del": ["del@6.1.1", "", { "dependencies": { "globby": "^11.0.1", "graceful-fs": "^4.2.4", "is-glob": "^4.0.1", "is-path-cwd": "^2.2.0", "is-path-inside": "^3.0.2", "p-map": "^4.0.0", "rimraf": "^3.0.2", "slash": "^3.0.0" } }, "sha512-ua8BhapfP0JUJKC/zV9yHHDW/rDoDxP4Zhn3AkA6/xT6gY7jYXJiaeyBZznYVujhZZET+UgcbZiQ7sN3WqcImg=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + "des.js": ["des.js@1.1.0", "", { "dependencies": { "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0" } }, "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg=="], "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], @@ -968,6 +988,8 @@ "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], + "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + "dom-serializer": ["dom-serializer@1.4.1", "", { "dependencies": { "domelementtype": "^2.0.1", "domhandler": "^4.2.0", "entities": "^2.0.0" } }, "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag=="], "domain-browser": ["domain-browser@4.22.0", "", {}, "sha512-IGBwjF7tNk3cwypFNH/7bfzBcgSCbaMOD3GsaY1AU/JRrnHnYgEM0+9kQt52iZxjNsjBtJYtao146V+f8jFZNw=="], @@ -1004,7 +1026,7 @@ "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], - "entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], @@ -1154,6 +1176,8 @@ "handlebars": ["handlebars@4.7.9", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ=="], + "happy-dom": ["happy-dom@20.9.0", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ=="], + "hard-rejection": ["hard-rejection@2.1.0", "", {}, "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], @@ -1368,6 +1392,8 @@ "lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "make-error": ["make-error@1.3.6", "", {}, "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw=="], @@ -1564,6 +1590,8 @@ "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], + "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], @@ -1618,6 +1646,8 @@ "react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="], + "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], "react-router": ["react-router@7.12.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw=="], @@ -1898,6 +1928,8 @@ "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -1916,7 +1948,7 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "ws": ["ws@8.17.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="], + "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], "xcode": ["xcode@3.0.1", "", { "dependencies": { "simple-plist": "^1.1.0", "uuid": "^7.0.3" } }, "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA=="], @@ -2172,6 +2204,8 @@ "dir-glob/path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], + "dom-serializer/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], + "elementtree/sax": ["sax@1.1.4", "", {}, "sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg=="], "error-ex/is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], @@ -2186,6 +2220,8 @@ "ethers/tslib": ["tslib@2.7.0", "", {}, "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA=="], + "ethers/ws": ["ws@8.17.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="], + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], @@ -2250,6 +2286,8 @@ "prebuild-install/tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], + "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], "pvtsutils/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -2300,8 +2338,6 @@ "viem/@scure/bip32": ["@scure/bip32@1.7.0", "", { "dependencies": { "@noble/curves": "~1.9.0", "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw=="], - "viem/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], - "widest-line/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], "xcode/uuid": ["uuid@7.0.3", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg=="], diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..37ff1f9 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[test] +preload = ["./src/__tests__/setup.ts"] diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index e7ff918..03b42d3 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -46,6 +46,7 @@ import type * as lists from "../lists.js"; import type * as listsHttp from "../listsHttp.js"; import type * as notificationActions from "../notificationActions.js"; import type * as notifications from "../notifications.js"; +import type * as originals from "../originals.js"; import type * as presence from "../presence.js"; import type * as presenceHttp from "../presenceHttp.js"; import type * as publication from "../publication.js"; @@ -107,6 +108,7 @@ declare const fullApi: ApiFromModules<{ listsHttp: typeof listsHttp; notificationActions: typeof notificationActions; notifications: typeof notifications; + originals: typeof originals; presence: typeof presence; presenceHttp: typeof presenceHttp; publication: typeof publication; diff --git a/convex/originals.ts b/convex/originals.ts new file mode 100644 index 0000000..65feabf --- /dev/null +++ b/convex/originals.ts @@ -0,0 +1,108 @@ +/** + * Originals Explorer — unified read-only query. + * + * Joins lists, sites, siteHostnames, publications, bitcoinAnchors, activities, + * itemAssignees into a single ExplorerRow[] sorted by updatedAt desc with + * id-ascending tiebreaker. Pure derivation logic lives in src/lib/explorer.ts. + */ + +import { v } from "convex/values"; +import { query } from "./_generated/server"; +import type { Doc } from "./_generated/dataModel"; +import { + deriveExplorerRows, + type DeriveInput, + type ExplorerRow, +} from "../src/lib/explorer"; + +export const listOwnedOriginals = query({ + args: { ownerDid: v.string() }, + handler: async (ctx, args): Promise => { + const [lists, sites] = await Promise.all([ + ctx.db + .query("lists") + .withIndex("by_owner", (q) => q.eq("ownerDid", args.ownerDid)) + .collect(), + ctx.db + .query("sites") + .withIndex("by_owner", (q) => q.eq("ownerDid", args.ownerDid)) + .collect(), + ]); + + // Per-site joins: hostnames. + const hostnamesBySite = new Map[]>(); + await Promise.all( + sites.map(async (site) => { + const hostnames = await ctx.db + .query("siteHostnames") + .withIndex("by_site", (q) => q.eq("siteId", site._id)) + .collect(); + hostnamesBySite.set(site._id, hostnames); + }), + ); + + // Per-list joins: publication, anchors, latest activity, assignee count. + const publicationsByList = new Map>(); + const anchorsByList = new Map[]>(); + const activitiesByList = new Map(); + const assigneesByList = new Map(); + + await Promise.all( + lists.map(async (list) => { + const [pub, anchors, latestActivity, assignees] = await Promise.all([ + ctx.db + .query("publications") + .withIndex("by_list", (q) => q.eq("listId", list._id)) + .first(), + ctx.db + .query("bitcoinAnchors") + .withIndex("by_list", (q) => q.eq("listId", list._id)) + .collect(), + ctx.db + .query("activities") + .withIndex("by_list_created", (q) => q.eq("listId", list._id)) + .order("desc") + .first(), + ctx.db + .query("itemAssignees") + .withIndex("by_list", (q) => q.eq("listId", list._id)) + .collect(), + ]); + + if (pub) publicationsByList.set(list._id, pub); + if (anchors.length > 0) anchorsByList.set(list._id, anchors); + if (latestActivity) activitiesByList.set(list._id, { createdAt: latestActivity.createdAt }); + + const uniqueAssignees = new Set(); + for (const a of assignees) uniqueAssignees.add(a.assigneeDid); + if (uniqueAssignees.size > 0) assigneesByList.set(list._id, uniqueAssignees.size); + }), + ); + + const input: DeriveInput = { + lists: lists.map((l) => ({ + _id: l._id, + _creationTime: l._creationTime, + name: l.name, + ownerDid: l.ownerDid, + assetDid: l.assetDid, + })), + sites: sites.map((s) => ({ + _id: s._id, + _creationTime: s._creationTime, + ownerDid: s.ownerDid, + primaryHostnameId: s.primaryHostnameId, + createdAt: s.createdAt, + updatedAt: s.updatedAt, + did: s.did, + })), + hostnamesBySite, + publicationsByList, + anchorsByList, + activitiesByList, + assigneesByList, + }; + + return deriveExplorerRows(input); + }, +}); diff --git a/package.json b/package.json index a7c03a7..cfaecaa 100644 --- a/package.json +++ b/package.json @@ -55,8 +55,10 @@ "devDependencies": { "@capacitor/assets": "^3.0.5", "@eslint/js": "^9.39.1", + "@happy-dom/global-registrator": "^20.9.0", "@napi-rs/canvas": "^0.1.90", "@tailwindcss/vite": "^4.1.18", + "@testing-library/react": "^16.3.2", "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", diff --git a/scripts/explorer.test.mjs b/scripts/explorer.test.mjs new file mode 100644 index 0000000..5ad91b8 --- /dev/null +++ b/scripts/explorer.test.mjs @@ -0,0 +1,472 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, rm } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; +import { build } from "esbuild"; + +const outdir = "tmp/explorer-test"; + +async function loadExplorer() { + await rm(outdir, { recursive: true, force: true }); + await mkdir(outdir, { recursive: true }); + await build({ + entryPoints: ["src/lib/explorer.ts"], + outfile: `${outdir}/explorer.mjs`, + bundle: true, + platform: "node", + format: "esm", + target: "node20", + external: ["convex/*"], + }); + return import( + `${pathToFileURL(`${process.cwd()}/${outdir}/explorer.mjs`).href}?t=${Date.now()}` + ); +} + +const explorer = await loadExplorer(); + +// Helpers to build raw input shapes ---------------------------------------- + +function makeList(over = {}) { + return { + _id: "list1", + _creationTime: 1000, + assetDid: "did:peer:list1", + name: "Untitled list", + ownerDid: "did:webvh:owner", + ...over, + }; +} + +function makeSite(over = {}) { + return { + _id: "site1", + _creationTime: 500, + ownerDid: "did:webvh:owner", + scid: "scid1", + did: "did:webvh:scid1.boop.ad", + primaryHostnameId: "host1", + fileId: "file1", + createdAt: 500, + updatedAt: 500, + ...over, + }; +} + +function makeHostname(over = {}) { + return { + _id: "host1", + siteId: "site1", + hostname: "brisk-paper-07.boop.ad", + kind: "boop_sub", + status: "active", + isPrimary: true, + createdAt: 500, + updatedAt: 500, + ...over, + }; +} + +// deriveExplorerRow -------------------------------------------------------- + +test("empty input → empty rows", () => { + const rows = explorer.deriveExplorerRows({ + lists: [], + sites: [], + hostnamesBySite: new Map(), + publicationsByList: new Map(), + anchorsByList: new Map(), + activitiesByList: new Map(), + assigneesByList: new Map(), + }); + assert.equal(rows.length, 0); +}); + +test("one list + one site → 2 rows ordered by updatedAt desc", () => { + const rows = explorer.deriveExplorerRows({ + lists: [makeList({ _id: "L", _creationTime: 100, name: "Older list" })], + sites: [makeSite({ _id: "S", _creationTime: 50, updatedAt: 999 })], + hostnamesBySite: new Map([["S", [makeHostname({ siteId: "S" })]]]), + publicationsByList: new Map(), + anchorsByList: new Map(), + activitiesByList: new Map(), + assigneesByList: new Map(), + }); + assert.equal(rows.length, 2); + assert.equal(rows[0].source, "site"); // updatedAt 999 + assert.equal(rows[1].source, "list"); // _creationTime 100 +}); + +test("updatedAt: list with no activities → list._creationTime", () => { + const rows = explorer.deriveExplorerRows({ + lists: [makeList({ _id: "L", _creationTime: 7777 })], + sites: [], + hostnamesBySite: new Map(), + publicationsByList: new Map(), + anchorsByList: new Map(), + activitiesByList: new Map(), + assigneesByList: new Map(), + }); + assert.equal(rows[0].updatedAt, 7777); +}); + +test("updatedAt: list with newer activity wins", () => { + const rows = explorer.deriveExplorerRows({ + lists: [makeList({ _id: "L", _creationTime: 100 })], + sites: [], + hostnamesBySite: new Map(), + publicationsByList: new Map(), + anchorsByList: new Map(), + activitiesByList: new Map([["L", { createdAt: 9999 }]]), + assigneesByList: new Map(), + }); + assert.equal(rows[0].updatedAt, 9999); +}); + +test("site missing primary hostname → title falls back to 'Untitled site'", () => { + const rows = explorer.deriveExplorerRows({ + lists: [], + sites: [makeSite({ _id: "S", primaryHostnameId: undefined })], + hostnamesBySite: new Map(), + publicationsByList: new Map(), + anchorsByList: new Map(), + activitiesByList: new Map(), + assigneesByList: new Map(), + }); + assert.equal(rows[0].title, "Untitled site"); + assert.equal(rows[0].identifier, null); +}); + +// Layer / identifier derivation -------------------------------------------- + +test("published list (active publication) → did:webvh + identifier", () => { + const rows = explorer.deriveExplorerRows({ + lists: [makeList({ _id: "L" })], + sites: [], + hostnamesBySite: new Map(), + publicationsByList: new Map([ + ["L", { status: "active", webvhDid: "did:webvh:list-L" }], + ]), + anchorsByList: new Map(), + activitiesByList: new Map(), + assigneesByList: new Map(), + }); + assert.equal(rows[0].layer, "did:webvh"); + assert.equal(rows[0].identifier, "did:webvh:list-L"); +}); + +test("list with no publication → did:peer + null identifier", () => { + const rows = explorer.deriveExplorerRows({ + lists: [makeList({ _id: "L" })], + sites: [], + hostnamesBySite: new Map(), + publicationsByList: new Map(), + anchorsByList: new Map(), + activitiesByList: new Map(), + assigneesByList: new Map(), + }); + assert.equal(rows[0].layer, "did:peer"); + assert.equal(rows[0].identifier, null); +}); + +test("list with unpublished publication → treated as did:peer", () => { + const rows = explorer.deriveExplorerRows({ + lists: [makeList({ _id: "L" })], + sites: [], + hostnamesBySite: new Map(), + publicationsByList: new Map([ + ["L", { status: "unpublished", webvhDid: "did:webvh:list-L" }], + ]), + anchorsByList: new Map(), + activitiesByList: new Map(), + assigneesByList: new Map(), + }); + assert.equal(rows[0].layer, "did:peer"); + assert.equal(rows[0].identifier, null); +}); + +// Verification derivation -------------------------------------------------- + +test("confirmed bitcoin anchor → verification 'anchored'", () => { + const rows = explorer.deriveExplorerRows({ + lists: [makeList({ _id: "L" })], + sites: [], + hostnamesBySite: new Map(), + publicationsByList: new Map(), + anchorsByList: new Map([ + ["L", [{ status: "confirmed", confirmedAt: 100, txid: "tx1" }]], + ]), + activitiesByList: new Map(), + assigneesByList: new Map(), + }); + assert.equal(rows[0].verification, "anchored"); + assert.equal(rows[0].anchorTxId, "tx1"); +}); + +test("multiple confirmed anchors → most recent confirmedAt wins", () => { + const rows = explorer.deriveExplorerRows({ + lists: [makeList({ _id: "L" })], + sites: [], + hostnamesBySite: new Map(), + publicationsByList: new Map(), + anchorsByList: new Map([ + ["L", [ + { status: "confirmed", confirmedAt: 100, txid: "older" }, + { status: "confirmed", confirmedAt: 500, txid: "newer" }, + ]], + ]), + activitiesByList: new Map(), + assigneesByList: new Map(), + }); + assert.equal(rows[0].anchorTxId, "newer"); +}); + +test("pending bitcoin anchor → verification 'pending'", () => { + const rows = explorer.deriveExplorerRows({ + lists: [makeList({ _id: "L" })], + sites: [], + hostnamesBySite: new Map(), + publicationsByList: new Map(), + anchorsByList: new Map([["L", [{ status: "pending" }]]]), + activitiesByList: new Map(), + assigneesByList: new Map(), + }); + assert.equal(rows[0].verification, "pending"); +}); + +test("publications.anchorTxId is ignored (only bitcoinAnchors counts)", () => { + const rows = explorer.deriveExplorerRows({ + lists: [makeList({ _id: "L" })], + sites: [], + hostnamesBySite: new Map(), + publicationsByList: new Map([ + ["L", { status: "active", webvhDid: "did:webvh:L", anchorTxId: "ghost", anchorStatus: "verified" }], + ]), + anchorsByList: new Map(), + activitiesByList: new Map(), + assigneesByList: new Map(), + }); + assert.equal(rows[0].verification, "none"); + assert.equal(rows[0].anchorTxId, undefined); +}); + +test("site primary custom + active → 'verified'", () => { + const rows = explorer.deriveExplorerRows({ + lists: [], + sites: [makeSite({ _id: "S", primaryHostnameId: "host1" })], + hostnamesBySite: new Map([ + ["S", [makeHostname({ _id: "host1", kind: "custom", cfStatus: "active", isPrimary: true })]], + ]), + publicationsByList: new Map(), + anchorsByList: new Map(), + activitiesByList: new Map(), + assigneesByList: new Map(), + }); + assert.equal(rows[0].verification, "verified"); +}); + +test("site primary boop_sub + non-primary custom pending → 'pending'", () => { + const rows = explorer.deriveExplorerRows({ + lists: [], + sites: [makeSite({ _id: "S", primaryHostnameId: "host1" })], + hostnamesBySite: new Map([ + ["S", [ + makeHostname({ _id: "host1", kind: "boop_sub", cfStatus: undefined, isPrimary: true }), + makeHostname({ _id: "host2", kind: "custom", cfStatus: "pending_validation", isPrimary: false }), + ]], + ]), + publicationsByList: new Map(), + anchorsByList: new Map(), + activitiesByList: new Map(), + assigneesByList: new Map(), + }); + assert.equal(rows[0].verification, "pending"); +}); + +test("site primary custom but cfStatus !== active → 'pending'", () => { + const rows = explorer.deriveExplorerRows({ + lists: [], + sites: [makeSite({ _id: "S", primaryHostnameId: "host1" })], + hostnamesBySite: new Map([ + ["S", [makeHostname({ _id: "host1", kind: "custom", cfStatus: "pending_issuance", isPrimary: true })]], + ]), + publicationsByList: new Map(), + anchorsByList: new Map(), + activitiesByList: new Map(), + assigneesByList: new Map(), + }); + assert.equal(rows[0].verification, "pending"); +}); + +test("site only boop_sub primary, no custom rows → 'none'", () => { + const rows = explorer.deriveExplorerRows({ + lists: [], + sites: [makeSite({ _id: "S" })], + hostnamesBySite: new Map([["S", [makeHostname()]]]), + publicationsByList: new Map(), + anchorsByList: new Map(), + activitiesByList: new Map(), + assigneesByList: new Map(), + }); + assert.equal(rows[0].verification, "none"); +}); + +test("site with primaryHostnameId === null and no custom rows → 'none' (no crash)", () => { + const rows = explorer.deriveExplorerRows({ + lists: [], + sites: [makeSite({ _id: "S", primaryHostnameId: undefined })], + hostnamesBySite: new Map(), + publicationsByList: new Map(), + anchorsByList: new Map(), + activitiesByList: new Map(), + assigneesByList: new Map(), + }); + assert.equal(rows[0].verification, "none"); +}); + +test("verification precedence: anchored > verified > pending > none", () => { + // Construct a site with both 'verified' and 'pending' signals to exercise the precedence path. + const rows = explorer.deriveExplorerRows({ + lists: [], + sites: [makeSite({ _id: "S", primaryHostnameId: "host1" })], + hostnamesBySite: new Map([ + ["S", [ + makeHostname({ _id: "host1", kind: "custom", cfStatus: "active", isPrimary: true }), + makeHostname({ _id: "host2", kind: "custom", cfStatus: "pending_validation", isPrimary: false }), + ]], + ]), + publicationsByList: new Map(), + anchorsByList: new Map(), + activitiesByList: new Map(), + assigneesByList: new Map(), + }); + assert.equal(rows[0].verification, "verified"); +}); + +// applyExplorerFilters ----------------------------------------------------- + +function rowsForFilterTests() { + return explorer.deriveExplorerRows({ + lists: [makeList({ _id: "L", name: "Groceries" })], + sites: [makeSite({ _id: "S", primaryHostnameId: "host1" })], + hostnamesBySite: new Map([ + ["S", [makeHostname({ _id: "host1", hostname: "essay.brian.dev", kind: "custom", cfStatus: "active", isPrimary: true })]], + ]), + publicationsByList: new Map(), + anchorsByList: new Map(), + activitiesByList: new Map(), + assigneesByList: new Map(), + }); +} + +test("filter by kind list-only → excludes sites", () => { + const filtered = explorer.applyExplorerFilters(rowsForFilterTests(), { + kind: ["list"], layer: [], verify: [], q: "", + }); + assert.equal(filtered.length, 1); + assert.equal(filtered[0].source, "list"); +}); + +test("filter by layer did:webvh → excludes did:peer", () => { + const filtered = explorer.applyExplorerFilters(rowsForFilterTests(), { + kind: [], layer: ["did:webvh"], verify: [], q: "", + }); + assert.equal(filtered.length, 1); + assert.equal(filtered[0].source, "site"); +}); + +test("filter by verification 'anchored' → only anchored rows pass", () => { + const rows = explorer.deriveExplorerRows({ + lists: [ + makeList({ _id: "L1", name: "Anchored list" }), + makeList({ _id: "L2", name: "Plain list" }), + ], + sites: [], + hostnamesBySite: new Map(), + publicationsByList: new Map(), + anchorsByList: new Map([["L1", [{ status: "confirmed", confirmedAt: 1, txid: "tx" }]]]), + activitiesByList: new Map(), + assigneesByList: new Map(), + }); + const filtered = explorer.applyExplorerFilters(rows, { + kind: [], layer: [], verify: ["anchored"], q: "", + }); + assert.equal(filtered.length, 1); + assert.equal(filtered[0].id, "list:L1"); +}); + +test("empty multi-select group → no filter applied (all pass)", () => { + const filtered = explorer.applyExplorerFilters(rowsForFilterTests(), { + kind: [], layer: [], verify: [], q: "", + }); + assert.equal(filtered.length, 2); +}); + +test("case-insensitive title search; matches identifier too", () => { + const rows = rowsForFilterTests(); + const byTitle = explorer.applyExplorerFilters(rows, { + kind: [], layer: [], verify: [], q: "GROC", + }); + assert.equal(byTitle.length, 1); + assert.equal(byTitle[0].source, "list"); + + const byIdentifier = explorer.applyExplorerFilters(rows, { + kind: [], layer: [], verify: [], q: "essay", + }); + assert.equal(byIdentifier.length, 1); + assert.equal(byIdentifier[0].source, "site"); +}); + +// compareExplorerRows ------------------------------------------------------ + +test("sort: updatedAt desc primary, id asc tiebreaker", () => { + const a = { id: "list:b", updatedAt: 100, createdAt: 1, title: "x" }; + const b = { id: "list:a", updatedAt: 100, createdAt: 1, title: "x" }; + const sorted = [a, b].sort(explorer.compareExplorerRows({ key: "updated", dir: "desc" })); + assert.equal(sorted[0].id, "list:a"); // tiebreaker: id ascending + assert.equal(sorted[1].id, "list:b"); +}); + +test("sort: title A-Z stable with id tiebreaker", () => { + const a = { id: "list:b", updatedAt: 1, createdAt: 1, title: "Apple" }; + const b = { id: "list:a", updatedAt: 1, createdAt: 1, title: "Apple" }; + const sorted = [a, b].sort(explorer.compareExplorerRows({ key: "title", dir: "asc" })); + assert.equal(sorted[0].id, "list:a"); +}); + +// URL param encode/decode -------------------------------------------------- + +test("encodeFiltersToParams: csv values for multi-select", () => { + const params = explorer.encodeFiltersToParams({ + kind: ["list", "site"], + layer: ["did:webvh"], + verify: [], + q: "foo", + }); + assert.equal(params.get("kind"), "list,site"); + assert.equal(params.get("layer"), "did:webvh"); + assert.equal(params.get("verify"), null); // empty arrays are omitted + assert.equal(params.get("q"), "foo"); +}); + +test("decodeFiltersFromParams: csv → arrays; empty string q omitted", () => { + const decoded = explorer.decodeFiltersFromParams( + new URLSearchParams("kind=list,site&q=foo") + ); + assert.deepEqual(decoded.kind, ["list", "site"]); + assert.deepEqual(decoded.layer, []); + assert.deepEqual(decoded.verify, []); + assert.equal(decoded.q, "foo"); +}); + +test("URL round-trip preserves commas in q via encoding", () => { + const original = { kind: [], layer: [], verify: [], q: "hello, world" }; + const params = explorer.encodeFiltersToParams(original); + const url = `?${params.toString()}`; + // Build new URLSearchParams from the URL string + const parsed = new URLSearchParams(url.slice(1)); + const decoded = explorer.decodeFiltersFromParams(parsed); + assert.equal(decoded.q, "hello, world"); +}); + +await rm(outdir, { recursive: true, force: true }); diff --git a/scripts/originals-query.test.mjs b/scripts/originals-query.test.mjs new file mode 100644 index 0000000..e173ced --- /dev/null +++ b/scripts/originals-query.test.mjs @@ -0,0 +1,172 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, rm } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; +import { build } from "esbuild"; + +const outdir = "tmp/originals-query-test"; + +async function loadModule() { + await rm(outdir, { recursive: true, force: true }); + await mkdir(outdir, { recursive: true }); + await build({ + entryPoints: ["./convex/originals.ts"], + outfile: `${outdir}/originals.mjs`, + bundle: true, + platform: "node", + format: "esm", + target: "node20", + external: ["convex/*"], + }); + return import( + `${pathToFileURL(`${process.cwd()}/${outdir}/originals.mjs`).href}?t=${Date.now()}` + ); +} + +const mod = await loadModule(); +const handler = mod.listOwnedOriginals._handler ?? mod.listOwnedOriginals.handler; + +// In-memory ctx.db mock matching Convex's surface. +function makeDb(tables) { + const all = new Map(); + for (const [name, rows] of Object.entries(tables)) { + for (const row of rows) all.set(row._id, row); + } + return { + query(tableName) { + return makeQuery(tables[tableName] ?? []); + }, + async get(id) { + return all.get(id) ?? null; + }, + }; +} + +function makeQuery(rows) { + let working = rows.slice(); + let order = "asc"; + return { + withIndex(_name, fn) { + const ops = []; + const builder = { + eq(field, value) { ops.push(["eq", field, value]); return builder; }, + }; + fn(builder); + working = working.filter((r) => + ops.every(([op, field, value]) => op === "eq" && r[field] === value) + ); + return this; + }, + order(dir) { order = dir; return this; }, + async collect() { + const sorted = working.slice().sort((a, b) => a._creationTime - b._creationTime); + return order === "desc" ? sorted.reverse() : sorted; + }, + async first() { + const all = await this.collect(); + return all[0] ?? null; + }, + }; +} + +test("ownerDid filter applied", async () => { + const ctx = { + db: makeDb({ + lists: [ + { _id: "L1", _creationTime: 1, name: "Mine", ownerDid: "me", assetDid: "did:peer:L1" }, + { _id: "L2", _creationTime: 1, name: "Theirs", ownerDid: "other", assetDid: "did:peer:L2" }, + ], + sites: [], + siteHostnames: [], + publications: [], + bitcoinAnchors: [], + activities: [], + itemAssignees: [], + }), + }; + const rows = await handler(ctx, { ownerDid: "me" }); + assert.equal(rows.length, 1); + assert.equal(rows[0].title, "Mine"); +}); + +test("joins all source tables and produces rows in updatedAt desc order", async () => { + const ctx = { + db: makeDb({ + lists: [{ _id: "L1", _creationTime: 100, name: "A list", ownerDid: "me", assetDid: "did:peer:L1" }], + sites: [{ _id: "S1", _creationTime: 50, ownerDid: "me", scid: "scid", did: "did:webvh:s1", primaryHostnameId: "H1", fileId: "F1", createdAt: 50, updatedAt: 9999 }], + siteHostnames: [{ _id: "H1", siteId: "S1", hostname: "brisk-paper-07.boop.ad", kind: "boop_sub", status: "active", isPrimary: true, createdAt: 50, updatedAt: 50 }], + publications: [{ _id: "P1", listId: "L1", webvhDid: "did:webvh:L1", status: "active", publishedAt: 200, publishedByDid: "me" }], + bitcoinAnchors: [{ _id: "A1", listId: "L1", status: "confirmed", confirmedAt: 300, txid: "tx-abc", contentHash: "h", requestedByDid: "me", createdAt: 300 }], + activities: [{ _id: "ACT1", listId: "L1", actorDid: "me", type: "list_updated", createdAt: 500 }], + itemAssignees: [ + { _id: "IA1", itemId: "I1", listId: "L1", assigneeDid: "u1", assignedByDid: "me", assignedAt: 1 }, + { _id: "IA2", itemId: "I2", listId: "L1", assigneeDid: "u1", assignedByDid: "me", assignedAt: 1 }, + { _id: "IA3", itemId: "I3", listId: "L1", assigneeDid: "u2", assignedByDid: "me", assignedAt: 1 }, + ], + }), + }; + const rows = await handler(ctx, { ownerDid: "me" }); + assert.equal(rows.length, 2); + assert.equal(rows[0].source, "site"); + assert.equal(rows[1].source, "list"); + assert.equal(rows[1].layer, "did:webvh"); + assert.equal(rows[1].verification, "anchored"); + assert.equal(rows[1].anchorTxId, "tx-abc"); + assert.equal(rows[1].collaborators, 2); + assert.equal(rows[1].updatedAt, 500); +}); + +test("missing optional joins do not crash", async () => { + const ctx = { + db: makeDb({ + lists: [{ _id: "L1", _creationTime: 100, name: "Bare", ownerDid: "me", assetDid: "did:peer:L1" }], + sites: [], + siteHostnames: [], + publications: [], + bitcoinAnchors: [], + activities: [], + itemAssignees: [], + }), + }; + const rows = await handler(ctx, { ownerDid: "me" }); + assert.equal(rows[0].layer, "did:peer"); + assert.equal(rows[0].verification, "none"); + assert.equal(rows[0].collaborators, undefined); +}); + +test("a site never reports anchored", async () => { + const ctx = { + db: makeDb({ + lists: [], + sites: [{ _id: "S1", _creationTime: 50, ownerDid: "me", scid: "scid", did: "did:webvh:s1", primaryHostnameId: "H1", fileId: "F1", createdAt: 50, updatedAt: 50 }], + siteHostnames: [{ _id: "H1", siteId: "S1", hostname: "brisk-paper-07.boop.ad", kind: "boop_sub", status: "active", isPrimary: true, createdAt: 50, updatedAt: 50 }], + publications: [], + bitcoinAnchors: [], + activities: [], + itemAssignees: [], + }), + }; + const rows = await handler(ctx, { ownerDid: "me" }); + assert.notEqual(rows[0].verification, "anchored"); +}); + +test("multiple confirmed anchors → most recent confirmedAt wins", async () => { + const ctx = { + db: makeDb({ + lists: [{ _id: "L1", _creationTime: 100, name: "Anchored", ownerDid: "me", assetDid: "did:peer:L1" }], + sites: [], + siteHostnames: [], + publications: [], + bitcoinAnchors: [ + { _id: "A1", listId: "L1", status: "confirmed", confirmedAt: 100, txid: "older", contentHash: "h", requestedByDid: "me", createdAt: 1 }, + { _id: "A2", listId: "L1", status: "confirmed", confirmedAt: 500, txid: "newer", contentHash: "h", requestedByDid: "me", createdAt: 2 }, + ], + activities: [], + itemAssignees: [], + }), + }; + const rows = await handler(ctx, { ownerDid: "me" }); + assert.equal(rows[0].anchorTxId, "newer"); +}); + +await rm(outdir, { recursive: true, force: true }); diff --git a/src/App.tsx b/src/App.tsx index b278fd8..90402a2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -28,6 +28,7 @@ const JoinList = lazy(() => import('./pages/JoinList').then(m => ({ default: m.J const PublicList = lazy(() => import('./pages/PublicList').then(m => ({ default: m.PublicList }))) const SharedListResource = lazy(() => import('./components/SharedListResource').then(m => ({ default: m.SharedListResource }))) const Profile = lazy(() => import('./pages/Profile').then(m => ({ default: m.Profile }))) +const Explorer = lazy(() => import('./pages/Explorer').then(m => ({ default: m.Explorer }))) const Templates = lazy(() => import('./pages/Templates').then(m => ({ default: m.Templates }))) const PriorityFocus = lazy(() => import('./pages/PriorityFocus').then(m => ({ default: m.PriorityFocus }))) const Pricing = lazy(() => import('./pages/Pricing').then(m => ({ default: m.Pricing }))) @@ -70,7 +71,8 @@ function AuthenticatedLayout({ children }: { children: React.ReactNode }) {