Merge branch 'dev'
This commit is contained in:
commit
91e9484219
39
bun.lock
39
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=="],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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%)</code></pre><p><em>Maya is tracking expenses in rF
|
|||
<div class="rapp-nav">
|
||||
<button class="rapp-nav__back" data-back="notebooks">\u2190 Notebooks</button>
|
||||
<span class="rapp-nav__title">${this.esc(nb.title)}${syncBadge}</span>
|
||||
<button class="rapp-nav__btn rapp-nav__btn--secondary" id="btn-export-notebook" title="Export this notebook">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 10V2M5 5l3-3 3 3"/><path d="M2 12v2h12v-2"/></svg>
|
||||
Export
|
||||
</button>
|
||||
<button class="rapp-nav__btn" id="create-note">+ New Note</button>
|
||||
</div>`;
|
||||
return;
|
||||
|
|
@ -1183,6 +1188,10 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
|||
this.navZone.innerHTML = `
|
||||
<div class="rapp-nav">
|
||||
<span class="rapp-nav__title">Notebooks</span>
|
||||
<button class="rapp-nav__btn rapp-nav__btn--secondary" id="btn-import-export" title="Import / Export">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v8M5 7l3 3 3-3"/><path d="M2 12v2h12v-2"/></svg>
|
||||
Import / Export
|
||||
</button>
|
||||
<button class="rapp-nav__btn" id="create-notebook">+ New Notebook</button>
|
||||
</div>
|
||||
<input class="search-bar" type="text" placeholder="Search notes..." id="search-input" value="${this.esc(this.searchQuery)}">`;
|
||||
|
|
@ -1272,11 +1281,21 @@ Gear: EUR 400 (10%)</code></pre><p><em>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%)</code></pre><p><em>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%)</code></pre><p><em>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 {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,701 @@
|
|||
/**
|
||||
* <import-export-dialog> — 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<string>();
|
||||
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 = `
|
||||
<style>${this.getStyles()}</style>
|
||||
<div class="dialog-overlay">
|
||||
<div class="dialog">
|
||||
<div class="dialog-header">
|
||||
<h2>Import / Export</h2>
|
||||
<button class="dialog-close" id="btn-close">×</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-bar">
|
||||
<button class="tab ${this.activeTab === 'import' ? 'active' : ''}" data-tab="import">Import</button>
|
||||
<button class="tab ${this.activeTab === 'export' ? 'active' : ''}" data-tab="export">Export</button>
|
||||
</div>
|
||||
|
||||
<div class="source-bar">
|
||||
${(['obsidian', 'logseq', 'notion', 'google-docs'] as const).map(s => `
|
||||
<button class="source-btn ${this.activeSource === s ? 'active' : ''}" data-source="${s}">
|
||||
${this.sourceIcon(s)} ${this.sourceName(s)}
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
|
||||
<div class="dialog-body">
|
||||
${this.activeTab === 'import' ? this.renderImportTab(isApiSource, isConnected) : this.renderExportTab(isApiSource, isConnected)}
|
||||
|
||||
<div class="status-message ${this.statusMessage ? `status-${this.statusType}` : ''}" style="display:${this.statusMessage ? 'block' : 'none'}">
|
||||
${this.esc(this.statusMessage)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
this.attachListeners();
|
||||
}
|
||||
|
||||
private renderImportTab(isApiSource: boolean, isConnected: boolean): string {
|
||||
if (isApiSource && !isConnected) {
|
||||
return `
|
||||
<div class="connect-prompt">
|
||||
<p>Connect your ${this.sourceName(this.activeSource)} account to import notes.</p>
|
||||
<button class="btn-primary" id="btn-connect">Connect ${this.sourceName(this.activeSource)}</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (isApiSource) {
|
||||
// Show page list for selection
|
||||
return `
|
||||
<div class="page-list-header">
|
||||
<span>Select pages to import:</span>
|
||||
<button class="btn-secondary btn-sm" id="btn-refresh-pages">Refresh</button>
|
||||
</div>
|
||||
<div class="page-list">
|
||||
${this.remotePages.length === 0
|
||||
? '<div class="empty-list">No pages found. Click Refresh to load.</div>'
|
||||
: this.remotePages.map(p => `
|
||||
<label class="page-item">
|
||||
<input type="checkbox" value="${p.id}" ${this.selectedPages.has(p.id) ? 'checked' : ''}>
|
||||
<span class="page-icon">${p.icon || (this.activeSource === 'notion' ? 'N' : 'G')}</span>
|
||||
<span class="page-title">${this.esc(p.title)}</span>
|
||||
</label>
|
||||
`).join('')}
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Target notebook:</label>
|
||||
<select id="target-notebook">
|
||||
<option value="">Create new notebook</option>
|
||||
${this.notebooks.map(nb => `<option value="${nb.id}">${this.esc(nb.title)}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn-primary" id="btn-import" ${this.importing ? 'disabled' : ''}>
|
||||
${this.importing ? 'Importing...' : `Import Selected (${this.selectedPages.size})`}
|
||||
</button>`;
|
||||
}
|
||||
|
||||
// File-based import (Obsidian/Logseq)
|
||||
return `
|
||||
<div class="upload-area" id="upload-area">
|
||||
<p>Upload a ZIP of your ${this.sourceName(this.activeSource)} vault</p>
|
||||
<input type="file" id="file-input" accept=".zip" style="display:none">
|
||||
<button class="btn-secondary" id="btn-choose-file">
|
||||
${this.selectedFile ? this.esc(this.selectedFile.name) : 'Choose File'}
|
||||
</button>
|
||||
<p class="upload-hint">or drag & drop a ZIP file here</p>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Target notebook:</label>
|
||||
<select id="target-notebook">
|
||||
<option value="">Create new notebook</option>
|
||||
${this.notebooks.map(nb => `<option value="${nb.id}">${this.esc(nb.title)}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn-primary" id="btn-import" ${this.importing || !this.selectedFile ? 'disabled' : ''}>
|
||||
${this.importing ? 'Importing...' : 'Import'}
|
||||
</button>`;
|
||||
}
|
||||
|
||||
private renderExportTab(isApiSource: boolean, isConnected: boolean): string {
|
||||
const formats = this.activeSource === 'notion' || this.activeSource === 'google-docs'
|
||||
? '' : '';
|
||||
|
||||
if (isApiSource && !isConnected) {
|
||||
return `
|
||||
<div class="connect-prompt">
|
||||
<p>Connect your ${this.sourceName(this.activeSource)} account to export notes.</p>
|
||||
<button class="btn-primary" id="btn-connect">Connect ${this.sourceName(this.activeSource)}</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="form-row">
|
||||
<label>Source notebook:</label>
|
||||
<select id="target-notebook">
|
||||
<option value="">Select a notebook</option>
|
||||
${this.notebooks.map(nb => `<option value="${nb.id}">${this.esc(nb.title)}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn-primary" id="btn-export" ${this.exporting ? 'disabled' : ''}>
|
||||
${this.exporting ? 'Exporting...'
|
||||
: isApiSource ? `Export to ${this.sourceName(this.activeSource)}`
|
||||
: 'Download ZIP'}
|
||||
</button>`;
|
||||
}
|
||||
|
||||
private sourceName(s: string): string {
|
||||
const names: Record<string, string> = {
|
||||
obsidian: 'Obsidian',
|
||||
logseq: 'Logseq',
|
||||
notion: 'Notion',
|
||||
'google-docs': 'Google Docs',
|
||||
};
|
||||
return names[s] || s;
|
||||
}
|
||||
|
||||
private sourceIcon(s: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
obsidian: '<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M8 1l6 3.5v7L8 15l-6-3.5v-7L8 1z"/></svg>',
|
||||
logseq: '<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><circle cx="8" cy="8" r="6"/></svg>',
|
||||
notion: '<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><rect x="2" y="2" width="12" height="12" rx="2"/></svg>',
|
||||
'google-docs': '<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M4 1h6l4 4v10H4V1z"/></svg>',
|
||||
};
|
||||
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 };
|
||||
|
|
@ -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<any> {
|
||||
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<ImportResult> {
|
||||
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<ExportResult> {
|
||||
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);
|
||||
|
|
@ -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<ImportResult>;
|
||||
|
||||
/** Export NoteItems to external format */
|
||||
export(notes: NoteItem[], opts: ExportOptions): Promise<ExportResult>;
|
||||
}
|
||||
|
||||
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<string, NoteConverter>();
|
||||
|
||||
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';
|
||||
|
|
@ -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<string, string>; body: string } {
|
||||
const lines = content.split('\n');
|
||||
const properties: Record<string, string> = {};
|
||||
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<ImportResult> {
|
||||
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<ExportResult> {
|
||||
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);
|
||||
|
|
@ -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 = `<u>${text}</u>`;
|
||||
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' : '');
|
||||
}
|
||||
|
|
@ -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<any> {
|
||||
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<ImportResult> {
|
||||
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<ExportResult> {
|
||||
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);
|
||||
|
|
@ -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<string, any>; 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<ImportResult> {
|
||||
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<ExportResult> {
|
||||
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<string, any> = {};
|
||||
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);
|
||||
|
|
@ -107,17 +107,21 @@ export function renderLanding(): string {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logseq Import/Export -->
|
||||
<!-- Data Source Integrations -->
|
||||
<div class="rl-card">
|
||||
<div class="rl-icon-box">🔄</div>
|
||||
<h3>Logseq Import & Export</h3>
|
||||
<h3>Import & Export</h3>
|
||||
<p>
|
||||
Export your notebooks as Logseq-compatible ZIP archives. Import a Logseq graph and keep your pages,
|
||||
properties, tags, and hierarchy intact.
|
||||
</p>
|
||||
<p style="font-size:0.75rem;color:#64748b;margin-top:0.5rem">
|
||||
Round-trip fidelity: card types, tags, attachments, and parent-child structure all survive the journey.
|
||||
Bring your notes from <strong>Logseq</strong>, <strong>Obsidian</strong>,
|
||||
<strong>Notion</strong>, and <strong>Google Docs</strong>.
|
||||
Export back to any format anytime — your data, your choice.
|
||||
</p>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:0.35rem;margin-top:0.5rem">
|
||||
<span class="rl-badge" style="background:rgba(34,197,94,0.2);color:#22c55e">Logseq</span>
|
||||
<span class="rl-badge" style="background:rgba(139,92,246,0.2);color:#8b5cf6">Obsidian</span>
|
||||
<span class="rl-badge" style="background:rgba(59,130,246,0.2);color:#3b82f6">Notion</span>
|
||||
<span class="rl-badge" style="background:rgba(245,158,11,0.2);color:#f59e0b">Google Docs</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dual Format Storage -->
|
||||
|
|
|
|||
|
|
@ -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<NotebookDoc>(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<ConnectionsDoc>(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<NotebookDoc>(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<NotebookDoc>(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<NotebookDoc>(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<NotebookDoc>(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<NotebookDoc>(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<NotebookDoc>(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<NotebookDoc>(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<NotebookDoc>(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";
|
||||
|
|
|
|||
|
|
@ -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<NotebookDoc> = {
|
||||
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<NotebookDoc> = {
|
|||
if (!(item as any).contentFormat) (item as any).contentFormat = 'html';
|
||||
}
|
||||
}
|
||||
// v2→v3: sourceRef field is optional, no migration needed
|
||||
return doc;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -285,13 +285,66 @@ routes.post("/api/threads/:id/image", async (c) => {
|
|||
return c.json({ imageUrl });
|
||||
});
|
||||
|
||||
routes.post("/api/threads/:id/upload-image", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400);
|
||||
|
||||
const thread = await loadThread(id);
|
||||
if (!thread) return c.json({ error: "Thread not found" }, 404);
|
||||
|
||||
let formData: FormData;
|
||||
try {
|
||||
formData = await c.req.formData();
|
||||
} catch {
|
||||
return c.json({ error: "Invalid form data" }, 400);
|
||||
}
|
||||
|
||||
const file = formData.get("file");
|
||||
if (!file || !(file instanceof File)) return c.json({ error: "No file provided" }, 400);
|
||||
|
||||
const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"];
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
return c.json({ error: "Invalid file type. Allowed: png, jpg, webp, gif" }, 400);
|
||||
}
|
||||
|
||||
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
if (file.size > MAX_SIZE) {
|
||||
return c.json({ error: "File too large. Maximum 5MB" }, 400);
|
||||
}
|
||||
|
||||
const ext = file.name.split(".").pop()?.toLowerCase() || "png";
|
||||
const safeExt = ["png", "jpg", "jpeg", "webp", "gif"].includes(ext) ? ext : "png";
|
||||
const filename = `thread-${id}.${safeExt}`;
|
||||
|
||||
const genDir = resolve(process.env.FILES_DIR || "./data/files", "generated");
|
||||
await mkdir(genDir, { recursive: true });
|
||||
|
||||
// Delete old image if it exists with a different extension
|
||||
if (thread.imageUrl) {
|
||||
const oldFilename = thread.imageUrl.split("/").pop();
|
||||
if (oldFilename && oldFilename !== filename) {
|
||||
try { await unlink(resolve(genDir, oldFilename)); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
await writeFile(resolve(genDir, filename), buffer);
|
||||
|
||||
const imageUrl = `/data/files/generated/${filename}`;
|
||||
thread.imageUrl = imageUrl;
|
||||
thread.updatedAt = Date.now();
|
||||
await saveThread(thread);
|
||||
|
||||
return c.json({ imageUrl });
|
||||
});
|
||||
|
||||
// ── Demo feed data (server-rendered, no API calls) ──
|
||||
const DEMO_FEED = [
|
||||
{
|
||||
username: "@alice",
|
||||
initial: "A",
|
||||
color: "#6366f1",
|
||||
content: "Just deployed the new rFunds river view! The enoughness score is such a powerful concept. \u{1F30A}",
|
||||
content: "Just deployed the new rFlows river view! The enoughness score is such a powerful concept. \u{1F30A}",
|
||||
timeAgo: "2 hours ago",
|
||||
likes: 5,
|
||||
replies: 2,
|
||||
|
|
@ -694,6 +747,21 @@ const THREAD_CSS = `
|
|||
.thread-page { grid-template-columns: 1fr; }
|
||||
.thread-compose { position: static; }
|
||||
}
|
||||
.thread-export-dropdown { position: relative; }
|
||||
.thread-export-menu {
|
||||
position: absolute; top: calc(100% + 4px); right: 0; z-index: 100;
|
||||
background: #1e293b; border: 1px solid #334155; border-radius: 8px;
|
||||
min-width: 180px; overflow: hidden;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
||||
}
|
||||
.thread-export-menu[hidden] { display: none; }
|
||||
.thread-export-menu button {
|
||||
display: block; width: 100%; padding: 0.6rem 0.75rem; border: none;
|
||||
background: transparent; color: #e2e8f0; font-size: 0.85rem;
|
||||
text-align: left; cursor: pointer; transition: background 0.1s;
|
||||
}
|
||||
.thread-export-menu button:hover { background: rgba(99,102,241,0.15); }
|
||||
.thread-export-menu button + button { border-top: 1px solid rgba(255,255,255,0.05); }
|
||||
`;
|
||||
|
||||
function renderThreadBuilderPage(space: string, threadData?: ThreadData | null): string {
|
||||
|
|
@ -712,6 +780,16 @@ function renderThreadBuilderPage(space: string, threadData?: ThreadData | null):
|
|||
<button class="thread-btn thread-btn--primary" id="thread-save">Save Draft</button>
|
||||
<button class="thread-btn thread-btn--success" id="thread-share">Share</button>
|
||||
<button class="thread-btn thread-btn--outline" id="thread-copy">Copy Thread</button>
|
||||
<div class="thread-export-dropdown">
|
||||
<button class="thread-btn thread-btn--outline" id="thread-export-btn">Export ▾</button>
|
||||
<div class="thread-export-menu" id="thread-export-menu" hidden>
|
||||
<button data-platform="twitter">𝕏 Twitter (280)</button>
|
||||
<button data-platform="bluesky">🦋 Bluesky (300)</button>
|
||||
<button data-platform="mastodon">🐘 Mastodon (500)</button>
|
||||
<button data-platform="linkedin">💼 LinkedIn</button>
|
||||
<button data-platform="plain">📄 Plain Text</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="thread-drafts">
|
||||
|
|
@ -727,7 +805,9 @@ function renderThreadBuilderPage(space: string, threadData?: ThreadData | null):
|
|||
</div>
|
||||
<input class="thread-compose__title" id="thread-title" placeholder="Thread title (defaults to first tweet)">
|
||||
<div class="thread-image-section">
|
||||
<button class="thread-btn thread-btn--outline" id="gen-image-btn">Generate Preview Image</button>
|
||||
<input type="file" id="upload-image-input" accept="image/png,image/jpeg,image/webp,image/gif" hidden>
|
||||
<button class="thread-btn thread-btn--outline" id="upload-image-btn">Upload Image</button>
|
||||
<button class="thread-btn thread-btn--outline" id="gen-image-btn">Generate with AI</button>
|
||||
<div class="thread-image-preview" id="thread-image-preview" hidden>
|
||||
<img id="thread-image-thumb" alt="Preview">
|
||||
</div>
|
||||
|
|
@ -751,6 +831,8 @@ function renderThreadBuilderPage(space: string, threadData?: ThreadData | null):
|
|||
const saveBtn = document.getElementById('thread-save');
|
||||
const shareBtn = document.getElementById('thread-share');
|
||||
const genImageBtn = document.getElementById('gen-image-btn');
|
||||
const uploadImageBtn = document.getElementById('upload-image-btn');
|
||||
const uploadImageInput = document.getElementById('upload-image-input');
|
||||
const toggleDraftsBtn = document.getElementById('toggle-drafts');
|
||||
const draftsList = document.getElementById('drafts-list');
|
||||
const imagePreview = document.getElementById('thread-image-preview');
|
||||
|
|
@ -838,7 +920,7 @@ function renderThreadBuilderPage(space: string, threadData?: ThreadData | null):
|
|||
const data = await res.json();
|
||||
if (data.id) {
|
||||
currentThreadId = data.id;
|
||||
history.replaceState(null, '', base + 'thread/' + data.id);
|
||||
history.replaceState(null, '', base + 'thread/' + data.id + '/edit');
|
||||
}
|
||||
|
||||
saveBtn.textContent = 'Saved!';
|
||||
|
|
@ -865,13 +947,15 @@ function renderThreadBuilderPage(space: string, threadData?: ThreadData | null):
|
|||
if (data.imageUrl) {
|
||||
imageThumb.src = data.imageUrl;
|
||||
imagePreview.hidden = false;
|
||||
genImageBtn.textContent = 'Regenerate Image';
|
||||
genImageBtn.textContent = 'Replace with AI';
|
||||
uploadImageBtn.textContent = 'Replace Image';
|
||||
} else {
|
||||
imagePreview.hidden = true;
|
||||
genImageBtn.textContent = 'Generate Preview Image';
|
||||
genImageBtn.textContent = 'Generate with AI';
|
||||
uploadImageBtn.textContent = 'Upload Image';
|
||||
}
|
||||
|
||||
history.replaceState(null, '', base + 'thread/' + data.id);
|
||||
history.replaceState(null, '', base + 'thread/' + data.id + '/edit');
|
||||
renderPreview();
|
||||
loadDraftList();
|
||||
} catch (e) {
|
||||
|
|
@ -887,7 +971,8 @@ function renderThreadBuilderPage(space: string, threadData?: ThreadData | null):
|
|||
currentThreadId = null;
|
||||
history.replaceState(null, '', base + 'thread');
|
||||
imagePreview.hidden = true;
|
||||
genImageBtn.textContent = 'Generate Preview Image';
|
||||
genImageBtn.textContent = 'Generate with AI';
|
||||
uploadImageBtn.textContent = 'Upload Image';
|
||||
shareLinkArea.innerHTML = '';
|
||||
}
|
||||
loadDraftList();
|
||||
|
|
@ -981,14 +1066,15 @@ function renderThreadBuilderPage(space: string, threadData?: ThreadData | null):
|
|||
if (data.imageUrl) {
|
||||
imageThumb.src = data.imageUrl;
|
||||
imagePreview.hidden = false;
|
||||
genImageBtn.textContent = 'Regenerate Image';
|
||||
genImageBtn.textContent = 'Replace with AI';
|
||||
uploadImageBtn.textContent = 'Replace Image';
|
||||
} else {
|
||||
genImageBtn.textContent = 'Generation Failed';
|
||||
setTimeout(() => { genImageBtn.textContent = 'Generate Preview Image'; }, 2000);
|
||||
setTimeout(() => { genImageBtn.textContent = imagePreview.hidden ? 'Generate with AI' : 'Replace with AI'; }, 2000);
|
||||
}
|
||||
} catch (e) {
|
||||
genImageBtn.textContent = 'Generation Failed';
|
||||
setTimeout(() => { genImageBtn.textContent = 'Generate Preview Image'; }, 2000);
|
||||
setTimeout(() => { genImageBtn.textContent = imagePreview.hidden ? 'Generate with AI' : 'Replace with AI'; }, 2000);
|
||||
} finally {
|
||||
genImageBtn.disabled = false;
|
||||
}
|
||||
|
|
@ -1011,9 +1097,47 @@ function renderThreadBuilderPage(space: string, threadData?: ThreadData | null):
|
|||
handleInput.addEventListener('blur', scheduleAutoSave);
|
||||
titleInput.addEventListener('blur', scheduleAutoSave);
|
||||
|
||||
async function uploadImage(file) {
|
||||
if (!currentThreadId) {
|
||||
await saveDraft();
|
||||
if (!currentThreadId) return;
|
||||
}
|
||||
|
||||
uploadImageBtn.textContent = 'Uploading...';
|
||||
uploadImageBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const res = await fetch(base + 'api/threads/' + currentThreadId + '/upload-image', { method: 'POST', body: form });
|
||||
const data = await res.json();
|
||||
|
||||
if (data.imageUrl) {
|
||||
imageThumb.src = data.imageUrl;
|
||||
imagePreview.hidden = false;
|
||||
uploadImageBtn.textContent = 'Replace Image';
|
||||
genImageBtn.textContent = 'Replace with AI';
|
||||
} else {
|
||||
uploadImageBtn.textContent = data.error || 'Upload Failed';
|
||||
setTimeout(() => { uploadImageBtn.textContent = imagePreview.hidden ? 'Upload Image' : 'Replace Image'; }, 2000);
|
||||
}
|
||||
} catch (e) {
|
||||
uploadImageBtn.textContent = 'Upload Failed';
|
||||
setTimeout(() => { uploadImageBtn.textContent = imagePreview.hidden ? 'Upload Image' : 'Replace Image'; }, 2000);
|
||||
} finally {
|
||||
uploadImageBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
saveBtn.addEventListener('click', saveDraft);
|
||||
shareBtn.addEventListener('click', shareThread);
|
||||
genImageBtn.addEventListener('click', generateImage);
|
||||
uploadImageBtn.addEventListener('click', () => uploadImageInput.click());
|
||||
uploadImageInput.addEventListener('change', () => {
|
||||
const file = uploadImageInput.files?.[0];
|
||||
if (file) uploadImage(file);
|
||||
uploadImageInput.value = '';
|
||||
});
|
||||
|
||||
toggleDraftsBtn.addEventListener('click', () => {
|
||||
draftsList.hidden = !draftsList.hidden;
|
||||
|
|
@ -1035,6 +1159,61 @@ function renderThreadBuilderPage(space: string, threadData?: ThreadData | null):
|
|||
}
|
||||
});
|
||||
|
||||
// ── Export dropdown ──
|
||||
const exportBtn = document.getElementById('thread-export-btn');
|
||||
const exportMenu = document.getElementById('thread-export-menu');
|
||||
if (exportBtn && exportMenu) {
|
||||
exportBtn.addEventListener('click', () => { exportMenu.hidden = !exportMenu.hidden; });
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!exportBtn.contains(e.target) && !exportMenu.contains(e.target)) exportMenu.hidden = true;
|
||||
});
|
||||
|
||||
const LIMITS = { twitter: 280, bluesky: 300, mastodon: 500, linkedin: 3000, plain: Infinity };
|
||||
|
||||
function formatForPlatform(platform) {
|
||||
const limit = LIMITS[platform] || 280;
|
||||
const tweets = textarea.value.split(/\\n---\\n/).map(t => t.trim()).filter(Boolean);
|
||||
const total = tweets.length;
|
||||
const title = titleInput.value;
|
||||
let warnings = [];
|
||||
|
||||
if (platform === 'linkedin') {
|
||||
let text = '';
|
||||
if (title) text += title + '\\n\\n';
|
||||
text += tweets.join('\\n\\n');
|
||||
text += '\\n\\n---\\nOriginally composed as a ' + total + '-tweet thread.';
|
||||
return { text, warnings: text.length > limit ? ['Content exceeds LinkedIn\\'s ' + limit + ' char limit (' + text.length + ' chars)'] : [] };
|
||||
}
|
||||
|
||||
const parts = tweets.map((t, i) => {
|
||||
const prefix = total > 1 ? (i + 1) + '/' + total + ' ' : '';
|
||||
const full = prefix + t;
|
||||
if (full.length > limit) warnings.push('Tweet ' + (i + 1) + ' exceeds ' + limit + ' chars (' + full.length + ')');
|
||||
return full;
|
||||
});
|
||||
|
||||
return { text: parts.join('\\n\\n'), warnings };
|
||||
}
|
||||
|
||||
exportMenu.querySelectorAll('button[data-platform]').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const platform = btn.dataset.platform;
|
||||
const tweets = textarea.value.split(/\\n---\\n/).map(t => t.trim()).filter(Boolean);
|
||||
if (!tweets.length) return;
|
||||
const { text, warnings } = formatForPlatform(platform);
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
const label = warnings.length
|
||||
? 'Copied with warnings: ' + warnings.join('; ')
|
||||
: 'Copied for ' + platform + '!';
|
||||
copyBtn.textContent = label;
|
||||
setTimeout(() => { copyBtn.textContent = 'Copy Thread'; }, 3000);
|
||||
} catch { copyBtn.textContent = 'Failed'; setTimeout(() => { copyBtn.textContent = 'Copy Thread'; }, 2000); }
|
||||
exportMenu.hidden = true;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Load initial data ──
|
||||
if (window.__THREAD_DATA__) {
|
||||
const data = window.__THREAD_DATA__;
|
||||
|
|
@ -1046,7 +1225,8 @@ function renderThreadBuilderPage(space: string, threadData?: ThreadData | null):
|
|||
if (data.imageUrl) {
|
||||
imageThumb.src = data.imageUrl;
|
||||
imagePreview.hidden = false;
|
||||
genImageBtn.textContent = 'Regenerate Image';
|
||||
genImageBtn.textContent = 'Replace with AI';
|
||||
uploadImageBtn.textContent = 'Replace Image';
|
||||
}
|
||||
renderPreview();
|
||||
}
|
||||
|
|
@ -1054,7 +1234,207 @@ function renderThreadBuilderPage(space: string, threadData?: ThreadData | null):
|
|||
</script>`;
|
||||
}
|
||||
|
||||
// ── Thread permalink with OG tags ──
|
||||
// ── Thread read-only view (shareable permalink) ──
|
||||
function renderThreadReadOnly(space: string, thread: ThreadData): string {
|
||||
const name = escapeHtml(thread.name || "Anonymous");
|
||||
const handle = escapeHtml(thread.handle || "@anonymous");
|
||||
const initial = name.charAt(0).toUpperCase();
|
||||
const total = thread.tweets.length;
|
||||
const dateStr = new Date(thread.createdAt).toLocaleDateString("en-US", {
|
||||
month: "long", day: "numeric", year: "numeric",
|
||||
});
|
||||
|
||||
const tweetCards = thread.tweets.map((text, i) => {
|
||||
const len = text.length;
|
||||
const connector = i > 0 ? '<div class="tweet-card__connector"></div>' : "";
|
||||
return `<div class="tweet-card">
|
||||
${connector}
|
||||
<div class="tweet-card__header">
|
||||
<div class="tweet-card__avatar">${escapeHtml(initial)}</div>
|
||||
<span class="tweet-card__name">${name}</span>
|
||||
<span class="tweet-card__handle">${handle}</span>
|
||||
<span class="tweet-card__dot">·</span>
|
||||
<span class="tweet-card__time">${escapeHtml(dateStr)}</span>
|
||||
</div>
|
||||
<p class="tweet-card__content">${escapeHtml(text)}</p>
|
||||
<div class="tweet-card__footer">
|
||||
<div class="tweet-card__actions">
|
||||
<span class="tweet-card__action"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg></span>
|
||||
<span class="tweet-card__action"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M17 1l4 4-4 4"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><path d="M7 23l-4-4 4-4"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg></span>
|
||||
<span class="tweet-card__action"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg></span>
|
||||
<span class="tweet-card__action"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><polyline points="16 6 12 2 8 6"/><line x1="12" y1="2" x2="12" y2="15"/></svg></span>
|
||||
</div>
|
||||
<div class="tweet-card__meta">
|
||||
<span class="tweet-card__chars${len > 280 ? " tweet-card__chars--over" : ""}">${len}/280</span>
|
||||
<span class="tweet-card__thread-num">${i + 1}/${total}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join("\n");
|
||||
|
||||
const imageHTML = thread.imageUrl
|
||||
? `<div class="thread-ro__image"><img src="${escapeHtml(thread.imageUrl)}" alt="Thread preview"></div>`
|
||||
: "";
|
||||
|
||||
return `
|
||||
<div class="thread-ro">
|
||||
<div class="thread-ro__header">
|
||||
<div class="thread-ro__author">
|
||||
<div class="tweet-card__avatar" style="width:48px;height:48px;font-size:1.2rem">${escapeHtml(initial)}</div>
|
||||
<div>
|
||||
<div class="thread-ro__name">${name}</div>
|
||||
<div class="thread-ro__handle">${handle}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="thread-ro__meta">
|
||||
<span>${total} tweet${total === 1 ? "" : "s"}</span>
|
||||
<span>·</span>
|
||||
<span>${escapeHtml(dateStr)}</span>
|
||||
</div>
|
||||
</div>
|
||||
${thread.title ? `<h1 class="thread-ro__title">${escapeHtml(thread.title)}</h1>` : ""}
|
||||
${imageHTML}
|
||||
<div class="thread-preview thread-ro__cards">
|
||||
${tweetCards}
|
||||
</div>
|
||||
<div class="thread-ro__actions">
|
||||
<a href="/${escapeHtml(space)}/rsocials/thread/${escapeHtml(thread.id)}/edit" class="thread-btn thread-btn--primary">Edit Thread</a>
|
||||
<button class="thread-btn thread-btn--outline" id="ro-copy-thread">Copy Thread</button>
|
||||
<button class="thread-btn thread-btn--outline" id="ro-copy-link">Copy Link</button>
|
||||
<div class="thread-export-dropdown">
|
||||
<button class="thread-btn thread-btn--outline" id="ro-export-btn">Export ▾</button>
|
||||
<div class="thread-export-menu" id="ro-export-menu" hidden>
|
||||
<button data-platform="twitter">𝕏 Twitter (280)</button>
|
||||
<button data-platform="bluesky">🦋 Bluesky (300)</button>
|
||||
<button data-platform="mastodon">🐘 Mastodon (500)</button>
|
||||
<button data-platform="linkedin">💼 LinkedIn</button>
|
||||
<button data-platform="plain">📄 Plain Text</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="thread-ro__cta">
|
||||
<a href="/${escapeHtml(space)}/rsocials/thread" class="thread-btn thread-btn--success">Create Your Own Thread</a>
|
||||
<a href="/${escapeHtml(space)}/rsocials/threads" class="thread-btn thread-btn--outline">Browse All Threads</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="thread-export-toast" id="export-toast" hidden></div>
|
||||
<script>
|
||||
(function() {
|
||||
const threadData = ${JSON.stringify({ tweets: thread.tweets, name: thread.name, handle: thread.handle, title: thread.title }).replace(/</g, "\\u003c")};
|
||||
const url = window.location.href;
|
||||
|
||||
function showToast(msg) {
|
||||
const toast = document.getElementById('export-toast');
|
||||
toast.textContent = msg;
|
||||
toast.hidden = false;
|
||||
setTimeout(() => { toast.hidden = true; }, 2500);
|
||||
}
|
||||
|
||||
document.getElementById('ro-copy-thread')?.addEventListener('click', async () => {
|
||||
const text = threadData.tweets.map((t, i) => (i + 1) + '/' + threadData.tweets.length + '\\n' + t).join('\\n\\n');
|
||||
try { await navigator.clipboard.writeText(text); showToast('Thread copied!'); }
|
||||
catch { showToast('Failed to copy'); }
|
||||
});
|
||||
|
||||
document.getElementById('ro-copy-link')?.addEventListener('click', async () => {
|
||||
try { await navigator.clipboard.writeText(url); showToast('Link copied!'); }
|
||||
catch { showToast('Failed to copy'); }
|
||||
});
|
||||
|
||||
// Export dropdown
|
||||
const exportBtn = document.getElementById('ro-export-btn');
|
||||
const exportMenu = document.getElementById('ro-export-menu');
|
||||
exportBtn?.addEventListener('click', () => { exportMenu.hidden = !exportMenu.hidden; });
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!exportBtn?.contains(e.target) && !exportMenu?.contains(e.target)) exportMenu.hidden = true;
|
||||
});
|
||||
|
||||
const LIMITS = { twitter: 280, bluesky: 300, mastodon: 500, linkedin: 3000, plain: Infinity };
|
||||
|
||||
function formatForPlatform(platform) {
|
||||
const limit = LIMITS[platform] || 280;
|
||||
const tweets = threadData.tweets;
|
||||
const total = tweets.length;
|
||||
const name = threadData.name || 'Thread';
|
||||
const handle = threadData.handle || '';
|
||||
let warnings = [];
|
||||
|
||||
if (platform === 'linkedin') {
|
||||
let text = '';
|
||||
if (threadData.title) text += threadData.title + '\\n\\n';
|
||||
text += tweets.join('\\n\\n');
|
||||
text += '\\n\\n---\\nOriginally composed as a ' + total + '-tweet thread.';
|
||||
return { text, warnings: text.length > limit ? ['Content exceeds LinkedIn\\'s ' + limit + ' char limit (' + text.length + ' chars)'] : [] };
|
||||
}
|
||||
|
||||
const parts = tweets.map((t, i) => {
|
||||
const prefix = total > 1 ? (i + 1) + '/' + total + ' ' : '';
|
||||
const full = prefix + t;
|
||||
if (full.length > limit) warnings.push('Tweet ' + (i + 1) + ' exceeds ' + limit + ' chars (' + full.length + ')');
|
||||
return full;
|
||||
});
|
||||
|
||||
return { text: parts.join('\\n\\n'), warnings };
|
||||
}
|
||||
|
||||
exportMenu?.querySelectorAll('button[data-platform]').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const platform = btn.dataset.platform;
|
||||
const { text, warnings } = formatForPlatform(platform);
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
if (warnings.length) {
|
||||
showToast('Copied with warnings: ' + warnings.join('; '));
|
||||
} else {
|
||||
showToast('Copied for ' + platform + '!');
|
||||
}
|
||||
} catch { showToast('Failed to copy'); }
|
||||
exportMenu.hidden = true;
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>`;
|
||||
}
|
||||
|
||||
const THREAD_RO_CSS = `
|
||||
.thread-ro { max-width: 640px; margin: 0 auto; padding: 2rem 1rem; }
|
||||
.thread-ro__header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem; }
|
||||
.thread-ro__author { display: flex; align-items: center; gap: 0.75rem; }
|
||||
.thread-ro__name { font-weight: 700; color: #f1f5f9; font-size: 1.1rem; }
|
||||
.thread-ro__handle { color: #64748b; font-size: 0.9rem; }
|
||||
.thread-ro__meta { display: flex; align-items: center; gap: 0.5rem; color: #64748b; font-size: 0.85rem; }
|
||||
.thread-ro__title { font-size: 1.4rem; color: #f1f5f9; margin: 0 0 1.5rem; line-height: 1.3; }
|
||||
.thread-ro__image { margin-bottom: 1.5rem; border-radius: 12px; overflow: hidden; border: 1px solid #334155; }
|
||||
.thread-ro__image img { display: block; width: 100%; height: auto; }
|
||||
.thread-ro__cards { margin-bottom: 1.5rem; }
|
||||
.thread-ro__actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 2rem; padding-bottom: 2rem; border-bottom: 1px solid #334155; }
|
||||
.thread-ro__cta { display: flex; gap: 0.75rem; justify-content: center; flex-wrap: wrap; }
|
||||
.thread-export-dropdown { position: relative; }
|
||||
.thread-export-menu {
|
||||
position: absolute; top: calc(100% + 4px); right: 0; z-index: 100;
|
||||
background: #1e293b; border: 1px solid #334155; border-radius: 8px;
|
||||
min-width: 180px; overflow: hidden;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
||||
}
|
||||
.thread-export-menu[hidden] { display: none; }
|
||||
.thread-export-menu button {
|
||||
display: block; width: 100%; padding: 0.6rem 0.75rem; border: none;
|
||||
background: transparent; color: #e2e8f0; font-size: 0.85rem;
|
||||
text-align: left; cursor: pointer; transition: background 0.1s;
|
||||
}
|
||||
.thread-export-menu button:hover { background: rgba(99,102,241,0.15); }
|
||||
.thread-export-menu button + button { border-top: 1px solid rgba(255,255,255,0.05); }
|
||||
.thread-export-toast {
|
||||
position: fixed; bottom: 1.5rem; left: 50%; transform: translateX(-50%);
|
||||
background: #1e293b; border: 1px solid #6366f1; color: #c4b5fd;
|
||||
padding: 0.6rem 1.25rem; border-radius: 8px; font-size: 0.85rem;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.4); z-index: 1000;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.thread-export-toast[hidden] { display: none; }
|
||||
`;
|
||||
|
||||
// ── Thread read-only permalink with OG tags ──
|
||||
routes.get("/thread/:id", async (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const id = c.req.param("id");
|
||||
|
|
@ -1087,12 +1467,33 @@ routes.get("/thread/:id", async (c) => {
|
|||
spaceSlug: space,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: renderThreadBuilderPage(space, thread),
|
||||
styles: `<style>${THREAD_CSS}</style>`,
|
||||
body: renderThreadReadOnly(space, thread),
|
||||
styles: `<style>${THREAD_CSS}${THREAD_RO_CSS}</style>`,
|
||||
head: ogHead,
|
||||
}));
|
||||
});
|
||||
|
||||
// ── Thread editor (edit existing) ──
|
||||
routes.get("/thread/:id/edit", async (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const id = c.req.param("id");
|
||||
if (!id || id.includes("..") || id.includes("/")) return c.text("Not found", 404);
|
||||
|
||||
const thread = await loadThread(id);
|
||||
if (!thread) return c.text("Thread not found", 404);
|
||||
|
||||
return c.html(renderShell({
|
||||
title: `Edit: ${thread.title || "Thread"} — rSocials | rSpace`,
|
||||
moduleId: "rsocials",
|
||||
spaceSlug: space,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: renderThreadBuilderPage(space, thread),
|
||||
styles: `<style>${THREAD_CSS}</style>`,
|
||||
}));
|
||||
});
|
||||
|
||||
// ── Thread builder (new) ──
|
||||
routes.get("/thread", (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
return c.html(renderShell({
|
||||
|
|
@ -1106,6 +1507,119 @@ routes.get("/thread", (c) => {
|
|||
}));
|
||||
});
|
||||
|
||||
// ── Thread listing / gallery ──
|
||||
const THREADS_LIST_CSS = `
|
||||
.threads-gallery { max-width: 900px; margin: 0 auto; padding: 2rem 1rem; }
|
||||
.threads-gallery__header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 0.75rem; }
|
||||
.threads-gallery__header h1 {
|
||||
margin: 0; font-size: 1.5rem;
|
||||
background: linear-gradient(135deg, #7dd3fc, #c4b5fd);
|
||||
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
||||
}
|
||||
.threads-gallery__grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem;
|
||||
}
|
||||
.threads-gallery__empty { color: #64748b; text-align: center; padding: 3rem 1rem; font-size: 0.9rem; }
|
||||
.thread-card {
|
||||
background: #1e293b; border: 1px solid #334155; border-radius: 0.75rem;
|
||||
padding: 1.25rem; transition: border-color 0.15s, transform 0.15s;
|
||||
display: flex; flex-direction: column; gap: 0.75rem;
|
||||
text-decoration: none; color: inherit;
|
||||
}
|
||||
.thread-card:hover { border-color: #6366f1; transform: translateY(-2px); }
|
||||
.thread-card__title { font-size: 1rem; font-weight: 700; color: #f1f5f9; margin: 0; line-height: 1.3; }
|
||||
.thread-card__preview {
|
||||
font-size: 0.85rem; color: #94a3b8; line-height: 1.5;
|
||||
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;
|
||||
}
|
||||
.thread-card__meta { display: flex; align-items: center; gap: 0.75rem; font-size: 0.75rem; color: #64748b; margin-top: auto; }
|
||||
.thread-card__author { display: flex; align-items: center; gap: 0.4rem; }
|
||||
.thread-card__avatar-sm {
|
||||
width: 20px; height: 20px; border-radius: 50%; background: #6366f1;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: white; font-weight: 700; font-size: 0.55rem; flex-shrink: 0;
|
||||
}
|
||||
.thread-card__image { border-radius: 8px; overflow: hidden; border: 1px solid #334155; margin-bottom: 0.25rem; }
|
||||
.thread-card__image img { display: block; width: 100%; height: 120px; object-fit: cover; }
|
||||
`;
|
||||
|
||||
async function renderThreadsGallery(space: string): Promise<string> {
|
||||
const dir = await ensureThreadsDir();
|
||||
const files = await readdir(dir);
|
||||
const threads: ThreadData[] = [];
|
||||
|
||||
for (const f of files) {
|
||||
if (!f.endsWith(".json")) continue;
|
||||
try {
|
||||
const raw = await readFile(resolve(dir, f), "utf-8");
|
||||
threads.push(JSON.parse(raw));
|
||||
} catch { /* skip corrupt */ }
|
||||
}
|
||||
threads.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
|
||||
if (!threads.length) {
|
||||
return `
|
||||
<div class="threads-gallery">
|
||||
<div class="threads-gallery__header">
|
||||
<h1>Threads</h1>
|
||||
<a href="/${escapeHtml(space)}/rsocials/thread" class="thread-btn thread-btn--primary">New Thread</a>
|
||||
</div>
|
||||
<div class="threads-gallery__empty">
|
||||
<p>No threads yet. Create your first thread!</p>
|
||||
<a href="/${escapeHtml(space)}/rsocials/thread" class="thread-btn thread-btn--success" style="margin-top:1rem;display:inline-flex">Create Thread</a>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const cards = threads.map((t) => {
|
||||
const initial = (t.name || "?").charAt(0).toUpperCase();
|
||||
const preview = escapeHtml((t.tweets[0] || "").substring(0, 200));
|
||||
const dateStr = new Date(t.updatedAt).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
const imageTag = t.imageUrl
|
||||
? `<div class="thread-card__image"><img src="${escapeHtml(t.imageUrl)}" alt="" loading="lazy"></div>`
|
||||
: "";
|
||||
|
||||
return `<a href="/${escapeHtml(space)}/rsocials/thread/${escapeHtml(t.id)}" class="thread-card">
|
||||
${imageTag}
|
||||
<h3 class="thread-card__title">${escapeHtml(t.title || "Untitled Thread")}</h3>
|
||||
<p class="thread-card__preview">${preview}</p>
|
||||
<div class="thread-card__meta">
|
||||
<div class="thread-card__author">
|
||||
<div class="thread-card__avatar-sm">${escapeHtml(initial)}</div>
|
||||
<span>${escapeHtml(t.handle || t.name || "Anonymous")}</span>
|
||||
</div>
|
||||
<span>${t.tweets.length} tweet${t.tweets.length === 1 ? "" : "s"}</span>
|
||||
<span>${dateStr}</span>
|
||||
</div>
|
||||
</a>`;
|
||||
}).join("\n");
|
||||
|
||||
return `
|
||||
<div class="threads-gallery">
|
||||
<div class="threads-gallery__header">
|
||||
<h1>Threads</h1>
|
||||
<a href="/${escapeHtml(space)}/rsocials/thread" class="thread-btn thread-btn--primary">New Thread</a>
|
||||
</div>
|
||||
<div class="threads-gallery__grid">
|
||||
${cards}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
routes.get("/threads", async (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const body = await renderThreadsGallery(space);
|
||||
return c.html(renderShell({
|
||||
title: `Threads — rSocials | rSpace`,
|
||||
moduleId: "rsocials",
|
||||
spaceSlug: space,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body,
|
||||
styles: `<style>${THREAD_CSS}${THREADS_LIST_CSS}</style>`,
|
||||
}));
|
||||
});
|
||||
|
||||
// ── Campaigns redirect (plural → singular) ──
|
||||
routes.get("/campaigns", (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<WSData>({
|
|||
// 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<WSData>({
|
|||
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<WSData>({
|
|||
},
|
||||
|
||||
close(ws: ServerWebSocket<WSData>) {
|
||||
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<WSData>({
|
|||
}
|
||||
}
|
||||
}
|
||||
// Pass syncServer to OAuth handlers
|
||||
setNotionOAuthSyncServer(syncServer);
|
||||
setGoogleOAuthSyncServer(syncServer);
|
||||
})();
|
||||
|
||||
// Ensure generated files directory exists
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
|
|
@ -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<string, any>;
|
||||
expiresAt?: Date;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WS CONNECTION REGISTRY
|
||||
// ============================================================================
|
||||
|
||||
const userConnections = new Map<string, Set<ServerWebSocket<any>>>();
|
||||
|
||||
export function registerUserConnection(userDid: string, ws: ServerWebSocket<any>): 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<any>): 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<StoredNotification> {
|
||||
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<NotifyOptions, 'userDid'>,
|
||||
): Promise<void> {
|
||||
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 })),
|
||||
);
|
||||
}
|
||||
|
|
@ -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<ConnectionsDoc>(docId);
|
||||
if (!doc) {
|
||||
doc = Automerge.change(Automerge.init<ConnectionsDoc>(), '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<ConnectionsDoc>(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<ConnectionsDoc>(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<ConnectionsDoc>(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<ConnectionsDoc>(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<ConnectionsDoc>(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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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<ConnectionsDoc>(docId);
|
||||
if (!doc) {
|
||||
doc = Automerge.change(Automerge.init<ConnectionsDoc>(), '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<ConnectionsDoc>(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<ConnectionsDoc>(docId);
|
||||
|
||||
if (doc?.notion) {
|
||||
_syncServer!.changeDoc<ConnectionsDoc>(docId, 'Disconnect Notion', (d) => {
|
||||
delete d.notion;
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
export { notionOAuthRoutes };
|
||||
|
|
@ -113,6 +113,7 @@ export function renderShell(opts: ShellOptions): string {
|
|||
</div>
|
||||
<div class="rstack-header__right">
|
||||
<a class="rstack-header__demo-btn" ${spaceSlug === "demo" ? 'data-hide' : ''} href="${shellDemoUrl}">Try Demo</a>
|
||||
<rstack-notification-bell></rstack-notification-bell>
|
||||
<rstack-identity></rstack-identity>
|
||||
</div>
|
||||
</header>
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<typeof setInterval> | 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 = `
|
||||
<div class="dropdown-divider"></div>
|
||||
<div class="dropdown-section-label">Access Requests</div>
|
||||
${this.#notifications.map((n) => `
|
||||
<div class="notif-item">
|
||||
<div class="notif-text"><strong>${(n.requesterUsername || "Someone").replace(/</g, "<")}</strong> wants to join <strong>${n.spaceSlug.replace(/</g, "<")}</strong></div>
|
||||
${n.message ? `<div class="notif-msg">"${n.message.replace(/</g, "<")}"</div>` : ""}
|
||||
<div class="notif-actions">
|
||||
<button class="notif-btn notif-btn--approve" data-notif-action="approve" data-slug="${n.spaceSlug}" data-req-id="${n.id}">Approve</button>
|
||||
<button class="notif-btn notif-btn--deny" data-notif-action="deny" data-slug="${n.spaceSlug}" data-req-id="${n.id}">Deny</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join("")}
|
||||
`;
|
||||
}
|
||||
|
||||
this.#shadow.innerHTML = `
|
||||
<style>${STYLES}</style>
|
||||
<div class="user" id="user-toggle">
|
||||
<div class="avatar-wrap">
|
||||
<div class="avatar">${initial}</div>
|
||||
<span class="notif-badge" style="display:${notifCount > 0 ? "flex" : "none"}">${notifCount > 0 ? notifCount : ""}</span>
|
||||
</div>
|
||||
<span class="name">${displayName}</span>
|
||||
<div class="dropdown" id="dropdown">
|
||||
<div class="dropdown-header">${displayName}</div>
|
||||
${notifsHTML}
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item" data-action="my-account">👤 My Account</button>
|
||||
<button class="dropdown-item" data-action="my-spaces">🌐 My Spaces</button>
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,510 @@
|
|||
/**
|
||||
* <rstack-notification-bell> — 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<typeof setInterval> | 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
|
||||
? `<span class="badge">${this.#unreadCount > 99 ? "99+" : this.#unreadCount}</span>`
|
||||
: "";
|
||||
|
||||
let panelHTML = "";
|
||||
if (this.#open) {
|
||||
const header = `
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">Notifications</span>
|
||||
${this.#unreadCount > 0 ? `<button class="mark-all-btn" data-action="mark-all-read">Mark all read</button>` : ""}
|
||||
</div>
|
||||
`;
|
||||
|
||||
let body: string;
|
||||
if (this.#loading) {
|
||||
body = `<div class="panel-empty">Loading...</div>`;
|
||||
} else if (this.#notifications.length === 0) {
|
||||
body = `<div class="panel-empty">No notifications yet</div>`;
|
||||
} else {
|
||||
body = this.#notifications.map(n => `
|
||||
<div class="notif-item ${n.read ? "read" : "unread"}" data-id="${n.id}">
|
||||
<div class="notif-icon">${this.#categoryIcon(n.category)}</div>
|
||||
<div class="notif-content">
|
||||
<div class="notif-title">${n.title}</div>
|
||||
${n.body ? `<div class="notif-body">${n.body}</div>` : ""}
|
||||
<div class="notif-meta">
|
||||
${n.actorUsername ? `<span class="notif-actor">${n.actorUsername}</span>` : ""}
|
||||
<span class="notif-time">${this.#timeAgo(n.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="notif-dismiss" data-dismiss="${n.id}" title="Dismiss">×</button>
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
panelHTML = `<div class="panel">${header}${body}</div>`;
|
||||
}
|
||||
|
||||
this.#shadow.innerHTML = `
|
||||
<style>${STYLES}</style>
|
||||
<div class="bell-wrapper">
|
||||
<button class="bell-btn" id="bell-toggle" aria-label="Notifications">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
||||
</svg>
|
||||
${badge}
|
||||
</button>
|
||||
${panelHTML}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
`;
|
||||
|
|
@ -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<string, any>;
|
||||
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<string, any>;
|
||||
expiresAt?: Date;
|
||||
}): Promise<StoredNotification> {
|
||||
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<StoredNotification[]> {
|
||||
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<number> {
|
||||
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<boolean> {
|
||||
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<number> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<number> {
|
||||
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<StoredNotificationPreferences | null> {
|
||||
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<Omit<StoredNotificationPreferences, 'userDid' | 'updatedAt'>>,
|
||||
): Promise<StoredNotificationPreferences> {
|
||||
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 };
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) => {
|
|||
<a href="https://rnotes.online">rNotes</a>
|
||||
<a href="https://rfiles.online">rFiles</a>
|
||||
<a href="https://rcart.online">rCart</a>
|
||||
<a href="https://rfunds.online">rFunds</a>
|
||||
<a href="https://rflows.online">rFlows</a>
|
||||
<a href="https://rwallet.online">rWallet</a>
|
||||
<a href="https://rauctions.online">rAuctions</a>
|
||||
<a href="https://rpubs.online">rPubs</a>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue