Browse Source

Merges pull request #49

Feature/text entry
master
silberengel 7 months ago
parent
commit
da890ba711
No known key found for this signature in database
GPG Key ID: 962BEC8725790894
  1. 426
      deno.lock
  2. 216
      package-lock.json
  3. 5
      package.json
  4. 181
      src/lib/components/EventInput.svelte
  5. 169
      src/lib/components/ZettelEditor.svelte
  6. 4
      src/lib/components/publications/PublicationFeed.svelte
  7. 103
      src/lib/services/publisher.ts
  8. 26
      src/lib/utils.ts
  9. 58
      src/lib/utils/ZettelParser.ts
  10. 500
      src/lib/utils/asciidoc_metadata.ts
  11. 342
      src/lib/utils/event_input_utils.ts
  12. 4
      src/lib/utils/event_search.ts
  13. 3
      src/lib/utils/network_detection.ts
  14. 5
      src/lib/utils/nostrUtils.ts
  15. 17
      src/lib/utils/search_types.ts
  16. 172
      src/routes/new/compose/+page.svelte
  17. 2
      src/routes/publication/[type]/[identifier]/+page.ts
  18. 429
      tests/unit/ZettelEditor.test.ts
  19. 446
      tests/unit/eventInput30040.test.ts
  20. 322
      tests/unit/metadataExtraction.test.ts
  21. 12
      vite.config.ts

426
deno.lock

@ -8,11 +8,6 @@ @@ -8,11 +8,6 @@
"npm:@nostr-dev-kit/ndk@^2.14.32": "2.14.32_nostr-tools@2.15.1__typescript@5.8.3_typescript@5.8.3",
"npm:@playwright/test@^1.54.1": "1.54.1",
"npm:@popperjs/core@2.11": "2.11.8",
"npm:@sveltejs/adapter-auto@^6.0.1": "6.0.1_@sveltejs+kit@2.25.1__@sveltejs+vite-plugin-svelte@6.1.0___svelte@5.36.8____acorn@8.15.0___vite@7.0.5____@types+node@24.0.15____picomatch@4.0.3___@types+node@24.0.15__svelte@5.36.8___acorn@8.15.0__vite@7.0.5___@types+node@24.0.15___picomatch@4.0.3__acorn@8.15.0__@types+node@24.0.15_@sveltejs+vite-plugin-svelte@6.1.0__svelte@5.36.8___acorn@8.15.0__vite@7.0.5___@types+node@24.0.15___picomatch@4.0.3__@types+node@24.0.15_svelte@5.36.8__acorn@8.15.0_vite@7.0.5__@types+node@24.0.15__picomatch@4.0.3_@types+node@24.0.15",
"npm:@sveltejs/adapter-node@^5.2.13": "5.2.13_@sveltejs+kit@2.25.1__@sveltejs+vite-plugin-svelte@6.1.0___svelte@5.36.8____acorn@8.15.0___vite@7.0.5____@types+node@24.0.15____picomatch@4.0.3___@types+node@24.0.15__svelte@5.36.8___acorn@8.15.0__vite@7.0.5___@types+node@24.0.15___picomatch@4.0.3__acorn@8.15.0__@types+node@24.0.15_rollup@4.45.1_@sveltejs+vite-plugin-svelte@6.1.0__svelte@5.36.8___acorn@8.15.0__vite@7.0.5___@types+node@24.0.15___picomatch@4.0.3__@types+node@24.0.15_svelte@5.36.8__acorn@8.15.0_vite@7.0.5__@types+node@24.0.15__picomatch@4.0.3_@types+node@24.0.15",
"npm:@sveltejs/adapter-static@3": "3.0.8_@sveltejs+kit@2.25.1__@sveltejs+vite-plugin-svelte@6.1.0___svelte@5.36.8____acorn@8.15.0___vite@7.0.5____@types+node@24.0.15____picomatch@4.0.3___@types+node@24.0.15__svelte@5.36.8___acorn@8.15.0__vite@7.0.5___@types+node@24.0.15___picomatch@4.0.3__acorn@8.15.0__@types+node@24.0.15_@sveltejs+vite-plugin-svelte@6.1.0__svelte@5.36.8___acorn@8.15.0__vite@7.0.5___@types+node@24.0.15___picomatch@4.0.3__@types+node@24.0.15_svelte@5.36.8__acorn@8.15.0_vite@7.0.5__@types+node@24.0.15__picomatch@4.0.3_@types+node@24.0.15",
"npm:@sveltejs/kit@^2.25.0": "2.25.1_@sveltejs+vite-plugin-svelte@6.1.0__svelte@5.36.8___acorn@8.15.0__vite@7.0.5___@types+node@24.0.15___picomatch@4.0.3__@types+node@24.0.15_svelte@5.36.8__acorn@8.15.0_vite@7.0.5__@types+node@24.0.15__picomatch@4.0.3_acorn@8.15.0_@types+node@24.0.15",
"npm:@sveltejs/vite-plugin-svelte@^6.1.0": "6.1.0_svelte@5.36.8__acorn@8.15.0_vite@7.0.5__@types+node@24.0.15__picomatch@4.0.3_@types+node@24.0.15",
"npm:@tailwindcss/forms@0.5": "0.5.10_tailwindcss@3.4.17__postcss@8.5.6",
"npm:@tailwindcss/typography@0.5": "0.5.16_tailwindcss@3.4.17__postcss@8.5.6",
"npm:@types/d3@^7.4.3": "7.4.3",
@ -50,9 +45,7 @@ @@ -50,9 +45,7 @@
"npm:tailwind-merge@^3.3.1": "3.3.1",
"npm:tailwindcss@^3.4.17": "3.4.17_postcss@8.5.6",
"npm:tslib@2.8": "2.8.1",
"npm:typescript@^5.8.3": "5.8.3",
"npm:vite@^7.0.5": "7.0.5_@types+node@24.0.15_picomatch@4.0.3",
"npm:vitest@^3.1.3": "3.2.4_@types+node@24.0.15_vite@7.0.5__@types+node@24.0.15__picomatch@4.0.3"
"npm:typescript@^5.8.3": "5.8.3"
},
"npm": {
"@alloc/quick-lru@5.2.0": {
@ -441,38 +434,9 @@ @@ -441,38 +434,9 @@
],
"bin": true
},
"@polka/url@1.0.0-next.29": {
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="
},
"@popperjs/core@2.11.8": {
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="
},
"@rollup/plugin-commonjs@28.0.6_rollup@4.45.1_picomatch@4.0.3": {
"integrity": "sha512-XSQB1K7FUU5QP+3lOQmVCE3I0FcbbNvmNT4VJSj93iUjayaARrTQeoRdiYQoftAJBLrR9t2agwAd3ekaTgHNlw==",
"dependencies": [
"@rollup/pluginutils",
"commondir",
"estree-walker@2.0.2",
"fdir",
"is-reference@1.2.1",
"magic-string",
"picomatch@4.0.3",
"rollup"
],
"optionalPeers": [
"rollup"
]
},
"@rollup/plugin-json@6.1.0_rollup@4.45.1": {
"integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==",
"dependencies": [
"@rollup/pluginutils",
"rollup"
],
"optionalPeers": [
"rollup"
]
},
"@rollup/plugin-node-resolve@15.3.1": {
"integrity": "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==",
"dependencies": [
@ -483,25 +447,11 @@ @@ -483,25 +447,11 @@
"resolve"
]
},
"@rollup/plugin-node-resolve@16.0.1_rollup@4.45.1": {
"integrity": "sha512-tk5YCxJWIG81umIvNkSod2qK5KyQW19qcBF/B78n1bjtOON6gzKoVeSzAE8yHCZEDmqkHKkxplExA8KzdJLJpA==",
"dependencies": [
"@rollup/pluginutils",
"@types/resolve",
"deepmerge",
"is-module",
"resolve",
"rollup"
],
"optionalPeers": [
"rollup"
]
},
"@rollup/pluginutils@5.2.0_rollup@4.45.1": {
"integrity": "sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw==",
"dependencies": [
"@types/estree",
"estree-walker@2.0.2",
"estree-walker",
"picomatch@4.0.3",
"rollup"
],
@ -639,71 +589,6 @@ @@ -639,71 +589,6 @@
"acorn@8.15.0"
]
},
"@sveltejs/adapter-auto@6.0.1_@sveltejs+kit@2.25.1__@sveltejs+vite-plugin-svelte@6.1.0___svelte@5.36.8____acorn@8.15.0___vite@7.0.5____@types+node@24.0.15____picomatch@4.0.3___@types+node@24.0.15__svelte@5.36.8___acorn@8.15.0__vite@7.0.5___@types+node@24.0.15___picomatch@4.0.3__acorn@8.15.0__@types+node@24.0.15_@sveltejs+vite-plugin-svelte@6.1.0__svelte@5.36.8___acorn@8.15.0__vite@7.0.5___@types+node@24.0.15___picomatch@4.0.3__@types+node@24.0.15_svelte@5.36.8__acorn@8.15.0_vite@7.0.5__@types+node@24.0.15__picomatch@4.0.3_@types+node@24.0.15": {
"integrity": "sha512-mcWud3pYGPWM2Pphdj8G9Qiq24nZ8L4LB7coCUckUEy5Y7wOWGJ/enaZ4AtJTcSm5dNK1rIkBRoqt+ae4zlxcQ==",
"dependencies": [
"@sveltejs/kit"
]
},
"@sveltejs/adapter-node@5.2.13_@sveltejs+kit@2.25.1__@sveltejs+vite-plugin-svelte@6.1.0___svelte@5.36.8____acorn@8.15.0___vite@7.0.5____@types+node@24.0.15____picomatch@4.0.3___@types+node@24.0.15__svelte@5.36.8___acorn@8.15.0__vite@7.0.5___@types+node@24.0.15___picomatch@4.0.3__acorn@8.15.0__@types+node@24.0.15_rollup@4.45.1_@sveltejs+vite-plugin-svelte@6.1.0__svelte@5.36.8___acorn@8.15.0__vite@7.0.5___@types+node@24.0.15___picomatch@4.0.3__@types+node@24.0.15_svelte@5.36.8__acorn@8.15.0_vite@7.0.5__@types+node@24.0.15__picomatch@4.0.3_@types+node@24.0.15": {
"integrity": "sha512-yS2TVFmIrxjGhYaV5/iIUrJ3mJl6zjaYn0lBD70vTLnYvJeqf3cjvLXeXCUCuYinhSBoyF4DpfGla49BnIy7sQ==",
"dependencies": [
"@rollup/plugin-commonjs",
"@rollup/plugin-json",
"@rollup/plugin-node-resolve@16.0.1_rollup@4.45.1",
"@sveltejs/kit",
"rollup"
]
},
"@sveltejs/adapter-static@3.0.8_@sveltejs+kit@2.25.1__@sveltejs+vite-plugin-svelte@6.1.0___svelte@5.36.8____acorn@8.15.0___vite@7.0.5____@types+node@24.0.15____picomatch@4.0.3___@types+node@24.0.15__svelte@5.36.8___acorn@8.15.0__vite@7.0.5___@types+node@24.0.15___picomatch@4.0.3__acorn@8.15.0__@types+node@24.0.15_@sveltejs+vite-plugin-svelte@6.1.0__svelte@5.36.8___acorn@8.15.0__vite@7.0.5___@types+node@24.0.15___picomatch@4.0.3__@types+node@24.0.15_svelte@5.36.8__acorn@8.15.0_vite@7.0.5__@types+node@24.0.15__picomatch@4.0.3_@types+node@24.0.15": {
"integrity": "sha512-YaDrquRpZwfcXbnlDsSrBQNCChVOT9MGuSg+dMAyfsAa1SmiAhrA5jUYUiIMC59G92kIbY/AaQOWcBdq+lh+zg==",
"dependencies": [
"@sveltejs/kit"
]
},
"@sveltejs/kit@2.25.1_@sveltejs+vite-plugin-svelte@6.1.0__svelte@5.36.8___acorn@8.15.0__vite@7.0.5___@types+node@24.0.15___picomatch@4.0.3__@types+node@24.0.15_svelte@5.36.8__acorn@8.15.0_vite@7.0.5__@types+node@24.0.15__picomatch@4.0.3_acorn@8.15.0_@types+node@24.0.15": {
"integrity": "sha512-8H+fxDEp7Xq6tLFdrGdS5fLu6ONDQQ9DgyjboXpChubuFdfH9QoFX09ypssBpyNkJNZFt9eW3yLmXIc9CesPCA==",
"dependencies": [
"@sveltejs/acorn-typescript",
"@sveltejs/vite-plugin-svelte",
"@types/cookie",
"acorn@8.15.0",
"cookie",
"devalue",
"esm-env",
"kleur",
"magic-string",
"mrmime",
"sade",
"set-cookie-parser",
"sirv",
"svelte",
"vite"
],
"bin": true
},
"@sveltejs/vite-plugin-svelte-inspector@5.0.0_@sveltejs+vite-plugin-svelte@6.1.0__svelte@5.36.8___acorn@8.15.0__vite@7.0.5___@types+node@24.0.15___picomatch@4.0.3__@types+node@24.0.15_svelte@5.36.8__acorn@8.15.0_vite@7.0.5__@types+node@24.0.15__picomatch@4.0.3_@types+node@24.0.15": {
"integrity": "sha512-iwQ8Z4ET6ZFSt/gC+tVfcsSBHwsqc6RumSaiLUkAurW3BCpJam65cmHw0oOlDMTO0u+PZi9hilBRYN+LZNHTUQ==",
"dependencies": [
"@sveltejs/vite-plugin-svelte",
"debug",
"svelte",
"vite"
]
},
"@sveltejs/vite-plugin-svelte@6.1.0_svelte@5.36.8__acorn@8.15.0_vite@7.0.5__@types+node@24.0.15__picomatch@4.0.3_@types+node@24.0.15": {
"integrity": "sha512-+U6lz1wvGEG/BvQyL4z/flyNdQ9xDNv5vrh+vWBWTHaebqT0c9RNggpZTo/XSPoHsSCWBlYaTlRX8pZ9GATXCw==",
"dependencies": [
"@sveltejs/vite-plugin-svelte-inspector",
"debug",
"deepmerge",
"kleur",
"magic-string",
"svelte",
"vite",
"vitefu"
]
},
"@svgdotjs/svg.draggable.js@3.0.6_@svgdotjs+svg.js@3.2.4": {
"integrity": "sha512-7iJFm9lL3C40HQcqzEfezK2l+dW2CpoVY3b77KQGqc8GXWa6LhhmX5Ckv7alQfUXBuZbjpICZ+Dvq1czlGx7gA==",
"dependencies": [
@ -749,15 +634,6 @@ @@ -749,15 +634,6 @@
"tailwindcss"
]
},
"@types/chai@5.2.2": {
"integrity": "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==",
"dependencies": [
"@types/deep-eql"
]
},
"@types/cookie@0.6.0": {
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="
},
"@types/d3-array@3.2.1": {
"integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg=="
},
@ -918,9 +794,6 @@ @@ -918,9 +794,6 @@
"@types/d3-zoom"
]
},
"@types/deep-eql@4.0.2": {
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="
},
"@types/estree@1.0.8": {
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="
},
@ -957,64 +830,6 @@ @@ -957,64 +830,6 @@
"@types/resolve@1.20.2": {
"integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="
},
"@vitest/expect@3.2.4": {
"integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
"dependencies": [
"@types/chai",
"@vitest/spy",
"@vitest/utils",
"chai",
"tinyrainbow"
]
},
"@vitest/mocker@3.2.4_vite@7.0.5__@types+node@24.0.15__picomatch@4.0.3_@types+node@24.0.15": {
"integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
"dependencies": [
"@vitest/spy",
"estree-walker@3.0.3",
"magic-string",
"vite"
],
"optionalPeers": [
"vite"
]
},
"@vitest/pretty-format@3.2.4": {
"integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
"dependencies": [
"tinyrainbow"
]
},
"@vitest/runner@3.2.4": {
"integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
"dependencies": [
"@vitest/utils",
"pathe",
"strip-literal"
]
},
"@vitest/snapshot@3.2.4": {
"integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
"dependencies": [
"@vitest/pretty-format",
"magic-string",
"pathe"
]
},
"@vitest/spy@3.2.4": {
"integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
"dependencies": [
"tinyspy"
]
},
"@vitest/utils@3.2.4": {
"integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
"dependencies": [
"@vitest/pretty-format",
"loupe",
"tinyrainbow"
]
},
"@yr/monotone-cubic-spline@1.0.3": {
"integrity": "sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA=="
},
@ -1119,9 +934,6 @@ @@ -1119,9 +934,6 @@
"assert-never@1.4.0": {
"integrity": "sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA=="
},
"assertion-error@2.0.1": {
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="
},
"async@3.2.6": {
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="
},
@ -1185,9 +997,6 @@ @@ -1185,9 +997,6 @@
],
"bin": true
},
"cac@6.7.14": {
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="
},
"call-bind-apply-helpers@1.0.2": {
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dependencies": [
@ -1214,16 +1023,6 @@ @@ -1214,16 +1023,6 @@
"caniuse-lite@1.0.30001727": {
"integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q=="
},
"chai@5.2.1": {
"integrity": "sha512-5nFxhUrX0PqtyogoYOA8IPswy5sZFTOsBFl/9bNsmDLgsxYTzSZQJDPppDnZPTQbzSEm0hqGjWPzRemQCYbD6A==",
"dependencies": [
"assertion-error",
"check-error",
"deep-eql",
"loupe",
"pathval"
]
},
"chalk@4.1.2": {
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dependencies": [
@ -1240,9 +1039,6 @@ @@ -1240,9 +1039,6 @@
"is-regex"
]
},
"check-error@2.1.1": {
"integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw=="
},
"chokidar@3.6.0": {
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dependencies": [
@ -1301,9 +1097,6 @@ @@ -1301,9 +1097,6 @@
"commander@7.2.0": {
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="
},
"commondir@1.0.1": {
"integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="
},
"concat-map@0.0.1": {
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="
},
@ -1314,9 +1107,6 @@ @@ -1314,9 +1107,6 @@
"@babel/types"
]
},
"cookie@0.6.0": {
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="
},
"cross-spawn@7.0.6": {
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dependencies": [
@ -1544,9 +1334,6 @@ @@ -1544,9 +1334,6 @@
"decamelize@1.2.0": {
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="
},
"deep-eql@5.0.2": {
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="
},
"deep-is@0.1.4": {
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="
},
@ -1559,9 +1346,6 @@ @@ -1559,9 +1346,6 @@
"robust-predicates"
]
},
"devalue@5.1.1": {
"integrity": "sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw=="
},
"dexie@4.0.11": {
"integrity": "sha512-SOKO002EqlvBYYKQSew3iymBoN2EQ4BDw/3yprjh7kAfFzjBYkaMNa/pZvcA7HSWlcKSQb9XhPe3wKyQ0x4A8A=="
},
@ -1613,48 +1397,12 @@ @@ -1613,48 +1397,12 @@
"es-errors@1.3.0": {
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="
},
"es-module-lexer@1.7.0": {
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="
},
"es-object-atoms@1.1.1": {
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dependencies": [
"es-errors"
]
},
"esbuild@0.25.7": {
"integrity": "sha512-daJB0q2dmTzo90L9NjRaohhRWrCzYxWNFTjEi72/h+p5DcY3yn4MacWfDakHmaBaDzDiuLJsCh0+6LK/iX+c+Q==",
"optionalDependencies": [
"@esbuild/aix-ppc64",
"@esbuild/android-arm",
"@esbuild/android-arm64",
"@esbuild/android-x64",
"@esbuild/darwin-arm64",
"@esbuild/darwin-x64",
"@esbuild/freebsd-arm64",
"@esbuild/freebsd-x64",
"@esbuild/linux-arm",
"@esbuild/linux-arm64",
"@esbuild/linux-ia32",
"@esbuild/linux-loong64",
"@esbuild/linux-mips64el",
"@esbuild/linux-ppc64",
"@esbuild/linux-riscv64",
"@esbuild/linux-s390x",
"@esbuild/linux-x64",
"@esbuild/netbsd-arm64",
"@esbuild/netbsd-x64",
"@esbuild/openbsd-arm64",
"@esbuild/openbsd-x64",
"@esbuild/openharmony-arm64",
"@esbuild/sunos-x64",
"@esbuild/win32-arm64",
"@esbuild/win32-ia32",
"@esbuild/win32-x64"
],
"scripts": true,
"bin": true
},
"escalade@3.2.0": {
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="
},
@ -1770,18 +1518,9 @@ @@ -1770,18 +1518,9 @@
"estree-walker@2.0.2": {
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
},
"estree-walker@3.0.3": {
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dependencies": [
"@types/estree"
]
},
"esutils@2.0.3": {
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="
},
"expect-type@1.2.2": {
"integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA=="
},
"fast-deep-equal@3.1.3": {
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
},
@ -1861,7 +1600,7 @@ @@ -1861,7 +1600,7 @@
"flowbite-datepicker@1.3.2": {
"integrity": "sha512-6Nfm0MCVX3mpaR7YSCjmEO2GO8CDt6CX8ZpQnGdeu03WUCWtEPQ/uy0PUiNtIJjJZWnX0Cm3H55MOhbD1g+E/g==",
"dependencies": [
"@rollup/plugin-node-resolve@15.3.1",
"@rollup/plugin-node-resolve",
"flowbite@2.5.2"
]
},
@ -2128,12 +1867,6 @@ @@ -2128,12 +1867,6 @@
"is-promise@2.2.2": {
"integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ=="
},
"is-reference@1.2.1": {
"integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==",
"dependencies": [
"@types/estree"
]
},
"is-reference@3.0.3": {
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"dependencies": [
@ -2178,9 +1911,6 @@ @@ -2178,9 +1911,6 @@
"js-stringify@1.0.2": {
"integrity": "sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g=="
},
"js-tokens@9.0.1": {
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="
},
"js-yaml@4.1.0": {
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"dependencies": [
@ -2210,9 +1940,6 @@ @@ -2210,9 +1940,6 @@
"json-buffer"
]
},
"kleur@4.1.5": {
"integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="
},
"known-css-properties@0.37.0": {
"integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ=="
},
@ -2262,9 +1989,6 @@ @@ -2262,9 +1989,6 @@
"lodash.merge@4.6.2": {
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="
},
"loupe@3.1.4": {
"integrity": "sha512-wJzkKwJrheKtknCOKNEtDK4iqg/MxmZheEMtSTYvnzRdEYaZzmgH976nenp8WdJRdx5Vc1X/9MO0Oszl6ezeXg=="
},
"lru-cache@10.4.3": {
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="
},
@ -2318,9 +2042,6 @@ @@ -2318,9 +2042,6 @@
"mri@1.2.0": {
"integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="
},
"mrmime@2.0.1": {
"integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="
},
"ms@2.1.3": {
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
@ -2463,12 +2184,6 @@ @@ -2463,12 +2184,6 @@
"minipass"
]
},
"pathe@2.0.3": {
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="
},
"pathval@2.0.1": {
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="
},
"picocolors@1.1.1": {
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
},
@ -2820,9 +2535,6 @@ @@ -2820,9 +2535,6 @@
"set-blocking@2.0.0": {
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="
},
"set-cookie-parser@2.7.1": {
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="
},
"shebang-command@2.0.0": {
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dependencies": [
@ -2832,20 +2544,9 @@ @@ -2832,20 +2544,9 @@
"shebang-regex@3.0.0": {
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="
},
"siginfo@2.0.0": {
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="
},
"signal-exit@4.1.0": {
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="
},
"sirv@3.0.1": {
"integrity": "sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==",
"dependencies": [
"@polka/url",
"mrmime",
"totalist"
]
},
"skin-tone@2.0.0": {
"integrity": "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==",
"dependencies": [
@ -2858,12 +2559,6 @@ @@ -2858,12 +2559,6 @@
"source-map@0.6.1": {
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
},
"stackback@0.0.2": {
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="
},
"std-env@3.9.0": {
"integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw=="
},
"string-width@4.2.3": {
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dependencies": [
@ -2895,12 +2590,6 @@ @@ -2895,12 +2590,6 @@
"strip-json-comments@3.1.1": {
"integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="
},
"strip-literal@3.0.0": {
"integrity": "sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==",
"dependencies": [
"js-tokens"
]
},
"sucrase@3.35.0": {
"integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==",
"dependencies": [
@ -2964,7 +2653,7 @@ @@ -2964,7 +2653,7 @@
"clsx",
"esm-env",
"esrap",
"is-reference@3.0.3",
"is-reference",
"locate-character",
"magic-string",
"zimmerframe"
@ -3069,28 +2758,6 @@ @@ -3069,28 +2758,6 @@
"any-promise"
]
},
"tinybench@2.9.0": {
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="
},
"tinyexec@0.3.2": {
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="
},
"tinyglobby@0.2.14_picomatch@4.0.3": {
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
"dependencies": [
"fdir",
"picomatch@4.0.3"
]
},
"tinypool@1.1.1": {
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="
},
"tinyrainbow@2.0.0": {
"integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="
},
"tinyspy@4.0.3": {
"integrity": "sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A=="
},
"to-regex-range@5.0.1": {
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dependencies": [
@ -3100,9 +2767,6 @@ @@ -3100,9 +2767,6 @@
"token-stream@1.0.0": {
"integrity": "sha512-VSsyNPPW74RpHwR8Fc21uubwHY7wMDeJLys2IX5zJNih+OnAnaifKHo+1LHT7DAdloQ7apeaaWg8l7qnf/TnEg=="
},
"totalist@3.0.1": {
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="
},
"ts-interface-checker@0.1.13": {
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="
},
@ -3159,78 +2823,6 @@ @@ -3159,78 +2823,6 @@
"util-deprecate@1.0.2": {
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"vite-node@3.2.4_@types+node@24.0.15": {
"integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
"dependencies": [
"cac",
"debug",
"es-module-lexer",
"pathe",
"vite"
],
"bin": true
},
"vite@7.0.5_@types+node@24.0.15_picomatch@4.0.3": {
"integrity": "sha512-1mncVwJxy2C9ThLwz0+2GKZyEXuC3MyWtAAlNftlZZXZDP3AJt5FmwcMit/IGGaNZ8ZOB2BNO/HFUB+CpN0NQw==",
"dependencies": [
"@types/node@24.0.15",
"esbuild",
"fdir",
"picomatch@4.0.3",
"postcss",
"rollup",
"tinyglobby"
],
"optionalDependencies": [
"fsevents@2.3.3"
],
"optionalPeers": [
"@types/node@24.0.15"
],
"bin": true
},
"vitefu@1.1.1_vite@7.0.5__@types+node@24.0.15__picomatch@4.0.3_@types+node@24.0.15": {
"integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==",
"dependencies": [
"vite"
],
"optionalPeers": [
"vite"
]
},
"vitest@3.2.4_@types+node@24.0.15_vite@7.0.5__@types+node@24.0.15__picomatch@4.0.3": {
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dependencies": [
"@types/chai",
"@types/node@24.0.15",
"@vitest/expect",
"@vitest/mocker",
"@vitest/pretty-format",
"@vitest/runner",
"@vitest/snapshot",
"@vitest/spy",
"@vitest/utils",
"chai",
"debug",
"expect-type",
"magic-string",
"pathe",
"picomatch@4.0.3",
"std-env",
"tinybench",
"tinyexec",
"tinyglobby",
"tinypool",
"tinyrainbow",
"vite",
"vite-node",
"why-is-node-running"
],
"optionalPeers": [
"@types/node@24.0.15"
],
"bin": true
},
"void-elements@3.1.0": {
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="
},
@ -3244,14 +2836,6 @@ @@ -3244,14 +2836,6 @@
],
"bin": true
},
"why-is-node-running@2.3.0": {
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dependencies": [
"siginfo",
"stackback"
],
"bin": true
},
"with@7.0.2": {
"integrity": "sha512-RNGKj82nUPg3g5ygxkQl0R937xLyho1J24ItRCBTr/m1YnZkzJy1hUiHUJrc/VlsDQzsCnInEGSg3bci0Lmd4w==",
"dependencies": [
@ -3421,7 +3005,7 @@ @@ -3421,7 +3005,7 @@
"npm:tailwindcss@^3.4.17",
"npm:tslib@2.8",
"npm:typescript@^5.8.3",
"npm:vite@^7.0.5",
"npm:vite@^6.3.5",
"npm:vitest@^3.1.3"
]
}

216
package-lock.json generated

@ -53,7 +53,7 @@ @@ -53,7 +53,7 @@
"tailwindcss": "^3.4.17",
"tslib": "2.8.x",
"typescript": "^5.8.3",
"vite": "^7.0.5",
"vite": "^6.3.5",
"vitest": "^3.1.3"
}
},
@ -2783,37 +2783,19 @@ @@ -2783,37 +2783,19 @@
}
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 8.10.0"
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/chokidar/node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/cliui": {
@ -3729,15 +3711,6 @@ @@ -3729,15 +3711,6 @@
}
}
},
"node_modules/eslint-plugin-svelte/node_modules/yaml": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
"dev": true,
"engines": {
"node": ">= 6"
}
},
"node_modules/eslint-scope": {
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
@ -5980,25 +5953,17 @@ @@ -5980,25 +5953,17 @@
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/readdirp/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
"node": ">= 14.18.0"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/require-directory": {
@ -6506,38 +6471,10 @@ @@ -6506,38 +6471,10 @@
"typescript": ">=5.0.0"
}
},
"node_modules/svelte-check/node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/svelte-check/node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true,
"engines": {
"node": ">= 14.18.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/svelte-eslint-parser": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.3.0.tgz",
"integrity": "sha512-VCgMHKV7UtOGcGLGNFSbmdm6kEKjtzo5nnpGU/mnx4OsFY6bZ7QwRF5DUx+Hokw5Lvdyo8dpk8B1m8mliomrNg==",
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.3.1.tgz",
"integrity": "sha512-0Iztj5vcOVOVkhy1pbo5uA9r+d3yaVoE5XPc9eABIWDOSJZ2mOsZ4D+t45rphWCOr0uMw3jtSG2fh2e7GvKnPg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -6734,6 +6671,54 @@ @@ -6734,6 +6671,54 @@
"node": ">=14.0.0"
}
},
"node_modules/tailwindcss/node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"license": "MIT",
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/tailwindcss/node_modules/chokidar/node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/tailwindcss/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tailwindcss/node_modules/postcss-load-config": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz",
@ -6782,6 +6767,30 @@ @@ -6782,6 +6767,30 @@
"node": ">=4"
}
},
"node_modules/tailwindcss/node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/tailwindcss/node_modules/yaml": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz",
"integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
}
},
"node_modules/thenify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
@ -7032,24 +7041,24 @@ @@ -7032,24 +7041,24 @@
"license": "MIT"
},
"node_modules/vite": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.0.6.tgz",
"integrity": "sha512-MHFiOENNBd+Bd9uvc8GEsIzdkn1JxMmEeYX35tI3fv0sJBUTfW5tQsoaOwuY4KhBI09A3dUJ/DXf2yxPVPUceg==",
"version": "6.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.6",
"picomatch": "^4.0.3",
"postcss": "^8.5.6",
"rollup": "^4.40.0",
"tinyglobby": "^0.2.14"
"fdir": "^6.4.4",
"picomatch": "^4.0.2",
"postcss": "^8.5.3",
"rollup": "^4.34.9",
"tinyglobby": "^0.2.13"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
@ -7058,14 +7067,14 @@ @@ -7058,14 +7067,14 @@
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"less": "*",
"lightningcss": "^1.21.0",
"sass": "^1.70.0",
"sass-embedded": "^1.70.0",
"stylus": ">=0.54.8",
"sugarss": "^5.0.0",
"sass": "*",
"sass-embedded": "*",
"stylus": "*",
"sugarss": "*",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
@ -7367,14 +7376,13 @@ @@ -7367,14 +7376,13 @@
}
},
"node_modules/yaml": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz",
"integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==",
"bin": {
"yaml": "bin.mjs"
},
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">= 14.6"
"node": ">= 6"
}
},
"node_modules/yargs": {

5
package.json

@ -5,6 +5,7 @@ @@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite dev",
"dev:node": "node --version && vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
@ -14,8 +15,8 @@ @@ -14,8 +15,8 @@
"test": "vitest"
},
"dependencies": {
"@noble/hashes": "^1.8.0",
"@noble/curves": "^1.9.4",
"@noble/hashes": "^1.8.0",
"@nostr-dev-kit/ndk": "^2.14.32",
"@nostr-dev-kit/ndk-cache-dexie": "2.6.x",
"@popperjs/core": "2.11.x",
@ -59,7 +60,7 @@ @@ -59,7 +60,7 @@
"tailwindcss": "^3.4.17",
"tslib": "2.8.x",
"typescript": "^5.8.3",
"vite": "^7.0.5",
"vite": "^6.3.5",
"vitest": "^3.1.3"
}
}

181
src/lib/components/EventInput.svelte

@ -12,6 +12,12 @@ @@ -12,6 +12,12 @@
analyze30040Event,
get30040FixGuidance,
} from "$lib/utils/event_input_utils";
import {
extractDocumentMetadata,
extractSmartMetadata,
metadataToTags,
removeMetadataFromContent
} from "$lib/utils/asciidoc_metadata";
import { get } from "svelte/store";
import { ndkInstance } from "$lib/ndk";
import { userPubkey } from "$lib/stores/authStore.Svelte";
@ -24,7 +30,7 @@ @@ -24,7 +30,7 @@
import { goto } from "$app/navigation";
import { WebSocketPool } from "$lib/data_structures/websocket_pool";
let kind = $state<number>(30023);
let kind = $state<number>(30040);
let tags = $state<[string, string][]>([]);
let content = $state("");
let createdAt = $state<number>(Math.floor(Date.now() / 1000));
@ -39,22 +45,68 @@ @@ -39,22 +45,68 @@
let dTagManuallyEdited = $state(false);
let dTagError = $state("");
let lastPublishedEventId = $state<string | null>(null);
let showWarning = $state(false);
let warningMessage = $state("");
let pendingPublish = $state(false);
let extractedMetadata = $state<[string, string][]>([]);
let hasLoadedFromStorage = $state(false);
// Load content from sessionStorage if available (from ZettelEditor)
$effect(() => {
if (hasLoadedFromStorage) return; // Prevent multiple loads
const storedContent = sessionStorage.getItem('zettelEditorContent');
const storedSource = sessionStorage.getItem('zettelEditorSource');
if (storedContent && storedSource === 'publication-format') {
content = storedContent;
hasLoadedFromStorage = true;
// Clear the stored content after loading
sessionStorage.removeItem('zettelEditorContent');
sessionStorage.removeItem('zettelEditorSource');
// Extract title and metadata using the standardized parser
const { metadata } = extractSmartMetadata(content);
if (metadata.title) {
title = metadata.title;
titleManuallyEdited = false;
dTagManuallyEdited = false;
}
// Extract metadata for 30040 and 30041 events
if (kind === 30040 || kind === 30041) {
extractedMetadata = metadataToTags(metadata);
}
}
});
/**
* Extracts the first Markdown/AsciiDoc header as the title.
* Extracts the first Markdown/AsciiDoc header as the title using the standardized parser.
*/
function extractTitleFromContent(content: string): string {
// Match Markdown (# Title) or AsciiDoc (= Title) headers
const match = content.match(/^(#|=)\s*(.+)$/m);
return match ? match[2].trim() : "";
const { metadata } = extractSmartMetadata(content);
return metadata.title || "";
}
function handleContentInput(e: Event) {
content = (e.target as HTMLTextAreaElement).value;
// Extract title and metadata using the standardized parser
const { metadata } = extractSmartMetadata(content);
if (!titleManuallyEdited) {
const extracted = extractTitleFromContent(content);
console.log("Content input - extracted title:", extracted);
title = extracted;
console.log("Content input - extracted title:", metadata.title);
title = metadata.title || "";
// Reset dTagManuallyEdited when title changes so d-tag can be auto-generated
dTagManuallyEdited = false;
}
// Extract metadata from AsciiDoc content for 30040 and 30041 events
if (kind === 30040 || kind === 30041) {
extractedMetadata = metadataToTags(metadata);
} else {
extractedMetadata = [];
}
}
@ -92,12 +144,24 @@ @@ -92,12 +144,24 @@
tags = tags.filter((_, i) => i !== index);
}
function addExtractedTag(key: string, value: string): void {
// Check if tag already exists
const existingIndex = tags.findIndex(([k]) => k === key);
if (existingIndex >= 0) {
// Update existing tag
tags = tags.map((t, i) => (i === existingIndex ? [key, value] : t));
} else {
// Add new tag
tags = [...tags, [key, value]];
}
}
function isValidKind(kind: number | string): boolean {
const n = Number(kind);
return Number.isInteger(n) && n >= 0 && n <= 65535;
}
function validate(): { valid: boolean; reason?: string } {
function validate(): { valid: boolean; reason?: string; warning?: string } {
const currentUserPubkey = get(userPubkey as any);
const userState = get(userStore);
@ -113,6 +177,7 @@ @@ -113,6 +177,7 @@
if (kind === 30040) {
const v = validate30040EventSet(content);
if (!v.valid) return v;
if (v.warning) return { valid: true, warning: v.warning };
}
if (kind === 30041 || kind === 30818) {
const v = validateAsciiDoc(content);
@ -124,10 +189,26 @@ @@ -124,10 +189,26 @@
function handleSubmit(e: Event) {
e.preventDefault();
dTagError = "";
error = null; // Clear any previous errors
if (requiresDTag(kind) && (!dTag || dTag.trim() === "")) {
dTagError = "A d-tag is required.";
return;
}
const validation = validate();
if (!validation.valid) {
error = validation.reason || "Validation failed.";
return;
}
if (validation.warning) {
warningMessage = validation.warning;
showWarning = true;
pendingPublish = true;
return;
}
handlePublish();
}
@ -235,8 +316,14 @@ @@ -235,8 +316,14 @@
eventTags = [...eventTags, ["title", titleValue]];
}
// For AsciiDoc events, remove metadata from content
let finalContent = content;
if (kind === 30040 || kind === 30041) {
finalContent = removeMetadataFromContent(content);
}
// Prefix Nostr addresses before publishing
const prefixedContent = prefixNostrAddresses(content);
const prefixedContent = prefixNostrAddresses(finalContent);
// Create event with proper serialization
const eventData = {
@ -330,6 +417,9 @@ @@ -330,6 +417,9 @@
}
}
};
// Send the event to the relay
ws.send(JSON.stringify(["EVENT", signedEvent]));
});
if (published) break;
} catch (e) {
@ -391,6 +481,18 @@ @@ -391,6 +481,18 @@
goto(`/events?id=${encodeURIComponent(lastPublishedEventId)}`);
}
}
function confirmWarning() {
showWarning = false;
pendingPublish = false;
handlePublish();
}
function cancelWarning() {
showWarning = false;
pendingPublish = false;
warningMessage = "";
}
</script>
<div
@ -412,9 +514,9 @@ @@ -412,9 +514,9 @@
Kind must be an integer between 0 and 65535 (NIP-01).
</div>
{/if}
{#if kind === 30040}
{#if Number(kind) === 30040}
<div
class="text-blue-600 text-sm mt-1 bg-blue-50 dark:bg-blue-900 p-2 rounded"
class="text-blue-600 text-sm mt-1 bg-blue-50 dark:bg-blue-50 dark:text-blue-800 p-2 rounded whitespace-pre-wrap"
>
<strong>30040 - Publication Index:</strong>
{get30040EventDescription()}
@ -423,6 +525,36 @@ @@ -423,6 +525,36 @@
</div>
<div>
<label class="block font-medium mb-1" for="tags-container">Tags</label>
<!-- Extracted Metadata Section -->
{#if extractedMetadata.length > 0}
<div class="mb-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<h4 class="text-sm font-medium text-blue-800 dark:text-blue-200 mb-2">
Extracted Metadata (from AsciiDoc header)
</h4>
<div class="space-y-2">
{#each extractedMetadata as [key, value], i}
<div class="flex gap-2 items-center">
<span class="text-xs text-blue-600 dark:text-blue-400 min-w-[60px]">{key}:</span>
<input
type="text"
class="input input-bordered input-sm flex-1 text-sm"
value={value}
readonly
/>
<button
type="button"
class="btn btn-sm btn-outline btn-primary"
onclick={() => addExtractedTag(key, value)}
>
Add to Tags
</button>
</div>
{/each}
</div>
</div>
{/if}
<div id="tags-container" class="space-y-2">
{#each tags as [key, value], i}
<div class="flex gap-2">
@ -528,3 +660,28 @@ @@ -528,3 +660,28 @@
{/if}
</form>
</div>
{#if showWarning}
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white dark:bg-gray-800 p-6 rounded-lg max-w-md mx-4">
<h3 class="text-lg font-bold mb-4">Warning</h3>
<p class="mb-4">{warningMessage}</p>
<div class="flex justify-end space-x-2">
<button
type="button"
class="btn btn-secondary"
onclick={cancelWarning}
>
Cancel
</button>
<button
type="button"
class="btn btn-primary"
onclick={confirmWarning}
>
Continue
</button>
</div>
</div>
</div>
{/if}

169
src/lib/components/ZettelEditor.svelte

@ -2,22 +2,31 @@ @@ -2,22 +2,31 @@
import { Textarea, Button } from "flowbite-svelte";
import { EyeOutline } from "flowbite-svelte-icons";
import {
parseAsciiDocSections,
type ZettelSection,
} from "$lib/utils/ZettelParser";
extractSmartMetadata,
parseAsciiDocWithMetadata,
type AsciiDocMetadata,
metadataToTags,
} from "$lib/utils/asciidoc_metadata";
import asciidoctor from "asciidoctor";
// Component props
let {
content = "",
placeholder = `== Note Title
:author: {author} // author is optional
:tags: tag1, tag2, tag3 // tags are optional
:author: Your Name
:version: 1.0
:published_on: 2024-01-01
:published_by: Alexandria
:summary: A brief description of this note
:tags: note, example, metadata
:image: https://example.com/image.jpg
note content here...
== Note Title 2
:tags: tag1, tag2, tag3
Some Other Author (this weeks even if there is no :author: attribute)
:keywords: second, note, example (keywords are converted to tags)
:description: This is a description of the note (description is converted to a summary tag)
Note content here...
`,
showPreview = false,
@ -31,11 +40,52 @@ Note content here... @@ -31,11 +40,52 @@ Note content here...
onPreviewToggle?: (show: boolean) => void;
}>();
// Initialize AsciiDoctor processor
const asciidoctorProcessor = asciidoctor();
// Parse sections for preview using the smart metadata service
let parsedSections = $derived.by(() => {
if (!content.trim()) return [];
// Parse sections for preview
let parsedSections = $derived(parseAsciiDocSections(content, 2));
// Use smart metadata extraction that handles both document headers and section-only content
const { metadata: docMetadata } = extractSmartMetadata(content);
// Parse the content using the standardized parser
const parsed = parseAsciiDocWithMetadata(content);
// Debug logging
console.log("Parsed sections:", parsed.sections);
return parsed.sections.map((section: { metadata: AsciiDocMetadata; content: string; title: string }) => {
// Use only section metadata for each section
// Don't combine with document metadata to avoid overriding section-specific metadata
const tags = metadataToTags(section.metadata);
// Debug logging
console.log(`Section "${section.title}":`, { metadata: section.metadata, tags });
return {
title: section.title || "Untitled",
content: section.content.trim(),
tags,
};
});
});
// Check for 30040-style document headers (publication format)
let hasPublicationHeader = $derived.by(() => {
if (!content.trim()) return false;
const lines = content.split(/\r?\n/);
for (const line of lines) {
// Check for document title (level 0 header)
if (line.match(/^=\s+(.+)$/)) {
return true;
}
// Check for "index card" format (case insensitive)
if (line.trim().toLowerCase() === 'index card') {
return true;
}
}
return false;
});
// Toggle preview panel
function togglePreview() {
@ -51,12 +101,95 @@ Note content here... @@ -51,12 +101,95 @@ Note content here...
</script>
<div class="flex flex-col space-y-4">
<!-- Error message for publication format -->
{#if hasPublicationHeader}
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-4">
<div class="flex items-start space-x-3">
<div class="flex-shrink-0">
<svg class="w-5 h-5 text-red-600 dark:text-red-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
</svg>
</div>
<div class="flex-1">
<h3 class="text-sm font-medium text-red-800 dark:text-red-200 mb-1">
Publication Format Detected
</h3>
<p class="text-sm text-red-700 dark:text-red-300 mb-3">
You're using a publication format (document title with <code>=</code> or "index card").
This editor is for individual notes only. Use the
<a href="/events?kind=30040" class="font-medium underline hover:text-red-600 dark:hover:text-red-400">Events form</a>
to create structured publications.
</p>
<div class="flex space-x-2">
<a
href="/events?kind=30040"
onclick={() => {
// Store the content in sessionStorage so it can be loaded in the Events form
sessionStorage.setItem('zettelEditorContent', content);
sessionStorage.setItem('zettelEditorSource', 'publication-format');
}}
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-red-700 dark:text-red-300 bg-red-100 dark:bg-red-800 border border-red-200 dark:border-red-700 rounded-md hover:bg-red-200 dark:hover:bg-red-700 transition-colors"
>
Switch to Publication Editor
</a>
<button
onclick={() => {
// Remove publication format by converting document title to section title
let convertedContent = content.replace(/^=\s+(.+)$/gm, '== $1');
// Remove "index card" line (case insensitive)
convertedContent = convertedContent.replace(/^index card$/gim, '');
// Clean up any double newlines that might result
const finalContent = convertedContent.replace(/\n\s*\n\s*\n/g, '\n\n');
// Update content through the prop callback
onContentChange(finalContent);
}}
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-red-700 dark:text-red-300 bg-red-100 dark:bg-red-800 border border-red-200 dark:border-red-700 rounded-md hover:bg-red-200 dark:hover:bg-red-700 transition-colors"
>
Convert to Notes Format
</button>
</div>
</div>
</div>
</div>
{:else}
<!-- Informative text about ZettelEditor purpose -->
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-4">
<div class="flex items-start space-x-3">
<div class="flex-shrink-0">
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
</svg>
</div>
<div class="flex-1">
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-200 mb-1">
Note-Taking Tool
</h3>
<p class="text-sm text-blue-700 dark:text-blue-300 mb-3">
This editor is for creating individual notes (30041 events) only. Each section becomes a separate note event.
You can add metadata like author, version, publication date, summary, and tags using AsciiDoc attributes.
To create structured publications with a 30040 index event that ties multiple notes together,
use the <a href="/events?kind=30040" class="font-medium underline hover:text-blue-600 dark:hover:text-blue-400">Events form</a>.
</p>
<div class="flex space-x-2">
<a
href="/events?kind=30040"
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-800 border border-blue-200 dark:border-blue-700 rounded-md hover:bg-blue-200 dark:hover:bg-blue-700 transition-colors"
>
Create Publication
</a>
</div>
</div>
</div>
</div>
{/if}
<div class="flex items-center justify-between">
<Button
color="light"
size="sm"
on:click={togglePreview}
class="flex items-center space-x-1"
disabled={hasPublicationHeader}
>
{#if showPreview}
<EyeOutline class="w-4 h-4" />
@ -76,14 +209,15 @@ Note content here... @@ -76,14 +209,15 @@ Note content here...
bind:value={content}
on:input={handleContentChange}
{placeholder}
class="h-full min-h-64 resize-none"
class="h-full min-h-64 resize-none {hasPublicationHeader ? 'opacity-50 cursor-not-allowed' : ''}"
rows={12}
disabled={hasPublicationHeader}
/>
</div>
</div>
<!-- Preview Panel -->
{#if showPreview}
{#if showPreview && !hasPublicationHeader}
<div class="w-1/2 border-l border-gray-200 dark:border-gray-700 pl-4">
<div class="sticky top-4">
<h3
@ -106,7 +240,7 @@ Note content here... @@ -106,7 +240,7 @@ Note content here...
<div
class="text-sm text-gray-800 dark:text-gray-200 asciidoc-content"
>
{@html asciidoctorProcessor.convert(
{@html asciidoctor().convert(
`== ${section.title}\n\n${section.content}`,
{
standalone: false,
@ -119,8 +253,7 @@ Note content here... @@ -119,8 +253,7 @@ Note content here...
)}
</div>
{#if index < parsedSections.length - 1}
<!-- Gray area with tag bubbles above event boundary -->
<!-- Gray area with tag bubbles for all sections -->
<div class="my-4 relative">
<!-- Gray background area -->
<div
@ -145,7 +278,8 @@ Note content here... @@ -145,7 +278,8 @@ Note content here...
</div>
</div>
<!-- Event boundary line -->
{#if index < parsedSections.length - 1}
<!-- Event boundary line only between sections -->
<div
class="border-t-2 border-dashed border-blue-400 relative"
>
@ -155,9 +289,9 @@ Note content here... @@ -155,9 +289,9 @@ Note content here...
Event Boundary
</div>
</div>
</div>
{/if}
</div>
</div>
{/each}
</div>
@ -169,7 +303,6 @@ Note content here... @@ -169,7 +303,6 @@ Note content here...
? "s"
: ""}
<br />
<strong>Note:</strong> Currently only the first event will be published.
</div>
{/if}
</div>

4
src/lib/components/publications/PublicationFeed.svelte

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
<script lang="ts">
import { indexKind } from "$lib/consts";
import { ndkInstance, activeInboxRelays, activeOutboxRelays } from "$lib/ndk";
import { filterValidIndexEvents, debounce } from "$lib/utils";
import { filterValidIndexEvents, debounceAsync } from "$lib/utils";
import { Button, P, Skeleton, Spinner } from "flowbite-svelte";
import ArticleHeader from "./PublicationHeader.svelte";
import { onMount, onDestroy } from "svelte";
@ -290,7 +290,7 @@ @@ -290,7 +290,7 @@
};
// Debounced search function
const debouncedSearch = debounce((query: string | undefined) => {
const debouncedSearch = debounceAsync(async (query: string) => {
console.debug("[PublicationFeed] Search query changed:", query);
if (query && query.trim()) {
const filtered = filterEventsBySearch(allIndexEvents);

103
src/lib/services/publisher.ts

@ -1,8 +1,9 @@ @@ -1,8 +1,9 @@
import { get } from "svelte/store";
import { ndkInstance } from "../ndk.ts";
import { getMimeTags } from "../utils/mime.ts";
import { parseAsciiDocSections } from "../utils/ZettelParser.ts";
import { parseAsciiDocWithMetadata, metadataToTags } from "../utils/asciidoc_metadata.ts";
import { NDKRelaySet, NDKEvent } from "@nostr-dev-kit/ndk";
import { nip19 } from "nostr-tools";
export interface PublishResult {
success: boolean;
@ -43,18 +44,18 @@ export async function publishZettel( @@ -43,18 +44,18 @@ export async function publishZettel(
}
try {
// Parse content into sections
const sections = parseAsciiDocSections(content, 2);
// Parse content into sections using the standardized parser
const parsed = parseAsciiDocWithMetadata(content);
if (sections.length === 0) {
if (parsed.sections.length === 0) {
throw new Error("No valid sections found in content");
}
// For now, publish only the first section
const firstSection = sections[0];
const firstSection = parsed.sections[0];
const title = firstSection.title;
const cleanContent = firstSection.content;
const sectionTags = firstSection.tags || [];
const sectionTags = metadataToTags(firstSection.metadata);
// Generate d-tag and create event
const dTag = generateDTag(title);
@ -103,6 +104,96 @@ export async function publishZettel( @@ -103,6 +104,96 @@ export async function publishZettel(
}
}
/**
* Publishes all AsciiDoc sections as separate Nostr events
* @param options - Publishing options
* @returns Promise resolving to array of publish results
*/
export async function publishMultipleZettels(
options: PublishOptions,
): Promise<PublishResult[]> {
const { content, kind = 30041, onError } = options;
if (!content.trim()) {
const error = 'Please enter some content';
onError?.(error);
return [{ success: false, error }];
}
const ndk = get(ndkInstance);
if (!ndk?.activeUser) {
const error = 'Please log in first';
onError?.(error);
return [{ success: false, error }];
}
try {
const parsed = parseAsciiDocWithMetadata(content);
if (parsed.sections.length === 0) {
throw new Error('No valid sections found in content');
}
const allRelayUrls = Array.from(ndk.pool?.relays.values() || []).map((r) => r.url);
if (allRelayUrls.length === 0) {
throw new Error('No relays available in NDK pool');
}
const relaySet = NDKRelaySet.fromRelayUrls(allRelayUrls, ndk);
const results: PublishResult[] = [];
const publishedEvents: NDKEvent[] = [];
for (const section of parsed.sections) {
const title = section.title;
const cleanContent = section.content;
const sectionTags = metadataToTags(section.metadata);
const dTag = generateDTag(title);
const [mTag, MTag] = getMimeTags(kind);
const tags: string[][] = [["d", dTag], mTag, MTag, ["title", title]];
if (sectionTags) {
tags.push(...sectionTags);
}
const ndkEvent = new NDKEvent(ndk);
ndkEvent.kind = kind;
ndkEvent.created_at = Math.floor(Date.now() / 1000);
ndkEvent.tags = tags;
ndkEvent.content = cleanContent;
ndkEvent.pubkey = ndk.activeUser.pubkey;
try {
await ndkEvent.sign();
const publishedToRelays = await ndkEvent.publish(relaySet);
if (publishedToRelays.size > 0) {
results.push({ success: true, eventId: ndkEvent.id });
publishedEvents.push(ndkEvent);
} else {
results.push({ success: false, error: 'Failed to publish to any relays' });
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
results.push({ success: false, error: errorMessage });
}
}
// Debug: extract and log 'e' and 'a' tags from all published events
publishedEvents.forEach(ev => {
// Extract d-tag from tags
const dTagEntry = ev.tags.find(t => t[0] === 'd');
const dTag = dTagEntry ? dTagEntry[1] : '';
const aTag = `${ev.kind}:${ev.pubkey}:${dTag}`;
console.log(`Event ${ev.id} tags:`);
console.log(' e:', ev.id);
console.log(' a:', aTag);
// Print nevent and naddr using nip19
const nevent = nip19.neventEncode({ id: ev.id });
const naddr = nip19.naddrEncode({ kind: ev.kind, pubkey: ev.pubkey, identifier: dTag });
console.log(' nevent:', nevent);
console.log(' naddr:', naddr);
});
return results;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
onError?.(errorMessage);
return [{ success: false, error: errorMessage }];
}
}
function generateDTag(title: string): string {
return title
.toLowerCase()

26
src/lib/utils.ts

@ -241,3 +241,29 @@ export function debounce<T extends (...args: any[]) => any>( @@ -241,3 +241,29 @@ export function debounce<T extends (...args: any[]) => any>(
timeout = setTimeout(later, wait);
};
}
/**
* Creates a debounced async function that delays invoking func until after wait milliseconds have elapsed
* since the last time the debounced function was invoked.
* @param func The async function to debounce
* @param wait The number of milliseconds to delay
* @returns A debounced version of the async function
*/
export function debounceAsync(
func: (query: string) => Promise<void>,
wait: number,
): (query: string) => void {
let timeout: ReturnType<typeof setTimeout> | undefined;
return function executedFunction(query: string) {
const later = () => {
timeout = undefined;
func(query);
};
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(later, wait);
};
}

58
src/lib/utils/ZettelParser.ts

@ -32,21 +32,40 @@ export function parseZettelSection(section: string): ZettelSection { @@ -32,21 +32,40 @@ export function parseZettelSection(section: string): ZettelSection {
const lines = section.split("\n");
let title = "Untitled";
const contentLines: string[] = [];
let inHeader = true;
let tags: string[][] = [];
tags = extractTags(section);
for (const line of lines) {
// Find the section title first
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
if (inHeader && trimmed.startsWith("==")) {
if (trimmed.startsWith("==")) {
title = trimmed.replace(/^==+/, "").trim();
continue;
} else if (inHeader && trimmed.startsWith(":")) {
continue;
// Process header metadata (everything after title until blank line)
let j = i + 1;
while (j < lines.length && lines[j].trim() !== "") {
const headerLine = lines[j].trim();
if (headerLine.startsWith(":")) {
// This is metadata, already handled by extractTags
j++;
} else {
// This is header content (like author name), skip from content
j++;
}
}
inHeader = false;
contentLines.push(line);
// Skip the blank line
if (j < lines.length && lines[j].trim() === "") {
j++;
}
// Everything after the blank line is content
for (let k = j; k < lines.length; k++) {
contentLines.push(lines[k]);
}
break;
}
}
return {
@ -69,6 +88,7 @@ export function parseAsciiDocSections( @@ -69,6 +88,7 @@ export function parseAsciiDocSections(
/**
* Extracts tag names and values from the content.
* :tagname: tagvalue // tags are optional
* Also handles AsciiDoc author line convention
* @param content The AsciiDoc string.
* @returns Array of tags.
*/
@ -76,11 +96,20 @@ export function extractTags(content: string): string[][] { @@ -76,11 +96,20 @@ export function extractTags(content: string): string[][] {
const tags: string[][] = [];
const lines = content.split("\n");
for (const line of lines) {
// Find the section title and process header metadata
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
if (trimmed.startsWith(":")) {
if (trimmed.startsWith("==")) {
// Process header metadata (everything after title until blank line)
let j = i + 1;
while (j < lines.length && lines[j].trim() !== "") {
const headerLine = lines[j].trim();
if (headerLine.startsWith(":")) {
// Parse AsciiDoc attribute format: :tagname: value
const match = trimmed.match(/^:([^:]+):\s*(.*)$/);
const match = headerLine.match(/^:([^:]+):\s*(.*)$/);
if (match) {
const tagName = match[1].trim();
const tagValue = match[2].trim();
@ -100,6 +129,13 @@ export function extractTags(content: string): string[][] { @@ -100,6 +129,13 @@ export function extractTags(content: string): string[][] {
tags.push([tagName, tagValue]);
}
}
} else {
// This is header content (like author name)
tags.push(["author", headerLine]);
}
j++;
}
break;
}
}

500
src/lib/utils/asciidoc_metadata.ts

@ -0,0 +1,500 @@ @@ -0,0 +1,500 @@
/**
* AsciiDoc Metadata Extraction Service using Asciidoctor
*
* Thin wrapper around Asciidoctor's built-in metadata extraction capabilities.
* Leverages the existing Pharos parser to avoid duplication.
*/
// @ts-ignore
import Processor from "asciidoctor";
import type { Document } from "asciidoctor";
export interface AsciiDocMetadata {
title?: string;
authors?: string[];
version?: string;
edition?: string;
publicationDate?: string;
publisher?: string;
summary?: string;
coverImage?: string;
isbn?: string;
tags?: string[];
source?: string;
publishedBy?: string;
type?: string;
autoUpdate?: 'yes' | 'ask' | 'no';
}
export type SectionMetadata = AsciiDocMetadata;
export interface ParsedAsciiDoc {
metadata: AsciiDocMetadata;
content: string;
sections: Array<{
metadata: SectionMetadata;
content: string;
title: string;
}>;
}
// Shared attribute mapping based on Asciidoctor standard attributes
const ATTRIBUTE_MAP: Record<string, keyof AsciiDocMetadata> = {
// Standard Asciidoctor attributes
'author': 'authors',
'description': 'summary',
'keywords': 'tags',
'revnumber': 'version',
'revdate': 'publicationDate',
'revremark': 'edition',
'title': 'title',
// Custom attributes for Alexandria
'published_by': 'publishedBy',
'publisher': 'publisher',
'summary': 'summary',
'image': 'coverImage',
'cover': 'coverImage',
'isbn': 'isbn',
'source': 'source',
'type': 'type',
'auto-update': 'autoUpdate',
'version': 'version',
'edition': 'edition',
'published_on': 'publicationDate',
'date': 'publicationDate',
'version-label': 'version',
};
/**
* Creates an Asciidoctor processor instance
*/
function createProcessor() {
return Processor();
}
/**
* Extracts tags from attributes, combining tags and keywords
*/
function extractTagsFromAttributes(attributes: Record<string, any>): string[] {
const tags: string[] = [];
const attrTags = attributes['tags'];
const attrKeywords = attributes['keywords'];
if (attrTags && typeof attrTags === 'string') {
tags.push(...attrTags.split(',').map(tag => tag.trim()));
}
if (attrKeywords && typeof attrKeywords === 'string') {
tags.push(...attrKeywords.split(',').map(tag => tag.trim()));
}
return [...new Set(tags)]; // Remove duplicates
}
/**
* Maps attributes to metadata with special handling for authors and tags
*/
function mapAttributesToMetadata(attributes: Record<string, any>, metadata: AsciiDocMetadata, isDocument: boolean = false): void {
for (const [key, value] of Object.entries(attributes)) {
const metadataKey = ATTRIBUTE_MAP[key.toLowerCase()];
if (metadataKey && value && typeof value === 'string') {
if (metadataKey === 'authors' && isDocument) {
// Skip author mapping for documents since it's handled manually
continue;
} else if (metadataKey === 'authors' && !isDocument) {
// For sections, append author to existing authors array
if (!metadata.authors) {
metadata.authors = [];
}
metadata.authors.push(value);
} else if (metadataKey === 'tags') {
// Skip tags mapping since it's handled by extractTagsFromAttributes
continue;
} else {
(metadata as any)[metadataKey] = value;
}
}
}
}
/**
* Extracts authors from header line (document or section)
*/
function extractAuthorsFromHeader(sourceContent: string, isSection: boolean = false): string[] {
const authors: string[] = [];
const lines = sourceContent.split(/\r?\n/);
const headerPattern = isSection ? /^==\s+/ : /^=\s+/;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.match(headerPattern)) {
// Found title line, check subsequent lines for authors
let j = i + 1;
while (j < lines.length) {
const authorLine = lines[j];
// Stop if we hit a blank line or content that's not an author
if (authorLine.trim() === '') {
break;
}
if (authorLine.includes('<') && !authorLine.startsWith(':')) {
// This is an author line like "John Doe <john@example.com>"
const authorName = authorLine.split('<')[0].trim();
if (authorName) {
authors.push(authorName);
}
} else if (isSection && authorLine.match(/^[A-Za-z\s]+$/) && authorLine.trim() !== '' && authorLine.trim().split(/\s+/).length <= 2) {
// This is a simple author name without email (for sections)
authors.push(authorLine.trim());
} else if (authorLine.startsWith(':')) {
// This is an attribute line, skip it - attributes are handled by mapAttributesToMetadata
// Don't break here, continue to next line
} else {
// Not an author line, stop looking
break;
}
j++;
}
break;
}
}
return authors;
}
/**
* Strips header and attribute lines from content
*/
function stripHeaderAndAttributes(content: string, isSection: boolean = false): string {
const lines = content.split(/\r?\n/);
let contentStart = 0;
const headerPattern = isSection ? /^==\s+/ : /^=\s+/;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Skip title line, author line, revision line, and attribute lines
if (!line.match(headerPattern) && !line.includes('<') && !line.match(/^.+,\s*.+:\s*.+$/) &&
!line.match(/^:[^:]+:\s*.+$/) && line.trim() !== '') {
contentStart = i;
break;
}
}
// Filter out all attribute lines and author lines from the content
const contentLines = lines.slice(contentStart);
const filteredLines = contentLines.filter(line => {
// Skip attribute lines
if (line.match(/^:[^:]+:\s*.+$/)) {
return false;
}
// Skip author lines (simple names without email)
if (isSection && line.match(/^[A-Za-z\s]+$/) && line.trim() !== '' && line.trim().split(/\s+/).length <= 2) {
return false;
}
return true;
});
// Remove extra blank lines and normalize newlines
return filteredLines.join('\n').replace(/\n\s*\n\s*\n/g, '\n\n').replace(/\n\s*\n/g, '\n').trim();
}
/**
* Parses attributes from section content
*/
function parseSectionAttributes(sectionContent: string): Record<string, any> {
const attributes: Record<string, any> = {};
const lines = sectionContent.split(/\r?\n/);
for (const line of lines) {
const match = line.match(/^:([^:]+):\s*(.+)$/);
if (match) {
const [, key, value] = match;
attributes[key.trim()] = value.trim();
}
}
return attributes;
}
/**
* Extracts metadata from AsciiDoc document using Asciidoctor
*/
export function extractDocumentMetadata(inputContent: string): {
metadata: AsciiDocMetadata;
content: string;
} {
const asciidoctor = createProcessor();
const document = asciidoctor.load(inputContent, { standalone: false }) as Document;
const metadata: AsciiDocMetadata = {};
const attributes = document.getAttributes();
// Extract basic metadata
const title = document.getTitle();
if (title) metadata.title = title;
// Handle multiple authors - combine header line and attributes
const authors = extractAuthorsFromHeader(document.getSource());
// Get authors from attributes (but avoid duplicates)
const attrAuthor = attributes['author'];
if (attrAuthor && typeof attrAuthor === 'string' && !authors.includes(attrAuthor)) {
authors.push(attrAuthor);
}
if (authors.length > 0) {
metadata.authors = [...new Set(authors)]; // Remove duplicates
}
// Extract revision info
const revisionNumber = document.getRevisionNumber();
if (revisionNumber) metadata.version = revisionNumber;
const revisionRemark = document.getRevisionRemark();
if (revisionRemark) metadata.publishedBy = revisionRemark;
const revisionDate = document.getRevisionDate();
if (revisionDate) metadata.publicationDate = revisionDate;
// Map attributes to metadata (but skip version and publishedBy if we already have them from revision)
mapAttributesToMetadata(attributes, metadata, true);
// If we got version from revision, don't override it with attribute
if (revisionNumber) {
metadata.version = revisionNumber;
}
// If we got publishedBy from revision, don't override it with attribute
if (revisionRemark) {
metadata.publishedBy = revisionRemark;
}
// Handle tags and keywords
const tags = extractTagsFromAttributes(attributes);
if (tags.length > 0) {
metadata.tags = tags;
}
const content = stripHeaderAndAttributes(document.getSource());
return { metadata, content };
}
/**
* Extracts metadata from a section using Asciidoctor
*/
export function extractSectionMetadata(inputSectionContent: string): {
metadata: SectionMetadata;
content: string;
title: string;
} {
const asciidoctor = createProcessor();
const document = asciidoctor.load(`= Temp\n\n${inputSectionContent}`, { standalone: false }) as Document;
const sections = document.getSections();
if (sections.length === 0) {
return { metadata: {}, content: inputSectionContent, title: '' };
}
const section = sections[0];
const title = section.getTitle() || '';
const metadata: SectionMetadata = { title };
// Parse attributes from the section content
const attributes = parseSectionAttributes(inputSectionContent);
// Extract authors from section content
const authors = extractAuthorsFromHeader(inputSectionContent, true);
if (authors.length > 0) {
metadata.authors = authors;
}
// Map attributes to metadata (sections can have authors)
mapAttributesToMetadata(attributes, metadata, false);
// Handle tags and keywords
const tags = extractTagsFromAttributes(attributes);
if (tags.length > 0) {
metadata.tags = tags;
}
const content = stripHeaderAndAttributes(inputSectionContent, true);
return { metadata, content, title };
}
/**
* Parses AsciiDoc content into sections with metadata
*/
export function parseAsciiDocWithMetadata(content: string): ParsedAsciiDoc {
const asciidoctor = createProcessor();
const document = asciidoctor.load(content, { standalone: false }) as Document;
const { metadata: docMetadata } = extractDocumentMetadata(content);
// Parse the original content to find section attributes
const lines = content.split(/\r?\n/);
const sectionsWithMetadata: Array<{
metadata: SectionMetadata;
content: string;
title: string;
}> = [];
let currentSection: string | null = null;
let currentSectionContent: string[] = [];
for (const line of lines) {
if (line.match(/^==\s+/)) {
// Save previous section if exists
if (currentSection) {
const sectionContent = currentSectionContent.join('\n');
sectionsWithMetadata.push(extractSectionMetadata(sectionContent));
}
// Start new section
currentSection = line;
currentSectionContent = [line];
} else if (currentSection) {
currentSectionContent.push(line);
}
}
// Save the last section
if (currentSection) {
const sectionContent = currentSectionContent.join('\n');
sectionsWithMetadata.push(extractSectionMetadata(sectionContent));
}
return {
metadata: docMetadata,
content: document.getSource(),
sections: sectionsWithMetadata
};
}
/**
* Converts metadata to Nostr event tags
*/
export function metadataToTags(metadata: AsciiDocMetadata | SectionMetadata): [string, string][] {
const tags: [string, string][] = [];
if (metadata.title) tags.push(['title', metadata.title]);
if (metadata.authors?.length) {
metadata.authors.forEach(author => tags.push(['author', author]));
}
if (metadata.version) tags.push(['version', metadata.version]);
if (metadata.edition) tags.push(['edition', metadata.edition]);
if (metadata.publicationDate) tags.push(['published_on', metadata.publicationDate]);
if (metadata.publishedBy) tags.push(['published_by', metadata.publishedBy]);
if (metadata.summary) tags.push(['summary', metadata.summary]);
if (metadata.coverImage) tags.push(['image', metadata.coverImage]);
if (metadata.isbn) tags.push(['i', metadata.isbn]);
if (metadata.source) tags.push(['source', metadata.source]);
if (metadata.type) tags.push(['type', metadata.type]);
if (metadata.autoUpdate) tags.push(['auto-update', metadata.autoUpdate]);
if (metadata.tags?.length) {
metadata.tags.forEach(tag => tags.push(['t', tag]));
}
return tags;
}
/**
* Removes metadata from AsciiDoc content
*/
export function removeMetadataFromContent(content: string): string {
const { content: cleanedContent } = extractDocumentMetadata(content);
return cleanedContent;
}
/**
* Extracts metadata from content that only contains sections (no document header)
* This is useful when content flows from ZettelEditor to EventInput
*/
export function extractMetadataFromSectionsOnly(content: string): {
metadata: AsciiDocMetadata;
content: string;
} {
const lines = content.split(/\r?\n/);
const sections: Array<{
metadata: SectionMetadata;
content: string;
title: string;
}> = [];
let currentSection: string | null = null;
let currentSectionContent: string[] = [];
// Parse sections from the content
for (const line of lines) {
if (line.match(/^==\s+/)) {
// Save previous section if exists
if (currentSection) {
const sectionContent = currentSectionContent.join('\n');
sections.push(extractSectionMetadata(sectionContent));
}
// Start new section
currentSection = line;
currentSectionContent = [line];
} else if (currentSection) {
currentSectionContent.push(line);
}
}
// Save the last section
if (currentSection) {
const sectionContent = currentSectionContent.join('\n');
sections.push(extractSectionMetadata(sectionContent));
}
// For section-only content, we don't have document metadata
// Return the first section's title as the document title if available
const metadata: AsciiDocMetadata = {};
if (sections.length > 0 && sections[0].title) {
metadata.title = sections[0].title;
}
return { metadata, content };
}
/**
* Smart metadata extraction that handles both document headers and section-only content
*/
export function extractSmartMetadata(content: string): {
metadata: AsciiDocMetadata;
content: string;
} {
// Check if content has a document header
const hasDocumentHeader = content.match(/^=\s+/m);
if (hasDocumentHeader) {
// Check if it's a minimal document header (just title, no other metadata)
const lines = content.split(/\r?\n/);
const titleLine = lines.find(line => line.match(/^=\s+/));
const hasOtherMetadata = lines.some(line =>
line.includes('<') || // author line
line.match(/^.+,\s*.+:\s*.+$/) // revision line
);
if (hasOtherMetadata) {
// Full document with metadata - use standard extraction
return extractDocumentMetadata(content);
} else {
// Minimal document header (just title) - preserve the title line for 30040 events
const title = titleLine?.replace(/^=\s+/, '').trim();
const metadata: AsciiDocMetadata = {};
if (title) {
metadata.title = title;
}
// Keep the title line in content for 30040 events
return { metadata, content };
}
} else {
return extractMetadataFromSectionsOnly(content);
}
}

342
src/lib/utils/event_input_utils.ts

@ -3,6 +3,13 @@ import { get } from "svelte/store"; @@ -3,6 +3,13 @@ import { get } from "svelte/store";
import { ndkInstance } from "../ndk.ts";
import { NDKEvent as NDKEventClass } from "@nostr-dev-kit/ndk";
import { EVENT_KINDS } from "./search_constants";
import {
extractDocumentMetadata,
extractSectionMetadata,
parseAsciiDocWithMetadata,
metadataToTags,
removeMetadataFromContent
} from "./asciidoc_metadata";
// =========================
// Validation
@ -79,24 +86,23 @@ export function validateAsciiDoc(content: string): { @@ -79,24 +86,23 @@ export function validateAsciiDoc(content: string): {
export function validate30040EventSet(content: string): {
valid: boolean;
reason?: string;
warning?: string;
} {
// First validate as AsciiDoc
const asciiDocValidation = validateAsciiDoc(content);
if (!asciiDocValidation.valid) {
return asciiDocValidation;
}
// Check that we have at least one section
const sectionsResult = splitAsciiDocSections(content);
if (sectionsResult.sections.length === 0) {
return {
valid: false,
reason: "30040 events must contain at least one section.",
};
// Check for "index card" format first
const lines = content.split(/\r?\n/);
const { metadata } = extractDocumentMetadata(content);
const documentTitle = metadata.title;
const nonEmptyLines = lines.filter(line => line.trim() !== "").map(line => line.trim());
const isIndexCardFormat = documentTitle &&
nonEmptyLines.length === 2 &&
nonEmptyLines[0].startsWith("=") &&
nonEmptyLines[1].toLowerCase() === "index card";
if (isIndexCardFormat) {
return { valid: true };
}
// Check that we have a document title
const documentTitle = extractAsciiDocDocumentHeader(content);
if (!documentTitle) {
return {
valid: false,
@ -114,6 +120,41 @@ export function validate30040EventSet(content: string): { @@ -114,6 +120,41 @@ export function validate30040EventSet(content: string): {
};
}
// Check for duplicate document headers (=)
const documentHeaderMatches = content.match(/^=\s+/gm);
if (documentHeaderMatches && documentHeaderMatches.length > 1) {
return {
valid: false,
reason: '30040 events must have exactly one document title ("="). Found multiple document headers.',
};
}
// Parse the content to check sections
const parsed = parseAsciiDocWithMetadata(content);
const hasSections = parsed.sections.length > 0;
if (!hasSections) {
return {
valid: true,
warning: "No section headers (==) found. This will create a 30040 index event and a single 30041 preamble section. Continue?",
};
}
// Only validate as AsciiDoc if we have sections
const asciiDocValidation = validateAsciiDoc(content);
if (!asciiDocValidation.valid) {
return asciiDocValidation;
}
// Check for empty sections
const emptySections = parsed.sections.filter(section => section.content.trim() === "");
if (emptySections.length > 0) {
return {
valid: true,
warning: "You are creating sections that contain no content. Proceed?",
};
}
return { valid: true };
}
@ -141,14 +182,6 @@ export function titleToDTag(title: string): string { @@ -141,14 +182,6 @@ export function titleToDTag(title: string): string {
.replace(/^-+|-+$/g, ""); // Trim leading/trailing hyphens
}
/**
* Extracts the first AsciiDoc document header (line starting with '= ').
*/
function extractAsciiDocDocumentHeader(content: string): string | null {
const match = content.match(/^=\s+(.+)$/m);
return match ? match[1].trim() : null;
}
/**
* Extracts the topmost Markdown # header (line starting with '# ').
*/
@ -157,71 +190,6 @@ function extractMarkdownTopHeader(content: string): string | null { @@ -157,71 +190,6 @@ function extractMarkdownTopHeader(content: string): string | null {
return match ? match[1].trim() : null;
}
/**
* Splits AsciiDoc content into sections at each '==' header. Returns array of section strings.
* Document title (= header) is excluded from sections and only used for the index event title.
* Section headers (==) are discarded from content.
* Text between document header and first section becomes a "Preamble" section.
*/
function splitAsciiDocSections(content: string): {
sections: string[];
sectionHeaders: string[];
hasPreamble: boolean;
} {
const lines = content.split(/\r?\n/);
const sections: string[] = [];
const sectionHeaders: string[] = [];
let current: string[] = [];
let foundFirstSection = false;
let hasPreamble = false;
const preambleContent: string[] = [];
for (const line of lines) {
// Skip document title lines (= header)
if (/^=\s+/.test(line)) {
continue;
}
// If we encounter a section header (==) and we have content, start a new section
if (/^==\s+/.test(line)) {
if (current.length > 0) {
sections.push(current.join("\n").trim());
current = [];
}
// Extract section header for title tag
const headerMatch = line.match(/^==\s+(.+)$/);
if (headerMatch) {
sectionHeaders.push(headerMatch[1].trim());
}
foundFirstSection = true;
} else if (foundFirstSection) {
// Only add lines to current section if we've found the first section
current.push(line);
} else {
// Text before first section becomes preamble
if (line.trim() !== "") {
preambleContent.push(line);
}
}
}
// Add the last section
if (current.length > 0) {
sections.push(current.join("\n").trim());
}
// Add preamble as first section if it exists
if (preambleContent.length > 0) {
sections.unshift(preambleContent.join("\n").trim());
sectionHeaders.unshift("Preamble");
hasPreamble = true;
}
return { sections, sectionHeaders, hasPreamble };
}
// =========================
// Event Construction
// =========================
@ -251,44 +219,90 @@ export function build30040EventSet( @@ -251,44 +219,90 @@ export function build30040EventSet(
const ndk = getNdk();
console.log("NDK instance:", ndk);
const sectionsResult = splitAsciiDocSections(content);
const sections = sectionsResult.sections;
const sectionHeaders = sectionsResult.sectionHeaders;
console.log("Sections:", sections);
console.log("Section headers:", sectionHeaders);
const dTags =
sectionHeaders.length === sections.length
? sectionHeaders.map(normalizeDTagValue)
: sections.map((_, i) => `section${i}`);
console.log("D tags:", dTags);
const sectionEvents: NDKEvent[] = sections.map((section, i) => {
const header = sectionHeaders[i] || `Section ${i + 1}`;
const dTag = dTags[i];
console.log(`Creating section ${i}:`, { header, dTag, content: section });
// Parse the AsciiDoc content with metadata extraction
const parsed = parseAsciiDocWithMetadata(content);
console.log("Parsed AsciiDoc:", parsed);
// Check if this is an "index card" format (no sections, just title + "index card")
const lines = content.split(/\r?\n/);
const documentTitle = parsed.metadata.title;
// For index card format, the content should be exactly: title + "index card"
const nonEmptyLines = lines.filter(line => line.trim() !== "").map(line => line.trim());
const isIndexCardFormat = documentTitle &&
nonEmptyLines.length === 2 &&
nonEmptyLines[0].startsWith("=") &&
nonEmptyLines[1].toLowerCase() === "index card";
if (isIndexCardFormat) {
console.log("Creating index card format (no sections)");
const indexDTag = normalizeDTagValue(documentTitle);
// Convert document metadata to tags
const metadataTags = metadataToTags(parsed.metadata);
const indexEvent: NDKEvent = new NDKEventClass(ndk, {
kind: 30040,
content: "",
tags: [
...tags,
...metadataTags,
["d", indexDTag],
["title", documentTitle],
],
pubkey: baseEvent.pubkey,
created_at: baseEvent.created_at,
});
console.log("Final index event (index card):", indexEvent);
console.log("=== build30040EventSet completed (index card) ===");
return { indexEvent, sectionEvents: [] };
}
// Generate the index d-tag first
const indexDTag = documentTitle ? normalizeDTagValue(documentTitle) : "index";
console.log("Index event:", { documentTitle, indexDTag });
// Create section events with their metadata
const sectionEvents: NDKEvent[] = parsed.sections.map((section, i) => {
const sectionDTag = `${indexDTag}-${normalizeDTagValue(section.title)}`;
console.log(`Creating section ${i}:`, {
title: section.title,
dTag: sectionDTag,
content: section.content,
metadata: section.metadata
});
// Convert section metadata to tags
const sectionMetadataTags = metadataToTags(section.metadata);
return new NDKEventClass(ndk, {
kind: 30041,
content: section,
tags: [...tags, ["d", dTag], ["title", header]],
content: section.content,
tags: [
...tags,
...sectionMetadataTags,
["d", sectionDTag],
["title", section.title]
],
pubkey: baseEvent.pubkey,
created_at: baseEvent.created_at,
});
});
// Create proper a tags with format: kind:pubkey:d-tag
const aTags = dTags.map(
(dTag) => ["a", `30041:${baseEvent.pubkey}:${dTag}`] as [string, string],
);
const aTags = sectionEvents.map(event => {
const dTag = event.tags.find(([k]) => k === "d")?.[1];
return ["a", `30041:${baseEvent.pubkey}:${dTag}`] as [string, string];
});
console.log("A tags:", aTags);
// Extract document title for the index event
const documentTitle = extractAsciiDocDocumentHeader(content);
const indexDTag = documentTitle ? normalizeDTagValue(documentTitle) : "index";
console.log("Index event:", { documentTitle, indexDTag });
// Convert document metadata to tags
const metadataTags = metadataToTags(parsed.metadata);
const indexTags = [
...tags,
...metadataTags,
["d", indexDTag],
["title", documentTitle || "Untitled"],
...aTags,
@ -316,7 +330,8 @@ export function getTitleTagForEvent( @@ -316,7 +330,8 @@ export function getTitleTagForEvent(
content: string,
): string | null {
if (kind === 30041 || kind === 30818) {
return extractAsciiDocDocumentHeader(content);
const { metadata } = extractDocumentMetadata(content);
return metadata.title || null;
}
if (kind === 30023) {
return extractMarkdownTopHeader(content);
@ -345,8 +360,8 @@ export function getDTagForEvent( @@ -345,8 +360,8 @@ export function getDTagForEvent(
}
if (kind === 30041 || kind === 30818) {
const title = extractAsciiDocDocumentHeader(content);
return title ? normalizeDTagValue(title) : null;
const { metadata } = extractDocumentMetadata(content);
return metadata.title ? normalizeDTagValue(metadata.title) : null;
}
return null;
@ -356,13 +371,59 @@ export function getDTagForEvent( @@ -356,13 +371,59 @@ export function getDTagForEvent(
* Returns a description of what a 30040 event structure should be.
*/
export function get30040EventDescription(): string {
return `30040 events are publication indexes that contain:
- Empty content (metadata only)
- A d-tag for the publication identifier
- A title tag for the publication title
- A tags referencing 30041 content events (one per section)
return `30040 events are publication indexes that organize AsciiDoc content into structured publications.
**Supported Structures:**
1. **Normal Document** (with sections):
= Document Title
:author: Author Name
:summary: Document description
:keywords: tag1, tag2, tag3
== Section 1
Section content here...
The content is split into sections, each published as a separate 30041 event.`;
== Section 2
More content...
2. **Index Card** (empty publication):
= Publication Title
index card
3. **Skeleton Document** (empty sections):
= Document Title
== Empty Section 1
== Empty Section 2
4. **Preamble Document** (with preamble content):
= Document Title
:author: Author Name
:summary: Document description
:keywords: tag1, tag2, tag3
Preamble content here...
== Section 1
Section content here...
**Metadata Extraction:**
- Document title, authors, version, publication date, and publisher are extracted from header lines
- Additional metadata (summary/description, keywords/tags, image, ISBN, etc.) are extracted from attributes
- Multiple authors and summaries are preserved
- All metadata is converted to appropriate Nostr event tags
**Event Structure:**
- 30040 index event: Empty content with metadata tags and a-tags referencing sections
- 30041 section events: Individual section content with section-specific metadata
**Special Features:**
- Preamble content (between header and first section) is preserved
- Multiple authors and descriptions are supported
- Keywords and tags are automatically converted to Nostr t-tags
- Index card format creates empty publications without sections`;
}
/**
@ -422,16 +483,31 @@ export function analyze30040Event(event: { @@ -422,16 +483,31 @@ export function analyze30040Event(event: {
export function get30040FixGuidance(): string {
return `To fix a 30040 event:
1. **Content Issue**: 30040 events should have empty content. All content should be split into separate 30041 events.
2. **Structure**: A proper 30040 event should contain:
- Empty content
- d tag: publication identifier
- title tag: publication title
- a tags: references to 30041 content events (format: "30041:pubkey:d-tag")
3. **Process**: When creating a 30040 event:
- Write your content with document title (= Title) and sections (== Section)
- The system will automatically split it into one 30040 index event and multiple 30041 content events
- The 30040 will have empty content and reference the 30041s via a tags`;
1. **Content Structure**: Ensure your AsciiDoc starts with a document title (= Title)
- Add at least one section (== Section) for normal documents
- Use "index card" format for empty publications
- Include metadata in header lines or attributes,
or add them manually to the tag list
2. **Metadata**: Add relevant metadata to improve discoverability:
- Author: Use header line or :author: attribute
- Summary: Use :summary: or :description: attribute
- Keywords: Use :keywords: or :tags: attribute
- Version: Use revision line or :version: attribute
- Publication date: Use revision line or :published_on: attribute
3. **Event Structure**: The system will automatically create:
- 30040 index event: Empty content with metadata and a-tags
- 30041 section events: Individual section content with section metadata
4. **Common Issues**:
- Missing document title: Start with "= Your Title"
- No sections: Add "== Section Name" or use "index card" format
- Invalid metadata: Use proper AsciiDoc attribute syntax (:key: value)
5. **Best Practices**:
- Include descriptive titles and summaries
- Use keywords for better searchability
- Add author information when relevant
- Consider using preamble content for introductions`;
}

4
src/lib/utils/event_search.ts

@ -1,8 +1,8 @@ @@ -1,8 +1,8 @@
import { ndkInstance } from "../ndk.ts";
import { fetchEventWithFallback } from "./nostrUtils.ts";
import { nip19 } from "nostr-tools";
import type { NDKFilter } from "@nostr-dev-kit/ndk";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import type { Filter } from "./search_types.ts";
import { get } from "svelte/store";
import { wellKnownUrl, isValidNip05Address } from "./search_utils.ts";
import { TIMEOUTS, VALIDATION } from "./search_constants.ts";
@ -13,7 +13,7 @@ import { TIMEOUTS, VALIDATION } from "./search_constants.ts"; @@ -13,7 +13,7 @@ import { TIMEOUTS, VALIDATION } from "./search_constants.ts";
export async function searchEvent(query: string): Promise<NDKEvent | null> {
// Clean the query and normalize to lowercase
const cleanedQuery = query.replace(/^nostr:/, "").toLowerCase();
let filterOrId: NDKFilter | string = cleanedQuery;
let filterOrId: Filter | string = cleanedQuery;
// If it's a valid hex string, try as event id first, then as pubkey (profile)
if (

3
src/lib/utils/network_detection.ts

@ -156,8 +156,7 @@ export function startNetworkMonitoring( @@ -156,8 +156,7 @@ export function startNetworkMonitoring(
checkInterval: number = 60000, // Increased to 60 seconds to reduce spam
): () => void {
let lastCondition: NetworkCondition | null = null;
// deno-lint-ignore no-explicit-any
let intervalId: any = null;
let intervalId: ReturnType<typeof setInterval> | null = null;
const checkNetwork = async () => {
try {

5
src/lib/utils/nostrUtils.ts

@ -3,7 +3,8 @@ import { nip19 } from "nostr-tools"; @@ -3,7 +3,8 @@ import { nip19 } from "nostr-tools";
import { ndkInstance } from "../ndk.ts";
import { npubCache } from "./npubCache.ts";
import NDK, { NDKEvent, NDKRelaySet, NDKUser } from "@nostr-dev-kit/ndk";
import type { NDKFilter, NDKKind, NostrEvent } from "@nostr-dev-kit/ndk";
import type { NDKKind, NostrEvent } from "@nostr-dev-kit/ndk";
import type { Filter } from "./search_types.ts";
import { communityRelays, secondaryRelays } from "../consts.ts";
import { activeInboxRelays, activeOutboxRelays } from "../ndk.ts";
import { NDKRelaySet as NDKRelaySetFromNDK } from "@nostr-dev-kit/ndk";
@ -439,7 +440,7 @@ Promise.prototype.withTimeout = function <T>( @@ -439,7 +440,7 @@ Promise.prototype.withTimeout = function <T>(
*/
export async function fetchEventWithFallback(
ndk: NDK,
filterOrId: string | NDKFilter<NDKKind>,
filterOrId: string | Filter,
timeoutMs: number = 3000,
): Promise<NDKEvent | null> {
// Use both inbox and outbox relays for better event discovery

17
src/lib/utils/search_types.ts

@ -1,5 +1,18 @@ @@ -1,5 +1,18 @@
import { NDKEvent, NDKSubscription } from "@nostr-dev-kit/ndk";
import type { NDKFilter } from "@nostr-dev-kit/ndk";
/**
* Nostr filter interface
*/
export interface Filter {
ids?: string[];
authors?: string[];
kinds?: number[];
since?: number;
until?: number;
limit?: number;
search?: string;
[key: string]: any;
}
/**
* Extended NostrProfile interface for search results
@ -46,7 +59,7 @@ export type SearchSubscriptionType = "d" | "t" | "n"; @@ -46,7 +59,7 @@ export type SearchSubscriptionType = "d" | "t" | "n";
* Search filter configuration
*/
export interface SearchFilter {
filter: NDKFilter;
filter: Filter;
subscriptionType: string;
}

172
src/routes/new/compose/+page.svelte

@ -4,15 +4,18 @@ @@ -4,15 +4,18 @@
import ZettelEditor from "$lib/components/ZettelEditor.svelte";
import { goto } from "$app/navigation";
import { nip19 } from "nostr-tools";
import { publishZettel } from "$lib/services/publisher";
import { publishMultipleZettels } from "$lib/services/publisher";
import { parseAsciiDocWithMetadata } from "$lib/utils/asciidoc_metadata";
let content = $state("");
let showPreview = $state(false);
let isPublishing = $state(false);
let publishResult = $state<{
success: boolean;
eventId?: string;
error?: string;
let publishResults = $state<{
successCount: number;
total: number;
errors: string[];
successfulEvents: Array<{ eventId: string; title: string }>;
failedEvents: Array<{ title: string; error: string; sectionIndex: number }>;
} | null>(null);
// Handle content changes from ZettelEditor
@ -27,20 +30,95 @@ @@ -27,20 +30,95 @@
async function handlePublish() {
isPublishing = true;
publishResult = null;
publishResults = null;
const result = await publishZettel({
const results = await publishMultipleZettels({
content,
onSuccess: (eventId) => {
publishResult = { success: true, eventId };
const nevent = nip19.neventEncode({ id: eventId });
goto(`/events?id=${nevent}`);
onError: (error) => {
// Only used for catastrophic errors
publishResults = { successCount: 0, total: 0, errors: [error], successfulEvents: [], failedEvents: [] };
},
});
const successCount = results.filter(r => r.success).length;
const errors = results.filter(r => !r.success && r.error).map(r => r.error!);
// Extract successful events with their titles
const parsed = parseAsciiDocWithMetadata(content);
const successfulEvents = results
.filter(r => r.success && r.eventId)
.map((r, index) => ({
eventId: r.eventId!,
title: parsed.sections[index]?.title || `Note ${index + 1}`
}));
// Extract failed events with their titles and errors
const failedEvents = results
.map((r, index) => ({ result: r, index }))
.filter(({ result }) => !result.success)
.map(({ result, index }) => ({
title: parsed.sections[index]?.title || `Note ${index + 1}`,
error: result.error || 'Unknown error',
sectionIndex: index
}));
publishResults = {
successCount,
total: results.length,
errors,
successfulEvents,
failedEvents,
};
isPublishing = false;
}
async function retryFailedEvent(sectionIndex: number) {
if (!publishResults) return;
isPublishing = true;
// Get the specific section content
const parsed = parseAsciiDocWithMetadata(content);
const section = parsed.sections[sectionIndex];
if (!section) return;
// Reconstruct the section content for publishing
const sectionContent = `== ${section.title}\n\n${section.content}`;
try {
const result = await publishMultipleZettels({
content: sectionContent,
onError: (error) => {
publishResult = { success: false, error };
console.error('Retry failed:', error);
},
});
if (result[0]?.success && result[0]?.eventId) {
// Update the successful events list
const newSuccessfulEvent = {
eventId: result[0].eventId,
title: section.title
};
// Remove from failed events
const updatedFailedEvents = publishResults.failedEvents.filter(
(_, index) => index !== sectionIndex
);
// Add to successful events
const updatedSuccessfulEvents = [...publishResults.successfulEvents, newSuccessfulEvent];
publishResults = {
...publishResults,
successCount: publishResults.successCount + 1,
successfulEvents: updatedSuccessfulEvents,
failedEvents: updatedFailedEvents,
};
}
} catch (error) {
console.error('Retry failed:', error);
}
isPublishing = false;
}
</script>
@ -81,16 +159,76 @@ @@ -81,16 +159,76 @@
</Button>
<!-- Status Messages -->
{#if publishResult}
{#if publishResult.success}
{#if publishResults}
{#if publishResults.successCount === publishResults.total}
<Alert color="green" dismissable>
<span class="font-medium">Success!</span>
Event published successfully. Event ID: {publishResult.eventId}
{publishResults.successCount} events published.
{#if publishResults.successfulEvents.length > 0}
<div class="mt-2">
<span class="text-sm font-medium">Published events:</span>
<div class="mt-1 space-y-1">
{#each publishResults.successfulEvents as event}
{@const nevent = nip19.neventEncode({ id: event.eventId })}
<div class="text-sm">
<a
href="/events?id={encodeURIComponent(event.eventId)}"
class="text-blue-600 dark:text-blue-400 hover:underline font-mono"
>
{event.title} ({nevent})
</a>
</div>
{/each}
</div>
</div>
{/if}
</Alert>
{:else}
<Alert color="red" dismissable>
<span class="font-medium">Error!</span>
{publishResult.error}
<span class="font-medium">Some events failed to publish.</span>
{publishResults.successCount} of {publishResults.total} events published.
{#if publishResults.successfulEvents.length > 0}
<div class="mt-2">
<span class="text-sm font-medium">Successfully published:</span>
<div class="mt-1 space-y-1">
{#each publishResults.successfulEvents as event}
{@const nevent = nip19.neventEncode({ id: event.eventId })}
<div class="text-sm">
<a
href="/events?id={encodeURIComponent(event.eventId)}"
class="text-blue-600 dark:text-blue-400 hover:underline font-mono"
>
{event.title} ({nevent})
</a>
</div>
{/each}
</div>
</div>
{/if}
{#if publishResults.failedEvents.length > 0}
<div class="mt-2">
<span class="text-sm font-medium">Failed to publish:</span>
<div class="mt-1 space-y-2">
{#each publishResults.failedEvents as failedEvent, index}
<div class="text-sm bg-red-50 dark:bg-red-900/20 p-2 rounded">
<div class="font-medium">{failedEvent.title}</div>
<div class="text-red-600 dark:text-red-400 text-xs">{failedEvent.error}</div>
<Button
size="xs"
color="light"
onclick={() => retryFailedEvent(failedEvent.sectionIndex)}
disabled={isPublishing}
class="mt-1"
>
{isPublishing ? 'Retrying...' : 'Retry'}
</Button>
</div>
{/each}
</div>
</div>
{/if}
</Alert>
{/if}
{/if}

2
src/routes/publication/[type]/[identifier]/+page.ts

@ -3,7 +3,7 @@ import type { PageLoad } from "./$types"; @@ -3,7 +3,7 @@ import type { PageLoad } from "./$types";
import { fetchEventByDTag, fetchEventById, fetchEventByNaddr, fetchEventByNevent } from "../../../../lib/utils/websocket_utils.ts";
import type { NostrEvent } from "../../../../lib/utils/websocket_utils.ts";
export const load: PageLoad = async ({ params }) => {
export const load: PageLoad = async ({ params }: { params: { type: string; identifier: string } }) => {
const { type, identifier } = params;
let indexEvent: NostrEvent | null;

429
tests/unit/ZettelEditor.test.ts

@ -0,0 +1,429 @@ @@ -0,0 +1,429 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { AsciiDocMetadata } from "../../src/lib/utils/asciidoc_metadata";
// Mock all Svelte components and dependencies
vi.mock("flowbite-svelte", () => ({
Textarea: vi.fn().mockImplementation((props) => {
return {
$$render: () => `<textarea data-testid="textarea" class="${props.class || ''}" rows="${props.rows || 12}" ${props.disabled ? 'disabled' : ''} placeholder="${props.placeholder || ''}"></textarea>`,
$$bind: { value: props.bind, oninput: props.oninput }
};
}),
Button: vi.fn().mockImplementation((props) => {
return {
$$render: () => `<button data-testid="preview-button" class="${props.class || ''}" ${props.disabled ? 'disabled' : ''} onclick="${props.onclick || ''}">${props.children || ''}</button>`,
$$bind: { onclick: props.onclick }
};
})
}));
vi.mock("flowbite-svelte-icons", () => ({
EyeOutline: vi.fn().mockImplementation(() => ({
$$render: () => `<svg data-testid="eye-icon"></svg>`
}))
}));
vi.mock("asciidoctor", () => ({
default: vi.fn(() => ({
convert: vi.fn((content, options) => {
// Mock AsciiDoctor conversion - return simple HTML
return content.replace(/^==\s+(.+)$/gm, '<h2>$1</h2>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>');
})
}))
}));
// Mock sessionStorage
const mockSessionStorage = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
};
Object.defineProperty(global, 'sessionStorage', {
value: mockSessionStorage,
writable: true
});
// Mock window object for DOM manipulation
Object.defineProperty(global, 'window', {
value: {
sessionStorage: mockSessionStorage,
document: {
querySelector: vi.fn(),
createElement: vi.fn(),
}
},
writable: true
});
// Mock DOM methods
const mockQuerySelector = vi.fn();
const mockCreateElement = vi.fn();
const mockAddEventListener = vi.fn();
const mockRemoveEventListener = vi.fn();
Object.defineProperty(global, 'document', {
value: {
querySelector: mockQuerySelector,
createElement: mockCreateElement,
addEventListener: mockAddEventListener,
removeEventListener: mockRemoveEventListener,
},
writable: true
});
describe("ZettelEditor Component Logic", () => {
let mockOnContentChange: ReturnType<typeof vi.fn>;
let mockOnPreviewToggle: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
mockOnContentChange = vi.fn();
mockOnPreviewToggle = vi.fn();
});
afterEach(() => {
vi.clearAllMocks();
});
describe("Publication Format Detection Logic", () => {
it("should detect document header format", () => {
const contentWithDocumentHeader = "= Document Title\n\n== Section 1\nContent";
// Test the regex pattern used in the component
const hasDocumentHeader = contentWithDocumentHeader.match(/^=\s+/m);
expect(hasDocumentHeader).toBeTruthy();
});
it("should detect index card format", () => {
const contentWithIndexCard = "index card\n\n== Section 1\nContent";
// Test the logic used in the component
const lines = contentWithIndexCard.split(/\r?\n/);
let hasIndexCard = false;
for (const line of lines) {
if (line.trim().toLowerCase() === 'index card') {
hasIndexCard = true;
break;
}
}
expect(hasIndexCard).toBe(true);
});
it("should not detect publication format for normal section content", () => {
const normalContent = "== Section 1\nContent\n\n== Section 2\nMore content";
// Test the logic used in the component
const lines = normalContent.split(/\r?\n/);
let hasPublicationHeader = false;
for (const line of lines) {
if (line.match(/^=\s+(.+)$/)) {
hasPublicationHeader = true;
break;
}
if (line.trim().toLowerCase() === 'index card') {
hasPublicationHeader = true;
break;
}
}
expect(hasPublicationHeader).toBe(false);
});
});
describe("Content Parsing Logic", () => {
it("should parse sections with document header", () => {
const content = "== Section 1\n:author: Test Author\n\nContent 1";
// Test the parsing logic
const hasDocumentHeader = content.match(/^=\s+/m);
expect(hasDocumentHeader).toBeFalsy(); // This content doesn't have a document header
// Test section splitting logic
const sectionStrings = content.split(/(?=^==\s+)/gm).filter((section: string) => section.trim());
expect(sectionStrings).toHaveLength(1);
expect(sectionStrings[0]).toContain("== Section 1");
});
it("should parse sections without document header", () => {
const content = "== Section 1\nContent 1";
// Test the parsing logic
const hasDocumentHeader = content.match(/^=\s+/m);
expect(hasDocumentHeader).toBeFalsy();
// Test section splitting logic
const sectionStrings = content.split(/(?=^==\s+)/gm).filter((section: string) => section.trim());
expect(sectionStrings).toHaveLength(1);
expect(sectionStrings[0]).toContain("== Section 1");
});
it("should handle empty content", () => {
const content = "";
const hasDocumentHeader = content.match(/^=\s+/m);
expect(hasDocumentHeader).toBeFalsy();
});
});
describe("Content Conversion Logic", () => {
it("should convert document title to section title", () => {
const contentWithDocumentHeader = "= Document Title\n\n== Section 1\nContent";
// Test the conversion logic
let convertedContent = contentWithDocumentHeader.replace(/^=\s+(.+)$/gm, '== $1');
convertedContent = convertedContent.replace(/^index card$/gim, '');
const finalContent = convertedContent.replace(/\n\s*\n\s*\n/g, '\n\n');
expect(finalContent).toBe("== Document Title\n\n== Section 1\nContent");
});
it("should remove index card line", () => {
const contentWithIndexCard = "index card\n\n== Section 1\nContent";
// Test the conversion logic
let convertedContent = contentWithIndexCard.replace(/^=\s+(.+)$/gm, '== $1');
convertedContent = convertedContent.replace(/^index card$/gim, '');
const finalContent = convertedContent.replace(/\n\s*\n\s*\n/g, '\n\n');
expect(finalContent).toBe("\n\n== Section 1\nContent");
});
it("should clean up double newlines", () => {
const contentWithExtraNewlines = "= Document Title\n\n\n== Section 1\nContent";
// Test the conversion logic
let convertedContent = contentWithExtraNewlines.replace(/^=\s+(.+)$/gm, '== $1');
convertedContent = convertedContent.replace(/^index card$/gim, '');
const finalContent = convertedContent.replace(/\n\s*\n\s*\n/g, '\n\n');
expect(finalContent).toBe("== Document Title\n\n== Section 1\nContent");
});
});
describe("SessionStorage Integration", () => {
it("should store content in sessionStorage when switching to publication editor", () => {
const contentWithDocumentHeader = "= Document Title\n\n== Section 1\nContent";
// Test the sessionStorage logic
mockSessionStorage.setItem('zettelEditorContent', contentWithDocumentHeader);
mockSessionStorage.setItem('zettelEditorSource', 'publication-format');
expect(mockSessionStorage.setItem).toHaveBeenCalledWith('zettelEditorContent', contentWithDocumentHeader);
expect(mockSessionStorage.setItem).toHaveBeenCalledWith('zettelEditorSource', 'publication-format');
});
});
describe("Event Count Logic", () => {
it("should calculate correct event count for single section", () => {
const sections = [{ title: "Section 1", content: "Content 1", tags: [] }];
const eventCount = sections.length;
const eventText = `${eventCount} event${eventCount !== 1 ? "s" : ""}`;
expect(eventCount).toBe(1);
expect(eventText).toBe("1 event");
});
it("should calculate correct event count for multiple sections", () => {
const sections = [
{ title: "Section 1", content: "Content 1", tags: [] },
{ title: "Section 2", content: "Content 2", tags: [] }
];
const eventCount = sections.length;
const eventText = `${eventCount} event${eventCount !== 1 ? "s" : ""}`;
expect(eventCount).toBe(2);
expect(eventText).toBe("2 events");
});
});
describe("Tag Processing Logic", () => {
it("should process tags correctly", () => {
// Mock the metadataToTags function
const mockMetadataToTags = vi.fn().mockReturnValue([["author", "Test Author"]]);
const mockMetadata = { title: "Section 1", author: "Test Author" } as AsciiDocMetadata;
const tags = mockMetadataToTags(mockMetadata);
expect(tags).toEqual([["author", "Test Author"]]);
expect(mockMetadataToTags).toHaveBeenCalledWith(mockMetadata);
});
it("should handle empty tags", () => {
// Mock the metadataToTags function
const mockMetadataToTags = vi.fn().mockReturnValue([]);
const mockMetadata = { title: "Section 1" } as AsciiDocMetadata;
const tags = mockMetadataToTags(mockMetadata);
expect(tags).toEqual([]);
});
});
describe("AsciiDoctor Processing", () => {
it("should process AsciiDoc content correctly", () => {
// Mock the asciidoctor conversion
const mockConvert = vi.fn((content, options) => {
return content.replace(/^==\s+(.+)$/gm, '<h2>$1</h2>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>');
});
const content = "== Test Section\n\nThis is **bold** and *italic* text.";
const processedContent = mockConvert(content, {
standalone: false,
doctype: "article",
attributes: {
showtitle: true,
sectids: true,
},
});
expect(processedContent).toContain('<h2>Test Section</h2>');
expect(processedContent).toContain('<strong>bold</strong>');
expect(processedContent).toContain('<em>italic</em>');
});
});
describe("Error Handling", () => {
it("should handle parsing errors gracefully", () => {
// Mock a function that might throw an error
const mockParseFunction = vi.fn().mockImplementation(() => {
throw new Error("Parsing error");
});
const content = "== Section 1\nContent 1";
// Should not throw error when called
expect(() => {
try {
mockParseFunction(content);
} catch (error) {
// Expected error, but should be handled gracefully
}
}).not.toThrow();
});
it("should handle empty content without errors", () => {
const content = "";
const hasDocumentHeader = content.match(/^=\s+/m);
expect(hasDocumentHeader).toBeFalsy();
});
});
describe("Component Props Interface", () => {
it("should have correct prop types", () => {
// Test that the component props interface is correctly defined
const expectedProps = {
content: "",
placeholder: "Default placeholder",
showPreview: false,
onContentChange: vi.fn(),
onPreviewToggle: vi.fn(),
};
expect(expectedProps).toHaveProperty('content');
expect(expectedProps).toHaveProperty('placeholder');
expect(expectedProps).toHaveProperty('showPreview');
expect(expectedProps).toHaveProperty('onContentChange');
expect(expectedProps).toHaveProperty('onPreviewToggle');
});
});
describe("Utility Function Integration", () => {
it("should integrate with ZettelParser utilities", () => {
// Mock the parseAsciiDocSections function
const mockParseAsciiDocSections = vi.fn().mockReturnValue([
{ title: "Section 1", content: "Content 1", tags: [] }
]);
const content = "== Section 1\nContent 1";
const sections = mockParseAsciiDocSections(content, 2);
expect(sections).toHaveLength(1);
expect(sections[0].title).toBe("Section 1");
});
it("should integrate with asciidoc_metadata utilities", () => {
// Mock the utility functions
const mockExtractDocumentMetadata = vi.fn().mockReturnValue({
metadata: { title: "Document Title" } as AsciiDocMetadata,
content: "Document content"
});
const mockExtractSectionMetadata = vi.fn().mockReturnValue({
metadata: { title: "Section Title" } as AsciiDocMetadata,
content: "Section content",
title: "Section Title"
});
const documentContent = "= Document Title\nDocument content";
const sectionContent = "== Section Title\nSection content";
const documentResult = mockExtractDocumentMetadata(documentContent);
const sectionResult = mockExtractSectionMetadata(sectionContent);
expect(documentResult.metadata.title).toBe("Document Title");
expect(sectionResult.title).toBe("Section Title");
});
});
describe("Content Validation", () => {
it("should validate content structure", () => {
const validContent = "== Section 1\nContent here\n\n== Section 2\nMore content";
const invalidContent = "Just some text without sections";
// Test section detection
const validSections = validContent.split(/(?=^==\s+)/gm).filter((section: string) => section.trim());
const invalidSections = invalidContent.split(/(?=^==\s+)/gm).filter((section: string) => section.trim());
expect(validSections.length).toBeGreaterThan(0);
// The invalid content will have one section (the entire content) since it doesn't start with ==
expect(invalidSections.length).toBe(1);
});
it("should handle mixed content types", () => {
const mixedContent = "= Document Title\n\n== Section 1\nContent\n\n== Section 2\nMore content";
// Test document header detection
const hasDocumentHeader = mixedContent.match(/^=\s+/m);
expect(hasDocumentHeader).toBeTruthy();
// Test section extraction
const sections = mixedContent.split(/(?=^==\s+)/gm).filter((section: string) => section.trim());
expect(sections.length).toBeGreaterThan(0);
});
});
describe("String Manipulation", () => {
it("should handle string replacements correctly", () => {
const originalContent = "= Title\n\n== Section\nContent";
// Test various string manipulations
const convertedContent = originalContent
.replace(/^=\s+(.+)$/gm, '== $1')
.replace(/^index card$/gim, '')
.replace(/\n\s*\n\s*\n/g, '\n\n');
expect(convertedContent).toBe("== Title\n\n== Section\nContent");
});
it("should handle edge cases in string manipulation", () => {
const edgeCases = [
"= Title\n\n\n== Section\nContent", // Multiple newlines
"index card\n\n== Section\nContent", // Index card
"= Title\nindex card\n== Section\nContent", // Both
];
edgeCases.forEach(content => {
const converted = content
.replace(/^=\s+(.+)$/gm, '== $1')
.replace(/^index card$/gim, '')
.replace(/\n\s*\n\s*\n/g, '\n\n');
expect(converted).toBeDefined();
expect(typeof converted).toBe('string');
});
});
});
});

446
tests/unit/eventInput30040.test.ts

@ -0,0 +1,446 @@ @@ -0,0 +1,446 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { build30040EventSet, validate30040EventSet } from "../../src/lib/utils/event_input_utils";
import { extractDocumentMetadata, parseAsciiDocWithMetadata } from "../../src/lib/utils/asciidoc_metadata";
// Mock NDK and other dependencies
vi.mock("@nostr-dev-kit/ndk", () => ({
NDKEvent: vi.fn().mockImplementation((ndk, eventData) => ({
...eventData,
id: "mock-event-id",
sig: "mock-signature",
kind: eventData.kind,
content: eventData.content,
tags: eventData.tags,
pubkey: eventData.pubkey,
created_at: eventData.created_at,
})),
}));
vi.mock("../../src/lib/ndk", () => ({
ndkInstance: {
subscribe: vi.fn(),
},
getNdk: vi.fn(() => ({})),
}));
vi.mock("svelte/store", () => ({
get: vi.fn(() => ({})),
}));
describe("EventInput 30040 Publishing", () => {
const baseEvent = {
pubkey: "test-pubkey",
created_at: 1234567890,
};
beforeEach(() => {
vi.clearAllMocks();
});
describe("Normal Structure with Preamble", () => {
it("should build 30040 event set with preamble content", () => {
const content = `= Test Document with Preamble
John Doe <john@example.com>
1.0, 2024-01-15, Alexandria Test
:summary: This is a test document with preamble
:keywords: test, preamble, asciidoc
This is the preamble content that should be included.
== First Section
:author: Section Author
:summary: This is the first section
This is the content of the first section.
== Second Section
:summary: This is the second section
This is the content of the second section.`;
const tags: [string, string][] = [["type", "article"]];
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent);
// Test index event
expect(indexEvent.kind).toBe(30040);
expect(indexEvent.content).toBe("");
expect(indexEvent.tags).toContainEqual(["d", "test-document-with-preamble"]);
expect(indexEvent.tags).toContainEqual(["title", "Test Document with Preamble"]);
expect(indexEvent.tags).toContainEqual(["author", "John Doe"]);
expect(indexEvent.tags).toContainEqual(["version", "1.0"]);
expect(indexEvent.tags).toContainEqual(["summary", "This is a test document with preamble"]);
expect(indexEvent.tags).toContainEqual(["t", "test"]);
expect(indexEvent.tags).toContainEqual(["t", "preamble"]);
expect(indexEvent.tags).toContainEqual(["t", "asciidoc"]);
expect(indexEvent.tags).toContainEqual(["type", "article"]);
// Test section events
expect(sectionEvents).toHaveLength(2);
// First section
expect(sectionEvents[0].kind).toBe(30041);
expect(sectionEvents[0].content).toBe("This is the content of the first section.");
expect(sectionEvents[0].tags).toContainEqual(["d", "test-document-with-preamble-first-section"]);
expect(sectionEvents[0].tags).toContainEqual(["title", "First Section"]);
expect(sectionEvents[0].tags).toContainEqual(["author", "Section Author"]);
expect(sectionEvents[0].tags).toContainEqual(["summary", "This is the first section"]);
// Second section
expect(sectionEvents[1].kind).toBe(30041);
expect(sectionEvents[1].content).toBe("This is the content of the second section.");
expect(sectionEvents[1].tags).toContainEqual(["d", "test-document-with-preamble-second-section"]);
expect(sectionEvents[1].tags).toContainEqual(["title", "Second Section"]);
expect(sectionEvents[1].tags).toContainEqual(["summary", "This is the second section"]);
// Test a-tags in index event
expect(indexEvent.tags).toContainEqual(["a", "30041:test-pubkey:test-document-with-preamble-first-section"]);
expect(indexEvent.tags).toContainEqual(["a", "30041:test-pubkey:test-document-with-preamble-second-section"]);
});
});
describe("Normal Structure without Preamble", () => {
it("should build 30040 event set without preamble content", () => {
const content = `= Test Document without Preamble
:summary: This is a test document without preamble
:keywords: test, no-preamble, asciidoc
== First Section
:author: Section Author
:summary: This is the first section
This is the content of the first section.
== Second Section
:summary: This is the second section
This is the content of the second section.`;
const tags: [string, string][] = [["type", "article"]];
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent);
// Test index event
expect(indexEvent.kind).toBe(30040);
expect(indexEvent.content).toBe("");
expect(indexEvent.tags).toContainEqual(["d", "test-document-without-preamble"]);
expect(indexEvent.tags).toContainEqual(["title", "Test Document without Preamble"]);
expect(indexEvent.tags).toContainEqual(["summary", "This is a test document without preamble"]);
// Test section events
expect(sectionEvents).toHaveLength(2);
// First section
expect(sectionEvents[0].kind).toBe(30041);
expect(sectionEvents[0].content).toBe("This is the content of the first section.");
expect(sectionEvents[0].tags).toContainEqual(["d", "test-document-without-preamble-first-section"]);
expect(sectionEvents[0].tags).toContainEqual(["title", "First Section"]);
expect(sectionEvents[0].tags).toContainEqual(["author", "Section Author"]);
expect(sectionEvents[0].tags).toContainEqual(["summary", "This is the first section"]);
// Second section
expect(sectionEvents[1].kind).toBe(30041);
expect(sectionEvents[1].content).toBe("This is the content of the second section.");
expect(sectionEvents[1].tags).toContainEqual(["d", "test-document-without-preamble-second-section"]);
expect(sectionEvents[1].tags).toContainEqual(["title", "Second Section"]);
expect(sectionEvents[1].tags).toContainEqual(["summary", "This is the second section"]);
});
});
describe("Skeleton Structure with Preamble", () => {
it("should build 30040 event set with skeleton structure and preamble", () => {
const content = `= Skeleton Document with Preamble
:summary: This is a skeleton document with preamble
:keywords: skeleton, preamble, empty
This is the preamble content.
== Empty Section 1
== Empty Section 2
== Empty Section 3`;
const tags: [string, string][] = [["type", "skeleton"]];
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent);
// Test index event
expect(indexEvent.kind).toBe(30040);
expect(indexEvent.content).toBe("");
expect(indexEvent.tags).toContainEqual(["d", "skeleton-document-with-preamble"]);
expect(indexEvent.tags).toContainEqual(["title", "Skeleton Document with Preamble"]);
expect(indexEvent.tags).toContainEqual(["summary", "This is a skeleton document with preamble"]);
// Test section events
expect(sectionEvents).toHaveLength(3);
// All sections should have empty content
sectionEvents.forEach((section, index) => {
expect(section.kind).toBe(30041);
expect(section.content).toBe("");
expect(section.tags).toContainEqual(["d", `skeleton-document-with-preamble-empty-section-${index + 1}`]);
expect(section.tags).toContainEqual(["title", `Empty Section ${index + 1}`]);
});
});
});
describe("Skeleton Structure without Preamble", () => {
it("should build 30040 event set with skeleton structure without preamble", () => {
const content = `= Skeleton Document without Preamble
:summary: This is a skeleton document without preamble
:keywords: skeleton, no-preamble, empty
== Empty Section 1
== Empty Section 2
== Empty Section 3`;
const tags: [string, string][] = [["type", "skeleton"]];
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent);
// Test index event
expect(indexEvent.kind).toBe(30040);
expect(indexEvent.content).toBe("");
expect(indexEvent.tags).toContainEqual(["d", "skeleton-document-without-preamble"]);
expect(indexEvent.tags).toContainEqual(["title", "Skeleton Document without Preamble"]);
expect(indexEvent.tags).toContainEqual(["summary", "This is a skeleton document without preamble"]);
// Test section events
expect(sectionEvents).toHaveLength(3);
// All sections should have empty content
sectionEvents.forEach((section, index) => {
expect(section.kind).toBe(30041);
expect(section.content).toBe("");
expect(section.tags).toContainEqual(["d", `skeleton-document-without-preamble-empty-section-${index + 1}`]);
expect(section.tags).toContainEqual(["title", `Empty Section ${index + 1}`]);
});
});
});
describe("Index Card Format", () => {
it("should build 30040 event set for index card format", () => {
const content = `= Test Index Card
index card`;
const tags: [string, string][] = [["type", "index-card"]];
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent);
// Test index event
expect(indexEvent.kind).toBe(30040);
expect(indexEvent.content).toBe("");
expect(indexEvent.tags).toContainEqual(["d", "test-index-card"]);
expect(indexEvent.tags).toContainEqual(["title", "Test Index Card"]);
expect(indexEvent.tags).toContainEqual(["type", "index-card"]);
// Should have no section events for index card
expect(sectionEvents).toHaveLength(0);
});
it("should build 30040 event set for index card with metadata", () => {
const content = `= Test Index Card with Metadata
:summary: This is an index card with metadata
:keywords: index, card, metadata
index card`;
const tags: [string, string][] = [["type", "index-card"]];
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent);
// Test index event
expect(indexEvent.kind).toBe(30040);
expect(indexEvent.content).toBe("");
expect(indexEvent.tags).toContainEqual(["d", "test-index-card-with-metadata"]);
expect(indexEvent.tags).toContainEqual(["title", "Test Index Card with Metadata"]);
expect(indexEvent.tags).toContainEqual(["summary", "This is an index card with metadata"]);
expect(indexEvent.tags).toContainEqual(["t", "index"]);
expect(indexEvent.tags).toContainEqual(["t", "card"]);
expect(indexEvent.tags).toContainEqual(["t", "metadata"]);
expect(indexEvent.tags).toContainEqual(["type", "index-card"]);
// Should have no section events for index card
expect(sectionEvents).toHaveLength(0);
});
});
describe("Complex Metadata Structures", () => {
it("should handle complex metadata with all attribute types", () => {
const content = `= Complex Metadata Document
Jane Smith <jane@example.com>
2.0, 2024-02-20, Alexandria Complex
:summary: This is a complex document with all metadata types
:description: Alternative description field
:keywords: complex, metadata, all-types
:tags: additional, tags, here
:author: Override Author
:author: Third Author
:version: 3.0
:published_on: 2024-03-01
:published_by: Alexandria Complex
:type: book
:image: https://example.com/cover.jpg
:isbn: 978-0-123456-78-9
:source: https://github.com/alexandria/complex
:auto-update: yes
This is the preamble content.
== Section with Complex Metadata
:author: Section Author
:author: Section Co-Author
:summary: This section has complex metadata
:description: Alternative description for section
:keywords: section, complex, metadata
:tags: section, tags
:type: chapter
:image: https://example.com/section-image.jpg
This is the section content.`;
const tags: [string, string][] = [["type", "complex"]];
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent);
// Test index event metadata
expect(indexEvent.kind).toBe(30040);
expect(indexEvent.tags).toContainEqual(["d", "complex-metadata-document"]);
expect(indexEvent.tags).toContainEqual(["title", "Complex Metadata Document"]);
expect(indexEvent.tags).toContainEqual(["author", "Jane Smith"]); // Should use header line author
expect(indexEvent.tags).toContainEqual(["author", "Override Author"]); // Additional author from attribute
expect(indexEvent.tags).toContainEqual(["author", "Third Author"]); // Additional author from attribute
expect(indexEvent.tags).toContainEqual(["version", "2.0"]); // Should use revision line version
expect(indexEvent.tags).toContainEqual(["summary", "This is a complex document with all metadata types Alternative description field"]);
expect(indexEvent.tags).toContainEqual(["published_on", "2024-03-01"]);
expect(indexEvent.tags).toContainEqual(["published_by", "Alexandria Complex"]);
expect(indexEvent.tags).toContainEqual(["type", "book"]);
expect(indexEvent.tags).toContainEqual(["image", "https://example.com/cover.jpg"]);
expect(indexEvent.tags).toContainEqual(["i", "978-0-123456-78-9"]);
expect(indexEvent.tags).toContainEqual(["source", "https://github.com/alexandria/complex"]);
expect(indexEvent.tags).toContainEqual(["auto-update", "yes"]);
expect(indexEvent.tags).toContainEqual(["t", "complex"]);
expect(indexEvent.tags).toContainEqual(["t", "metadata"]);
expect(indexEvent.tags).toContainEqual(["t", "all-types"]);
expect(indexEvent.tags).toContainEqual(["t", "additional"]);
expect(indexEvent.tags).toContainEqual(["t", "tags"]);
expect(indexEvent.tags).toContainEqual(["t", "here"]);
// Test section metadata
expect(sectionEvents).toHaveLength(1);
expect(sectionEvents[0].kind).toBe(30041);
expect(sectionEvents[0].content).toBe("This is the section content.");
expect(sectionEvents[0].tags).toContainEqual(["d", "complex-metadata-document-section-with-complex-metadata"]);
expect(sectionEvents[0].tags).toContainEqual(["title", "Section with Complex Metadata"]);
expect(sectionEvents[0].tags).toContainEqual(["author", "Section Author"]);
expect(sectionEvents[0].tags).toContainEqual(["author", "Section Co-Author"]);
expect(sectionEvents[0].tags).toContainEqual(["summary", "This section has complex metadata Alternative description for section"]);
expect(sectionEvents[0].tags).toContainEqual(["type", "chapter"]);
expect(sectionEvents[0].tags).toContainEqual(["image", "https://example.com/section-image.jpg"]);
expect(sectionEvents[0].tags).toContainEqual(["t", "section"]);
expect(sectionEvents[0].tags).toContainEqual(["t", "complex"]);
expect(sectionEvents[0].tags).toContainEqual(["t", "metadata"]);
expect(sectionEvents[0].tags).toContainEqual(["t", "tags"]);
});
});
describe("Validation Tests", () => {
it("should validate normal structure correctly", () => {
const content = `= Valid Document
:summary: This is a valid document
== Section 1
Content here.
== Section 2
More content.`;
const validation = validate30040EventSet(content);
expect(validation.valid).toBe(true);
});
it("should validate index card format correctly", () => {
const content = `= Valid Index Card
index card`;
const validation = validate30040EventSet(content);
expect(validation.valid).toBe(true);
});
it("should validate skeleton structure correctly", () => {
const content = `= Skeleton Document
== Empty Section 1
== Empty Section 2`;
const validation = validate30040EventSet(content);
expect(validation.valid).toBe(true);
});
it("should reject invalid structure", () => {
const content = `This is not a valid AsciiDoc document.`;
const validation = validate30040EventSet(content);
expect(validation.valid).toBe(false);
expect(validation.reason).toContain("30040 events must have a document title");
});
});
describe("Edge Cases", () => {
it("should handle document with only title and no sections", () => {
const content = `= Document with No Sections
:summary: This document has no sections
This is just preamble content.`;
const tags: [string, string][] = [];
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent);
expect(indexEvent.kind).toBe(30040);
expect(indexEvent.tags).toContainEqual(["d", "document-with-no-sections"]);
expect(indexEvent.tags).toContainEqual(["title", "Document with No Sections"]);
expect(sectionEvents).toHaveLength(0);
});
it("should handle document with special characters in title", () => {
const content = `= Document with Special Characters: Test & More!
:summary: This document has special characters in the title
== Section 1
Content here.`;
const tags: [string, string][] = [];
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent);
expect(indexEvent.kind).toBe(30040);
expect(indexEvent.tags).toContainEqual(["d", "document-with-special-characters-test-more"]);
expect(indexEvent.tags).toContainEqual(["title", "Document with Special Characters: Test & More!"]);
expect(sectionEvents).toHaveLength(1);
});
it("should handle document with very long title", () => {
const content = `= This is a very long document title that should be handled properly by the system and should not cause any issues with the d-tag generation or any other functionality
:summary: This document has a very long title
== Section 1
Content here.`;
const tags: [string, string][] = [];
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent);
expect(indexEvent.kind).toBe(30040);
expect(indexEvent.tags).toContainEqual(["title", "This is a very long document title that should be handled properly by the system and should not cause any issues with the d-tag generation or any other functionality"]);
expect(sectionEvents).toHaveLength(1);
});
});
});

322
tests/unit/metadataExtraction.test.ts

@ -0,0 +1,322 @@ @@ -0,0 +1,322 @@
import { describe, it, expect } from "vitest";
import {
extractDocumentMetadata,
extractSectionMetadata,
parseAsciiDocWithMetadata,
metadataToTags,
extractSmartMetadata
} from "../../src/lib/utils/asciidoc_metadata.ts";
describe("AsciiDoc Metadata Extraction", () => {
const testContent = `= Test Document with Metadata
John Doe <john@example.com>
1.0, 2024-01-15: Alexandria Test
:summary: This is a test document for metadata extraction
:author: Jane Smith
:published_on: 2024-01-15
:published_by: Alexandria Project
:type: article
:keywords: test, metadata, asciidoc
:image: https://example.com/cover.jpg
:isbn: 978-0-123456-78-9
:source: https://github.com/alexandria/test
:auto-update: yes
This is the preamble content that should be included in the document body.
== First Section
:author: Section Author
:summary: This is the first section
:keywords: section1, content
This is the content of the first section.
== Second Section
:summary: This is the second section
:type: chapter
This is the content of the second section.`;
it("extractDocumentMetadata should extract document metadata correctly", () => {
const { metadata, content } = extractDocumentMetadata(testContent);
expect(metadata.title).toBe("Test Document with Metadata");
expect(metadata.authors).toEqual(["John Doe", "Jane Smith"]);
expect(metadata.version).toBe("1.0");
expect(metadata.publicationDate).toBe("2024-01-15");
expect(metadata.publishedBy).toBe("Alexandria Test");
expect(metadata.summary).toBe("This is a test document for metadata extraction");
expect(metadata.authors).toEqual(["John Doe", "Jane Smith"]);
expect(metadata.type).toBe("article");
expect(metadata.tags).toEqual(["test", "metadata", "asciidoc"]);
expect(metadata.coverImage).toBe("https://example.com/cover.jpg");
expect(metadata.isbn).toBe("978-0-123456-78-9");
expect(metadata.source).toBe("https://github.com/alexandria/test");
expect(metadata.autoUpdate).toBe("yes");
// Content should not include the header metadata
expect(content).toContain("This is the preamble content");
expect(content).toContain("== First Section");
expect(content).not.toContain("= Test Document with Metadata");
expect(content).not.toContain(":summary:");
});
it("extractSectionMetadata should extract section metadata correctly", () => {
const sectionContent = `== First Section
:author: Section Author
:description: This is the first section
:tags: section1, content
This is the content of the first section.`;
const { metadata, content, title } = extractSectionMetadata(sectionContent);
expect(title).toBe("First Section");
expect(metadata.authors).toEqual(["Section Author"]);
expect(metadata.summary).toBe("This is the first section");
expect(metadata.tags).toEqual(["section1", "content"]);
expect(content).toBe("This is the content of the first section.");
});
it("extractSectionMetadata should extract standalone author names and remove them from content", () => {
const sectionContent = `== Section Header1
Stella
:description: Some summary
Some context text`;
const { metadata, content, title } = extractSectionMetadata(sectionContent);
expect(title).toBe("Section Header1");
expect(metadata.authors).toEqual(["Stella"]);
expect(metadata.summary).toBe("Some summary");
expect(content.trim()).toBe("Some context text");
});
it("extractSectionMetadata should handle multiple standalone author names", () => {
const sectionContent = `== Section Header1
Stella
:author: John Doe
:description: Some summary
Some context text`;
const { metadata, content, title } = extractSectionMetadata(sectionContent);
expect(title).toBe("Section Header1");
expect(metadata.authors).toEqual(["Stella", "John Doe"]);
expect(metadata.summary).toBe("Some summary");
expect(content.trim()).toBe("Some context text");
});
it("extractSectionMetadata should not extract non-author lines as authors", () => {
const sectionContent = `== Section Header1
Stella
This is not an author line
:description: Some summary
Some context text`;
const { metadata, content, title } = extractSectionMetadata(sectionContent);
expect(title).toBe("Section Header1");
expect(metadata.authors).toEqual(["Stella"]);
expect(metadata.summary).toBe("Some summary");
expect(content.trim()).toBe("This is not an author line\nSome context text");
});
it("parseAsciiDocWithMetadata should parse complete document", () => {
const parsed = parseAsciiDocWithMetadata(testContent);
expect(parsed.metadata.title).toBe("Test Document with Metadata");
expect(parsed.sections).toHaveLength(2);
expect(parsed.sections[0].title).toBe("First Section");
expect(parsed.sections[1].title).toBe("Second Section");
expect(parsed.sections[0].metadata.authors).toEqual(["Section Author"]);
expect(parsed.sections[1].metadata.summary).toBe("This is the second section");
});
it("metadataToTags should convert metadata to Nostr tags", () => {
const metadata = {
title: "Test Title",
authors: ["Author 1", "Author 2"],
version: "1.0",
summary: "Test summary",
tags: ["tag1", "tag2"]
};
const tags = metadataToTags(metadata);
expect(tags).toContainEqual(["title", "Test Title"]);
expect(tags).toContainEqual(["author", "Author 1"]);
expect(tags).toContainEqual(["author", "Author 2"]);
expect(tags).toContainEqual(["version", "1.0"]);
expect(tags).toContainEqual(["summary", "Test summary"]);
expect(tags).toContainEqual(["t", "tag1"]);
expect(tags).toContainEqual(["t", "tag2"]);
});
it("should handle index card format correctly", () => {
const indexCardContent = `= Test Index Card
index card`;
const { metadata, content } = extractDocumentMetadata(indexCardContent);
expect(metadata.title).toBe("Test Index Card");
expect(content.trim()).toBe("index card");
});
it("should handle empty content gracefully", () => {
const emptyContent = "";
const { metadata, content } = extractDocumentMetadata(emptyContent);
expect(metadata.title).toBeUndefined();
expect(content).toBe("");
});
it("should handle keywords as tags", () => {
const contentWithKeywords = `= Test Document
:keywords: keyword1, keyword2, keyword3
Some content here.`;
const { metadata } = extractDocumentMetadata(contentWithKeywords);
expect(metadata.tags).toEqual(["keyword1", "keyword2", "keyword3"]);
});
it("should handle both tags and keywords", () => {
const contentWithBoth = `= Test Document
:tags: tag1, tag2
:keywords: keyword1, keyword2
Some content here.`;
const { metadata } = extractDocumentMetadata(contentWithBoth);
// Both tags and keywords are valid, both should be accumulated
expect(metadata.tags).toEqual(["tag1", "tag2", "keyword1", "keyword2"]);
});
it("should handle tags only", () => {
const contentWithTags = `= Test Document
:tags: tag1, tag2, tag3
Content here.`;
const { metadata } = extractDocumentMetadata(contentWithTags);
expect(metadata.tags).toEqual(["tag1", "tag2", "tag3"]);
});
it("should handle both summary and description", () => {
const contentWithSummary = `= Test Document
:summary: This is a summary
Content here.`;
const contentWithDescription = `= Test Document
:description: This is a description
Content here.`;
const { metadata: summaryMetadata } = extractDocumentMetadata(contentWithSummary);
const { metadata: descriptionMetadata } = extractDocumentMetadata(contentWithDescription);
expect(summaryMetadata.summary).toBe("This is a summary");
expect(descriptionMetadata.summary).toBe("This is a description");
});
describe('Smart metadata extraction', () => {
it('should handle section-only content correctly', () => {
const sectionOnlyContent = `== First Section
:author: Section Author
:description: This is the first section
:tags: section1, content
This is the content of the first section.
== Second Section
:summary: This is the second section
:type: chapter
This is the content of the second section.`;
const { metadata, content } = extractSmartMetadata(sectionOnlyContent);
// Should extract title from first section
expect(metadata.title).toBe('First Section');
// Should not have document-level metadata since there's no document header
expect(metadata.authors).toBeUndefined();
expect(metadata.version).toBeUndefined();
expect(metadata.publicationDate).toBeUndefined();
// Content should be preserved
expect(content).toBe(sectionOnlyContent);
});
it('should handle minimal document header (just title) correctly', () => {
const minimalDocumentHeader = `= Test Document
== First Section
:author: Section Author
:description: This is the first section
This is the content of the first section.
== Second Section
:summary: This is the second section
:type: chapter
This is the content of the second section.`;
const { metadata, content } = extractSmartMetadata(minimalDocumentHeader);
// Should extract title from document header
expect(metadata.title).toBe('Test Document');
// Should not have document-level metadata since there's no other metadata
expect(metadata.authors).toBeUndefined();
// Note: version might be set from section attributes like :type: chapter
expect(metadata.publicationDate).toBeUndefined();
// Content should preserve the title line for 30040 events
expect(content).toContain('= Test Document');
expect(content).toContain('== First Section');
expect(content).toContain('== Second Section');
});
it('should handle document with full header correctly', () => {
const documentWithHeader = `= Test Document
John Doe <john@example.com>
1.0, 2024-01-15: Alexandria Test
:summary: This is a test document
:author: Jane Smith
== First Section
:author: Section Author
:description: This is the first section
This is the content.`;
const { metadata, content } = extractSmartMetadata(documentWithHeader);
// Should extract document-level metadata
expect(metadata.title).toBe('Test Document');
expect(metadata.authors).toEqual(['John Doe', 'Jane Smith']);
expect(metadata.version).toBe('1.0');
expect(metadata.publishedBy).toBe('Alexandria Test');
expect(metadata.publicationDate).toBe('2024-01-15');
expect(metadata.summary).toBe('This is a test document');
// Content should be cleaned
expect(content).not.toContain('= Test Document');
expect(content).not.toContain('John Doe <john@example.com>');
expect(content).not.toContain('1.0, 2024-01-15: Alexandria Test');
expect(content).not.toContain(':summary: This is a test document');
expect(content).not.toContain(':author: Jane Smith');
});
});
});

12
vite.config.ts

@ -43,4 +43,16 @@ export default defineConfig({ @@ -43,4 +43,16 @@ export default defineConfig({
// Expose the app version as a global variable
"import.meta.env.APP_VERSION": JSON.stringify(getAppVersionString()),
},
optimizeDeps: {
esbuildOptions: {
define: {
global: 'globalThis',
},
},
},
server: {
fs: {
allow: ['..'],
},
},
});

Loading…
Cancel
Save