diff --git a/bun.lock b/bun.lock index e5a9969..d22af2d 100644 --- a/bun.lock +++ b/bun.lock @@ -29,13 +29,16 @@ "cron-parser": "^5.5.0", "hono": "^4.11.7", "imapflow": "^1.0.170", + "jszip": "^3.10.1", "lowlight": "^3.3.0", "mailparser": "^3.7.2", + "marked": "^17.0.3", "nodemailer": "^6.9.0", "perfect-arrows": "^0.3.7", "perfect-freehand": "^1.2.2", "postgres": "^3.4.5", "sharp": "^0.33.0", + "yaml": "^2.8.2", }, "devDependencies": { "@types/mailparser": "^3.4.0", @@ -600,6 +603,8 @@ "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], + "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], + "crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="], "cron-parser": ["cron-parser@5.5.0", "", { "dependencies": { "luxon": "^3.7.1" } }, "sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww=="], @@ -690,12 +695,18 @@ "imapflow": ["imapflow@1.2.10", "", { "dependencies": { "@zone-eu/mailsplit": "5.4.8", "encoding-japanese": "2.2.0", "iconv-lite": "0.7.2", "libbase64": "1.3.0", "libmime": "5.3.7", "libqp": "2.1.1", "nodemailer": "8.0.1", "pino": "10.3.1", "socks": "2.8.7" } }, "sha512-tqmk0Gj4YBEnGCjjrUYWIf3Z4tzn4iihUcMkBRbafvHF3LqEiYNCSJAAYYbwERFxlikOJ3zzqtEcoxCUTjMv2Q=="], + "immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], "is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="], "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "isows": ["isows@1.0.7", "", { "peerDependencies": { "ws": "*" } }, "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg=="], @@ -706,6 +717,8 @@ "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="], + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], @@ -718,6 +731,8 @@ "libqp": ["libqp@2.1.1", "", {}, "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow=="], + "lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="], + "linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="], "linkifyjs": ["linkifyjs@4.3.2", "", {}, "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA=="], @@ -734,6 +749,8 @@ "markdown-it": ["markdown-it@14.1.1", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA=="], + "marked": ["marked@17.0.3", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-jt1v2ObpyOKR8p4XaUJVk3YWRJ5n+i4+rjQopxvV32rSndTJXvIzuUdWWIy/1pFQMkQmvTXawzDNqOH/CUmx6A=="], + "mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="], "minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], @@ -760,6 +777,8 @@ "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + "parseley": ["parseley@0.12.1", "", { "dependencies": { "leac": "^0.6.0", "peberminta": "^0.9.0" } }, "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], @@ -786,6 +805,8 @@ "postgres": ["postgres@3.4.8", "", {}, "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg=="], + "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + "process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="], "prosemirror-changeset": ["prosemirror-changeset@2.4.0", "", { "dependencies": { "prosemirror-transform": "^1.0.0" } }, "sha512-LvqH2v7Q2SF6yxatuPP2e8vSUKS/L+xAU7dPDC4RMyHMhZoGDfBC74mYuyYF4gLqOEG758wajtyhNnsTkuhvng=="], @@ -832,6 +853,8 @@ "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], + "readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], @@ -844,7 +867,7 @@ "rope-sequence": ["rope-sequence@1.3.4", "", {}, "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ=="], - "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], @@ -854,6 +877,8 @@ "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="], + "sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -880,6 +905,8 @@ "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -904,6 +931,8 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="], "valid-url": ["valid-url@1.0.9", "", {}, "sha512-QQDsV8OnSf5Uc30CKSwG9lnhMPe6exHtTXLRYX8uMwKENy640pU+2BgBL0LRbDh/eYRahNCS7aewCx0wf3NYVA=="], @@ -928,6 +957,8 @@ "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=="], + "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@automerge/automerge/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], @@ -942,6 +973,8 @@ "@encryptid/sdk/@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="], + "ecdsa-sig-formatter/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "ethers/@adraffy/ens-normalize": ["@adraffy/ens-normalize@1.10.1", "", {}, "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw=="], "ethers/@noble/curves": ["@noble/curves@1.2.0", "", { "dependencies": { "@noble/hashes": "1.3.2" } }, "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw=="], @@ -958,6 +991,10 @@ "imapflow/nodemailer": ["nodemailer@8.0.1", "", {}, "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg=="], + "jwa/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "jws/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "mailparser/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], "mailparser/nodemailer": ["nodemailer@7.0.13", "", {}, "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw=="], diff --git a/lib/community-sync.ts b/lib/community-sync.ts index 47b60ef..8e24d40 100644 --- a/lib/community-sync.ts +++ b/lib/community-sync.ts @@ -354,6 +354,11 @@ export class CommunitySync extends EventTarget { case "ping-user": this.dispatchEvent(new CustomEvent("ping-user", { detail: msg })); break; + + case "notification": + // Dispatch to window so the notification bell component picks it up + window.dispatchEvent(new CustomEvent("rspace-notification", { detail: msg })); + break; } } catch (e) { console.error("[CommunitySync] Failed to handle message:", e); diff --git a/modules/rfunds/components/funds-demo.ts b/modules/rflows/components/flows-demo.ts similarity index 100% rename from modules/rfunds/components/funds-demo.ts rename to modules/rflows/components/flows-demo.ts diff --git a/modules/rfunds/components/funds.css b/modules/rflows/components/flows.css similarity index 100% rename from modules/rfunds/components/funds.css rename to modules/rflows/components/flows.css diff --git a/modules/rfunds/components/folk-budget-river.ts b/modules/rflows/components/folk-flow-river.ts similarity index 100% rename from modules/rfunds/components/folk-budget-river.ts rename to modules/rflows/components/folk-flow-river.ts diff --git a/modules/rfunds/components/folk-funds-app.ts b/modules/rflows/components/folk-flows-app.ts similarity index 100% rename from modules/rfunds/components/folk-funds-app.ts rename to modules/rflows/components/folk-flows-app.ts diff --git a/modules/rfunds/db/schema.sql.archived b/modules/rflows/db/schema.sql.archived similarity index 100% rename from modules/rfunds/db/schema.sql.archived rename to modules/rflows/db/schema.sql.archived diff --git a/modules/rfunds/landing.ts b/modules/rflows/landing.ts similarity index 100% rename from modules/rfunds/landing.ts rename to modules/rflows/landing.ts diff --git a/modules/rfunds/lib/map-flow.ts b/modules/rflows/lib/map-flow.ts similarity index 100% rename from modules/rfunds/lib/map-flow.ts rename to modules/rflows/lib/map-flow.ts diff --git a/modules/rfunds/lib/presets.ts b/modules/rflows/lib/presets.ts similarity index 100% rename from modules/rfunds/lib/presets.ts rename to modules/rflows/lib/presets.ts diff --git a/modules/rfunds/lib/simulation.ts b/modules/rflows/lib/simulation.ts similarity index 100% rename from modules/rfunds/lib/simulation.ts rename to modules/rflows/lib/simulation.ts diff --git a/modules/rfunds/lib/types.ts b/modules/rflows/lib/types.ts similarity index 100% rename from modules/rfunds/lib/types.ts rename to modules/rflows/lib/types.ts diff --git a/modules/rfunds/local-first-client.ts b/modules/rflows/local-first-client.ts similarity index 100% rename from modules/rfunds/local-first-client.ts rename to modules/rflows/local-first-client.ts diff --git a/modules/rfunds/mod.ts b/modules/rflows/mod.ts similarity index 100% rename from modules/rfunds/mod.ts rename to modules/rflows/mod.ts diff --git a/modules/rfunds/schemas.ts b/modules/rflows/schemas.ts similarity index 100% rename from modules/rfunds/schemas.ts rename to modules/rflows/schemas.ts diff --git a/modules/rnotes/components/folk-notes-app.ts b/modules/rnotes/components/folk-notes-app.ts index 3170ace..1243697 100644 --- a/modules/rnotes/components/folk-notes-app.ts +++ b/modules/rnotes/components/folk-notes-app.ts @@ -22,6 +22,7 @@ import Typography from '@tiptap/extension-typography'; import Underline from '@tiptap/extension-underline'; import { common, createLowlight } from 'lowlight'; import { createSlashCommandPlugin } from './slash-command'; +import type { ImportExportDialog } from './import-export-dialog'; const lowlight = createLowlight(common); @@ -1174,6 +1175,10 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF

${this.esc(nb.title)}${syncBadge} +
`; return; @@ -1183,6 +1188,10 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF this.navZone.innerHTML = `

Notebooks +
`; @@ -1272,11 +1281,21 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF private attachListeners() { const isDemo = this.space === "demo"; + // Import / Export button + this.shadow.getElementById("btn-import-export")?.addEventListener("click", () => { + this.openImportExportDialog(); + }); + // Create notebook this.shadow.getElementById("create-notebook")?.addEventListener("click", () => { isDemo ? this.demoCreateNotebook() : this.createNotebook(); }); + // Export notebook button (in notebook detail view) + this.shadow.getElementById("btn-export-notebook")?.addEventListener("click", () => { + this.openImportExportDialog('export'); + }); + // Create note this.shadow.getElementById("create-note")?.addEventListener("click", () => { isDemo ? this.demoCreateNote() : this.createNoteViaSync(); @@ -1356,6 +1375,46 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF } } + // ── Import/Export Dialog ── + + private importExportDialog: ImportExportDialog | null = null; + + private openImportExportDialog(tab: 'import' | 'export' = 'import') { + if (!this.importExportDialog) { + // Dynamically import the dialog component + import('./import-export-dialog').then(() => { + this.importExportDialog = document.createElement('import-export-dialog') as unknown as ImportExportDialog; + this.importExportDialog.setAttribute('space', this.space); + this.shadow.appendChild(this.importExportDialog); + + this.importExportDialog.addEventListener('import-complete', () => { + // Refresh notebooks list after import + if (this.space === 'demo') { + this.loadDemoData(); + } else { + this.loadNotebooks(); + } + }); + + this.showDialog(tab); + }); + } else { + this.showDialog(tab); + } + } + + private showDialog(tab: 'import' | 'export') { + if (!this.importExportDialog) return; + + // Gather notebook list for the dialog + const notebooks = this.notebooks.map(nb => ({ + id: nb.id, + title: nb.title, + })); + + this.importExportDialog.open(notebooks, tab); + } + private esc(s: string): string { const d = document.createElement("div"); d.textContent = s || ""; @@ -1372,8 +1431,10 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF .rapp-nav__back { padding: 4px 10px; border-radius: 6px; border: 1px solid var(--rs-border); background: transparent; color: var(--rs-text-secondary); cursor: pointer; font-size: 13px; transition: all 0.15s; } .rapp-nav__back:hover { color: var(--rs-text-primary); border-color: var(--rs-border-strong); } .rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: var(--rs-text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .rapp-nav__btn { padding: 6px 14px; border-radius: 6px; border: none; background: var(--rs-primary); color: #fff; font-weight: 600; cursor: pointer; font-size: 13px; transition: background 0.15s; } + .rapp-nav__btn { padding: 6px 14px; border-radius: 6px; border: none; background: var(--rs-primary); color: #fff; font-weight: 600; cursor: pointer; font-size: 13px; transition: background 0.15s; display: flex; align-items: center; gap: 4px; } .rapp-nav__btn:hover { background: var(--rs-primary-hover); } + .rapp-nav__btn--secondary { background: transparent; border: 1px solid var(--rs-border); color: var(--rs-text-secondary); font-weight: 500; } + .rapp-nav__btn--secondary:hover { border-color: var(--rs-border-strong); color: var(--rs-text-primary); background: transparent; } /* ── Search ── */ .search-bar { diff --git a/modules/rnotes/components/import-export-dialog.ts b/modules/rnotes/components/import-export-dialog.ts new file mode 100644 index 0000000..aacdb9a --- /dev/null +++ b/modules/rnotes/components/import-export-dialog.ts @@ -0,0 +1,701 @@ +/** + * — Modal dialog for importing/exporting notes. + * + * Supports 4 sources: Logseq, Obsidian, Notion, Google Docs. + * File-based (Logseq/Obsidian) use ZIP upload/download. + * API-based (Notion/Google) use OAuth connections. + */ + +interface NotebookOption { + id: string; + title: string; +} + +interface ConnectionStatus { + notion: { connected: boolean; workspaceName?: string }; + google: { connected: boolean; email?: string }; + logseq: { connected: boolean }; + obsidian: { connected: boolean }; +} + +interface RemotePage { + id: string; + title: string; + lastEdited?: string; + lastModified?: string; + icon?: string; +} + +class ImportExportDialog extends HTMLElement { + private shadow!: ShadowRoot; + private space = ''; + private activeTab: 'import' | 'export' = 'import'; + private activeSource: 'obsidian' | 'logseq' | 'notion' | 'google-docs' = 'obsidian'; + private notebooks: NotebookOption[] = []; + private connections: ConnectionStatus = { + notion: { connected: false }, + google: { connected: false }, + logseq: { connected: true }, + obsidian: { connected: true }, + }; + private remotePages: RemotePage[] = []; + private selectedPages = new Set(); + private importing = false; + private exporting = false; + private statusMessage = ''; + private statusType: 'info' | 'success' | 'error' = 'info'; + private selectedFile: File | null = null; + private targetNotebookId = ''; + + constructor() { + super(); + this.shadow = this.attachShadow({ mode: 'open' }); + } + + connectedCallback() { + this.space = this.getAttribute('space') || 'demo'; + this.render(); + this.loadConnections(); + } + + /** Open the dialog. */ + open(notebooks: NotebookOption[], tab: 'import' | 'export' = 'import') { + this.notebooks = notebooks; + this.activeTab = tab; + this.statusMessage = ''; + this.selectedPages.clear(); + this.selectedFile = null; + this.render(); + (this.shadow.querySelector('.dialog-overlay') as HTMLElement)?.classList.add('open'); + } + + /** Close the dialog. */ + close() { + (this.shadow.querySelector('.dialog-overlay') as HTMLElement)?.classList.remove('open'); + } + + private async loadConnections() { + try { + const res = await fetch(`/${this.space}/rnotes/api/connections`); + if (res.ok) { + this.connections = await res.json(); + } + } catch { /* ignore */ } + } + + private async loadRemotePages() { + this.remotePages = []; + this.selectedPages.clear(); + + if (this.activeSource === 'notion') { + try { + const res = await fetch(`/${this.space}/rnotes/api/import/notion/pages`); + if (res.ok) { + const data = await res.json(); + this.remotePages = data.pages || []; + } + } catch { /* ignore */ } + } else if (this.activeSource === 'google-docs') { + try { + const res = await fetch(`/${this.space}/rnotes/api/import/google-docs/list`); + if (res.ok) { + const data = await res.json(); + this.remotePages = data.docs || []; + } + } catch { /* ignore */ } + } + + this.render(); + (this.shadow.querySelector('.dialog-overlay') as HTMLElement)?.classList.add('open'); + } + + private setStatus(msg: string, type: 'info' | 'success' | 'error' = 'info') { + this.statusMessage = msg; + this.statusType = type; + const statusEl = this.shadow.querySelector('.status-message') as HTMLElement; + if (statusEl) { + statusEl.textContent = msg; + statusEl.className = `status-message status-${type}`; + statusEl.style.display = msg ? 'block' : 'none'; + } + } + + private async handleImport() { + this.importing = true; + this.setStatus('Importing...', 'info'); + + try { + if (this.activeSource === 'obsidian' || this.activeSource === 'logseq') { + if (!this.selectedFile) { + this.setStatus('Please select a ZIP file', 'error'); + this.importing = false; + return; + } + + const formData = new FormData(); + formData.append('file', this.selectedFile); + formData.append('source', this.activeSource); + if (this.targetNotebookId) { + formData.append('notebookId', this.targetNotebookId); + } + + const token = localStorage.getItem('encryptid_token') || ''; + const res = await fetch(`/${this.space}/rnotes/api/import/upload`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` }, + body: formData, + }); + + const data = await res.json(); + if (data.ok) { + this.setStatus( + `Imported ${data.imported} notes${data.updated ? `, updated ${data.updated}` : ''}${data.warnings?.length ? ` (${data.warnings.length} warnings)` : ''}`, + 'success' + ); + this.dispatchEvent(new CustomEvent('import-complete', { detail: data })); + } else { + this.setStatus(data.error || 'Import failed', 'error'); + } + } else if (this.activeSource === 'notion') { + if (this.selectedPages.size === 0) { + this.setStatus('Please select at least one page', 'error'); + this.importing = false; + return; + } + + const token = localStorage.getItem('encryptid_token') || ''; + const res = await fetch(`/${this.space}/rnotes/api/import/notion`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + pageIds: Array.from(this.selectedPages), + notebookId: this.targetNotebookId || undefined, + }), + }); + + const data = await res.json(); + if (data.ok) { + this.setStatus(`Imported ${data.imported} notes from Notion`, 'success'); + this.dispatchEvent(new CustomEvent('import-complete', { detail: data })); + } else { + this.setStatus(data.error || 'Notion import failed', 'error'); + } + } else if (this.activeSource === 'google-docs') { + if (this.selectedPages.size === 0) { + this.setStatus('Please select at least one document', 'error'); + this.importing = false; + return; + } + + const token = localStorage.getItem('encryptid_token') || ''; + const res = await fetch(`/${this.space}/rnotes/api/import/google-docs`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + docIds: Array.from(this.selectedPages), + notebookId: this.targetNotebookId || undefined, + }), + }); + + const data = await res.json(); + if (data.ok) { + this.setStatus(`Imported ${data.imported} notes from Google Docs`, 'success'); + this.dispatchEvent(new CustomEvent('import-complete', { detail: data })); + } else { + this.setStatus(data.error || 'Google Docs import failed', 'error'); + } + } + } catch (err) { + this.setStatus(`Import error: ${(err as Error).message}`, 'error'); + } + + this.importing = false; + } + + private async handleExport() { + if (!this.targetNotebookId) { + this.setStatus('Please select a notebook to export', 'error'); + return; + } + + this.exporting = true; + this.setStatus('Exporting...', 'info'); + + try { + if (this.activeSource === 'obsidian' || this.activeSource === 'logseq' || this.activeSource === 'markdown' as any) { + const format = this.activeSource === 'markdown' as any ? 'markdown' : this.activeSource; + const url = `/${this.space}/rnotes/api/export/${format}?notebookId=${encodeURIComponent(this.targetNotebookId)}`; + const res = await fetch(url); + + if (res.ok) { + const blob = await res.blob(); + const disposition = res.headers.get('Content-Disposition') || ''; + const filename = disposition.match(/filename="(.+)"/)?.[1] || `export-${format}.zip`; + + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = filename; + a.click(); + URL.revokeObjectURL(a.href); + + this.setStatus('Download started!', 'success'); + } else { + const data = await res.json(); + this.setStatus(data.error || 'Export failed', 'error'); + } + } else if (this.activeSource === 'notion') { + const token = localStorage.getItem('encryptid_token') || ''; + const res = await fetch(`/${this.space}/rnotes/api/export/notion`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ notebookId: this.targetNotebookId }), + }); + + const data = await res.json(); + if (data.exported) { + this.setStatus(`Exported ${data.exported.length} notes to Notion`, 'success'); + } else { + this.setStatus(data.error || 'Notion export failed', 'error'); + } + } else if (this.activeSource === 'google-docs') { + const token = localStorage.getItem('encryptid_token') || ''; + const res = await fetch(`/${this.space}/rnotes/api/export/google-docs`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ notebookId: this.targetNotebookId }), + }); + + const data = await res.json(); + if (data.exported) { + this.setStatus(`Exported ${data.exported.length} notes to Google Docs`, 'success'); + } else { + this.setStatus(data.error || 'Google Docs export failed', 'error'); + } + } + } catch (err) { + this.setStatus(`Export error: ${(err as Error).message}`, 'error'); + } + + this.exporting = false; + } + + private esc(s: string): string { + const d = document.createElement('div'); + d.textContent = s || ''; + return d.innerHTML; + } + + private render() { + const isApiSource = this.activeSource === 'notion' || this.activeSource === 'google-docs'; + const sourceConnKey = this.activeSource === 'google-docs' ? 'google' : this.activeSource; + const isConnected = (this.connections as any)[sourceConnKey]?.connected || false; + + this.shadow.innerHTML = ` + +

+
+
+

Import / Export

+ +
+ +
+ + +
+ +
+ ${(['obsidian', 'logseq', 'notion', 'google-docs'] as const).map(s => ` + + `).join('')} +
+ +
+ ${this.activeTab === 'import' ? this.renderImportTab(isApiSource, isConnected) : this.renderExportTab(isApiSource, isConnected)} + +
+ ${this.esc(this.statusMessage)} +
+
+
+
`; + + this.attachListeners(); + } + + private renderImportTab(isApiSource: boolean, isConnected: boolean): string { + if (isApiSource && !isConnected) { + return ` +
+

Connect your ${this.sourceName(this.activeSource)} account to import notes.

+ +
`; + } + + if (isApiSource) { + // Show page list for selection + return ` +
+ Select pages to import: + +
+
+ ${this.remotePages.length === 0 + ? '
No pages found. Click Refresh to load.
' + : this.remotePages.map(p => ` + + `).join('')} +
+
+ + +
+ `; + } + + // File-based import (Obsidian/Logseq) + return ` +
+

Upload a ZIP of your ${this.sourceName(this.activeSource)} vault

+ + +

or drag & drop a ZIP file here

+
+
+ + +
+ `; + } + + private renderExportTab(isApiSource: boolean, isConnected: boolean): string { + const formats = this.activeSource === 'notion' || this.activeSource === 'google-docs' + ? '' : ''; + + if (isApiSource && !isConnected) { + return ` +
+

Connect your ${this.sourceName(this.activeSource)} account to export notes.

+ +
`; + } + + return ` +
+ + +
+ `; + } + + private sourceName(s: string): string { + const names: Record = { + obsidian: 'Obsidian', + logseq: 'Logseq', + notion: 'Notion', + 'google-docs': 'Google Docs', + }; + return names[s] || s; + } + + private sourceIcon(s: string): string { + const icons: Record = { + obsidian: '', + logseq: '', + notion: '', + 'google-docs': '', + }; + return icons[s] || ''; + } + + private attachListeners() { + // Close button + this.shadow.getElementById('btn-close')?.addEventListener('click', () => this.close()); + + // Overlay click to close + this.shadow.querySelector('.dialog-overlay')?.addEventListener('click', (e) => { + if ((e.target as HTMLElement).classList.contains('dialog-overlay')) this.close(); + }); + + // Tab switching + this.shadow.querySelectorAll('.tab').forEach(btn => { + btn.addEventListener('click', () => { + this.activeTab = (btn as HTMLElement).dataset.tab as any; + this.render(); + (this.shadow.querySelector('.dialog-overlay') as HTMLElement)?.classList.add('open'); + }); + }); + + // Source switching + this.shadow.querySelectorAll('.source-btn').forEach(btn => { + btn.addEventListener('click', () => { + this.activeSource = (btn as HTMLElement).dataset.source as any; + this.remotePages = []; + this.selectedPages.clear(); + this.selectedFile = null; + this.statusMessage = ''; + this.render(); + (this.shadow.querySelector('.dialog-overlay') as HTMLElement)?.classList.add('open'); + }); + }); + + // File input + const fileInput = this.shadow.getElementById('file-input') as HTMLInputElement; + const chooseBtn = this.shadow.getElementById('btn-choose-file'); + chooseBtn?.addEventListener('click', () => fileInput?.click()); + fileInput?.addEventListener('change', () => { + this.selectedFile = fileInput.files?.[0] || null; + if (chooseBtn) chooseBtn.textContent = this.selectedFile?.name || 'Choose File'; + const importBtn = this.shadow.getElementById('btn-import') as HTMLButtonElement; + if (importBtn) importBtn.disabled = !this.selectedFile; + }); + + // Drag & drop + const uploadArea = this.shadow.getElementById('upload-area'); + if (uploadArea) { + uploadArea.addEventListener('dragover', (e) => { e.preventDefault(); uploadArea.classList.add('dragover'); }); + uploadArea.addEventListener('dragleave', () => uploadArea.classList.remove('dragover')); + uploadArea.addEventListener('drop', (e) => { + e.preventDefault(); + uploadArea.classList.remove('dragover'); + const file = (e as DragEvent).dataTransfer?.files[0]; + if (file && file.name.endsWith('.zip')) { + this.selectedFile = file; + if (chooseBtn) chooseBtn.textContent = file.name; + const importBtn = this.shadow.getElementById('btn-import') as HTMLButtonElement; + if (importBtn) importBtn.disabled = false; + } + }); + } + + // Target notebook select + const notebookSelect = this.shadow.getElementById('target-notebook') as HTMLSelectElement; + notebookSelect?.addEventListener('change', () => { + this.targetNotebookId = notebookSelect.value; + }); + + // Page checkboxes + this.shadow.querySelectorAll('.page-item input[type="checkbox"]').forEach(cb => { + cb.addEventListener('change', () => { + const input = cb as HTMLInputElement; + if (input.checked) { + this.selectedPages.add(input.value); + } else { + this.selectedPages.delete(input.value); + } + const importBtn = this.shadow.getElementById('btn-import'); + if (importBtn) importBtn.textContent = `Import Selected (${this.selectedPages.size})`; + }); + }); + + // Refresh pages + this.shadow.getElementById('btn-refresh-pages')?.addEventListener('click', () => { + this.loadRemotePages(); + }); + + // Connect button + this.shadow.getElementById('btn-connect')?.addEventListener('click', () => { + const provider = this.activeSource === 'google-docs' ? 'google' : this.activeSource; + window.location.href = `/api/oauth/${provider}/authorize?space=${this.space}`; + }); + + // Import button + this.shadow.getElementById('btn-import')?.addEventListener('click', () => this.handleImport()); + + // Export button + this.shadow.getElementById('btn-export')?.addEventListener('click', () => this.handleExport()); + } + + private getStyles(): string { + return ` + :host { display: block; } + + .dialog-overlay { + display: none; + position: fixed; top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0,0,0,0.5); backdrop-filter: blur(4px); + z-index: 10000; justify-content: center; align-items: center; + } + .dialog-overlay.open { display: flex; } + + .dialog { + background: var(--rs-bg-surface, #1a1a2e); + border: 1px solid var(--rs-border, #2a2a4a); + border-radius: 16px; width: 560px; max-width: 95vw; + max-height: 80vh; display: flex; flex-direction: column; + box-shadow: 0 24px 80px rgba(0,0,0,0.5); + } + + .dialog-header { + display: flex; justify-content: space-between; align-items: center; + padding: 16px 20px; border-bottom: 1px solid var(--rs-border-subtle, #2a2a4a); + } + .dialog-header h2 { margin: 0; font-size: 16px; font-weight: 600; color: var(--rs-text-primary, #e0e0e0); } + .dialog-close { + background: none; border: none; color: var(--rs-text-muted, #888); + font-size: 22px; cursor: pointer; padding: 0 4px; line-height: 1; + } + .dialog-close:hover { color: var(--rs-text-primary, #e0e0e0); } + + .tab-bar { + display: flex; gap: 0; border-bottom: 1px solid var(--rs-border-subtle, #2a2a4a); + } + .tab { + flex: 1; padding: 10px; border: none; background: none; + color: var(--rs-text-secondary, #aaa); font-size: 13px; font-weight: 500; + cursor: pointer; transition: all 0.15s; + border-bottom: 2px solid transparent; + } + .tab.active { + color: var(--rs-primary, #6366f1); + border-bottom-color: var(--rs-primary, #6366f1); + } + .tab:hover { color: var(--rs-text-primary, #e0e0e0); } + + .source-bar { + display: flex; gap: 4px; padding: 12px 16px; + border-bottom: 1px solid var(--rs-border-subtle, #2a2a4a); + } + .source-btn { + padding: 6px 12px; border-radius: 6px; border: 1px solid var(--rs-border, #2a2a4a); + background: transparent; color: var(--rs-text-secondary, #aaa); + font-size: 12px; cursor: pointer; display: flex; align-items: center; gap: 4px; + transition: all 0.15s; + } + .source-btn:hover { border-color: var(--rs-border-strong, #444); color: var(--rs-text-primary, #e0e0e0); } + .source-btn.active { + background: var(--rs-primary, #6366f1); color: #fff; + border-color: var(--rs-primary, #6366f1); + } + + .dialog-body { + padding: 16px 20px; overflow-y: auto; flex: 1; + } + + .upload-area { + border: 2px dashed var(--rs-border, #2a2a4a); + border-radius: 10px; padding: 24px; text-align: center; + margin-bottom: 16px; transition: border-color 0.15s, background 0.15s; + } + .upload-area.dragover { + border-color: var(--rs-primary, #6366f1); + background: rgba(99,102,241,0.05); + } + .upload-area p { margin: 0 0 8px; color: var(--rs-text-secondary, #aaa); font-size: 13px; } + .upload-hint { font-size: 11px !important; color: var(--rs-text-muted, #666) !important; margin-top: 8px !important; } + + .form-row { + display: flex; align-items: center; gap: 10px; margin-bottom: 14px; + } + .form-row label { font-size: 13px; color: var(--rs-text-secondary, #aaa); white-space: nowrap; } + .form-row select { + flex: 1; padding: 7px 10px; border-radius: 6px; + border: 1px solid var(--rs-border, #2a2a4a); + background: var(--rs-input-bg, #111); color: var(--rs-text-primary, #e0e0e0); + font-size: 13px; + } + + .btn-primary { + width: 100%; padding: 10px; border-radius: 8px; border: none; + background: var(--rs-primary, #6366f1); color: #fff; font-weight: 600; + font-size: 13px; cursor: pointer; transition: background 0.15s; + } + .btn-primary:hover { background: var(--rs-primary-hover, #5558e6); } + .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } + + .btn-secondary { + padding: 8px 16px; border-radius: 6px; + border: 1px solid var(--rs-border, #2a2a4a); + background: transparent; color: var(--rs-text-primary, #e0e0e0); + font-size: 13px; cursor: pointer; + } + .btn-secondary:hover { border-color: var(--rs-border-strong, #444); } + .btn-sm { padding: 4px 10px; font-size: 11px; } + + .connect-prompt { + text-align: center; padding: 24px; + } + .connect-prompt p { color: var(--rs-text-secondary, #aaa); margin-bottom: 16px; font-size: 13px; } + + .page-list-header { + display: flex; justify-content: space-between; align-items: center; + margin-bottom: 8px; + } + .page-list-header span { font-size: 13px; color: var(--rs-text-secondary, #aaa); } + + .page-list { + max-height: 200px; overflow-y: auto; + border: 1px solid var(--rs-border-subtle, #2a2a4a); + border-radius: 8px; margin-bottom: 14px; + } + .page-item { + display: flex; align-items: center; gap: 8px; padding: 8px 12px; + cursor: pointer; transition: background 0.1s; + border-bottom: 1px solid var(--rs-border-subtle, #1a1a2e); + } + .page-item:last-child { border-bottom: none; } + .page-item:hover { background: rgba(255,255,255,0.03); } + .page-item input[type="checkbox"] { accent-color: var(--rs-primary, #6366f1); } + .page-icon { + width: 20px; height: 20px; font-size: 12px; + display: flex; align-items: center; justify-content: center; + background: var(--rs-bg-surface-raised, #222); border-radius: 4px; + color: var(--rs-text-muted, #888); + } + .page-title { font-size: 13px; color: var(--rs-text-primary, #e0e0e0); flex: 1; } + + .empty-list { + padding: 20px; text-align: center; color: var(--rs-text-muted, #666); font-size: 12px; + } + + .status-message { + margin-top: 12px; padding: 8px 12px; border-radius: 6px; + font-size: 12px; text-align: center; + } + .status-info { background: rgba(99,102,241,0.1); color: var(--rs-primary, #6366f1); } + .status-success { background: rgba(34,197,94,0.1); color: var(--rs-success, #22c55e); } + .status-error { background: rgba(239,68,68,0.1); color: var(--rs-error, #ef4444); } + `; + } +} + +customElements.define('import-export-dialog', ImportExportDialog); + +export { ImportExportDialog }; diff --git a/modules/rnotes/converters/google-docs.ts b/modules/rnotes/converters/google-docs.ts new file mode 100644 index 0000000..2c2514e --- /dev/null +++ b/modules/rnotes/converters/google-docs.ts @@ -0,0 +1,296 @@ +/** + * Google Docs ↔ rNotes converter. + * + * Import: Google Docs API structural JSON → markdown → TipTap JSON + * Export: TipTap JSON → Google Docs batch update requests + */ + +import { markdownToTiptap, tiptapToMarkdown, extractPlainTextFromTiptap } from './markdown-tiptap'; +import { registerConverter } from './index'; +import type { ConvertedNote, ImportInput, ImportResult, ExportOptions, ExportResult, NoteConverter } from './index'; +import type { NoteItem } from '../schemas'; + +const DOCS_API_BASE = 'https://docs.googleapis.com/v1'; +const DRIVE_API_BASE = 'https://www.googleapis.com/drive/v3'; + +/** Fetch from Google APIs with auth. */ +async function googleFetch(url: string, token: string, opts: RequestInit = {}): Promise { + const res = await fetch(url, { + ...opts, + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + ...opts.headers, + }, + }); + + if (!res.ok) { + const body = await res.text(); + throw new Error(`Google API error ${res.status}: ${body}`); + } + return res.json(); +} + +/** Convert Google Docs structural elements to markdown. */ +function structuralElementToMarkdown(element: any): string { + if (element.paragraph) { + return paragraphToMarkdown(element.paragraph); + } + if (element.table) { + return tableToMarkdown(element.table); + } + if (element.sectionBreak) { + return '\n---\n'; + } + return ''; +} + +/** Convert a Google Docs paragraph to markdown. */ +function paragraphToMarkdown(paragraph: any): string { + const style = paragraph.paragraphStyle?.namedStyleType || 'NORMAL_TEXT'; + const elements = paragraph.elements || []; + let text = ''; + + for (const el of elements) { + if (el.textRun) { + text += textRunToMarkdown(el.textRun); + } else if (el.inlineObjectElement) { + // Inline images — reference only, actual URL requires separate lookup + text += `![image](inline-object)`; + } + } + + // Remove trailing newline that Google Docs adds to every paragraph + text = text.replace(/\n$/, ''); + + // Apply heading styles + switch (style) { + case 'HEADING_1': return `# ${text}`; + case 'HEADING_2': return `## ${text}`; + case 'HEADING_3': return `### ${text}`; + case 'HEADING_4': return `#### ${text}`; + case 'HEADING_5': return `##### ${text}`; + case 'HEADING_6': return `###### ${text}`; + default: return text; + } +} + +/** Convert a Google Docs TextRun to markdown with formatting. */ +function textRunToMarkdown(textRun: any): string { + let text = textRun.content || ''; + const style = textRun.textStyle || {}; + + // Don't apply formatting to whitespace-only text + if (!text.trim()) return text; + + if (style.bold) text = `**${text.trim()}** `; + if (style.italic) text = `*${text.trim()}* `; + if (style.strikethrough) text = `~~${text.trim()}~~ `; + if (style.link?.url) text = `[${text.trim()}](${style.link.url})`; + + return text; +} + +/** Convert a Google Docs table to markdown. */ +function tableToMarkdown(table: any): string { + const rows = table.tableRows || []; + if (rows.length === 0) return ''; + + const mdRows: string[] = []; + for (let r = 0; r < rows.length; r++) { + const cells = rows[r].tableCells || []; + const cellTexts = cells.map((cell: any) => { + const content = (cell.content || []) + .map((el: any) => structuralElementToMarkdown(el)) + .join('') + .trim(); + return content || ' '; + }); + mdRows.push(`| ${cellTexts.join(' | ')} |`); + + // Separator after header + if (r === 0) { + mdRows.push(`| ${cellTexts.map(() => '---').join(' | ')} |`); + } + } + + return mdRows.join('\n'); +} + +/** Convert TipTap markdown to Google Docs batchUpdate requests. */ +function markdownToGoogleDocsRequests(md: string): any[] { + const requests: any[] = []; + const lines = md.split('\n'); + let index = 1; // Google Docs indexes start at 1 + + for (const line of lines) { + if (!line && lines.indexOf(line) < lines.length - 1) { + // Empty line → insert newline + requests.push({ + insertText: { location: { index }, text: '\n' }, + }); + index += 1; + continue; + } + + // Headings + const headingMatch = line.match(/^(#{1,6})\s+(.+)/); + if (headingMatch) { + const level = headingMatch[1].length; + const text = headingMatch[2] + '\n'; + requests.push({ + insertText: { location: { index }, text }, + }); + requests.push({ + updateParagraphStyle: { + range: { startIndex: index, endIndex: index + text.length }, + paragraphStyle: { namedStyleType: `HEADING_${level}` }, + fields: 'namedStyleType', + }, + }); + index += text.length; + continue; + } + + // Regular text + const text = line.replace(/^[-*]\s+/, '').replace(/^\d+\.\s+/, '') + '\n'; + requests.push({ + insertText: { location: { index }, text }, + }); + + // Apply bullet/list styles + if (line.match(/^[-*]\s+/)) { + requests.push({ + createParagraphBullets: { + range: { startIndex: index, endIndex: index + text.length }, + bulletPreset: 'BULLET_DISC_CIRCLE_SQUARE', + }, + }); + } else if (line.match(/^\d+\.\s+/)) { + requests.push({ + createParagraphBullets: { + range: { startIndex: index, endIndex: index + text.length }, + bulletPreset: 'NUMBERED_DECIMAL_ALPHA_ROMAN', + }, + }); + } + + index += text.length; + } + + return requests; +} + +const googleDocsConverter: NoteConverter = { + id: 'google-docs', + name: 'Google Docs', + requiresAuth: true, + + async import(input: ImportInput): Promise { + const token = input.accessToken; + if (!token) throw new Error('Google Docs import requires an access token. Connect your Google account first.'); + if (!input.pageIds || input.pageIds.length === 0) { + throw new Error('No Google Docs selected for import'); + } + + const notes: ConvertedNote[] = []; + const warnings: string[] = []; + + for (const docId of input.pageIds) { + try { + // Fetch document + const doc = await googleFetch(`${DOCS_API_BASE}/documents/${docId}`, token); + const title = doc.title || 'Untitled'; + + // Convert structural elements to markdown + const body = doc.body?.content || []; + const mdParts: string[] = []; + + for (const element of body) { + const md = structuralElementToMarkdown(element); + if (md) mdParts.push(md); + } + + const markdown = mdParts.join('\n\n'); + const tiptapJson = markdownToTiptap(markdown); + const contentPlain = extractPlainTextFromTiptap(tiptapJson); + + notes.push({ + title, + content: tiptapJson, + contentPlain, + markdown, + tags: [], + sourceRef: { + source: 'google-docs', + externalId: docId, + lastSyncedAt: Date.now(), + contentHash: String(body.length), + }, + }); + } catch (err) { + warnings.push(`Failed to import doc ${docId}: ${(err as Error).message}`); + } + } + + return { notes, notebookTitle: 'Google Docs Import', warnings }; + }, + + async export(notes: NoteItem[], opts: ExportOptions): Promise { + const token = opts.accessToken; + if (!token) throw new Error('Google Docs export requires an access token. Connect your Google account first.'); + + const warnings: string[] = []; + const results: any[] = []; + + for (const note of notes) { + try { + // Create a new Google Doc + const doc = await googleFetch(`${DOCS_API_BASE}/documents`, token, { + method: 'POST', + body: JSON.stringify({ title: note.title }), + }); + + // Convert to markdown + let md: string; + if (note.contentFormat === 'tiptap-json' && note.content) { + md = tiptapToMarkdown(note.content); + } else { + md = note.content?.replace(/<[^>]*>/g, '').trim() || ''; + } + + // Build batch update requests + const requests = markdownToGoogleDocsRequests(md); + + if (requests.length > 0) { + await googleFetch(`${DOCS_API_BASE}/documents/${doc.documentId}:batchUpdate`, token, { + method: 'POST', + body: JSON.stringify({ requests }), + }); + } + + // Move to folder if parentId specified + if (opts.parentId) { + await googleFetch( + `${DRIVE_API_BASE}/files/${doc.documentId}?addParents=${opts.parentId}`, + token, + { method: 'PATCH', body: JSON.stringify({}) } + ); + } + + results.push({ noteId: note.id, googleDocId: doc.documentId }); + } catch (err) { + warnings.push(`Failed to export "${note.title}": ${(err as Error).message}`); + } + } + + const data = new TextEncoder().encode(JSON.stringify({ exported: results, warnings })); + return { + data, + filename: 'google-docs-export-results.json', + mimeType: 'application/json', + }; + }, +}; + +registerConverter(googleDocsConverter); diff --git a/modules/rnotes/converters/index.ts b/modules/rnotes/converters/index.ts new file mode 100644 index 0000000..769b40b --- /dev/null +++ b/modules/rnotes/converters/index.ts @@ -0,0 +1,88 @@ +/** + * Converter registry and shared types for rNotes import/export. + * + * All source-specific converters implement NoteConverter. + * ConvertedNote is the intermediate format between external sources and NoteItem. + */ + +import type { NoteItem, SourceRef } from '../schemas'; + +// ── Shared types ── + +export interface ConvertedNote { + title: string; + content: string; // TipTap JSON string + contentPlain: string; // Plain text for search + markdown: string; // Original/generated markdown (for canvas shapes) + tags: string[]; + sourceRef: SourceRef; + /** Optional note type override */ + type?: NoteItem['type']; +} + +export interface ImportResult { + notes: ConvertedNote[]; + notebookTitle: string; + warnings: string[]; +} + +export interface ExportResult { + data: Uint8Array; + filename: string; + mimeType: string; +} + +export interface NoteConverter { + id: string; + name: string; + requiresAuth: boolean; + + /** Import from external source into ConvertedNote[] */ + import(input: ImportInput): Promise; + + /** Export NoteItems to external format */ + export(notes: NoteItem[], opts: ExportOptions): Promise; +} + +export interface ImportInput { + /** ZIP file data for file-based sources (Logseq, Obsidian) */ + fileData?: Uint8Array; + /** Page/doc IDs for API-based sources (Notion, Google Docs) */ + pageIds?: string[]; + /** Whether to import recursively (sub-pages) */ + recursive?: boolean; + /** Access token for authenticated sources */ + accessToken?: string; +} + +export interface ExportOptions { + /** Notebook title for the export */ + notebookTitle?: string; + /** Access token for authenticated sources */ + accessToken?: string; + /** Target parent page/folder ID for API-based exports */ + parentId?: string; +} + +// ── Converter registry ── + +const converters = new Map(); + +export function registerConverter(converter: NoteConverter): void { + converters.set(converter.id, converter); +} + +export function getConverter(id: string): NoteConverter | undefined { + return converters.get(id); +} + +export function getAllConverters(): NoteConverter[] { + return Array.from(converters.values()); +} + +// ── Import converters on module load ── +// These register themselves when imported +import './obsidian'; +import './logseq'; +import './notion'; +import './google-docs'; diff --git a/modules/rnotes/converters/logseq.ts b/modules/rnotes/converters/logseq.ts new file mode 100644 index 0000000..318831d --- /dev/null +++ b/modules/rnotes/converters/logseq.ts @@ -0,0 +1,273 @@ +/** + * Logseq graph ↔ rNotes converter. + * + * Import: ZIP of pages/ + journals/ dirs, property:: value syntax, bullet outliner blocks + * Export: ZIP with Logseq-compatible page files + properties + */ + +import JSZip from 'jszip'; +import { markdownToTiptap, tiptapToMarkdown, extractPlainTextFromTiptap } from './markdown-tiptap'; +import { registerConverter } from './index'; +import type { ConvertedNote, ImportInput, ImportResult, ExportOptions, ExportResult, NoteConverter } from './index'; +import type { NoteItem } from '../schemas'; + +/** Hash content for conflict detection. */ +function hashContent(content: string): string { + let hash = 0; + for (let i = 0; i < content.length; i++) { + const char = content.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash |= 0; + } + return Math.abs(hash).toString(36); +} + +/** Parse Logseq property:: value lines from the top of a page. */ +function parseLogseqProperties(content: string): { properties: Record; body: string } { + const lines = content.split('\n'); + const properties: Record = {}; + let bodyStart = 0; + + for (let i = 0; i < lines.length; i++) { + const match = lines[i].match(/^([a-zA-Z_-]+)::\s*(.*)$/); + if (match) { + properties[match[1].toLowerCase()] = match[2].trim(); + bodyStart = i + 1; + } else if (lines[i].trim() === '') { + bodyStart = i + 1; + continue; + } else { + break; + } + } + + return { properties, body: lines.slice(bodyStart).join('\n') }; +} + +/** + * Convert Logseq outliner bullet format to regular markdown. + * Logseq uses `- content` for all blocks with indentation for nesting. + */ +function convertOutlinerToMarkdown(content: string): string { + const lines = content.split('\n'); + const result: string[] = []; + + for (const line of lines) { + // Detect indented bullets: tabs or spaces followed by - + const match = line.match(/^(\t*|\s*)- (.*)$/); + if (match) { + const indent = match[1]; + const text = match[2]; + + // Calculate nesting level + const level = indent.replace(/ /g, '\t').split('\t').length - 1; + + // Check if this looks like a heading (common Logseq pattern) + if (level === 0 && text.startsWith('# ')) { + result.push(text); + } else if (level === 0 && !text.startsWith('- ')) { + // Top-level bullet → paragraph or list item + result.push(`- ${text}`); + } else { + // Nested bullet → indented list item + const indentation = ' '.repeat(level); + result.push(`${indentation}- ${text}`); + } + } else { + result.push(line); + } + } + + return result.join('\n'); +} + +/** Convert [[page references]] to standard links. */ +function convertPageRefs(md: string): string { + return md.replace(/\[\[([^\]]+)\]\]/g, '[$1]($1)'); +} + +/** Convert Logseq tags (#tag or #[[multi word tag]]). */ +function extractLogseqTags(content: string): string[] { + const tags: string[] = []; + // #tag + const singleTags = content.match(/#([a-zA-Z0-9_-]+)/g); + if (singleTags) tags.push(...singleTags.map(t => t.slice(1).toLowerCase())); + // #[[multi word tag]] + const multiTags = content.match(/#\[\[([^\]]+)\]\]/g); + if (multiTags) tags.push(...multiTags.map(t => t.slice(3, -2).toLowerCase().replace(/\s+/g, '-'))); + return [...new Set(tags)]; +} + +/** Parse Logseq journal filename to date. */ +function parseJournalDate(filename: string): string | null { + // Common Logseq journal formats: 2026_03_01.md, 2026-03-01.md + const match = filename.match(/(\d{4})[_-](\d{2})[_-](\d{2})\.md$/); + if (match) return `${match[1]}-${match[2]}-${match[3]}`; + return null; +} + +/** Extract title from filename. */ +function titleFromPath(filePath: string): string { + const filename = filePath.split('/').pop() || 'Untitled'; + return filename.replace(/\.md$/i, '').replace(/%2F/g, '/').replace(/_/g, ' '); +} + +const logseqConverter: NoteConverter = { + id: 'logseq', + name: 'Logseq', + requiresAuth: false, + + async import(input: ImportInput): Promise { + if (!input.fileData) { + throw new Error('Logseq import requires a ZIP file'); + } + + const zip = await JSZip.loadAsync(input.fileData); + const notes: ConvertedNote[] = []; + const warnings: string[] = []; + let graphName = 'Logseq Import'; + + // Collect all .md files + const mdFiles: { path: string; file: JSZip.JSZipObject; isJournal: boolean }[] = []; + zip.forEach((path, file) => { + if (file.dir) return; + if (!path.endsWith('.md')) return; + // Skip config/hidden files + if (path.includes('logseq/') && !path.includes('pages/') && !path.includes('journals/')) return; + if (path.includes('.recycle/')) return; + + const isJournal = path.includes('journals/'); + mdFiles.push({ path, file, isJournal }); + }); + + if (mdFiles.length === 0) { + warnings.push('No .md files found in pages/ or journals/ directories'); + return { notes, notebookTitle: graphName, warnings }; + } + + // Detect graph name from common root + const firstPath = mdFiles[0].path; + const rootFolder = firstPath.split('/')[0]; + if (rootFolder && mdFiles.every(f => f.path.startsWith(rootFolder + '/'))) { + graphName = rootFolder; + for (const f of mdFiles) { + f.path = f.path.slice(rootFolder.length + 1); + } + } + + for (const { path, file, isJournal } of mdFiles) { + try { + const raw = await file.async('string'); + const { properties, body } = parseLogseqProperties(raw); + + // Convert Logseq format to standard markdown + let md = convertOutlinerToMarkdown(body); + md = convertPageRefs(md); + + const filename = path.split('/').pop() || ''; + let title: string; + + if (isJournal) { + const date = parseJournalDate(filename); + title = date ? `Journal: ${date}` : titleFromPath(path); + } else { + title = properties.title || titleFromPath(path); + } + + const tiptapJson = markdownToTiptap(md); + const contentPlain = extractPlainTextFromTiptap(tiptapJson); + + // Collect tags + const tags: string[] = []; + if (properties.tags) { + const tagStr = properties.tags.replace(/\[\[|\]\]/g, ''); + tags.push(...tagStr.split(',').map(t => t.trim().toLowerCase()).filter(Boolean)); + } + tags.push(...extractLogseqTags(raw)); + if (isJournal) tags.push('journal'); + + notes.push({ + title, + content: tiptapJson, + contentPlain, + markdown: md, + tags: [...new Set(tags)], + sourceRef: { + source: 'logseq', + externalId: path, + lastSyncedAt: Date.now(), + contentHash: hashContent(raw), + }, + }); + } catch (err) { + warnings.push(`Failed to parse ${path}: ${(err as Error).message}`); + } + } + + return { notes, notebookTitle: graphName, warnings }; + }, + + async export(notes: NoteItem[], opts: ExportOptions): Promise { + const zip = new JSZip(); + const graphName = opts.notebookTitle || 'rNotes Export'; + const pagesDir = zip.folder('pages')!; + + for (const note of notes) { + // Convert content to markdown + let md: string; + if (note.contentFormat === 'tiptap-json' && note.content) { + md = tiptapToMarkdown(note.content); + } else if (note.content) { + md = note.content.replace(/<[^>]*>/g, '').trim(); + } else { + md = ''; + } + + // Build Logseq properties block + const props: string[] = []; + if (note.tags.length > 0) { + props.push(`tags:: ${note.tags.map(t => `[[${t}]]`).join(', ')}`); + } + if (note.type !== 'NOTE') { + props.push(`type:: ${note.type.toLowerCase()}`); + } + props.push(`created:: ${new Date(note.createdAt).toISOString().split('T')[0]}`); + + // Convert markdown paragraphs to Logseq outliner bullets + const mdLines = md.split('\n'); + const outliner: string[] = []; + for (const line of mdLines) { + if (line.trim() === '') continue; + if (line.startsWith('#')) { + outliner.push(`- ${line}`); + } else if (line.startsWith('- ') || line.startsWith('* ')) { + outliner.push(`- ${line.slice(2)}`); + } else if (line.match(/^\d+\.\s/)) { + outliner.push(`- ${line.replace(/^\d+\.\s/, '')}`); + } else { + outliner.push(`- ${line}`); + } + } + + const propsBlock = props.length > 0 ? props.join('\n') + '\n\n' : ''; + const fileContent = `${propsBlock}${outliner.join('\n')}\n`; + + // Sanitize filename for Logseq (uses %2F for namespaced pages) + const filename = note.title + .replace(/[<>:"/\\|?*]/g, '') + .replace(/\//g, '%2F') + .trim() || 'Untitled'; + + pagesDir.file(`${filename}.md`, fileContent); + } + + const data = await zip.generateAsync({ type: 'uint8array' }); + return { + data, + filename: `${graphName.replace(/\s+/g, '-').toLowerCase()}-logseq.zip`, + mimeType: 'application/zip', + }; + }, +}; + +registerConverter(logseqConverter); diff --git a/modules/rnotes/converters/markdown-tiptap.ts b/modules/rnotes/converters/markdown-tiptap.ts new file mode 100644 index 0000000..d4de3fb --- /dev/null +++ b/modules/rnotes/converters/markdown-tiptap.ts @@ -0,0 +1,458 @@ +/** + * Core Markdown ↔ TipTap JSON conversion utility. + * + * All import/export converters pass through this module. + * - Import: source format → markdown → TipTap JSON + * - Export: TipTap JSON → markdown → source format + */ + +import { marked } from 'marked'; + +// ── Markdown → TipTap JSON ── + +/** + * Convert a markdown string to TipTap-compatible JSON. + * Uses `marked` to parse markdown → HTML tokens, then builds TipTap JSON nodes. + */ +export function markdownToTiptap(md: string): string { + const tokens = marked.lexer(md); + const doc = { + type: 'doc', + content: tokensToTiptap(tokens), + }; + return JSON.stringify(doc); +} + +/** Convert marked tokens to TipTap JSON node array. */ +function tokensToTiptap(tokens: any[]): any[] { + const nodes: any[] = []; + + for (const token of tokens) { + switch (token.type) { + case 'heading': + nodes.push({ + type: 'heading', + attrs: { level: token.depth }, + content: inlineToTiptap(token.tokens || []), + }); + break; + + case 'paragraph': + nodes.push({ + type: 'paragraph', + content: inlineToTiptap(token.tokens || []), + }); + break; + + case 'blockquote': + nodes.push({ + type: 'blockquote', + content: tokensToTiptap(token.tokens || []), + }); + break; + + case 'list': { + const listType = token.ordered ? 'orderedList' : 'bulletList'; + const attrs: any = {}; + if (token.ordered && token.start !== 1) attrs.start = token.start; + nodes.push({ + type: listType, + ...(Object.keys(attrs).length ? { attrs } : {}), + content: token.items.map((item: any) => { + // Check if this is a task list item + if (item.task) { + return { + type: 'taskItem', + attrs: { checked: item.checked || false }, + content: tokensToTiptap(item.tokens || []), + }; + } + return { + type: 'listItem', + content: tokensToTiptap(item.tokens || []), + }; + }), + }); + // If any items were task items, wrap in taskList instead + const lastNode = nodes[nodes.length - 1]; + if (lastNode.content?.some((c: any) => c.type === 'taskItem')) { + lastNode.type = 'taskList'; + } + break; + } + + case 'code': + nodes.push({ + type: 'codeBlock', + attrs: { language: token.lang || null }, + content: [{ type: 'text', text: token.text }], + }); + break; + + case 'hr': + nodes.push({ type: 'horizontalRule' }); + break; + + case 'table': { + const rows: any[] = []; + // Header row + if (token.header && token.header.length > 0) { + rows.push({ + type: 'tableRow', + content: token.header.map((cell: any) => ({ + type: 'tableHeader', + content: [{ + type: 'paragraph', + content: inlineToTiptap(cell.tokens || []), + }], + })), + }); + } + // Body rows + if (token.rows) { + for (const row of token.rows) { + rows.push({ + type: 'tableRow', + content: row.map((cell: any) => ({ + type: 'tableCell', + content: [{ + type: 'paragraph', + content: inlineToTiptap(cell.tokens || []), + }], + })), + }); + } + } + nodes.push({ type: 'table', content: rows }); + break; + } + + case 'image': + nodes.push({ + type: 'image', + attrs: { + src: token.href, + alt: token.text || null, + title: token.title || null, + }, + }); + break; + + case 'html': + // Pass through raw HTML as a paragraph with text + if (token.text.trim()) { + nodes.push({ + type: 'paragraph', + content: [{ type: 'text', text: token.text.trim() }], + }); + } + break; + + case 'space': + // Ignore whitespace-only tokens + break; + + default: + // Fallback: treat as paragraph if there are tokens + if ((token as any).tokens) { + nodes.push({ + type: 'paragraph', + content: inlineToTiptap((token as any).tokens), + }); + } else if ((token as any).text) { + nodes.push({ + type: 'paragraph', + content: [{ type: 'text', text: (token as any).text }], + }); + } + } + } + + return nodes; +} + +/** Convert inline marked tokens to TipTap inline content. */ +function inlineToTiptap(tokens: any[]): any[] { + const result: any[] = []; + + for (const token of tokens) { + switch (token.type) { + case 'text': + if (token.text) { + result.push({ type: 'text', text: token.text }); + } + break; + + case 'strong': + for (const child of inlineToTiptap(token.tokens || [])) { + result.push(addMark(child, { type: 'bold' })); + } + break; + + case 'em': + for (const child of inlineToTiptap(token.tokens || [])) { + result.push(addMark(child, { type: 'italic' })); + } + break; + + case 'del': + for (const child of inlineToTiptap(token.tokens || [])) { + result.push(addMark(child, { type: 'strike' })); + } + break; + + case 'codespan': + result.push({ + type: 'text', + text: token.text, + marks: [{ type: 'code' }], + }); + break; + + case 'link': + for (const child of inlineToTiptap(token.tokens || [])) { + result.push(addMark(child, { + type: 'link', + attrs: { href: token.href, target: '_blank' }, + })); + } + break; + + case 'image': + // Inline images become their own node — push text before if any + result.push({ + type: 'text', + text: `![${token.text || ''}](${token.href})`, + }); + break; + + case 'br': + result.push({ type: 'hardBreak' }); + break; + + case 'escape': + result.push({ type: 'text', text: token.text }); + break; + + default: + if ((token as any).text) { + result.push({ type: 'text', text: (token as any).text }); + } + } + } + + return result; +} + +/** Add a mark to a TipTap text node, preserving existing marks. */ +function addMark(node: any, mark: any): any { + const marks = [...(node.marks || []), mark]; + return { ...node, marks }; +} + +// ── TipTap JSON → Markdown ── + +/** + * Convert TipTap JSON string to markdown. + * Walks the TipTap node tree and produces CommonMark-compatible output. + */ +export function tiptapToMarkdown(json: string): string { + try { + const doc = JSON.parse(json); + if (!doc.content) return ''; + return nodesToMarkdown(doc.content).trim(); + } catch { + // If it's not valid JSON, return as-is (might already be markdown/plain text) + return json; + } +} + +/** Convert an array of TipTap nodes to markdown. */ +function nodesToMarkdown(nodes: any[], indent = ''): string { + const parts: string[] = []; + + for (const node of nodes) { + switch (node.type) { + case 'heading': { + const level = node.attrs?.level || 1; + const prefix = '#'.repeat(level); + parts.push(`${prefix} ${inlineToMarkdown(node.content || [])}`); + parts.push(''); + break; + } + + case 'paragraph': { + const text = inlineToMarkdown(node.content || []); + parts.push(`${indent}${text}`); + parts.push(''); + break; + } + + case 'blockquote': { + const inner = nodesToMarkdown(node.content || []); + const lines = inner.split('\n').filter((l: string) => l !== '' || parts.length === 0); + for (const line of lines) { + parts.push(line ? `> ${line}` : '>'); + } + parts.push(''); + break; + } + + case 'bulletList': { + for (const item of node.content || []) { + const inner = nodesToMarkdown(item.content || [], ' ').trim(); + const lines = inner.split('\n'); + parts.push(`- ${lines[0]}`); + for (let i = 1; i < lines.length; i++) { + parts.push(` ${lines[i]}`); + } + } + parts.push(''); + break; + } + + case 'orderedList': { + const start = node.attrs?.start || 1; + const items = node.content || []; + for (let i = 0; i < items.length; i++) { + const num = start + i; + const inner = nodesToMarkdown(items[i].content || [], ' ').trim(); + const lines = inner.split('\n'); + parts.push(`${num}. ${lines[0]}`); + for (let j = 1; j < lines.length; j++) { + parts.push(` ${lines[j]}`); + } + } + parts.push(''); + break; + } + + case 'taskList': { + for (const item of node.content || []) { + const checked = item.attrs?.checked ? 'x' : ' '; + const inner = nodesToMarkdown(item.content || [], ' ').trim(); + parts.push(`- [${checked}] ${inner}`); + } + parts.push(''); + break; + } + + case 'codeBlock': { + const lang = node.attrs?.language || ''; + const text = node.content?.map((c: any) => c.text || '').join('') || ''; + parts.push(`\`\`\`${lang}`); + parts.push(text); + parts.push('```'); + parts.push(''); + break; + } + + case 'horizontalRule': + parts.push('---'); + parts.push(''); + break; + + case 'image': { + const alt = node.attrs?.alt || ''; + const src = node.attrs?.src || ''; + const title = node.attrs?.title ? ` "${node.attrs.title}"` : ''; + parts.push(`![${alt}](${src}${title})`); + parts.push(''); + break; + } + + case 'table': { + const rows = node.content || []; + if (rows.length === 0) break; + + for (let r = 0; r < rows.length; r++) { + const cells = rows[r].content || []; + const cellTexts = cells.map((cell: any) => { + const inner = nodesToMarkdown(cell.content || []).trim(); + return inner || ' '; + }); + parts.push(`| ${cellTexts.join(' | ')} |`); + + // Add separator after header row + if (r === 0) { + parts.push(`| ${cellTexts.map(() => '---').join(' | ')} |`); + } + } + parts.push(''); + break; + } + + case 'hardBreak': + parts.push(' '); + break; + + default: + // Unknown node type — try to extract text + if (node.content) { + parts.push(nodesToMarkdown(node.content, indent)); + } else if (node.text) { + parts.push(node.text); + } + } + } + + return parts.join('\n'); +} + +/** Convert TipTap inline content nodes to markdown string. */ +function inlineToMarkdown(nodes: any[]): string { + return nodes.map((node) => { + if (node.type === 'hardBreak') return ' \n'; + + let text = node.text || ''; + if (!text && node.content) { + text = inlineToMarkdown(node.content); + } + + if (node.marks) { + for (const mark of node.marks) { + switch (mark.type) { + case 'bold': + text = `**${text}**`; + break; + case 'italic': + text = `*${text}*`; + break; + case 'strike': + text = `~~${text}~~`; + break; + case 'code': + text = `\`${text}\``; + break; + case 'link': + text = `[${text}](${mark.attrs?.href || ''})`; + break; + case 'underline': + // No standard markdown for underline, use HTML + text = `${text}`; + break; + } + } + } + + return text; + }).join(''); +} + +// ── Utility: extract plain text from TipTap JSON ── + +/** Recursively extract plain text from a TipTap JSON string. */ +export function extractPlainTextFromTiptap(json: string): string { + try { + const doc = JSON.parse(json); + return walkPlainText(doc).trim(); + } catch { + return json.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); + } +} + +function walkPlainText(node: any): string { + if (node.text) return node.text; + if (!node.content) return ''; + return node.content.map(walkPlainText).join(node.type === 'paragraph' ? '\n' : ''); +} diff --git a/modules/rnotes/converters/notion.ts b/modules/rnotes/converters/notion.ts new file mode 100644 index 0000000..47a9175 --- /dev/null +++ b/modules/rnotes/converters/notion.ts @@ -0,0 +1,461 @@ +/** + * Notion ↔ rNotes converter. + * + * Import: Notion API block types → markdown → TipTap JSON + * Export: TipTap JSON → Notion block format, creates pages via API + */ + +import { markdownToTiptap, tiptapToMarkdown, extractPlainTextFromTiptap } from './markdown-tiptap'; +import { registerConverter } from './index'; +import type { ConvertedNote, ImportInput, ImportResult, ExportOptions, ExportResult, NoteConverter } from './index'; +import type { NoteItem } from '../schemas'; + +const NOTION_API_VERSION = '2022-06-28'; +const NOTION_API_BASE = 'https://api.notion.com/v1'; + +/** Rate-limited fetch for Notion API (3 req/s). */ +let lastRequestTime = 0; +async function notionFetch(url: string, opts: RequestInit & { token: string }): Promise { + const now = Date.now(); + const elapsed = now - lastRequestTime; + if (elapsed < 334) { // ~3 req/s + await new Promise(r => setTimeout(r, 334 - elapsed)); + } + lastRequestTime = Date.now(); + + const res = await fetch(url, { + ...opts, + headers: { + 'Authorization': `Bearer ${opts.token}`, + 'Notion-Version': NOTION_API_VERSION, + 'Content-Type': 'application/json', + ...opts.headers, + }, + }); + + if (!res.ok) { + const body = await res.text(); + throw new Error(`Notion API error ${res.status}: ${body}`); + } + return res.json(); +} + +/** Convert a Notion rich text array to markdown. */ +function richTextToMarkdown(richText: any[]): string { + if (!richText) return ''; + return richText.map((rt: any) => { + let text = rt.plain_text || ''; + const ann = rt.annotations || {}; + if (ann.code) text = `\`${text}\``; + if (ann.bold) text = `**${text}**`; + if (ann.italic) text = `*${text}*`; + if (ann.strikethrough) text = `~~${text}~~`; + if (rt.href) text = `[${text}](${rt.href})`; + return text; + }).join(''); +} + +/** Convert a Notion block to markdown. */ +function blockToMarkdown(block: any, indent = ''): string { + const type = block.type; + const data = block[type]; + if (!data) return ''; + + switch (type) { + case 'paragraph': + return `${indent}${richTextToMarkdown(data.rich_text)}`; + + case 'heading_1': + return `# ${richTextToMarkdown(data.rich_text)}`; + + case 'heading_2': + return `## ${richTextToMarkdown(data.rich_text)}`; + + case 'heading_3': + return `### ${richTextToMarkdown(data.rich_text)}`; + + case 'bulleted_list_item': + return `${indent}- ${richTextToMarkdown(data.rich_text)}`; + + case 'numbered_list_item': + return `${indent}1. ${richTextToMarkdown(data.rich_text)}`; + + case 'to_do': { + const checked = data.checked ? 'x' : ' '; + return `${indent}- [${checked}] ${richTextToMarkdown(data.rich_text)}`; + } + + case 'toggle': + return `${indent}- ${richTextToMarkdown(data.rich_text)}`; + + case 'code': { + const lang = data.language || ''; + const code = richTextToMarkdown(data.rich_text); + return `\`\`\`${lang}\n${code}\n\`\`\``; + } + + case 'quote': + return `> ${richTextToMarkdown(data.rich_text)}`; + + case 'callout': { + const icon = data.icon?.emoji || ''; + return `> ${icon} ${richTextToMarkdown(data.rich_text)}`; + } + + case 'divider': + return '---'; + + case 'image': { + const url = data.file?.url || data.external?.url || ''; + const caption = data.caption ? richTextToMarkdown(data.caption) : ''; + return `![${caption}](${url})`; + } + + case 'bookmark': + return `[${data.url}](${data.url})`; + + case 'table': { + // Tables are handled via children blocks + return ''; + } + + case 'table_row': { + const cells = (data.cells || []).map((cell: any[]) => richTextToMarkdown(cell)); + return `| ${cells.join(' | ')} |`; + } + + case 'child_page': + return `**${data.title}** (sub-page)`; + + case 'child_database': + return `**${data.title}** (database)`; + + default: + // Try to extract rich_text if available + if (data.rich_text) { + return richTextToMarkdown(data.rich_text); + } + return ''; + } +} + +/** Convert TipTap markdown content to Notion blocks. */ +function markdownToNotionBlocks(md: string): any[] { + const lines = md.split('\n'); + const blocks: any[] = []; + + let i = 0; + while (i < lines.length) { + const line = lines[i]; + + // Empty line + if (!line.trim()) { + i++; + continue; + } + + // Headings + const headingMatch = line.match(/^(#{1,3})\s+(.+)/); + if (headingMatch) { + const level = headingMatch[1].length; + const text = headingMatch[2]; + const type = `heading_${level}` as string; + blocks.push({ + type, + [type]: { + rich_text: [{ type: 'text', text: { content: text } }], + }, + }); + i++; + continue; + } + + // Code blocks + if (line.startsWith('```')) { + const lang = line.slice(3).trim(); + const codeLines: string[] = []; + i++; + while (i < lines.length && !lines[i].startsWith('```')) { + codeLines.push(lines[i]); + i++; + } + blocks.push({ + type: 'code', + code: { + rich_text: [{ type: 'text', text: { content: codeLines.join('\n') } }], + language: lang || 'plain text', + }, + }); + i++; // skip closing ``` + continue; + } + + // Blockquotes + if (line.startsWith('> ')) { + blocks.push({ + type: 'quote', + quote: { + rich_text: [{ type: 'text', text: { content: line.slice(2) } }], + }, + }); + i++; + continue; + } + + // Task list items + const taskMatch = line.match(/^- \[([ x])\]\s+(.+)/); + if (taskMatch) { + blocks.push({ + type: 'to_do', + to_do: { + rich_text: [{ type: 'text', text: { content: taskMatch[2] } }], + checked: taskMatch[1] === 'x', + }, + }); + i++; + continue; + } + + // Bullet list items + if (line.match(/^[-*]\s+/)) { + blocks.push({ + type: 'bulleted_list_item', + bulleted_list_item: { + rich_text: [{ type: 'text', text: { content: line.replace(/^[-*]\s+/, '') } }], + }, + }); + i++; + continue; + } + + // Numbered list items + if (line.match(/^\d+\.\s+/)) { + blocks.push({ + type: 'numbered_list_item', + numbered_list_item: { + rich_text: [{ type: 'text', text: { content: line.replace(/^\d+\.\s+/, '') } }], + }, + }); + i++; + continue; + } + + // Horizontal rule + if (line.match(/^---+$/)) { + blocks.push({ type: 'divider', divider: {} }); + i++; + continue; + } + + // Default: paragraph + blocks.push({ + type: 'paragraph', + paragraph: { + rich_text: [{ type: 'text', text: { content: line } }], + }, + }); + i++; + } + + return blocks; +} + +const notionConverter: NoteConverter = { + id: 'notion', + name: 'Notion', + requiresAuth: true, + + async import(input: ImportInput): Promise { + const token = input.accessToken; + if (!token) throw new Error('Notion import requires an access token. Connect your Notion account first.'); + if (!input.pageIds || input.pageIds.length === 0) { + throw new Error('No Notion pages selected for import'); + } + + const notes: ConvertedNote[] = []; + const warnings: string[] = []; + + for (const pageId of input.pageIds) { + try { + // Fetch page metadata + const page = await notionFetch(`${NOTION_API_BASE}/pages/${pageId}`, { + method: 'GET', + token, + }); + + // Extract title + const titleProp = page.properties?.title || page.properties?.Name; + const title = titleProp?.title?.[0]?.plain_text || 'Untitled'; + + // Fetch all blocks (paginated) + const allBlocks: any[] = []; + let cursor: string | undefined; + do { + const url = `${NOTION_API_BASE}/blocks/${pageId}/children?page_size=100${cursor ? `&start_cursor=${cursor}` : ''}`; + const result = await notionFetch(url, { method: 'GET', token }); + allBlocks.push(...(result.results || [])); + cursor = result.has_more ? result.next_cursor : undefined; + } while (cursor); + + // Handle table rows specially + const mdParts: string[] = []; + let inTable = false; + let tableRowIndex = 0; + + for (const block of allBlocks) { + if (block.type === 'table') { + inTable = true; + tableRowIndex = 0; + // Fetch table children + const tableChildren = await notionFetch( + `${NOTION_API_BASE}/blocks/${block.id}/children?page_size=100`, + { method: 'GET', token } + ); + for (const child of tableChildren.results || []) { + const rowMd = blockToMarkdown(child); + mdParts.push(rowMd); + if (tableRowIndex === 0) { + // Add separator after header + const cellCount = (child.table_row?.cells || []).length; + mdParts.push(`| ${Array(cellCount).fill('---').join(' | ')} |`); + } + tableRowIndex++; + } + inTable = false; + } else { + const md = blockToMarkdown(block); + if (md) mdParts.push(md); + } + } + + const markdown = mdParts.join('\n\n'); + const tiptapJson = markdownToTiptap(markdown); + const contentPlain = extractPlainTextFromTiptap(tiptapJson); + + // Extract tags from Notion properties + const tags: string[] = []; + if (page.properties) { + for (const [key, value] of Object.entries(page.properties) as [string, any][]) { + if (value.type === 'multi_select') { + tags.push(...(value.multi_select || []).map((s: any) => s.name.toLowerCase())); + } else if (value.type === 'select' && value.select) { + tags.push(value.select.name.toLowerCase()); + } + } + } + + notes.push({ + title, + content: tiptapJson, + contentPlain, + markdown, + tags: [...new Set(tags)], + sourceRef: { + source: 'notion', + externalId: pageId, + lastSyncedAt: Date.now(), + contentHash: String(allBlocks.length), + }, + }); + + // Recursively import child pages if requested + if (input.recursive) { + for (const block of allBlocks) { + if (block.type === 'child_page') { + try { + const childResult = await this.import({ + ...input, + pageIds: [block.id], + recursive: true, + }); + notes.push(...childResult.notes); + warnings.push(...childResult.warnings); + } catch (err) { + warnings.push(`Failed to import child page "${block.child_page?.title}": ${(err as Error).message}`); + } + } + } + } + } catch (err) { + warnings.push(`Failed to import page ${pageId}: ${(err as Error).message}`); + } + } + + return { notes, notebookTitle: 'Notion Import', warnings }; + }, + + async export(notes: NoteItem[], opts: ExportOptions): Promise { + const token = opts.accessToken; + if (!token) throw new Error('Notion export requires an access token. Connect your Notion account first.'); + + const warnings: string[] = []; + const results: any[] = []; + + for (const note of notes) { + try { + // Convert to markdown first + let md: string; + if (note.contentFormat === 'tiptap-json' && note.content) { + md = tiptapToMarkdown(note.content); + } else { + md = note.content?.replace(/<[^>]*>/g, '').trim() || ''; + } + + // Convert markdown to Notion blocks + const blocks = markdownToNotionBlocks(md); + + // Create page in Notion + // If parentId is provided, create as child page; otherwise create in workspace root + const parent = opts.parentId + ? { page_id: opts.parentId } + : { type: 'page_id' as const, page_id: opts.parentId || '' }; + + // For workspace-level pages, we need a database or page parent + // Default to creating standalone pages + const createBody: any = { + parent: opts.parentId + ? { page_id: opts.parentId } + : { type: 'workspace', workspace: true }, + properties: { + title: { + title: [{ type: 'text', text: { content: note.title } }], + }, + }, + children: blocks.slice(0, 100), // Notion limit: 100 blocks per request + }; + + const page = await notionFetch(`${NOTION_API_BASE}/pages`, { + method: 'POST', + token, + body: JSON.stringify(createBody), + }); + + results.push({ noteId: note.id, notionPageId: page.id }); + + // If more than 100 blocks, append in batches + if (blocks.length > 100) { + for (let i = 100; i < blocks.length; i += 100) { + const batch = blocks.slice(i, i + 100); + await notionFetch(`${NOTION_API_BASE}/blocks/${page.id}/children`, { + method: 'PATCH', + token, + body: JSON.stringify({ children: batch }), + }); + } + } + } catch (err) { + warnings.push(`Failed to export "${note.title}": ${(err as Error).message}`); + } + } + + // Return results as JSON since we don't produce a file + const data = new TextEncoder().encode(JSON.stringify({ exported: results, warnings })); + return { + data, + filename: 'notion-export-results.json', + mimeType: 'application/json', + }; + }, +}; + +registerConverter(notionConverter); diff --git a/modules/rnotes/converters/obsidian.ts b/modules/rnotes/converters/obsidian.ts new file mode 100644 index 0000000..17e8cbb --- /dev/null +++ b/modules/rnotes/converters/obsidian.ts @@ -0,0 +1,201 @@ +/** + * Obsidian vault ↔ rNotes converter. + * + * Import: ZIP of .md files with YAML frontmatter, [[wikilinks]], callouts, nested folders → tags + * Export: ZIP of .md files with YAML frontmatter, organized by notebook + */ + +import JSZip from 'jszip'; +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; +import { markdownToTiptap, tiptapToMarkdown, extractPlainTextFromTiptap } from './markdown-tiptap'; +import { registerConverter } from './index'; +import type { ConvertedNote, ImportInput, ImportResult, ExportOptions, ExportResult, NoteConverter } from './index'; +import type { NoteItem } from '../schemas'; + +/** Hash content for conflict detection. */ +function hashContent(content: string): string { + let hash = 0; + for (let i = 0; i < content.length; i++) { + const char = content.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash |= 0; + } + return Math.abs(hash).toString(36); +} + +/** Parse YAML frontmatter from an Obsidian markdown file. */ +function parseFrontmatter(content: string): { frontmatter: Record; body: string } { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/); + if (!match) return { frontmatter: {}, body: content }; + + try { + const frontmatter = parseYaml(match[1]) || {}; + return { frontmatter, body: match[2] }; + } catch { + return { frontmatter: {}, body: content }; + } +} + +/** Convert Obsidian [[wikilinks]] to standard markdown links. */ +function convertWikilinks(md: string): string { + // [[Page Name|Display Text]] → [Display Text](Page Name) + // [[Page Name]] → [Page Name](Page Name) + return md.replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g, '[$2]($1)') + .replace(/\[\[([^\]]+)\]\]/g, '[$1]($1)'); +} + +/** Convert Obsidian callouts to blockquotes. */ +function convertCallouts(md: string): string { + // > [!type] Title → > **Type:** Title + return md.replace(/^> \[!(\w+)\]\s*(.*)/gm, (_, type, title) => { + const label = type.charAt(0).toUpperCase() + type.slice(1); + return title ? `> **${label}:** ${title}` : `> **${label}**`; + }); +} + +/** Extract folder path as tag prefix from file path. */ +function pathToTags(filePath: string): string[] { + const parts = filePath.split('/').slice(0, -1); // Remove filename + // Filter out common vault root folders + const filtered = parts.filter(p => !['attachments', 'assets', 'templates', '.obsidian'].includes(p.toLowerCase())); + if (filtered.length === 0) return []; + return filtered.map(p => p.toLowerCase().replace(/\s+/g, '-')); +} + +/** Extract title from filename (without .md extension). */ +function titleFromPath(filePath: string): string { + const filename = filePath.split('/').pop() || 'Untitled'; + return filename.replace(/\.md$/i, ''); +} + +const obsidianConverter: NoteConverter = { + id: 'obsidian', + name: 'Obsidian', + requiresAuth: false, + + async import(input: ImportInput): Promise { + if (!input.fileData) { + throw new Error('Obsidian import requires a ZIP file'); + } + + const zip = await JSZip.loadAsync(input.fileData); + const notes: ConvertedNote[] = []; + const warnings: string[] = []; + let vaultName = 'Obsidian Import'; + + // Find markdown files in the ZIP + const mdFiles: { path: string; file: JSZip.JSZipObject }[] = []; + zip.forEach((path, file) => { + if (file.dir) return; + if (!path.endsWith('.md')) return; + // Skip hidden/config files + if (path.includes('.obsidian/') || path.includes('.trash/')) return; + mdFiles.push({ path, file }); + }); + + if (mdFiles.length === 0) { + warnings.push('No .md files found in the ZIP archive'); + return { notes, notebookTitle: vaultName, warnings }; + } + + // Try to detect vault name from common root folder + const firstPath = mdFiles[0].path; + const rootFolder = firstPath.split('/')[0]; + if (rootFolder && mdFiles.every(f => f.path.startsWith(rootFolder + '/'))) { + vaultName = rootFolder; + // Strip root folder prefix from all paths + for (const f of mdFiles) { + f.path = f.path.slice(rootFolder.length + 1); + } + } + + for (const { path, file } of mdFiles) { + try { + const raw = await file.async('string'); + const { frontmatter, body } = parseFrontmatter(raw); + + // Process markdown + let md = convertWikilinks(body); + md = convertCallouts(md); + + const title = frontmatter.title || titleFromPath(path); + const tiptapJson = markdownToTiptap(md); + const contentPlain = extractPlainTextFromTiptap(tiptapJson); + + // Collect tags from frontmatter + folder path + const tags: string[] = []; + if (frontmatter.tags) { + const fmTags = Array.isArray(frontmatter.tags) ? frontmatter.tags : [frontmatter.tags]; + tags.push(...fmTags.map((t: string) => String(t).toLowerCase().replace(/^#/, ''))); + } + tags.push(...pathToTags(path)); + + notes.push({ + title, + content: tiptapJson, + contentPlain, + markdown: md, + tags: [...new Set(tags)], + sourceRef: { + source: 'obsidian', + externalId: path, + lastSyncedAt: Date.now(), + contentHash: hashContent(raw), + }, + }); + } catch (err) { + warnings.push(`Failed to parse ${path}: ${(err as Error).message}`); + } + } + + return { notes, notebookTitle: vaultName, warnings }; + }, + + async export(notes: NoteItem[], opts: ExportOptions): Promise { + const zip = new JSZip(); + const notebookTitle = opts.notebookTitle || 'rNotes Export'; + + for (const note of notes) { + // Convert content to markdown + let md: string; + if (note.contentFormat === 'tiptap-json' && note.content) { + md = tiptapToMarkdown(note.content); + } else if (note.content) { + // Legacy HTML — strip tags for basic markdown + md = note.content.replace(/<[^>]*>/g, '').trim(); + } else { + md = ''; + } + + // Build YAML frontmatter + const frontmatter: Record = {}; + if (note.tags.length > 0) frontmatter.tags = note.tags; + frontmatter.created = new Date(note.createdAt).toISOString(); + frontmatter.updated = new Date(note.updatedAt).toISOString(); + if (note.type !== 'NOTE') frontmatter.type = note.type.toLowerCase(); + if (note.sourceRef) { + frontmatter['rnotes-id'] = note.id; + } + + const yamlStr = stringifyYaml(frontmatter).trim(); + const fileContent = `---\n${yamlStr}\n---\n\n${md}\n`; + + // Sanitize filename + const filename = note.title + .replace(/[<>:"/\\|?*]/g, '') + .replace(/\s+/g, ' ') + .trim() || 'Untitled'; + + zip.file(`${notebookTitle}/${filename}.md`, fileContent); + } + + const data = await zip.generateAsync({ type: 'uint8array' }); + return { + data, + filename: `${notebookTitle.replace(/\s+/g, '-').toLowerCase()}-obsidian.zip`, + mimeType: 'application/zip', + }; + }, +}; + +registerConverter(obsidianConverter); diff --git a/modules/rnotes/landing.ts b/modules/rnotes/landing.ts index 0047665..aba6aa2 100644 --- a/modules/rnotes/landing.ts +++ b/modules/rnotes/landing.ts @@ -107,17 +107,21 @@ export function renderLanding(): string { - +
🔄
-

Logseq Import & Export

+

Import & Export

- Export your notebooks as Logseq-compatible ZIP archives. Import a Logseq graph and keep your pages, - properties, tags, and hierarchy intact. -

-

- Round-trip fidelity: card types, tags, attachments, and parent-child structure all survive the journey. + Bring your notes from Logseq, Obsidian, + Notion, and Google Docs. + Export back to any format anytime — your data, your choice.

+
+ Logseq + Obsidian + Notion + Google Docs +
diff --git a/modules/rnotes/mod.ts b/modules/rnotes/mod.ts index 6fdd868..b3e45ac 100644 --- a/modules/rnotes/mod.ts +++ b/modules/rnotes/mod.ts @@ -14,8 +14,10 @@ import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule, SpaceLifecycleContext } from "../../shared/module"; import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; import { renderLanding } from "./landing"; -import { notebookSchema, notebookDocId, createNoteItem } from "./schemas"; -import type { NotebookDoc, NoteItem } from "./schemas"; +import { notebookSchema, notebookDocId, connectionsDocId, createNoteItem } from "./schemas"; +import type { NotebookDoc, NoteItem, ConnectionsDoc } from "./schemas"; +import { getConverter, getAllConverters } from "./converters/index"; +import type { ConvertedNote } from "./converters/index"; import type { SyncServer } from "../../server/local-first/sync-server"; const routes = new Hono(); @@ -514,6 +516,467 @@ routes.delete("/api/notes/:id", async (c) => { return c.json({ ok: true }); }); +// ── Import/Export API ── + +/** Helper: import ConvertedNotes into a notebook. */ +function importNotesIntoNotebook( + space: string, + notebookId: string, + convertedNotes: ConvertedNote[], +): { imported: number; updated: number } { + let imported = 0; + let updated = 0; + + ensureDoc(space, notebookId); + const docId = notebookDocId(space, notebookId); + + _syncServer!.changeDoc(docId, `Import ${convertedNotes.length} notes`, (d) => { + for (const cn of convertedNotes) { + // Check if a note with the same sourceRef already exists (re-import) + let existingId: string | null = null; + if (cn.sourceRef) { + for (const [id, item] of Object.entries(d.items)) { + if (item.sourceRef?.source === cn.sourceRef.source && + item.sourceRef?.externalId === cn.sourceRef.externalId) { + existingId = id; + break; + } + } + } + + if (existingId) { + // Update existing note + const item = d.items[existingId]; + item.title = cn.title; + item.content = cn.content; + item.contentPlain = cn.contentPlain; + item.contentFormat = 'tiptap-json'; + item.tags = cn.tags; + item.sourceRef = cn.sourceRef; + item.updatedAt = Date.now(); + updated++; + } else { + // Create new note + const noteId = newId(); + d.items[noteId] = { + ...createNoteItem(noteId, notebookId, cn.title, { + content: cn.content, + contentPlain: cn.contentPlain, + contentFormat: 'tiptap-json', + tags: cn.tags, + type: cn.type || 'NOTE', + sourceRef: cn.sourceRef, + }), + }; + imported++; + } + } + d.notebook.updatedAt = Date.now(); + }); + + return { imported, updated }; +} + +/** Get connection tokens for a space. */ +function getConnectionDoc(space: string): ConnectionsDoc | null { + const docId = connectionsDocId(space); + return _syncServer?.getDoc(docId) || null; +} + +// POST /api/import/upload — ZIP upload for Logseq/Obsidian +routes.post("/api/import/upload", async (c) => { + const space = c.req.param("space") || "demo"; + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const formData = await c.req.formData(); + const file = formData.get("file") as File | null; + const source = formData.get("source") as string; + const notebookId = formData.get("notebookId") as string | null; + + if (!file) return c.json({ error: "No file uploaded" }, 400); + if (!source || !['logseq', 'obsidian'].includes(source)) { + return c.json({ error: "source must be 'logseq' or 'obsidian'" }, 400); + } + + const converter = getConverter(source); + if (!converter) return c.json({ error: `Unknown converter: ${source}` }, 400); + + const fileData = new Uint8Array(await file.arrayBuffer()); + const result = await converter.import({ fileData }); + + // Determine target notebook + let targetNotebookId = notebookId; + if (!targetNotebookId) { + // Create a new notebook with the import title + targetNotebookId = newId(); + const now = Date.now(); + ensureDoc(space, targetNotebookId); + _syncServer!.changeDoc(notebookDocId(space, targetNotebookId), "Create import notebook", (d) => { + d.notebook.id = targetNotebookId!; + d.notebook.title = result.notebookTitle; + d.notebook.slug = slugify(result.notebookTitle); + d.notebook.createdAt = now; + d.notebook.updatedAt = now; + }); + } + + const { imported, updated } = importNotesIntoNotebook(space, targetNotebookId, result.notes); + + return c.json({ + ok: true, + notebookId: targetNotebookId, + notebookTitle: result.notebookTitle, + imported, + updated, + warnings: result.warnings, + }); +}); + +// POST /api/import/notion — Import selected Notion pages +routes.post("/api/import/notion", async (c) => { + const space = c.req.param("space") || "demo"; + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const body = await c.req.json(); + const { pageIds, notebookId, recursive } = body; + + if (!pageIds || !Array.isArray(pageIds) || pageIds.length === 0) { + return c.json({ error: "pageIds array is required" }, 400); + } + + const conn = getConnectionDoc(space); + if (!conn?.notion?.accessToken) { + return c.json({ error: "Notion not connected. Connect your Notion account first." }, 400); + } + + const converter = getConverter('notion')!; + const result = await converter.import({ + pageIds, + recursive: recursive || false, + accessToken: conn.notion.accessToken, + }); + + let targetNotebookId = notebookId; + if (!targetNotebookId) { + targetNotebookId = newId(); + const now = Date.now(); + ensureDoc(space, targetNotebookId); + _syncServer!.changeDoc(notebookDocId(space, targetNotebookId), "Create Notion import notebook", (d) => { + d.notebook.id = targetNotebookId!; + d.notebook.title = result.notebookTitle; + d.notebook.slug = slugify(result.notebookTitle); + d.notebook.createdAt = now; + d.notebook.updatedAt = now; + }); + } + + const { imported, updated } = importNotesIntoNotebook(space, targetNotebookId, result.notes); + + return c.json({ ok: true, notebookId: targetNotebookId, imported, updated, warnings: result.warnings }); +}); + +// POST /api/import/google-docs — Import selected Google Docs +routes.post("/api/import/google-docs", async (c) => { + const space = c.req.param("space") || "demo"; + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const body = await c.req.json(); + const { docIds, notebookId } = body; + + if (!docIds || !Array.isArray(docIds) || docIds.length === 0) { + return c.json({ error: "docIds array is required" }, 400); + } + + const conn = getConnectionDoc(space); + if (!conn?.google?.accessToken) { + return c.json({ error: "Google not connected. Connect your Google account first." }, 400); + } + + const converter = getConverter('google-docs')!; + const result = await converter.import({ + pageIds: docIds, + accessToken: conn.google.accessToken, + }); + + let targetNotebookId = notebookId; + if (!targetNotebookId) { + targetNotebookId = newId(); + const now = Date.now(); + ensureDoc(space, targetNotebookId); + _syncServer!.changeDoc(notebookDocId(space, targetNotebookId), "Create Google Docs import notebook", (d) => { + d.notebook.id = targetNotebookId!; + d.notebook.title = result.notebookTitle; + d.notebook.slug = slugify(result.notebookTitle); + d.notebook.createdAt = now; + d.notebook.updatedAt = now; + }); + } + + const { imported, updated } = importNotesIntoNotebook(space, targetNotebookId, result.notes); + + return c.json({ ok: true, notebookId: targetNotebookId, imported, updated, warnings: result.warnings }); +}); + +// GET /api/import/notion/pages — Browse Notion pages for selection +routes.get("/api/import/notion/pages", async (c) => { + const space = c.req.param("space") || "demo"; + const conn = getConnectionDoc(space); + if (!conn?.notion?.accessToken) { + return c.json({ error: "Notion not connected" }, 400); + } + + try { + const res = await fetch("https://api.notion.com/v1/search", { + method: "POST", + headers: { + "Authorization": `Bearer ${conn.notion.accessToken}`, + "Notion-Version": "2022-06-28", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + filter: { property: "object", value: "page" }, + sort: { direction: "descending", timestamp: "last_edited_time" }, + page_size: 50, + }), + }); + if (!res.ok) return c.json({ error: "Failed to fetch Notion pages" }, 502); + + const data = await res.json() as any; + const pages = (data.results || []).map((p: any) => { + const titleProp = p.properties?.title || p.properties?.Name; + const title = titleProp?.title?.[0]?.plain_text || "Untitled"; + return { + id: p.id, + title, + lastEdited: p.last_edited_time, + icon: p.icon?.emoji || null, + }; + }); + + return c.json({ pages }); + } catch (err) { + return c.json({ error: (err as Error).message }, 500); + } +}); + +// GET /api/import/google-docs/list — Browse Google Docs for selection +routes.get("/api/import/google-docs/list", async (c) => { + const space = c.req.param("space") || "demo"; + const conn = getConnectionDoc(space); + if (!conn?.google?.accessToken) { + return c.json({ error: "Google not connected" }, 400); + } + + try { + const res = await fetch( + "https://www.googleapis.com/drive/v3/files?q=mimeType='application/vnd.google-apps.document'&orderBy=modifiedTime desc&pageSize=50&fields=files(id,name,modifiedTime)", + { + headers: { "Authorization": `Bearer ${conn.google.accessToken}` }, + } + ); + if (!res.ok) return c.json({ error: "Failed to fetch Google Docs" }, 502); + + const data = await res.json() as any; + const docs = (data.files || []).map((f: any) => ({ + id: f.id, + title: f.name, + lastModified: f.modifiedTime, + })); + + return c.json({ docs }); + } catch (err) { + return c.json({ error: (err as Error).message }, 500); + } +}); + +// GET /api/export/obsidian — Download Obsidian-format ZIP +routes.get("/api/export/obsidian", async (c) => { + const space = c.req.param("space") || "demo"; + const notebookId = c.req.query("notebookId"); + if (!notebookId) return c.json({ error: "notebookId is required" }, 400); + + const docId = notebookDocId(space, notebookId); + const doc = _syncServer?.getDoc(docId); + if (!doc || !doc.notebook?.title) return c.json({ error: "Notebook not found" }, 404); + + const notes = Object.values(doc.items); + const converter = getConverter('obsidian')!; + const result = await converter.export(notes, { notebookTitle: doc.notebook.title }); + + return new Response(result.data as unknown as BodyInit, { + headers: { + "Content-Type": result.mimeType, + "Content-Disposition": `attachment; filename="${result.filename}"`, + }, + }); +}); + +// GET /api/export/logseq — Download Logseq-format ZIP +routes.get("/api/export/logseq", async (c) => { + const space = c.req.param("space") || "demo"; + const notebookId = c.req.query("notebookId"); + if (!notebookId) return c.json({ error: "notebookId is required" }, 400); + + const docId = notebookDocId(space, notebookId); + const doc = _syncServer?.getDoc(docId); + if (!doc || !doc.notebook?.title) return c.json({ error: "Notebook not found" }, 404); + + const notes = Object.values(doc.items); + const converter = getConverter('logseq')!; + const result = await converter.export(notes, { notebookTitle: doc.notebook.title }); + + return new Response(result.data as unknown as BodyInit, { + headers: { + "Content-Type": result.mimeType, + "Content-Disposition": `attachment; filename="${result.filename}"`, + }, + }); +}); + +// GET /api/export/markdown — Download universal Markdown ZIP +routes.get("/api/export/markdown", async (c) => { + const space = c.req.param("space") || "demo"; + const notebookId = c.req.query("notebookId"); + const noteIds = c.req.query("noteIds"); + + let notes: NoteItem[] = []; + let title = "rNotes Export"; + + if (notebookId) { + const docId = notebookDocId(space, notebookId); + const doc = _syncServer?.getDoc(docId); + if (!doc || !doc.notebook?.title) return c.json({ error: "Notebook not found" }, 404); + notes = Object.values(doc.items); + title = doc.notebook.title; + } else if (noteIds) { + const ids = noteIds.split(",").map(id => id.trim()); + for (const id of ids) { + const found = findNote(space, id); + if (found) notes.push(found.item); + } + } else { + return c.json({ error: "notebookId or noteIds is required" }, 400); + } + + // Use obsidian converter for generic markdown export (it produces clean markdown + YAML frontmatter) + const converter = getConverter('obsidian')!; + const result = await converter.export(notes, { notebookTitle: title }); + + // Rename the file + const filename = `${title.replace(/\s+/g, '-').toLowerCase()}-markdown.zip`; + return new Response(result.data as unknown as BodyInit, { + headers: { + "Content-Type": "application/zip", + "Content-Disposition": `attachment; filename="${filename}"`, + }, + }); +}); + +// POST /api/export/notion — Push notes to Notion +routes.post("/api/export/notion", async (c) => { + const space = c.req.param("space") || "demo"; + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const body = await c.req.json(); + const { notebookId, noteIds, parentId } = body; + + const conn = getConnectionDoc(space); + if (!conn?.notion?.accessToken) { + return c.json({ error: "Notion not connected" }, 400); + } + + let notes: NoteItem[] = []; + if (notebookId) { + const docId = notebookDocId(space, notebookId); + const doc = _syncServer?.getDoc(docId); + if (doc) notes = Object.values(doc.items); + } else if (noteIds && Array.isArray(noteIds)) { + for (const id of noteIds) { + const found = findNote(space, id); + if (found) notes.push(found.item); + } + } + + if (notes.length === 0) return c.json({ error: "No notes to export" }, 400); + + const converter = getConverter('notion')!; + const result = await converter.export(notes, { + accessToken: conn.notion.accessToken, + parentId, + }); + + const resultData = JSON.parse(new TextDecoder().decode(result.data)); + return c.json(resultData); +}); + +// POST /api/export/google-docs — Push notes to Google Docs +routes.post("/api/export/google-docs", async (c) => { + const space = c.req.param("space") || "demo"; + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const body = await c.req.json(); + const { notebookId, noteIds, parentId } = body; + + const conn = getConnectionDoc(space); + if (!conn?.google?.accessToken) { + return c.json({ error: "Google not connected" }, 400); + } + + let notes: NoteItem[] = []; + if (notebookId) { + const docId = notebookDocId(space, notebookId); + const doc = _syncServer?.getDoc(docId); + if (doc) notes = Object.values(doc.items); + } else if (noteIds && Array.isArray(noteIds)) { + for (const id of noteIds) { + const found = findNote(space, id); + if (found) notes.push(found.item); + } + } + + if (notes.length === 0) return c.json({ error: "No notes to export" }, 400); + + const converter = getConverter('google-docs')!; + const result = await converter.export(notes, { + accessToken: conn.google.accessToken, + parentId, + }); + + const resultData = JSON.parse(new TextDecoder().decode(result.data)); + return c.json(resultData); +}); + +// GET /api/connections — Status of all integrations +routes.get("/api/connections", async (c) => { + const space = c.req.param("space") || "demo"; + const conn = getConnectionDoc(space); + + return c.json({ + notion: conn?.notion ? { + connected: true, + workspaceName: conn.notion.workspaceName, + connectedAt: conn.notion.connectedAt, + } : { connected: false }, + google: conn?.google ? { + connected: true, + email: conn.google.email, + connectedAt: conn.google.connectedAt, + } : { connected: false }, + logseq: { connected: true, note: "File-based, no account needed" }, + obsidian: { connected: true, note: "File-based, no account needed" }, + }); +}); + // ── Page route ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; diff --git a/modules/rnotes/schemas.ts b/modules/rnotes/schemas.ts index f3a050c..0ef9501 100644 --- a/modules/rnotes/schemas.ts +++ b/modules/rnotes/schemas.ts @@ -13,6 +13,13 @@ import type { DocSchema } from '../../shared/local-first/document'; // ── Document types ── +export interface SourceRef { + source: 'logseq' | 'obsidian' | 'notion' | 'google-docs' | 'manual'; + externalId: string; // Notion page ID, Google Doc ID, file path, etc. + lastSyncedAt: number; + contentHash?: string; // For conflict detection on re-import +} + export interface NoteItem { id: string; notebookId: string; @@ -31,6 +38,7 @@ export interface NoteItem { isPinned: boolean; sortOrder: number; tags: string[]; + sourceRef?: SourceRef; createdAt: number; updatedAt: number; } @@ -60,15 +68,43 @@ export interface NotebookDoc { // ── Schema registration ── +export interface ConnectionsDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + notion?: { + accessToken: string; + workspaceId: string; + workspaceName: string; + connectedAt: number; + }; + google?: { + refreshToken: string; + accessToken: string; + expiresAt: number; + email: string; + connectedAt: number; + }; +} + +/** Generate a docId for a space's integration connections. */ +export function connectionsDocId(space: string) { + return `${space}:notes:connections` as const; +} + export const notebookSchema: DocSchema = { module: 'notes', collection: 'notebooks', - version: 2, + version: 3, init: (): NotebookDoc => ({ meta: { module: 'notes', collection: 'notebooks', - version: 2, + version: 3, spaceSlug: '', createdAt: Date.now(), }, @@ -90,6 +126,7 @@ export const notebookSchema: DocSchema = { if (!(item as any).contentFormat) (item as any).contentFormat = 'html'; } } + // v2→v3: sourceRef field is optional, no migration needed return doc; }, }; diff --git a/package.json b/package.json index ed6b4db..4bc850d 100644 --- a/package.json +++ b/package.json @@ -37,13 +37,16 @@ "cron-parser": "^5.5.0", "hono": "^4.11.7", "imapflow": "^1.0.170", + "jszip": "^3.10.1", "lowlight": "^3.3.0", "mailparser": "^3.7.2", + "marked": "^17.0.3", "nodemailer": "^6.9.0", "perfect-arrows": "^0.3.7", "perfect-freehand": "^1.2.2", "postgres": "^3.4.5", - "sharp": "^0.33.0" + "sharp": "^0.33.0", + "yaml": "^2.8.2" }, "devDependencies": { "@types/mailparser": "^3.4.0", diff --git a/server/index.ts b/server/index.ts index f45690e..5c6b7c1 100644 --- a/server/index.ts +++ b/server/index.ts @@ -49,7 +49,7 @@ import { pubsModule } from "../modules/rpubs/mod"; import { cartModule } from "../modules/rcart/mod"; import { swagModule } from "../modules/rswag/mod"; import { choicesModule } from "../modules/rchoices/mod"; -import { fundsModule } from "../modules/rfunds/mod"; +import { flowsModule } from "../modules/rflows/mod"; import { filesModule } from "../modules/rfiles/mod"; import { forumModule } from "../modules/rforum/mod"; import { walletModule } from "../modules/rwallet/mod"; @@ -78,6 +78,11 @@ import { fetchLandingPage } from "./landing-proxy"; import { syncServer } from "./sync-instance"; import { loadAllDocs } from "./local-first/doc-persistence"; import { backupRouter } from "./local-first/backup-routes"; +import { oauthRouter } from "./oauth/index"; +import { setNotionOAuthSyncServer } from "./oauth/notion"; +import { setGoogleOAuthSyncServer } from "./oauth/google"; +import { notificationRouter } from "./notification-routes"; +import { registerUserConnection, unregisterUserConnection, notify } from "./notification-service"; // Register modules registerModule(canvasModule); @@ -86,7 +91,7 @@ registerModule(pubsModule); registerModule(cartModule); registerModule(swagModule); registerModule(choicesModule); -registerModule(fundsModule); +registerModule(flowsModule); registerModule(filesModule); registerModule(forumModule); registerModule(walletModule); @@ -160,6 +165,12 @@ app.route("/api/spaces", spaces); // ── Backup API (encrypted blob storage) ── app.route("/api/backup", backupRouter); +// ── OAuth API (Notion, Google integrations) ── +app.route("/api/oauth", oauthRouter); + +// ── Notifications API ── +app.route("/api/notifications", notificationRouter); + // ── mi — AI assistant endpoint ── const MI_MODEL = process.env.MI_MODEL || "llama3.2"; const OLLAMA_URL = process.env.OLLAMA_URL || "http://localhost:11434"; @@ -309,7 +320,7 @@ function generateFallbackResponse( } if (q.includes("help") || q.includes("what can")) { - return `rSpace has ${modules.length} apps you can use. Some popular ones: **rSpace** (spatial canvas), **rNotes** (notes), **rChat** (messaging), **rFunds** (community funding), and **rVote** (governance). What would you like to explore?`; + return `rSpace has ${modules.length} apps you can use. Some popular ones: **rSpace** (spatial canvas), **rNotes** (notes), **rChat** (messaging), **rFlows** (community funding), and **rVote** (governance). What would you like to explore?`; } if (q.includes("search") || q.includes("find")) { @@ -1960,6 +1971,9 @@ const server = Bun.serve({ // Register with DocSyncManager for multi-doc sync syncServer.addPeer(peerId, ws, claims ? { sub: claims.sub, username: claims.username } : undefined); + // Register for notification delivery + if (claims?.sub) registerUserConnection(claims.sub, ws); + const nestLabel = nestFrom ? ` (nested from ${nestFrom})` : ""; console.log(`[WS] Client ${peerId} connected to ${communitySlug} (mode: ${mode})${nestLabel}`); @@ -2094,6 +2108,23 @@ const server = Bun.serve({ fromUsername: senderInfo.username, viewport, })); + + // Persist ping as a notification + const targetDid = _client.data.claims?.sub; + if (targetDid && ws.data.claims?.sub) { + notify({ + userDid: targetDid, + category: 'social', + eventType: 'ping_user', + title: `${senderInfo.username} pinged you in "${communitySlug}"`, + spaceSlug: communitySlug, + actorDid: ws.data.claims.sub, + actorUsername: senderInfo.username, + actionUrl: `/${communitySlug}/rspace`, + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24h + }).catch(() => {}); + } + break; } } @@ -2154,7 +2185,10 @@ const server = Bun.serve({ }, close(ws: ServerWebSocket) { - const { communitySlug, peerId } = ws.data; + const { communitySlug, peerId, claims } = ws.data; + + // Unregister from notification delivery + if (claims?.sub) unregisterUserConnection(claims.sub, ws); // Broadcast peer-left before cleanup const slugAnnouncements = peerAnnouncements.get(communitySlug); @@ -2201,6 +2235,9 @@ const server = Bun.serve({ } } } + // Pass syncServer to OAuth handlers + setNotionOAuthSyncServer(syncServer); + setGoogleOAuthSyncServer(syncServer); })(); // Ensure generated files directory exists diff --git a/server/notification-routes.ts b/server/notification-routes.ts new file mode 100644 index 0000000..a71c904 --- /dev/null +++ b/server/notification-routes.ts @@ -0,0 +1,131 @@ +/** + * Notification REST API — mounted at /api/notifications + * + * All endpoints require Bearer auth (same pattern as spaces.ts). + */ + +import { Hono } from "hono"; +import { + verifyEncryptIDToken, + extractToken, +} from "@encryptid/sdk/server"; +import { + getUserNotifications, + getUnreadCount, + markNotificationRead, + markAllNotificationsRead, + dismissNotification, + getNotificationPreferences, + upsertNotificationPreferences, +} from "../src/encryptid/db"; + +export const notificationRouter = new Hono(); + +// ── Auth helper ── +async function requireAuth(req: Request) { + const token = extractToken(req.headers); + if (!token) return null; + try { + return await verifyEncryptIDToken(token); + } catch { + return null; + } +} + +// ── GET / — Paginated notification list ── +notificationRouter.get("/", async (c) => { + const claims = await requireAuth(c.req.raw); + if (!claims) return c.json({ error: "Authentication required" }, 401); + + const url = new URL(c.req.url); + const unreadOnly = url.searchParams.get("unread") === "true"; + const category = url.searchParams.get("category") || undefined; + const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 100); + const offset = Number(url.searchParams.get("offset")) || 0; + + const notifications = await getUserNotifications(claims.sub, { + unreadOnly, + category, + limit, + offset, + }); + + return c.json({ notifications }); +}); + +// ── GET /count — Lightweight unread count (polling fallback) ── +notificationRouter.get("/count", async (c) => { + const claims = await requireAuth(c.req.raw); + if (!claims) return c.json({ error: "Authentication required" }, 401); + + const count = await getUnreadCount(claims.sub); + return c.json({ unreadCount: count }); +}); + +// ── PATCH /:id/read — Mark one notification as read ── +notificationRouter.patch("/:id/read", async (c) => { + const claims = await requireAuth(c.req.raw); + if (!claims) return c.json({ error: "Authentication required" }, 401); + + const id = c.req.param("id"); + const ok = await markNotificationRead(id, claims.sub); + if (!ok) return c.json({ error: "Notification not found" }, 404); + return c.json({ ok: true }); +}); + +// ── POST /read-all — Mark all read (optional scope) ── +notificationRouter.post("/read-all", async (c) => { + const claims = await requireAuth(c.req.raw); + if (!claims) return c.json({ error: "Authentication required" }, 401); + + let spaceSlug: string | undefined; + let category: string | undefined; + try { + const body = await c.req.json(); + spaceSlug = body.spaceSlug; + category = body.category; + } catch { + // No body is fine — mark everything read + } + + const count = await markAllNotificationsRead(claims.sub, { spaceSlug, category }); + return c.json({ ok: true, markedRead: count }); +}); + +// ── DELETE /:id — Dismiss/archive a notification ── +notificationRouter.delete("/:id", async (c) => { + const claims = await requireAuth(c.req.raw); + if (!claims) return c.json({ error: "Authentication required" }, 401); + + const id = c.req.param("id"); + const ok = await dismissNotification(id, claims.sub); + if (!ok) return c.json({ error: "Notification not found" }, 404); + return c.json({ ok: true }); +}); + +// ── GET /preferences — Get notification preferences ── +notificationRouter.get("/preferences", async (c) => { + const claims = await requireAuth(c.req.raw); + if (!claims) return c.json({ error: "Authentication required" }, 401); + + const prefs = await getNotificationPreferences(claims.sub); + return c.json({ preferences: prefs || { + emailEnabled: true, + pushEnabled: true, + quietHoursStart: null, + quietHoursEnd: null, + mutedSpaces: [], + mutedCategories: [], + digestFrequency: 'none', + }}); +}); + +// ── PATCH /preferences — Update notification preferences ── +notificationRouter.patch("/preferences", async (c) => { + const claims = await requireAuth(c.req.raw); + if (!claims) return c.json({ error: "Authentication required" }, 401); + + const body = await c.req.json(); + const prefs = await upsertNotificationPreferences(claims.sub, body); + return c.json({ preferences: prefs }); +}); diff --git a/server/notification-service.ts b/server/notification-service.ts new file mode 100644 index 0000000..a01b904 --- /dev/null +++ b/server/notification-service.ts @@ -0,0 +1,153 @@ +/** + * Notification Service — Core notification dispatch + WebSocket delivery. + * + * Modules call `notify()` to persist a notification to PostgreSQL and + * attempt real-time delivery via WebSocket. The WS registry tracks + * which user DIDs have active connections. + */ + +import type { ServerWebSocket } from "bun"; +import { + createNotification, + getUnreadCount, + markNotificationDelivered, + listSpaceMembers, + type StoredNotification, +} from "../src/encryptid/db"; + +// ============================================================================ +// TYPES +// ============================================================================ + +export type NotificationCategory = 'space' | 'module' | 'system' | 'social'; + +export type NotificationEventType = + // Space + | 'access_request' | 'access_approved' | 'access_denied' + | 'member_joined' | 'member_left' | 'role_changed' + | 'nest_request' | 'nest_created' | 'space_invite' + // Module + | 'inbox_new_mail' | 'inbox_approval_needed' | 'choices_result' + | 'notes_shared' | 'canvas_mention' + // System + | 'guardian_invite' | 'guardian_accepted' | 'recovery_initiated' + | 'recovery_approved' | 'device_linked' | 'security_alert' + // Social + | 'mention' | 'ping_user'; + +export interface NotifyOptions { + userDid: string; + category: NotificationCategory; + eventType: NotificationEventType; + title: string; + body?: string; + spaceSlug?: string; + moduleId?: string; + actionUrl?: string; + actorDid?: string; + actorUsername?: string; + metadata?: Record; + expiresAt?: Date; +} + +// ============================================================================ +// WS CONNECTION REGISTRY +// ============================================================================ + +const userConnections = new Map>>(); + +export function registerUserConnection(userDid: string, ws: ServerWebSocket): void { + let conns = userConnections.get(userDid); + if (!conns) { + conns = new Set(); + userConnections.set(userDid, conns); + } + conns.add(ws); +} + +export function unregisterUserConnection(userDid: string, ws: ServerWebSocket): void { + const conns = userConnections.get(userDid); + if (!conns) return; + conns.delete(ws); + if (conns.size === 0) userConnections.delete(userDid); +} + +// ============================================================================ +// CORE DISPATCH +// ============================================================================ + +export async function notify(opts: NotifyOptions): Promise { + const id = crypto.randomUUID(); + + // 1. Persist to DB + const stored = await createNotification({ + id, + userDid: opts.userDid, + category: opts.category, + eventType: opts.eventType, + title: opts.title, + body: opts.body, + spaceSlug: opts.spaceSlug, + moduleId: opts.moduleId, + actionUrl: opts.actionUrl, + actorDid: opts.actorDid, + actorUsername: opts.actorUsername, + metadata: opts.metadata, + expiresAt: opts.expiresAt, + }); + + // 2. Attempt WS delivery + const conns = userConnections.get(opts.userDid); + if (conns && conns.size > 0) { + const unreadCount = await getUnreadCount(opts.userDid); + const payload = JSON.stringify({ + type: "notification", + notification: { + id: stored.id, + category: stored.category, + eventType: stored.eventType, + title: stored.title, + body: stored.body, + spaceSlug: stored.spaceSlug, + actorUsername: stored.actorUsername, + actionUrl: stored.actionUrl, + createdAt: stored.createdAt, + }, + unreadCount, + }); + + let delivered = false; + for (const ws of conns) { + try { + if (ws.readyState === WebSocket.OPEN) { + ws.send(payload); + delivered = true; + } + } catch { + // Connection may have closed between check and send + } + } + + if (delivered) { + await markNotificationDelivered(stored.id, 'ws'); + } + } + + return stored; +} + +// ============================================================================ +// CONVENIENCE: NOTIFY SPACE ADMINS/MODS +// ============================================================================ + +export async function notifySpaceAdmins( + spaceSlug: string, + opts: Omit, +): Promise { + const members = await listSpaceMembers(spaceSlug); + const targets = members.filter(m => m.role === 'admin' || m.role === 'moderator'); + + await Promise.all( + targets.map(m => notify({ ...opts, userDid: m.userDID })), + ); +} diff --git a/server/oauth/google.ts b/server/oauth/google.ts new file mode 100644 index 0000000..236818b --- /dev/null +++ b/server/oauth/google.ts @@ -0,0 +1,204 @@ +/** + * Google OAuth2 flow with token refresh. + * + * GET /authorize?space=X → redirect to Google + * GET /callback → exchange code, store tokens, redirect back + * POST /disconnect?space=X → revoke token + * POST /refresh?space=X → refresh access token using refresh token + */ + +import { Hono } from 'hono'; +import * as Automerge from '@automerge/automerge'; +import { connectionsDocId } from '../../modules/rnotes/schemas'; +import type { ConnectionsDoc } from '../../modules/rnotes/schemas'; +import type { SyncServer } from '../local-first/sync-server'; + +const googleOAuthRoutes = new Hono(); + +const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID || ''; +const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET || ''; +const GOOGLE_REDIRECT_URI = process.env.GOOGLE_REDIRECT_URI || ''; + +const SCOPES = [ + 'https://www.googleapis.com/auth/documents', + 'https://www.googleapis.com/auth/drive.file', + 'https://www.googleapis.com/auth/userinfo.email', +].join(' '); + +let _syncServer: SyncServer | null = null; + +export function setGoogleOAuthSyncServer(ss: SyncServer) { + _syncServer = ss; +} + +function ensureConnectionsDoc(space: string): ConnectionsDoc { + if (!_syncServer) throw new Error('SyncServer not initialized'); + const docId = connectionsDocId(space); + let doc = _syncServer.getDoc(docId); + if (!doc) { + doc = Automerge.change(Automerge.init(), 'init connections', (d) => { + d.meta = { + module: 'notes', + collection: 'connections', + version: 1, + spaceSlug: space, + createdAt: Date.now(), + }; + }); + _syncServer.setDoc(docId, doc); + } + return doc; +} + +// GET /authorize — redirect to Google OAuth +googleOAuthRoutes.get('/authorize', (c) => { + const space = c.req.query('space'); + if (!space) return c.json({ error: 'space query param required' }, 400); + if (!GOOGLE_CLIENT_ID) return c.json({ error: 'Google OAuth not configured' }, 500); + + const state = Buffer.from(JSON.stringify({ space })).toString('base64url'); + const params = new URLSearchParams({ + client_id: GOOGLE_CLIENT_ID, + redirect_uri: GOOGLE_REDIRECT_URI, + response_type: 'code', + scope: SCOPES, + access_type: 'offline', + prompt: 'consent', + state, + }); + + return c.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params}`); +}); + +// GET /callback — exchange code for tokens +googleOAuthRoutes.get('/callback', async (c) => { + const code = c.req.query('code'); + const stateParam = c.req.query('state'); + + if (!code || !stateParam) return c.json({ error: 'Missing code or state' }, 400); + + let state: { space: string }; + try { + state = JSON.parse(Buffer.from(stateParam, 'base64url').toString()); + } catch { + return c.json({ error: 'Invalid state parameter' }, 400); + } + + // Exchange code for tokens + const tokenRes = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + code, + client_id: GOOGLE_CLIENT_ID, + client_secret: GOOGLE_CLIENT_SECRET, + redirect_uri: GOOGLE_REDIRECT_URI, + grant_type: 'authorization_code', + }), + }); + + if (!tokenRes.ok) { + const err = await tokenRes.text(); + return c.json({ error: `Token exchange failed: ${err}` }, 502); + } + + const tokenData = await tokenRes.json() as any; + + // Get user email + let email = ''; + try { + const userRes = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { + headers: { 'Authorization': `Bearer ${tokenData.access_token}` }, + }); + if (userRes.ok) { + const userData = await userRes.json() as any; + email = userData.email || ''; + } + } catch { + // Non-critical + } + + // Store tokens + ensureConnectionsDoc(state.space); + const docId = connectionsDocId(state.space); + + _syncServer!.changeDoc(docId, 'Connect Google', (d) => { + d.google = { + refreshToken: tokenData.refresh_token || '', + accessToken: tokenData.access_token, + expiresAt: Date.now() + (tokenData.expires_in || 3600) * 1000, + email, + connectedAt: Date.now(), + }; + }); + + const redirectUrl = `/${state.space}/rnotes?connected=google`; + return c.redirect(redirectUrl); +}); + +// POST /disconnect — revoke and remove token +googleOAuthRoutes.post('/disconnect', async (c) => { + const space = c.req.query('space'); + if (!space) return c.json({ error: 'space query param required' }, 400); + + const docId = connectionsDocId(space); + const doc = _syncServer?.getDoc(docId); + + if (doc?.google?.accessToken) { + // Revoke token with Google + try { + await fetch(`https://oauth2.googleapis.com/revoke?token=${doc.google.accessToken}`, { + method: 'POST', + }); + } catch { + // Best-effort revocation + } + + _syncServer!.changeDoc(docId, 'Disconnect Google', (d) => { + delete d.google; + }); + } + + return c.json({ ok: true }); +}); + +// POST /refresh — refresh access token +googleOAuthRoutes.post('/refresh', async (c) => { + const space = c.req.query('space'); + if (!space) return c.json({ error: 'space query param required' }, 400); + + const docId = connectionsDocId(space); + const doc = _syncServer?.getDoc(docId); + + if (!doc?.google?.refreshToken) { + return c.json({ error: 'No Google refresh token available' }, 400); + } + + const tokenRes = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: GOOGLE_CLIENT_ID, + client_secret: GOOGLE_CLIENT_SECRET, + refresh_token: doc.google.refreshToken, + grant_type: 'refresh_token', + }), + }); + + if (!tokenRes.ok) { + return c.json({ error: 'Token refresh failed' }, 502); + } + + const tokenData = await tokenRes.json() as any; + + _syncServer!.changeDoc(docId, 'Refresh Google token', (d) => { + if (d.google) { + d.google.accessToken = tokenData.access_token; + d.google.expiresAt = Date.now() + (tokenData.expires_in || 3600) * 1000; + } + }); + + return c.json({ ok: true }); +}); + +export { googleOAuthRoutes }; diff --git a/server/oauth/index.ts b/server/oauth/index.ts new file mode 100644 index 0000000..09de1eb --- /dev/null +++ b/server/oauth/index.ts @@ -0,0 +1,20 @@ +/** + * OAuth route mounting for external integrations. + * + * Provides OAuth2 authorize/callback/disconnect flows for: + * - Notion (workspace-level integration) + * - Google (user-level, with token refresh) + * + * Tokens are stored in Automerge docs per space via SyncServer. + */ + +import { Hono } from 'hono'; +import { notionOAuthRoutes } from './notion'; +import { googleOAuthRoutes } from './google'; + +const oauthRouter = new Hono(); + +oauthRouter.route('/notion', notionOAuthRoutes); +oauthRouter.route('/google', googleOAuthRoutes); + +export { oauthRouter }; diff --git a/server/oauth/notion.ts b/server/oauth/notion.ts new file mode 100644 index 0000000..1f83c43 --- /dev/null +++ b/server/oauth/notion.ts @@ -0,0 +1,129 @@ +/** + * Notion OAuth2 flow. + * + * GET /authorize?space=X → redirect to Notion + * GET /callback → exchange code, store token, redirect back + * POST /disconnect?space=X → revoke token + */ + +import { Hono } from 'hono'; +import * as Automerge from '@automerge/automerge'; +import { connectionsDocId } from '../../modules/rnotes/schemas'; +import type { ConnectionsDoc } from '../../modules/rnotes/schemas'; +import type { SyncServer } from '../local-first/sync-server'; + +const notionOAuthRoutes = new Hono(); + +const NOTION_CLIENT_ID = process.env.NOTION_CLIENT_ID || ''; +const NOTION_CLIENT_SECRET = process.env.NOTION_CLIENT_SECRET || ''; +const NOTION_REDIRECT_URI = process.env.NOTION_REDIRECT_URI || ''; + +// We'll need a reference to the sync server — set externally +let _syncServer: SyncServer | null = null; + +export function setNotionOAuthSyncServer(ss: SyncServer) { + _syncServer = ss; +} + +function ensureConnectionsDoc(space: string): ConnectionsDoc { + if (!_syncServer) throw new Error('SyncServer not initialized'); + const docId = connectionsDocId(space); + let doc = _syncServer.getDoc(docId); + if (!doc) { + doc = Automerge.change(Automerge.init(), 'init connections', (d) => { + d.meta = { + module: 'notes', + collection: 'connections', + version: 1, + spaceSlug: space, + createdAt: Date.now(), + }; + }); + _syncServer.setDoc(docId, doc); + } + return doc; +} + +// GET /authorize — redirect to Notion OAuth +notionOAuthRoutes.get('/authorize', (c) => { + const space = c.req.query('space'); + if (!space) return c.json({ error: 'space query param required' }, 400); + if (!NOTION_CLIENT_ID) return c.json({ error: 'Notion OAuth not configured' }, 500); + + const state = Buffer.from(JSON.stringify({ space })).toString('base64url'); + const url = `https://api.notion.com/v1/oauth/authorize?client_id=${NOTION_CLIENT_ID}&response_type=code&owner=user&redirect_uri=${encodeURIComponent(NOTION_REDIRECT_URI)}&state=${state}`; + + return c.redirect(url); +}); + +// GET /callback — exchange code for token +notionOAuthRoutes.get('/callback', async (c) => { + const code = c.req.query('code'); + const stateParam = c.req.query('state'); + + if (!code || !stateParam) return c.json({ error: 'Missing code or state' }, 400); + + let state: { space: string }; + try { + state = JSON.parse(Buffer.from(stateParam, 'base64url').toString()); + } catch { + return c.json({ error: 'Invalid state parameter' }, 400); + } + + // Exchange code for access token + const tokenRes = await fetch('https://api.notion.com/v1/oauth/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Basic ${Buffer.from(`${NOTION_CLIENT_ID}:${NOTION_CLIENT_SECRET}`).toString('base64')}`, + }, + body: JSON.stringify({ + grant_type: 'authorization_code', + code, + redirect_uri: NOTION_REDIRECT_URI, + }), + }); + + if (!tokenRes.ok) { + const err = await tokenRes.text(); + return c.json({ error: `Token exchange failed: ${err}` }, 502); + } + + const tokenData = await tokenRes.json() as any; + + // Store token in Automerge connections doc + ensureConnectionsDoc(state.space); + const docId = connectionsDocId(state.space); + + _syncServer!.changeDoc(docId, 'Connect Notion', (d) => { + d.notion = { + accessToken: tokenData.access_token, + workspaceId: tokenData.workspace_id || '', + workspaceName: tokenData.workspace_name || 'Notion Workspace', + connectedAt: Date.now(), + }; + }); + + // Redirect back to rNotes + const redirectUrl = `/${state.space}/rnotes?connected=notion`; + return c.redirect(redirectUrl); +}); + +// POST /disconnect — revoke and remove token +notionOAuthRoutes.post('/disconnect', async (c) => { + const space = c.req.query('space'); + if (!space) return c.json({ error: 'space query param required' }, 400); + + const docId = connectionsDocId(space); + const doc = _syncServer?.getDoc(docId); + + if (doc?.notion) { + _syncServer!.changeDoc(docId, 'Disconnect Notion', (d) => { + delete d.notion; + }); + } + + return c.json({ ok: true }); +}); + +export { notionOAuthRoutes }; diff --git a/server/shell.ts b/server/shell.ts index 6a5750e..f2198cb 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -113,6 +113,7 @@ export function renderShell(opts: ShellOptions): string { diff --git a/server/spaces.ts b/server/spaces.ts index 2ecc5ec..455f7b4 100644 --- a/server/spaces.ts +++ b/server/spaces.ts @@ -68,6 +68,7 @@ import { getAllModules, getModule } from "../shared/module"; import type { SpaceLifecycleContext } from "../shared/module"; import { syncServer } from "./sync-instance"; import { seedTemplateShapes } from "./seed-template"; +import { notify, notifySpaceAdmins } from "./notification-service"; // ── Role types and helpers ── @@ -738,6 +739,20 @@ spaces.patch("/:slug/members/:did", async (c) => { } setMember(slug, did, body.role); + + // Notify the member about their role change + notify({ + userDid: did, + category: 'space', + eventType: 'role_changed', + title: `Your role in "${slug}" changed to ${body.role}`, + spaceSlug: slug, + actorDid: claims.sub, + actorUsername: claims.username, + actionUrl: `/${slug}/rspace`, + metadata: { newRole: body.role }, + }).catch(() => {}); + return c.json({ ok: true, did, role: body.role }); }); @@ -768,6 +783,18 @@ spaces.delete("/:slug/members/:did", async (c) => { } removeMember(slug, did); + + // Notify the removed member + notify({ + userDid: did, + category: 'space', + eventType: 'member_left', + title: `You were removed from "${slug}"`, + spaceSlug: slug, + actorDid: claims.sub, + actorUsername: claims.username, + }).catch(() => {}); + return c.json({ ok: true }); }); @@ -1802,6 +1829,19 @@ spaces.post("/:slug/access-requests", async (c) => { }; accessRequests.set(reqId, request); + // Notify space admins about the access request + notifySpaceAdmins(slug, { + category: 'space', + eventType: 'access_request', + title: `${request.requesterUsername} requested access to "${slug}"`, + body: body.message || undefined, + spaceSlug: slug, + actorDid: claims.sub, + actorUsername: request.requesterUsername, + actionUrl: `/${slug}/rspace`, + metadata: { requestId: reqId }, + }).catch(() => {}); + return c.json({ id: reqId, status: "pending" }, 201); }); @@ -1844,6 +1884,17 @@ spaces.patch("/:slug/access-requests/:reqId", async (c) => { request.status = "denied"; request.resolvedAt = Date.now(); request.resolvedBy = claims.sub; + + notify({ + userDid: request.requesterDID, + category: 'space', + eventType: 'access_denied', + title: `Your request to join "${slug}" was denied`, + spaceSlug: slug, + actorDid: claims.sub, + actorUsername: claims.username, + }).catch(() => {}); + return c.json({ ok: true, status: "denied" }); } @@ -1855,6 +1906,19 @@ spaces.patch("/:slug/access-requests/:reqId", async (c) => { // Add requester as member setMember(slug, request.requesterDID, body.role || "viewer", request.requesterUsername); + notify({ + userDid: request.requesterDID, + category: 'space', + eventType: 'access_approved', + title: `You've been granted access to "${slug}"`, + body: `You were added as ${body.role || "viewer"}.`, + spaceSlug: slug, + actorDid: claims.sub, + actorUsername: claims.username, + actionUrl: `/${slug}/rspace`, + metadata: { role: body.role || "viewer" }, + }).catch(() => {}); + return c.json({ ok: true, status: "approved" }); } @@ -2109,6 +2173,20 @@ spaces.post("/:slug/members/add", async (c) => { console.error("Failed to sync member to EncryptID:", e); } + // Notify the new member + notify({ + userDid: user.did, + category: 'space', + eventType: 'member_joined', + title: `You were added to "${slug}"`, + body: `You were added as ${role} by ${claims.username || "an admin"}.`, + spaceSlug: slug, + actorDid: claims.sub, + actorUsername: claims.username, + actionUrl: `/${slug}/rspace`, + metadata: { role }, + }).catch(() => {}); + return c.json({ ok: true, did: user.did, username: user.username, role }); }); diff --git a/shared/components/rstack-identity.ts b/shared/components/rstack-identity.ts index 631307c..6c272d8 100644 --- a/shared/components/rstack-identity.ts +++ b/shared/components/rstack-identity.ts @@ -252,20 +252,8 @@ function _navUrl(space: string, moduleId: string): string { // ── The custom element ── -interface AccessNotification { - id: string; - spaceSlug: string; - requesterDID: string; - requesterUsername: string; - message?: string; - status: string; - createdAt: number; -} - export class RStackIdentity extends HTMLElement { #shadow: ShadowRoot; - #notifications: AccessNotification[] = []; - #notifTimer: ReturnType | null = null; constructor() { super(); @@ -275,7 +263,6 @@ export class RStackIdentity extends HTMLElement { connectedCallback() { this.#refreshIfNeeded(); this.#render(); - this.#startNotifPolling(); // Belt-and-suspenders: if a session already exists on page load, // ensure the user's personal space is provisioned (catches edge @@ -287,7 +274,6 @@ export class RStackIdentity extends HTMLElement { } disconnectedCallback() { - this.#stopNotifPolling(); } async #refreshIfNeeded() { @@ -329,48 +315,11 @@ export class RStackIdentity extends HTMLElement { const payload = parseJWT(newToken); storeSession(newToken, (payload.username as string) || username, (payload.did as string) || did); this.#render(); - this.#startNotifPolling(); - this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true })); + this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true })); } } catch { /* offline — keep whatever we have */ } } - #startNotifPolling() { - this.#stopNotifPolling(); - if (!getSession()) return; - this.#fetchNotifications(); - this.#notifTimer = setInterval(() => this.#fetchNotifications(), 30_000); - } - - #stopNotifPolling() { - if (this.#notifTimer) { clearInterval(this.#notifTimer); this.#notifTimer = null; } - } - - async #fetchNotifications() { - const token = getAccessToken(); - if (!token) { this.#notifications = []; return; } - try { - const res = await fetch("/api/spaces/notifications", { - headers: { Authorization: `Bearer ${token}` }, - }); - if (res.ok) { - const data = await res.json(); - const prev = this.#notifications.length; - this.#notifications = data.requests || []; - // Update badge without full re-render - if (prev !== this.#notifications.length) this.#updateBadge(); - } - } catch { /* offline */ } - } - - #updateBadge() { - const badge = this.#shadow.querySelector(".notif-badge") as HTMLElement; - if (badge) { - badge.textContent = this.#notifications.length > 0 ? String(this.#notifications.length) : ""; - badge.style.display = this.#notifications.length > 0 ? "flex" : "none"; - } - } - #render() { const session = getSession(); @@ -379,38 +328,16 @@ export class RStackIdentity extends HTMLElement { const did = session.claims.did || session.claims.sub; const displayName = username || (did.length > 24 ? did.slice(0, 16) + "..." + did.slice(-6) : did); const initial = username ? username[0].toUpperCase() : did.slice(8, 10).toUpperCase(); - const notifCount = this.#notifications.length; - - // Build notifications HTML - let notifsHTML = ""; - if (notifCount > 0) { - notifsHTML = ` - - - ${this.#notifications.map((n) => ` -
-
${(n.requesterUsername || "Someone").replace(/ wants to join ${n.spaceSlug.replace(/
- ${n.message ? `
"${n.message.replace(/` : ""} -
- - -
-
- `).join("")} - `; - } this.#shadow.innerHTML = `
${initial}
- ${notifCount > 0 ? notifCount : ""}
${displayName}