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
\u2190 Notebooks
${this.esc(nb.title)}${syncBadge}
+
+
+ Export
+
+ New Note
`;
return;
@@ -1183,6 +1188,10 @@ Gear: EUR 400 (10%)Maya is tracking expenses in rF
this.navZone.innerHTML = `
Notebooks
+
+
+ Import / Export
+
+ New Notebook
`;
@@ -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 => `
+
+ ${this.sourceIcon(s)} ${this.sourceName(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.
+
Connect ${this.sourceName(this.activeSource)}
+
`;
+ }
+
+ if (isApiSource) {
+ // Show page list for selection
+ return `
+
+
+ ${this.remotePages.length === 0
+ ? '
No pages found. Click Refresh to load.
'
+ : this.remotePages.map(p => `
+
+
+ ${p.icon || (this.activeSource === 'notion' ? 'N' : 'G')}
+ ${this.esc(p.title)}
+
+ `).join('')}
+
+
+ Target notebook:
+
+ Create new notebook
+ ${this.notebooks.map(nb => `${this.esc(nb.title)} `).join('')}
+
+
+
+ ${this.importing ? 'Importing...' : `Import Selected (${this.selectedPages.size})`}
+ `;
+ }
+
+ // File-based import (Obsidian/Logseq)
+ return `
+
+
Upload a ZIP of your ${this.sourceName(this.activeSource)} vault
+
+
+ ${this.selectedFile ? this.esc(this.selectedFile.name) : 'Choose File'}
+
+
or drag & drop a ZIP file here
+
+
+ Target notebook:
+
+ Create new notebook
+ ${this.notebooks.map(nb => `${this.esc(nb.title)} `).join('')}
+
+
+
+ ${this.importing ? 'Importing...' : 'Import'}
+ `;
+ }
+
+ 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.
+
Connect ${this.sourceName(this.activeSource)}
+
`;
+ }
+
+ return `
+
+ Source notebook:
+
+ Select a notebook
+ ${this.notebooks.map(nb => `${this.esc(nb.title)} `).join('')}
+
+
+
+ ${this.exporting ? 'Exporting...'
+ : isApiSource ? `Export to ${this.sourceName(this.activeSource)}`
+ : 'Download ZIP'}
+ `;
+ }
+
+ 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 += ``;
+ }
+ }
+
+ // 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: ``,
+ });
+ 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(``);
+ 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 ``;
+ }
+
+ 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 = `
-
- Access Requests
- ${this.#notifications.map((n) => `
-
-
${(n.requesterUsername || "Someone").replace(/ wants to join ${n.spaceSlug.replace(/
- ${n.message ? `
"${n.message.replace(/` : ""}
-
- Approve
- Deny
-
-
- `).join("")}
- `;
- }
this.#shadow.innerHTML = `
${initial}
-
${notifCount > 0 ? notifCount : ""}
${displayName}
- ${notifsHTML}
👤 My Account
🌐 My Spaces
@@ -430,37 +357,6 @@ export class RStackIdentity extends HTMLElement {
document.addEventListener("click", () => dropdown.classList.remove("open"));
- // Notification approve/deny handlers
- this.#shadow.querySelectorAll("[data-notif-action]").forEach((el) => {
- el.addEventListener("click", async (e) => {
- e.stopPropagation();
- const btn = el as HTMLButtonElement;
- const action = btn.dataset.notifAction as "approve" | "deny";
- const slug = btn.dataset.slug!;
- const reqId = btn.dataset.reqId!;
- btn.disabled = true;
- btn.textContent = action === "approve" ? "Approving..." : "Denying...";
- try {
- const token = getAccessToken();
- const res = await fetch(`/api/spaces/${slug}/access-requests/${reqId}`, {
- method: "PATCH",
- headers: {
- "Content-Type": "application/json",
- ...(token ? { Authorization: `Bearer ${token}` } : {}),
- },
- body: JSON.stringify({ action }),
- });
- if (!res.ok) throw new Error("Failed");
- // Refresh notifications and re-render
- await this.#fetchNotifications();
- this.#render();
- } catch {
- btn.disabled = false;
- btn.textContent = action === "approve" ? "Approve" : "Deny";
- }
- });
- });
-
this.#shadow.querySelectorAll("[data-action]").forEach((el) => {
el.addEventListener("click", (e) => {
e.stopPropagation();
@@ -469,8 +365,6 @@ export class RStackIdentity extends HTMLElement {
if (action === "signout") {
clearSession();
resetDocBridge();
- this.#stopNotifPolling();
- this.#notifications = [];
this.#render();
this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true }));
} else if (action === "my-account") {
@@ -580,8 +474,7 @@ export class RStackIdentity extends HTMLElement {
storeSession(data.token, data.username || "", data.did || "");
close();
this.#render();
- this.#startNotifPolling();
- this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true }));
+ this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true }));
callbacks?.onSuccess?.();
// Auto-redirect to personal space
autoResolveSpace(data.token, data.username || "");
@@ -656,8 +549,7 @@ export class RStackIdentity extends HTMLElement {
storeSession(data.token, username, data.did || "");
close();
this.#render();
- this.#startNotifPolling();
- this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true }));
+ this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true }));
callbacks?.onSuccess?.();
// Auto-redirect to personal space
autoResolveSpace(data.token, username);
diff --git a/shared/components/rstack-notification-bell.ts b/shared/components/rstack-notification-bell.ts
new file mode 100644
index 0000000..bc54212
--- /dev/null
+++ b/shared/components/rstack-notification-bell.ts
@@ -0,0 +1,510 @@
+/**
+ *
— Notification bell with dropdown panel.
+ *
+ * Shows unread count badge over a bell icon. Click opens a dropdown
+ * with the notification list. Listens for real-time WS events and
+ * polls /api/notifications/count as a fallback.
+ */
+
+import { getSession } from "./rstack-identity";
+
+const POLL_INTERVAL = 30_000; // 30s fallback poll
+
+interface NotificationItem {
+ id: string;
+ category: string;
+ eventType: string;
+ title: string;
+ body: string | null;
+ spaceSlug: string | null;
+ actorUsername: string | null;
+ actionUrl: string | null;
+ createdAt: string;
+ read: boolean;
+}
+
+export class RStackNotificationBell extends HTMLElement {
+ #shadow: ShadowRoot;
+ #unreadCount = 0;
+ #notifications: NotificationItem[] = [];
+ #pollTimer: ReturnType | null = null;
+ #open = false;
+ #loading = false;
+
+ constructor() {
+ super();
+ this.#shadow = this.attachShadow({ mode: "open" });
+ }
+
+ connectedCallback() {
+ this.#render();
+ this.#fetchCount();
+ this.#pollTimer = setInterval(() => this.#fetchCount(), POLL_INTERVAL);
+
+ // Listen for real-time WS notifications
+ window.addEventListener("rspace-notification", this.#onWsNotification as EventListener);
+
+ // Re-render on auth change
+ document.addEventListener("auth-change", this.#onAuthChange);
+ }
+
+ disconnectedCallback() {
+ if (this.#pollTimer) clearInterval(this.#pollTimer);
+ window.removeEventListener("rspace-notification", this.#onWsNotification as EventListener);
+ document.removeEventListener("auth-change", this.#onAuthChange);
+ }
+
+ #onAuthChange = () => {
+ this.#unreadCount = 0;
+ this.#notifications = [];
+ this.#open = false;
+ this.#render();
+ this.#fetchCount();
+ };
+
+ #onWsNotification = (e: CustomEvent) => {
+ const data = e.detail;
+ if (!data?.notification) return;
+
+ this.#unreadCount = data.unreadCount ?? this.#unreadCount + 1;
+ // Prepend to list if panel is loaded
+ this.#notifications.unshift({
+ ...data.notification,
+ read: false,
+ });
+ this.#render();
+ };
+
+ #getToken(): string | null {
+ const session = getSession();
+ return session?.accessToken ?? null;
+ }
+
+ async #fetchCount() {
+ const token = this.#getToken();
+ if (!token) {
+ this.#unreadCount = 0;
+ this.#render();
+ return;
+ }
+
+ try {
+ const res = await fetch("/api/notifications/count", {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ if (res.ok) {
+ const data = await res.json();
+ this.#unreadCount = data.unreadCount || 0;
+ this.#render();
+ }
+ } catch {
+ // Silently fail
+ }
+ }
+
+ async #fetchNotifications() {
+ const token = this.#getToken();
+ if (!token) return;
+
+ this.#loading = true;
+ this.#render();
+
+ try {
+ const res = await fetch("/api/notifications?limit=20", {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ if (res.ok) {
+ const data = await res.json();
+ this.#notifications = data.notifications || [];
+ }
+ } catch {
+ // Silently fail
+ }
+ this.#loading = false;
+ this.#render();
+ }
+
+ async #markRead(id: string) {
+ const token = this.#getToken();
+ if (!token) return;
+
+ try {
+ await fetch(`/api/notifications/${id}/read`, {
+ method: "PATCH",
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ } catch {
+ // Silently fail
+ }
+
+ const n = this.#notifications.find(n => n.id === id);
+ if (n && !n.read) {
+ n.read = true;
+ this.#unreadCount = Math.max(0, this.#unreadCount - 1);
+ this.#render();
+ }
+ }
+
+ async #markAllRead() {
+ const token = this.#getToken();
+ if (!token) return;
+
+ try {
+ await fetch("/api/notifications/read-all", {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ },
+ body: "{}",
+ });
+ } catch {
+ // Silently fail
+ }
+
+ this.#notifications.forEach(n => n.read = true);
+ this.#unreadCount = 0;
+ this.#render();
+ }
+
+ async #dismiss(id: string) {
+ const token = this.#getToken();
+ if (!token) return;
+
+ try {
+ await fetch(`/api/notifications/${id}`, {
+ method: "DELETE",
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ } catch {
+ // Silently fail
+ }
+
+ const idx = this.#notifications.findIndex(n => n.id === id);
+ if (idx >= 0) {
+ const n = this.#notifications[idx];
+ if (!n.read) this.#unreadCount = Math.max(0, this.#unreadCount - 1);
+ this.#notifications.splice(idx, 1);
+ this.#render();
+ }
+ }
+
+ #togglePanel() {
+ this.#open = !this.#open;
+ if (this.#open && this.#notifications.length === 0) {
+ this.#fetchNotifications();
+ }
+ this.#render();
+ }
+
+ #categoryIcon(category: string): string {
+ switch (category) {
+ case "space": return "🏠";
+ case "module": return "📦";
+ case "system": return "🔐";
+ case "social": return "💬";
+ default: return "🔔";
+ }
+ }
+
+ #timeAgo(iso: string): string {
+ const diff = Date.now() - new Date(iso).getTime();
+ const mins = Math.floor(diff / 60_000);
+ if (mins < 1) return "just now";
+ if (mins < 60) return `${mins}m ago`;
+ const hrs = Math.floor(mins / 60);
+ if (hrs < 24) return `${hrs}h ago`;
+ const days = Math.floor(hrs / 24);
+ return `${days}d ago`;
+ }
+
+ #render() {
+ const session = getSession();
+ if (!session) {
+ this.#shadow.innerHTML = "";
+ return;
+ }
+
+ const badge = this.#unreadCount > 0
+ ? `${this.#unreadCount > 99 ? "99+" : this.#unreadCount} `
+ : "";
+
+ let panelHTML = "";
+ if (this.#open) {
+ const header = `
+
+ `;
+
+ let body: string;
+ if (this.#loading) {
+ body = `Loading...
`;
+ } else if (this.#notifications.length === 0) {
+ body = `No notifications yet
`;
+ } else {
+ body = this.#notifications.map(n => `
+
+
${this.#categoryIcon(n.category)}
+
+
${n.title}
+ ${n.body ? `
${n.body}
` : ""}
+
+ ${n.actorUsername ? `${n.actorUsername} ` : ""}
+ ${this.#timeAgo(n.createdAt)}
+
+
+
×
+
+ `).join("");
+ }
+
+ panelHTML = `${header}${body}
`;
+ }
+
+ this.#shadow.innerHTML = `
+
+
+
+
+
+
+
+ ${badge}
+
+ ${panelHTML}
+
+ `;
+
+ // ── Event listeners ──
+ const toggleBtn = this.#shadow.getElementById("bell-toggle");
+ toggleBtn?.addEventListener("click", (e) => {
+ e.stopPropagation();
+ this.#togglePanel();
+ });
+
+ // Close on outside click
+ const closeHandler = () => {
+ if (this.#open) {
+ this.#open = false;
+ this.#render();
+ }
+ };
+ document.addEventListener("click", closeHandler, { once: true });
+
+ // Stop propagation from panel clicks
+ this.#shadow.querySelector(".panel")?.addEventListener("click", (e) => e.stopPropagation());
+
+ // Mark all read
+ this.#shadow.querySelector('[data-action="mark-all-read"]')?.addEventListener("click", (e) => {
+ e.stopPropagation();
+ this.#markAllRead();
+ });
+
+ // Notification item clicks (mark read + navigate)
+ this.#shadow.querySelectorAll(".notif-item").forEach((el) => {
+ const id = (el as HTMLElement).dataset.id!;
+ el.addEventListener("click", (e) => {
+ e.stopPropagation();
+ this.#markRead(id);
+ const n = this.#notifications.find(n => n.id === id);
+ if (n?.actionUrl) {
+ window.location.href = n.actionUrl;
+ }
+ });
+ });
+
+ // Dismiss buttons
+ this.#shadow.querySelectorAll(".notif-dismiss").forEach((btn) => {
+ const id = (btn as HTMLElement).dataset.dismiss!;
+ btn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ this.#dismiss(id);
+ });
+ });
+ }
+
+ static define(tag = "rstack-notification-bell") {
+ if (!customElements.get(tag)) customElements.define(tag, RStackNotificationBell);
+ }
+}
+
+// ============================================================================
+// STYLES
+// ============================================================================
+
+const STYLES = `
+:host {
+ display: inline-flex;
+ align-items: center;
+}
+
+.bell-wrapper {
+ position: relative;
+}
+
+.bell-btn {
+ position: relative;
+ background: none;
+ border: none;
+ color: var(--rs-text-muted, #94a3b8);
+ cursor: pointer;
+ padding: 6px;
+ border-radius: 8px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: color 0.15s, background 0.15s;
+}
+.bell-btn:hover {
+ color: var(--rs-text-primary, #e2e8f0);
+ background: var(--rs-bg-hover, rgba(255,255,255,0.08));
+}
+
+.badge {
+ position: absolute;
+ top: 2px;
+ right: 2px;
+ min-width: 16px;
+ height: 16px;
+ border-radius: 8px;
+ background: #ef4444;
+ color: white;
+ font-size: 10px;
+ font-weight: 700;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0 4px;
+ line-height: 1;
+ pointer-events: none;
+}
+
+.panel {
+ position: absolute;
+ top: 100%;
+ right: 0;
+ margin-top: 8px;
+ width: 360px;
+ max-height: 480px;
+ overflow-y: auto;
+ border-radius: 10px;
+ background: var(--rs-bg-surface, #1e293b);
+ border: 1px solid var(--rs-border, rgba(255,255,255,0.1));
+ box-shadow: 0 8px 30px rgba(0,0,0,0.3);
+ z-index: 200;
+}
+
+.panel-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--rs-border, rgba(255,255,255,0.1));
+}
+
+.panel-title {
+ font-size: 0.875rem;
+ font-weight: 600;
+ color: var(--rs-text-primary, #e2e8f0);
+}
+
+.mark-all-btn {
+ background: none;
+ border: none;
+ color: var(--rs-accent, #06b6d4);
+ font-size: 0.75rem;
+ cursor: pointer;
+ padding: 4px 8px;
+ border-radius: 4px;
+ transition: background 0.15s;
+}
+.mark-all-btn:hover {
+ background: var(--rs-bg-hover, rgba(255,255,255,0.08));
+}
+
+.panel-empty {
+ padding: 32px 16px;
+ text-align: center;
+ color: var(--rs-text-muted, #94a3b8);
+ font-size: 0.8rem;
+}
+
+.notif-item {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ padding: 10px 16px;
+ cursor: pointer;
+ transition: background 0.15s;
+ border-bottom: 1px solid var(--rs-border, rgba(255,255,255,0.05));
+}
+.notif-item:hover {
+ background: var(--rs-bg-hover, rgba(255,255,255,0.06));
+}
+.notif-item.unread {
+ background: rgba(6, 182, 212, 0.05);
+}
+.notif-item.unread .notif-title {
+ font-weight: 600;
+}
+
+.notif-icon {
+ flex-shrink: 0;
+ font-size: 1rem;
+ margin-top: 2px;
+}
+
+.notif-content {
+ flex: 1;
+ min-width: 0;
+}
+
+.notif-title {
+ font-size: 0.8rem;
+ color: var(--rs-text-primary, #e2e8f0);
+ line-height: 1.3;
+}
+
+.notif-body {
+ font-size: 0.75rem;
+ color: var(--rs-text-muted, #94a3b8);
+ margin-top: 2px;
+ line-height: 1.3;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.notif-meta {
+ display: flex;
+ gap: 8px;
+ margin-top: 4px;
+ font-size: 0.7rem;
+ color: var(--rs-text-muted, #64748b);
+}
+
+.notif-actor {
+ font-weight: 500;
+}
+
+.notif-dismiss {
+ flex-shrink: 0;
+ background: none;
+ border: none;
+ color: var(--rs-text-muted, #64748b);
+ font-size: 1rem;
+ cursor: pointer;
+ padding: 2px 4px;
+ border-radius: 4px;
+ opacity: 0;
+ transition: opacity 0.15s, color 0.15s;
+}
+.notif-item:hover .notif-dismiss {
+ opacity: 1;
+}
+.notif-dismiss:hover {
+ color: var(--rs-text-primary, #e2e8f0);
+}
+`;
diff --git a/src/encryptid/db.ts b/src/encryptid/db.ts
index 790bd44..f7b91ce 100644
--- a/src/encryptid/db.ts
+++ b/src/encryptid/db.ts
@@ -918,4 +918,256 @@ export async function revokeSpaceInvite(id: string, spaceSlug: string): Promise<
return result.count > 0;
}
+// ============================================================================
+// NOTIFICATION OPERATIONS
+// ============================================================================
+
+export interface StoredNotification {
+ id: string;
+ userDid: string;
+ category: 'space' | 'module' | 'system' | 'social';
+ eventType: string;
+ title: string;
+ body: string | null;
+ spaceSlug: string | null;
+ moduleId: string | null;
+ actionUrl: string | null;
+ actorDid: string | null;
+ actorUsername: string | null;
+ metadata: Record;
+ read: boolean;
+ dismissed: boolean;
+ deliveredWs: boolean;
+ deliveredEmail: boolean;
+ deliveredPush: boolean;
+ createdAt: string;
+ readAt: string | null;
+ expiresAt: string | null;
+}
+
+function rowToNotification(row: any): StoredNotification {
+ return {
+ id: row.id,
+ userDid: row.user_did,
+ category: row.category,
+ eventType: row.event_type,
+ title: row.title,
+ body: row.body || null,
+ spaceSlug: row.space_slug || null,
+ moduleId: row.module_id || null,
+ actionUrl: row.action_url || null,
+ actorDid: row.actor_did || null,
+ actorUsername: row.actor_username || null,
+ metadata: row.metadata || {},
+ read: row.read,
+ dismissed: row.dismissed,
+ deliveredWs: row.delivered_ws,
+ deliveredEmail: row.delivered_email,
+ deliveredPush: row.delivered_push,
+ createdAt: row.created_at?.toISOString?.() || new Date(row.created_at).toISOString(),
+ readAt: row.read_at ? (row.read_at?.toISOString?.() || new Date(row.read_at).toISOString()) : null,
+ expiresAt: row.expires_at ? (row.expires_at?.toISOString?.() || new Date(row.expires_at).toISOString()) : null,
+ };
+}
+
+export async function createNotification(notif: {
+ id: string;
+ userDid: string;
+ category: 'space' | 'module' | 'system' | 'social';
+ eventType: string;
+ title: string;
+ body?: string;
+ spaceSlug?: string;
+ moduleId?: string;
+ actionUrl?: string;
+ actorDid?: string;
+ actorUsername?: string;
+ metadata?: Record;
+ expiresAt?: Date;
+}): Promise {
+ const rows = await sql`
+ INSERT INTO notifications (id, user_did, category, event_type, title, body, space_slug, module_id, action_url, actor_did, actor_username, metadata, expires_at)
+ VALUES (
+ ${notif.id},
+ ${notif.userDid},
+ ${notif.category},
+ ${notif.eventType},
+ ${notif.title},
+ ${notif.body || null},
+ ${notif.spaceSlug || null},
+ ${notif.moduleId || null},
+ ${notif.actionUrl || null},
+ ${notif.actorDid || null},
+ ${notif.actorUsername || null},
+ ${JSON.stringify(notif.metadata || {})},
+ ${notif.expiresAt || null}
+ )
+ RETURNING *
+ `;
+ return rowToNotification(rows[0]);
+}
+
+export async function getUserNotifications(
+ userDid: string,
+ opts: { unreadOnly?: boolean; limit?: number; offset?: number; category?: string } = {},
+): Promise {
+ const limit = opts.limit || 50;
+ const offset = opts.offset || 0;
+
+ let rows;
+ if (opts.unreadOnly && opts.category) {
+ rows = await sql`
+ SELECT * FROM notifications
+ WHERE user_did = ${userDid} AND NOT dismissed AND NOT read AND category = ${opts.category}
+ ORDER BY created_at DESC LIMIT ${limit} OFFSET ${offset}
+ `;
+ } else if (opts.unreadOnly) {
+ rows = await sql`
+ SELECT * FROM notifications
+ WHERE user_did = ${userDid} AND NOT dismissed AND NOT read
+ ORDER BY created_at DESC LIMIT ${limit} OFFSET ${offset}
+ `;
+ } else if (opts.category) {
+ rows = await sql`
+ SELECT * FROM notifications
+ WHERE user_did = ${userDid} AND NOT dismissed AND category = ${opts.category}
+ ORDER BY created_at DESC LIMIT ${limit} OFFSET ${offset}
+ `;
+ } else {
+ rows = await sql`
+ SELECT * FROM notifications
+ WHERE user_did = ${userDid} AND NOT dismissed
+ ORDER BY created_at DESC LIMIT ${limit} OFFSET ${offset}
+ `;
+ }
+ return rows.map(rowToNotification);
+}
+
+export async function getUnreadCount(userDid: string): Promise {
+ const [row] = await sql`
+ SELECT COUNT(*)::int as count FROM notifications
+ WHERE user_did = ${userDid} AND NOT read AND NOT dismissed
+ `;
+ return row.count;
+}
+
+export async function markNotificationRead(id: string, userDid: string): Promise {
+ const result = await sql`
+ UPDATE notifications SET read = TRUE, read_at = NOW()
+ WHERE id = ${id} AND user_did = ${userDid}
+ `;
+ return result.count > 0;
+}
+
+export async function markAllNotificationsRead(
+ userDid: string,
+ opts: { spaceSlug?: string; category?: string } = {},
+): Promise {
+ let result;
+ if (opts.spaceSlug && opts.category) {
+ result = await sql`
+ UPDATE notifications SET read = TRUE, read_at = NOW()
+ WHERE user_did = ${userDid} AND NOT read AND space_slug = ${opts.spaceSlug} AND category = ${opts.category}
+ `;
+ } else if (opts.spaceSlug) {
+ result = await sql`
+ UPDATE notifications SET read = TRUE, read_at = NOW()
+ WHERE user_did = ${userDid} AND NOT read AND space_slug = ${opts.spaceSlug}
+ `;
+ } else if (opts.category) {
+ result = await sql`
+ UPDATE notifications SET read = TRUE, read_at = NOW()
+ WHERE user_did = ${userDid} AND NOT read AND category = ${opts.category}
+ `;
+ } else {
+ result = await sql`
+ UPDATE notifications SET read = TRUE, read_at = NOW()
+ WHERE user_did = ${userDid} AND NOT read
+ `;
+ }
+ return result.count;
+}
+
+export async function dismissNotification(id: string, userDid: string): Promise {
+ const result = await sql`
+ UPDATE notifications SET dismissed = TRUE
+ WHERE id = ${id} AND user_did = ${userDid}
+ `;
+ return result.count > 0;
+}
+
+export async function markNotificationDelivered(id: string, channel: 'ws' | 'email' | 'push'): Promise {
+ const col = channel === 'ws' ? 'delivered_ws' : channel === 'email' ? 'delivered_email' : 'delivered_push';
+ await sql.unsafe(`UPDATE notifications SET ${col} = TRUE WHERE id = $1`, [id]);
+}
+
+export async function cleanExpiredNotifications(): Promise {
+ const result = await sql`DELETE FROM notifications WHERE expires_at IS NOT NULL AND expires_at < NOW()`;
+ return result.count;
+}
+
+// ── Notification Preferences ──
+
+export interface StoredNotificationPreferences {
+ userDid: string;
+ emailEnabled: boolean;
+ pushEnabled: boolean;
+ quietHoursStart: string | null;
+ quietHoursEnd: string | null;
+ mutedSpaces: string[];
+ mutedCategories: string[];
+ digestFrequency: 'none' | 'daily' | 'weekly';
+ updatedAt: string;
+}
+
+function rowToPreferences(row: any): StoredNotificationPreferences {
+ return {
+ userDid: row.user_did,
+ emailEnabled: row.email_enabled,
+ pushEnabled: row.push_enabled,
+ quietHoursStart: row.quiet_hours_start || null,
+ quietHoursEnd: row.quiet_hours_end || null,
+ mutedSpaces: row.muted_spaces || [],
+ mutedCategories: row.muted_categories || [],
+ digestFrequency: row.digest_frequency || 'none',
+ updatedAt: row.updated_at?.toISOString?.() || new Date(row.updated_at).toISOString(),
+ };
+}
+
+export async function getNotificationPreferences(userDid: string): Promise {
+ const [row] = await sql`SELECT * FROM notification_preferences WHERE user_did = ${userDid}`;
+ if (!row) return null;
+ return rowToPreferences(row);
+}
+
+export async function upsertNotificationPreferences(
+ userDid: string,
+ prefs: Partial>,
+): Promise {
+ const rows = await sql`
+ INSERT INTO notification_preferences (user_did, email_enabled, push_enabled, quiet_hours_start, quiet_hours_end, muted_spaces, muted_categories, digest_frequency)
+ VALUES (
+ ${userDid},
+ ${prefs.emailEnabled ?? true},
+ ${prefs.pushEnabled ?? true},
+ ${prefs.quietHoursStart || null},
+ ${prefs.quietHoursEnd || null},
+ ${prefs.mutedSpaces || []},
+ ${prefs.mutedCategories || []},
+ ${prefs.digestFrequency || 'none'}
+ )
+ ON CONFLICT (user_did) DO UPDATE SET
+ email_enabled = COALESCE(${prefs.emailEnabled ?? null}::boolean, notification_preferences.email_enabled),
+ push_enabled = COALESCE(${prefs.pushEnabled ?? null}::boolean, notification_preferences.push_enabled),
+ quiet_hours_start = COALESCE(${prefs.quietHoursStart ?? null}, notification_preferences.quiet_hours_start),
+ quiet_hours_end = COALESCE(${prefs.quietHoursEnd ?? null}, notification_preferences.quiet_hours_end),
+ muted_spaces = COALESCE(${prefs.mutedSpaces ?? null}, notification_preferences.muted_spaces),
+ muted_categories = COALESCE(${prefs.mutedCategories ?? null}, notification_preferences.muted_categories),
+ digest_frequency = COALESCE(${prefs.digestFrequency ?? null}, notification_preferences.digest_frequency),
+ updated_at = NOW()
+ RETURNING *
+ `;
+ return rowToPreferences(rows[0]);
+}
+
export { sql };
diff --git a/src/encryptid/schema.sql b/src/encryptid/schema.sql
index 0afaa31..3572013 100644
--- a/src/encryptid/schema.sql
+++ b/src/encryptid/schema.sql
@@ -182,3 +182,49 @@ CREATE TABLE IF NOT EXISTS space_invites (
);
CREATE INDEX IF NOT EXISTS idx_space_invites_token ON space_invites(token);
CREATE INDEX IF NOT EXISTS idx_space_invites_space ON space_invites(space_slug);
+
+-- ============================================================================
+-- NOTIFICATIONS
+-- ============================================================================
+
+CREATE TABLE IF NOT EXISTS notifications (
+ id TEXT PRIMARY KEY,
+ user_did TEXT NOT NULL,
+ category TEXT NOT NULL CHECK (category IN ('space', 'module', 'system', 'social')),
+ event_type TEXT NOT NULL,
+ title TEXT NOT NULL,
+ body TEXT,
+ space_slug TEXT,
+ module_id TEXT,
+ action_url TEXT,
+ actor_did TEXT,
+ actor_username TEXT,
+ metadata JSONB DEFAULT '{}',
+ read BOOLEAN DEFAULT FALSE,
+ dismissed BOOLEAN DEFAULT FALSE,
+ delivered_ws BOOLEAN DEFAULT FALSE,
+ delivered_email BOOLEAN DEFAULT FALSE,
+ delivered_push BOOLEAN DEFAULT FALSE,
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ read_at TIMESTAMPTZ,
+ expires_at TIMESTAMPTZ
+);
+
+CREATE INDEX IF NOT EXISTS idx_notif_user_unread ON notifications(user_did, read)
+ WHERE NOT read AND NOT dismissed;
+CREATE INDEX IF NOT EXISTS idx_notif_user_created ON notifications(user_did, created_at DESC);
+CREATE INDEX IF NOT EXISTS idx_notif_expires ON notifications(expires_at)
+ WHERE expires_at IS NOT NULL;
+
+CREATE TABLE IF NOT EXISTS notification_preferences (
+ user_did TEXT PRIMARY KEY,
+ email_enabled BOOLEAN DEFAULT TRUE,
+ push_enabled BOOLEAN DEFAULT TRUE,
+ quiet_hours_start TEXT,
+ quiet_hours_end TEXT,
+ muted_spaces TEXT[] DEFAULT '{}',
+ muted_categories TEXT[] DEFAULT '{}',
+ digest_frequency TEXT DEFAULT 'none'
+ CHECK (digest_frequency IN ('none', 'daily', 'weekly')),
+ updated_at TIMESTAMPTZ DEFAULT NOW()
+);
diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts
index 7e9e4cb..00ed4d8 100644
--- a/src/encryptid/server.ts
+++ b/src/encryptid/server.ts
@@ -82,6 +82,7 @@ import {
updateAlias,
aliasExists,
} from './mailcow.js';
+import { notify } from '../../server/notification-service';
// ============================================================================
// CONFIGURATION
@@ -122,7 +123,7 @@ const CONFIG = {
'https://rmaps.online',
'https://rfiles.online',
'https://rnotes.online',
- 'https://rfunds.online',
+ 'https://rflows.online',
'https://rtrips.online',
'https://rnetwork.online',
'https://rcart.online',
@@ -1948,6 +1949,20 @@ app.post('/api/guardian/accept', async (c) => {
}
await acceptGuardianInvite(guardian.id, claims.sub as string);
+
+ // Notify the account owner that their guardian accepted
+ const acceptorUser = await getUserById(claims.sub as string);
+ notify({
+ userDid: guardian.userId,
+ category: 'system',
+ eventType: 'guardian_accepted',
+ title: `${acceptorUser?.username || guardian.name} accepted your guardian invite`,
+ body: `${guardian.name} is now a recovery guardian for your account.`,
+ actorDid: claims.sub as string,
+ actorUsername: acceptorUser?.username || guardian.name,
+ metadata: { guardianId: guardian.id },
+ }).catch(() => {});
+
return c.json({ success: true, message: 'You are now a guardian!' });
});
@@ -1987,6 +2002,16 @@ app.post('/api/recovery/social/initiate', async (c) => {
const expiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000;
await createRecoveryRequest(requestId, user.id, 2, expiresAt);
+ // Notify account owner about the recovery initiation
+ notify({
+ userDid: user.id,
+ category: 'system',
+ eventType: 'recovery_initiated',
+ title: 'Account recovery initiated',
+ body: `A recovery request was initiated for your account. ${accepted.length} guardians have been contacted.`,
+ metadata: { recoveryRequestId: requestId, threshold: 2, totalGuardians: accepted.length },
+ }).catch(() => {});
+
// Create approval tokens and notify guardians
for (const guardian of accepted) {
const approvalToken = generateToken();
@@ -1994,6 +2019,20 @@ app.post('/api/recovery/social/initiate', async (c) => {
const approveUrl = `https://auth.rspace.online/approve?token=${approvalToken}`;
+ // In-app notification for guardians with rSpace accounts
+ if (guardian.guardianUserId) {
+ notify({
+ userDid: guardian.guardianUserId,
+ category: 'system',
+ eventType: 'recovery_initiated',
+ title: `${user.username} needs your help to recover their account`,
+ body: 'Review and approve the recovery request if legitimate.',
+ actionUrl: approveUrl,
+ actorUsername: user.username,
+ metadata: { recoveryRequestId: requestId, guardianId: guardian.id },
+ }).catch(() => {});
+ }
+
// Send email if available
if (guardian.email && smtpTransport) {
try {
@@ -2162,6 +2201,16 @@ app.post('/api/recovery/social/approve', async (c) => {
// Check if threshold met
if (request.approvalCount >= request.threshold && request.status === 'pending') {
await updateRecoveryRequestStatus(request.id, 'approved');
+
+ // Notify account owner that recovery is approved
+ notify({
+ userDid: request.userId,
+ category: 'system',
+ eventType: 'recovery_approved',
+ title: 'Account recovery approved',
+ body: `${request.approvalCount}/${request.threshold} guardians approved. You can now recover your account.`,
+ metadata: { recoveryRequestId: request.id },
+ }).catch(() => {});
}
return c.json({
@@ -3114,7 +3163,7 @@ app.get('/', (c) => {
rNotes
rFiles
rCart
- rFunds
+ rFlows
rWallet
rAuctions
rPubs
diff --git a/website/shell.ts b/website/shell.ts
index 69a916e..de1a877 100644
--- a/website/shell.ts
+++ b/website/shell.ts
@@ -8,6 +8,7 @@
*/
import { RStackIdentity } from "../shared/components/rstack-identity";
+import { RStackNotificationBell } from "../shared/components/rstack-notification-bell";
import { RStackAppSwitcher } from "../shared/components/rstack-app-switcher";
import { RStackSpaceSwitcher } from "../shared/components/rstack-space-switcher";
import { RStackTabBar } from "../shared/components/rstack-tab-bar";
@@ -24,6 +25,7 @@ import { TabCache } from "../shared/tab-cache";
// Register all header components
RStackIdentity.define();
+RStackNotificationBell.define();
RStackAppSwitcher.define();
RStackSpaceSwitcher.define();
RStackTabBar.define();