@jant/core 0.6.16 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -11
- package/bin/commands/assets/upload.js +192 -15
- package/bin/commands/import-site.js +60 -4
- package/bin/commands/reset-password.js +21 -8
- package/bin/commands/site/snapshot/export.js +1 -1
- package/bin/commands/site/snapshot/import.js +1 -1
- package/bin/lib/d1-query.js +5 -16
- package/bin/lib/node-env.js +40 -4
- package/bin/lib/site-selection.js +85 -29
- package/bin/lib/wrangler-cli.js +28 -2
- package/bin/lib/wrangler-json.js +35 -0
- package/dist/{app-C7_4oab9.js → app-K_Aa1MMn.js} +52476 -13330
- package/dist/client/.vite/manifest.json +255 -90
- package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
- package/dist/client/_assets/chunks/create-editor-CD3FhrOB.js +257 -0
- package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
- package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
- package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
- package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
- package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
- package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
- package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
- package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
- package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
- package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
- package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
- package/dist/client/_assets/chunks/sortable-list-CgaL2jCs.js +2 -0
- package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
- package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
- package/dist/client/_assets/chunks/unsafe-svg-0QCkP0vZ.js +2 -0
- package/dist/client/_assets/client-GiYENVw8.css +2 -0
- package/dist/client/_assets/client-VnFxJN7G.js +295 -0
- package/dist/client/_assets/client-auth-k8gJ6QJj.js +828 -0
- package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
- package/dist/client/_assets/client-compose-B_kDtWkW.js +2004 -0
- package/dist/client/_assets/client-manage-M90aSTOk.js +2170 -0
- package/dist/client/_assets/client-settings-DfYs9n1F.js +1112 -0
- package/dist/{export-Dl5KCiEs.js → github-sync-BPAvT999.js} +2254 -107
- package/dist/index.js +2 -5
- package/dist/node.js +5 -7
- package/package.json +3 -3
- package/src/__tests__/auth-session-freshness.test.ts +57 -0
- package/src/__tests__/basecoat-variants.test.ts +121 -0
- package/src/__tests__/bin/assets-upload.test.ts +68 -0
- package/src/__tests__/export-hugo-build.test.ts +260 -0
- package/src/__tests__/export-service.test.ts +231 -0
- package/src/__tests__/helpers/component-css.ts +91 -0
- package/src/__tests__/helpers/db.ts +33 -0
- package/src/__tests__/helpers/export-fixtures.ts +3 -0
- package/src/__tests__/stylesheet-audience.test.ts +159 -0
- package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
- package/src/app.tsx +71 -16
- package/src/auth.ts +18 -1
- package/src/client/__tests__/collection-page-actions.test.ts +51 -2
- package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
- package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
- package/src/client/__tests__/compose-bridge.test.ts +272 -0
- package/src/client/__tests__/compose-launch.test.ts +62 -1
- package/src/client/__tests__/compose-shortcuts.test.ts +38 -9
- package/src/client/__tests__/compose-triggers.test.ts +90 -0
- package/src/client/__tests__/copy-field.test.ts +162 -0
- package/src/client/__tests__/custom-url-menu.test.ts +127 -6
- package/src/client/__tests__/image-processor.test.ts +324 -2
- package/src/client/__tests__/lazy-entries.test.ts +92 -0
- package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
- package/src/client/__tests__/runtime-paths.test.ts +84 -0
- package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
- package/src/client/__tests__/toast.test.ts +23 -0
- package/src/client/__tests__/video-processor-probe.test.ts +175 -0
- package/src/client/__tests__/video-processor.test.ts +181 -0
- package/src/client/audio-processor.ts +24 -15
- package/src/client/audio-waveform.ts +49 -0
- package/src/client/collection-dialog-host.ts +72 -0
- package/src/client/collection-navigation.ts +51 -16
- package/src/client/collection-page-actions.ts +26 -0
- package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
- package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
- package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
- package/src/client/components/__tests__/jant-command-palette.test.ts +28 -0
- package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +1250 -53
- package/src/client/components/__tests__/jant-compose-editor.test.ts +544 -40
- package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +0 -1
- package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
- package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
- package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
- package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
- package/src/client/components/__tests__/jant-settings-general.test.ts +541 -172
- package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
- package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
- package/src/client/components/collection-dialog-types.ts +49 -0
- package/src/client/components/collection-manager-types.ts +32 -1
- package/src/client/components/collection-types.ts +1 -15
- package/src/client/components/compose-types.ts +75 -1
- package/src/client/components/jant-collection-dialog.ts +722 -0
- package/src/client/components/jant-collection-directory.ts +567 -182
- package/src/client/components/jant-collection-form.ts +53 -232
- package/src/client/components/jant-command-palette.ts +100 -49
- package/src/client/components/jant-compose-dialog.ts +1486 -707
- package/src/client/components/jant-compose-editor.ts +673 -336
- package/src/client/components/jant-compose-fullscreen.ts +51 -43
- package/src/client/components/jant-config-editor.ts +65 -53
- package/src/client/components/jant-confirm-dialog.ts +13 -11
- package/src/client/components/jant-locale-picker.ts +418 -0
- package/src/client/components/jant-media-lightbox.ts +146 -136
- package/src/client/components/jant-nav-manager.ts +690 -366
- package/src/client/components/jant-post-menu.ts +1086 -282
- package/src/client/components/jant-post-picker.ts +324 -0
- package/src/client/components/jant-repo-picker.ts +138 -106
- package/src/client/components/jant-settings-avatar.ts +31 -27
- package/src/client/components/jant-settings-general.ts +617 -543
- package/src/client/components/jant-settings-language.ts +1087 -0
- package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
- package/src/client/components/jant-text-preview.ts +94 -86
- package/src/client/components/nav-manager-types.ts +62 -1
- package/src/client/components/settings-types.ts +14 -29
- package/src/client/components/smart-collection-conditions.ts +285 -0
- package/src/client/components/smart-collection-dialog-types.ts +68 -0
- package/src/client/compose-bridge.ts +214 -141
- package/src/client/compose-launch.ts +76 -2
- package/src/client/compose-shortcuts.ts +6 -8
- package/src/client/compose-triggers.ts +50 -0
- package/src/client/copy-field.ts +108 -0
- package/src/client/custom-url-menu.ts +60 -4
- package/src/client/icons.ts +14 -0
- package/src/client/image-plan.ts +165 -0
- package/src/client/image-processor.ts +195 -117
- package/src/client/image-worker-client.ts +45 -0
- package/src/client/image-worker.ts +135 -0
- package/src/client/lazy-entries.ts +95 -0
- package/src/client/lazy-slugify.ts +2 -2
- package/src/client/media-scroll-hint.ts +43 -2
- package/src/client/mediabunny.ts +29 -0
- package/src/client/post-picker.ts +41 -0
- package/src/client/runtime-paths.ts +74 -1
- package/src/client/settings-bridge.ts +4 -10
- package/src/client/site-header-nav.js +18 -6
- package/src/client/smart-collection-dialog-host.ts +116 -0
- package/src/client/smart-collection-page-actions.ts +208 -0
- package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
- package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
- package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
- package/src/client/tiptap/bubble-menu.ts +47 -16
- package/src/client/tiptap/floating-position.ts +216 -2
- package/src/client/tiptap/inline-image-upload.ts +7 -3
- package/src/client/tiptap/link-toolbar.ts +22 -9
- package/src/client/toast.ts +13 -0
- package/src/client/types/vite-worker.d.ts +12 -0
- package/src/client/upload-with-metadata.ts +3 -13
- package/src/client/video-processor.ts +351 -107
- package/src/client-auth.ts +14 -31
- package/src/client-compose.ts +19 -0
- package/src/client-manage.ts +13 -0
- package/src/client-settings.ts +14 -0
- package/src/client-site.ts +10 -3
- package/src/client.ts +4 -0
- package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
- package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
- package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
- package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
- package/src/db/migrations/0032_married_tomas.sql +3 -0
- package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
- package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
- package/src/db/migrations/meta/0032_snapshot.json +2582 -0
- package/src/db/migrations/meta/0033_snapshot.json +2816 -0
- package/src/db/migrations/meta/0034_snapshot.json +2816 -0
- package/src/db/migrations/meta/_journal.json +21 -0
- package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
- package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
- package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
- package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
- package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
- package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
- package/src/db/migrations/pg/meta/_journal.json +21 -0
- package/src/db/pg/schema.ts +195 -27
- package/src/db/post-visibility.ts +117 -0
- package/src/db/schema.ts +222 -27
- package/src/i18n/__tests__/detect.test.ts +26 -81
- package/src/i18n/__tests__/supported-locales.test.ts +83 -0
- package/src/i18n/context.tsx +12 -2
- package/src/i18n/detect.ts +7 -66
- package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
- package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
- package/src/i18n/locales/public/en.po +490 -158
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +487 -155
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +487 -155
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/i18n/locales/settings/en.po +551 -69
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +597 -115
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +610 -128
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/i18n/locales.ts +94 -0
- package/src/i18n/middleware.ts +12 -2
- package/src/i18n/supported-locales.ts +80 -0
- package/src/lib/__tests__/api-settings.test.ts +11 -2
- package/src/lib/__tests__/asset-path.test.ts +92 -0
- package/src/lib/__tests__/deferred.test.ts +90 -0
- package/src/lib/__tests__/discover-ping.test.ts +89 -0
- package/src/lib/__tests__/discover.test.ts +410 -0
- package/src/lib/__tests__/feed-policy.test.ts +26 -0
- package/src/lib/__tests__/feed.test.ts +1526 -15
- package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
- package/src/lib/__tests__/hosted-signin.test.ts +1 -1
- package/src/lib/__tests__/http-cache.test.ts +162 -0
- package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
- package/src/lib/__tests__/jant-docs.test.ts +23 -0
- package/src/lib/__tests__/lang-detect.test.ts +231 -0
- package/src/lib/__tests__/media-helpers.test.ts +81 -0
- package/src/lib/__tests__/navigation.test.ts +135 -1
- package/src/lib/__tests__/resolve-config.test.ts +64 -3
- package/src/lib/__tests__/schemas.test.ts +42 -4
- package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
- package/src/lib/__tests__/slug.test.ts +70 -0
- package/src/lib/__tests__/slugify.test.ts +85 -0
- package/src/lib/__tests__/storage.test.ts +32 -0
- package/src/lib/__tests__/thread-fold.test.ts +113 -0
- package/src/lib/__tests__/upload-sideload.test.ts +14 -0
- package/src/lib/__tests__/url-fetch.test.ts +1 -2
- package/src/lib/__tests__/url.test.ts +78 -72
- package/src/lib/__tests__/view-language.test.ts +413 -0
- package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
- package/src/lib/address.ts +57 -0
- package/src/lib/api-posts.ts +12 -0
- package/src/lib/asset-path.ts +98 -1
- package/src/lib/build-env.ts +16 -0
- package/src/lib/collection-groups.ts +5 -1
- package/src/lib/collection-paths.ts +3 -10
- package/src/lib/constants.ts +28 -3
- package/src/lib/copy-field.ts +36 -0
- package/src/lib/crypto.ts +40 -0
- package/src/lib/csp-builder.ts +2 -1
- package/src/lib/deferred.ts +79 -0
- package/src/lib/discover-ping.ts +141 -0
- package/src/lib/discover.ts +437 -0
- package/src/lib/env.ts +134 -0
- package/src/lib/errors.ts +33 -0
- package/src/lib/feed-policy.ts +236 -2
- package/src/lib/feed.ts +926 -73
- package/src/lib/filter-dimensions.ts +1349 -0
- package/src/lib/github-sync-site-config.ts +3 -0
- package/src/lib/hosted-signin.ts +1 -1
- package/src/lib/http-cache.ts +148 -0
- package/src/lib/hugo-markdown.ts +16 -0
- package/src/lib/icons.ts +9 -2
- package/src/lib/ids.ts +3 -0
- package/src/lib/jant-docs.ts +42 -0
- package/src/lib/lang-detect.ts +308 -0
- package/src/lib/media-helpers.ts +13 -1
- package/src/lib/navigation.ts +78 -11
- package/src/lib/pagination.ts +28 -24
- package/src/lib/per-language-surfaces.ts +67 -0
- package/src/lib/post-meta.ts +19 -0
- package/src/lib/render.tsx +55 -7
- package/src/lib/resolve-config.ts +53 -5
- package/src/lib/schemas.ts +201 -10
- package/src/lib/site-header-fragment.tsx +2 -1
- package/src/lib/site-unavailable-page.ts +47 -0
- package/src/lib/slug.ts +51 -7
- package/src/lib/slugify.ts +113 -0
- package/src/lib/startup-config.ts +4 -6
- package/src/lib/storage.ts +7 -0
- package/src/lib/summary.ts +50 -0
- package/src/lib/thread-fold.ts +161 -0
- package/src/lib/time.ts +32 -0
- package/src/lib/timeline.ts +42 -1
- package/src/lib/tiptap-render.ts +1 -2
- package/src/lib/upload.ts +16 -13
- package/src/lib/url.ts +139 -86
- package/src/lib/version.ts +10 -8
- package/src/lib/view-language.ts +436 -0
- package/src/lib/view.ts +51 -23
- package/src/lib/viewer-context.ts +62 -0
- package/src/middleware/__tests__/auth.test.ts +70 -2
- package/src/middleware/__tests__/cache-control.test.ts +71 -3
- package/src/middleware/__tests__/onboarding.test.ts +76 -5
- package/src/middleware/__tests__/secure-headers.test.ts +1 -1
- package/src/middleware/__tests__/session.test.ts +181 -33
- package/src/middleware/auth.ts +50 -31
- package/src/middleware/cache-control.ts +75 -10
- package/src/middleware/config.ts +6 -6
- package/src/middleware/error-handler.ts +12 -0
- package/src/middleware/onboarding.ts +32 -10
- package/src/middleware/secure-headers.ts +1 -1
- package/src/middleware/session.ts +96 -8
- package/src/node/__tests__/cli-migrate.test.ts +21 -19
- package/src/node/__tests__/cli-reset-password.test.ts +81 -8
- package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
- package/src/node/__tests__/cli-site-selection.test.ts +128 -38
- package/src/node/__tests__/runtime.test.ts +37 -0
- package/src/node/index.ts +1 -0
- package/src/preset.css +76 -22
- package/src/routes/__tests__/feed-validator.test.ts +156 -0
- package/src/routes/api/__tests__/collections.test.ts +114 -0
- package/src/routes/api/__tests__/discover.test.ts +222 -0
- package/src/routes/api/__tests__/nav-items.test.ts +212 -2
- package/src/routes/api/__tests__/post-translations.test.ts +829 -0
- package/src/routes/api/__tests__/settings.test.ts +24 -3
- package/src/routes/api/__tests__/telegram.test.ts +6 -5
- package/src/routes/api/collections.ts +49 -6
- package/src/routes/api/custom-urls.ts +0 -1
- package/src/routes/api/discover.ts +95 -0
- package/src/routes/api/export.ts +3 -0
- package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
- package/src/routes/api/nav-items.ts +55 -8
- package/src/routes/api/posts.ts +159 -0
- package/src/routes/api/public/__tests__/archive.test.ts +111 -0
- package/src/routes/api/public/archive.ts +77 -81
- package/src/routes/api/public/posts.ts +23 -6
- package/src/routes/api/smart-collections.ts +132 -0
- package/src/routes/api/telegram.ts +2 -40
- package/src/routes/api/uploads.ts +1 -2
- package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
- package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
- package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
- package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
- package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
- package/src/routes/auth/__tests__/setup.test.ts +278 -26
- package/src/routes/auth/__tests__/signin.test.ts +135 -0
- package/src/routes/auth/setup.tsx +934 -186
- package/src/routes/auth/signin.tsx +10 -8
- package/src/routes/compose.tsx +5 -0
- package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
- package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
- package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
- package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
- package/src/routes/dash/custom-urls.tsx +1 -37
- package/src/routes/dash/settings.tsx +502 -41
- package/src/routes/discover-announce.ts +96 -0
- package/src/routes/feed/__tests__/feed.test.ts +165 -2
- package/src/routes/feed/feed.ts +69 -40
- package/src/routes/feed/sitemap.ts +95 -11
- package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
- package/src/routes/pages/__tests__/archive-params.test.ts +370 -6
- package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
- package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
- package/src/routes/pages/__tests__/collections.test.ts +9 -3
- package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
- package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
- package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
- package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
- package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
- package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
- package/src/routes/pages/archive.tsx +471 -369
- package/src/routes/pages/collection.tsx +94 -96
- package/src/routes/pages/collections.tsx +38 -50
- package/src/routes/pages/featured.tsx +32 -13
- package/src/routes/pages/home.tsx +25 -6
- package/src/routes/pages/language.tsx +113 -0
- package/src/routes/pages/latest.tsx +26 -16
- package/src/routes/pages/new.tsx +3 -0
- package/src/routes/pages/page.tsx +178 -17
- package/src/routes/pages/partials.tsx +41 -0
- package/src/routes/pages/search.tsx +18 -3
- package/src/routes/pages/smart-collection.tsx +340 -0
- package/src/routes/pages/subscribe.tsx +182 -0
- package/src/runtime/__tests__/index.test.ts +19 -0
- package/src/runtime/__tests__/node.test.ts +129 -4
- package/src/runtime/cloudflare.ts +21 -2
- package/src/runtime/index.ts +27 -1
- package/src/runtime/node.ts +18 -1
- package/src/runtime/site.ts +95 -24
- package/src/services/__tests__/auth.test.ts +116 -7
- package/src/services/__tests__/collection.test.ts +108 -4
- package/src/services/__tests__/language.test.ts +495 -0
- package/src/services/__tests__/navigation.test.ts +213 -10
- package/src/services/__tests__/path.test.ts +180 -0
- package/src/services/__tests__/post-language.test.ts +830 -0
- package/src/services/__tests__/post.test.ts +46 -9
- package/src/services/__tests__/settings.test.ts +228 -17
- package/src/services/__tests__/site-admin.test.ts +5 -5
- package/src/services/__tests__/smart-collection.test.ts +651 -0
- package/src/services/about-page.ts +22 -1
- package/src/services/auth.ts +51 -49
- package/src/services/bootstrap.ts +188 -38
- package/src/services/collection-directory-position.ts +76 -0
- package/src/services/collection.ts +331 -46
- package/src/services/custom-url.ts +27 -22
- package/src/services/export-theme/assets/client-site.css +1 -1
- package/src/services/export-theme/assets/client-site.js +123 -123
- package/src/services/export-theme/layouts/_default/rss.xml +77 -1
- package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
- package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
- package/src/services/export-theme/layouts/partials/pagination.html +14 -13
- package/src/services/export-theme/layouts/partials/post-card.html +8 -4
- package/src/services/export-theme/layouts/partials/reply.html +2 -1
- package/src/services/export-theme/layouts/partials/thread-preview.html +40 -30
- package/src/services/export-theme/styles/main.css +13 -1
- package/src/services/export.ts +278 -19
- package/src/services/index.ts +34 -3
- package/src/services/language.ts +412 -0
- package/src/services/mcp.ts +3 -1
- package/src/services/media.ts +14 -6
- package/src/services/navigation.ts +429 -67
- package/src/services/path.ts +221 -6
- package/src/services/post.ts +1021 -152
- package/src/services/search.ts +39 -2
- package/src/services/settings.ts +238 -43
- package/src/services/site-admin.ts +24 -7
- package/src/services/site.ts +77 -1
- package/src/services/smart-collection.ts +636 -0
- package/src/services/telegram.ts +20 -22
- package/src/services/upload-session.ts +1 -3
- package/src/style-author.css +27 -0
- package/src/styles/components-author.css +1622 -0
- package/src/styles/components.css +24 -1571
- package/src/styles/site-media.css +16 -2
- package/src/styles/tokens.css +82 -12
- package/src/styles/ui-author.css +7602 -0
- package/src/styles/ui.css +1087 -7219
- package/src/types/app-context.ts +7 -0
- package/src/types/bindings.ts +3 -0
- package/src/types/config.ts +94 -15
- package/src/types/constants.ts +188 -16
- package/src/types/entities.ts +87 -1
- package/src/types/operations.ts +64 -2
- package/src/types/props.ts +157 -31
- package/src/types/views.ts +55 -1
- package/src/ui/__tests__/font-themes.test.ts +52 -12
- package/src/ui/compose/ComposeDialog.tsx +201 -7
- package/src/ui/compose/ComposePrompt.tsx +1 -5
- package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
- package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
- package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
- package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
- package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
- package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
- package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
- package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
- package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
- package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
- package/src/ui/dash/settings/GeneralContent.tsx +275 -113
- package/src/ui/dash/settings/LanguageContent.tsx +409 -0
- package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
- package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
- package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
- package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
- package/src/ui/dash/settings/discover-copy.ts +116 -0
- package/src/ui/feed/LinkCard.tsx +55 -45
- package/src/ui/feed/NoteCard.tsx +9 -4
- package/src/ui/feed/PostStatusBadges.tsx +90 -43
- package/src/ui/feed/QuoteCard.tsx +55 -30
- package/src/ui/feed/ThreadPreview.tsx +6 -2
- package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
- package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
- package/src/ui/feed/thread-preview-state.ts +20 -6
- package/src/ui/font-themes.ts +39 -29
- package/src/ui/layouts/BaseLayout.tsx +155 -10
- package/src/ui/layouts/SiteLayout.tsx +192 -34
- package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
- package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
- package/src/ui/pages/ArchivePage.tsx +282 -289
- package/src/ui/pages/CollectionPage.tsx +86 -76
- package/src/ui/pages/CollectionsPage.tsx +5 -0
- package/src/ui/pages/ComposePage.tsx +8 -0
- package/src/ui/pages/PostPage.tsx +47 -0
- package/src/ui/pages/SearchPage.tsx +3 -3
- package/src/ui/pages/SmartCollectionPage.tsx +434 -0
- package/src/ui/pages/SubscribePage.tsx +236 -0
- package/src/ui/pages/__tests__/ArchivePage.test.tsx +65 -9
- package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
- package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
- package/src/ui/shared/CollectionDirectory.tsx +178 -3
- package/src/ui/shared/CollectionsManager.tsx +70 -17
- package/src/ui/shared/CopyField.tsx +127 -0
- package/src/ui/shared/EmptyPostContent.tsx +23 -0
- package/src/ui/shared/MediaGallery.tsx +125 -46
- package/src/ui/shared/Pagination.tsx +20 -5
- package/src/ui/shared/PostFooter.tsx +20 -11
- package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
- package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
- package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
- package/src/ui/shared/__tests__/pagination.test.ts +38 -24
- package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
- package/src/ui/shared/archive-labels.ts +98 -0
- package/src/ui/shared/collection-management-labels.ts +94 -16
- package/src/ui/shared/navigation-labels.ts +45 -71
- package/src/ui/shared/post-article-attributes.ts +2 -0
- package/src/ui/shared/smart-collection-labels.ts +343 -0
- package/dist/app-C75Zc8dD.js +0 -6
- package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
- package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
- package/dist/client/_assets/chunks/url-CU9pEbk5.js +0 -1
- package/dist/client/_assets/client-Bq0vre8Y.js +0 -296
- package/dist/client/_assets/client-D3VZyG4L.css +0 -2
- package/dist/client/_assets/client-auth-F2rb9WfF.js +0 -5571
- package/dist/env-BPYViDJJ.js +0 -281
- package/dist/github-api-BNF35Lkx.js +0 -176
- package/dist/github-app-DdiD-bC4.js +0 -275
- package/dist/github-sync-Bqg62IvV.js +0 -4
- package/dist/github-sync-HdK2EgvJ.js +0 -431
- package/dist/url-CbLAtlZe.js +0 -900
- package/src/client/__tests__/collection-created-notice.test.ts +0 -36
- package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
- package/src/client/__tests__/post-form-bridge.test.ts +0 -82
- package/src/client/collection-created-notice.ts +0 -66
- package/src/client/collection-form-bridge.ts +0 -191
- package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
- package/src/client/components/jant-post-form.ts +0 -422
- package/src/client/components/post-form-template.ts +0 -352
- package/src/client/components/post-form-types.ts +0 -96
- package/src/client/media-metadata.ts +0 -247
- package/src/client/post-form-bridge.ts +0 -193
- package/src/ui/pages/CollectionEditorPage.tsx +0 -175
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smart Collection Service
|
|
3
|
+
*
|
|
4
|
+
* A smart collection is a collection whose members are decided by conditions
|
|
5
|
+
* rather than by tagging. To a reader it is a collection: root-level address,
|
|
6
|
+
* title, description, post list, feed. To the author it is a different gesture
|
|
7
|
+
* — writing conditions instead of tagging posts — and the asymmetry is
|
|
8
|
+
* permanent: nothing is added by hand, pinned, or reordered.
|
|
9
|
+
*
|
|
10
|
+
* Two rules this service exists to hold:
|
|
11
|
+
*
|
|
12
|
+
* - **A smart collection is always public.** `visibility` can only be one of
|
|
13
|
+
* the public archive values, so there is no page that answers 404 to a
|
|
14
|
+
* reader, no feed guard, and no placement guard. Per-post visibility is
|
|
15
|
+
* unchanged: the same base predicate a manual collection page applies, so a
|
|
16
|
+
* signed-in author may see a larger count than an anonymous reader — exactly
|
|
17
|
+
* as on a manual collection.
|
|
18
|
+
* - **A collection another object depends on is not deleted silently.**
|
|
19
|
+
* `collection_id` carries no foreign key — no ON DELETE action was the right
|
|
20
|
+
* one, and a constraint could only fail anonymously — so `assertCollectionUnused`
|
|
21
|
+
* is the whole of that rule, and it refuses by name.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { and, asc, eq, inArray } from "drizzle-orm";
|
|
25
|
+
import { type Database } from "../db/index.js";
|
|
26
|
+
import {
|
|
27
|
+
sqliteSchemaBundle,
|
|
28
|
+
type DatabaseSchema,
|
|
29
|
+
} from "../db/schema-bundle.js";
|
|
30
|
+
import { createEntityId } from "../lib/ids.js";
|
|
31
|
+
import { now } from "../lib/time.js";
|
|
32
|
+
import { isUniqueConstraintError } from "../db/dialect.js";
|
|
33
|
+
import {
|
|
34
|
+
DIRECTORY_POSITION_RETRY_ATTEMPTS,
|
|
35
|
+
getAppendDirectoryPosition,
|
|
36
|
+
} from "./collection-directory-position.js";
|
|
37
|
+
import {
|
|
38
|
+
ConflictError,
|
|
39
|
+
NotFoundError,
|
|
40
|
+
ValidationError,
|
|
41
|
+
} from "../lib/errors.js";
|
|
42
|
+
import { getCollectionPagePath } from "../lib/collection-paths.js";
|
|
43
|
+
import { generatePostSlug } from "../lib/slug.js";
|
|
44
|
+
import { getSlugValidationIssue } from "../lib/slug-format.js";
|
|
45
|
+
import {
|
|
46
|
+
selectionFromRow,
|
|
47
|
+
selectionToColumns,
|
|
48
|
+
toPostFilters,
|
|
49
|
+
type PostFilterSelection,
|
|
50
|
+
} from "../lib/filter-dimensions.js";
|
|
51
|
+
import {
|
|
52
|
+
CollectionDescriptionValueSchema,
|
|
53
|
+
CollectionSlugSchema,
|
|
54
|
+
CollectionTitleSchema,
|
|
55
|
+
parseValidated,
|
|
56
|
+
PostFilterSelectionSchema,
|
|
57
|
+
} from "../lib/schemas.js";
|
|
58
|
+
import type {
|
|
59
|
+
CreateSmartCollection,
|
|
60
|
+
SmartCollection,
|
|
61
|
+
SmartCollectionDirectoryEntry,
|
|
62
|
+
UpdateSmartCollection,
|
|
63
|
+
} from "../types.js";
|
|
64
|
+
import { toCollectionPath, type PathService } from "./path.js";
|
|
65
|
+
import type { PostFilters, PostService } from "./post.js";
|
|
66
|
+
|
|
67
|
+
/** Who is looking, for the per-post visibility rules a count has to respect. */
|
|
68
|
+
export interface SmartCollectionViewer {
|
|
69
|
+
/** Signed-in authors see their private threads counted; readers do not. */
|
|
70
|
+
isAuthenticated: boolean;
|
|
71
|
+
/** Content language of the current view, when the site has more than one. */
|
|
72
|
+
lang?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface SmartCollectionService {
|
|
76
|
+
getById(id: string): Promise<SmartCollection | null>;
|
|
77
|
+
getBySlug(slug: string): Promise<SmartCollection | null>;
|
|
78
|
+
list(): Promise<SmartCollection[]>;
|
|
79
|
+
/**
|
|
80
|
+
* Every smart collection with the count and freshness this viewer would see.
|
|
81
|
+
*
|
|
82
|
+
* Both measures are the viewer's own: a thread only the author can see is
|
|
83
|
+
* counted for the author alone, and it dates the collection for the author
|
|
84
|
+
* alone. A reader is never told that something they cannot read just moved.
|
|
85
|
+
*/
|
|
86
|
+
listDirectoryEntries(
|
|
87
|
+
viewer: SmartCollectionViewer,
|
|
88
|
+
): Promise<SmartCollectionDirectoryEntry[]>;
|
|
89
|
+
create(data: CreateSmartCollection): Promise<SmartCollection>;
|
|
90
|
+
update(
|
|
91
|
+
id: string,
|
|
92
|
+
data: UpdateSmartCollection,
|
|
93
|
+
): Promise<SmartCollection | null>;
|
|
94
|
+
delete(id: string): Promise<boolean>;
|
|
95
|
+
/**
|
|
96
|
+
* A slug this smart collection could take, derived from a title.
|
|
97
|
+
*
|
|
98
|
+
* Same shape posts use, because the namespace is the same one: an address is
|
|
99
|
+
* free or it is not, whoever holds it.
|
|
100
|
+
*/
|
|
101
|
+
suggestSlug(input: { title?: string; excludeId?: string }): Promise<string>;
|
|
102
|
+
/** Whether an address is free, ignoring the one this smart collection holds. */
|
|
103
|
+
checkSlugAvailability(slug: string, excludeId?: string): Promise<boolean>;
|
|
104
|
+
/**
|
|
105
|
+
* How many threads a set of conditions matches, next to the unfiltered total.
|
|
106
|
+
*
|
|
107
|
+
* What makes the editing dialog possible: without it an author is filling in
|
|
108
|
+
* a blind form and can save something that matches nothing.
|
|
109
|
+
*/
|
|
110
|
+
preview(
|
|
111
|
+
selection: PostFilterSelection,
|
|
112
|
+
viewer: SmartCollectionViewer,
|
|
113
|
+
): Promise<{ count: number; baseline: number }>;
|
|
114
|
+
/**
|
|
115
|
+
* The `PostFilters` this smart collection's conditions imply, for one viewer.
|
|
116
|
+
*
|
|
117
|
+
* The only place conditions become a query. Page, feed, and count all read
|
|
118
|
+
* it, so none of them can answer with a different set than the others.
|
|
119
|
+
*/
|
|
120
|
+
toPostFilters(
|
|
121
|
+
smartCollection: SmartCollection,
|
|
122
|
+
viewer: SmartCollectionViewer,
|
|
123
|
+
): PostFilters;
|
|
124
|
+
/**
|
|
125
|
+
* Refuse to delete a collection that a smart collection filters by.
|
|
126
|
+
*
|
|
127
|
+
* Called before deleting a collection. Nothing is repaired automatically:
|
|
128
|
+
* clearing the condition would quietly widen a smart collection the author
|
|
129
|
+
* never edited, which this project does not do.
|
|
130
|
+
*
|
|
131
|
+
* @param collectionId - The collection about to be deleted
|
|
132
|
+
* @throws ValidationError naming the smart collections that depend on it
|
|
133
|
+
*/
|
|
134
|
+
assertCollectionUnused(collectionId: string): Promise<void>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function createSmartCollectionService(
|
|
138
|
+
db: Database,
|
|
139
|
+
siteId: string,
|
|
140
|
+
resolvedPaths: PathService,
|
|
141
|
+
posts: PostService,
|
|
142
|
+
databaseSchema: DatabaseSchema = sqliteSchemaBundle,
|
|
143
|
+
slugIdLength = 5,
|
|
144
|
+
): SmartCollectionService {
|
|
145
|
+
const {
|
|
146
|
+
smartCollections,
|
|
147
|
+
navItems,
|
|
148
|
+
collectionDirectoryItems: directoryItemsTable,
|
|
149
|
+
} = databaseSchema;
|
|
150
|
+
|
|
151
|
+
type SmartCollectionRow = typeof smartCollections.$inferSelect;
|
|
152
|
+
|
|
153
|
+
function toSmartCollection(
|
|
154
|
+
row: SmartCollectionRow,
|
|
155
|
+
slug: string,
|
|
156
|
+
): SmartCollection {
|
|
157
|
+
return {
|
|
158
|
+
id: row.id,
|
|
159
|
+
siteId: row.siteId,
|
|
160
|
+
slug,
|
|
161
|
+
title: row.title,
|
|
162
|
+
description: row.description,
|
|
163
|
+
selection: selectionFromRow(row as unknown as Record<string, unknown>),
|
|
164
|
+
sort: row.sort,
|
|
165
|
+
layout: row.layout ?? null,
|
|
166
|
+
createdAt: row.createdAt,
|
|
167
|
+
updatedAt: row.updatedAt,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function hydrate(
|
|
172
|
+
row: SmartCollectionRow | undefined,
|
|
173
|
+
): Promise<SmartCollection | null> {
|
|
174
|
+
if (!row) return null;
|
|
175
|
+
const slug = await resolvedPaths.getSmartCollectionSlug(row.id);
|
|
176
|
+
return slug ? toSmartCollection(row, slug) : null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function hydrateMany(
|
|
180
|
+
rows: SmartCollectionRow[],
|
|
181
|
+
): Promise<SmartCollection[]> {
|
|
182
|
+
if (rows.length === 0) return [];
|
|
183
|
+
const slugMap = await resolvedPaths.getSmartCollectionSlugMap(
|
|
184
|
+
rows.map((row) => row.id),
|
|
185
|
+
);
|
|
186
|
+
return rows
|
|
187
|
+
.map((row) => {
|
|
188
|
+
const slug = slugMap.get(row.id);
|
|
189
|
+
return slug ? toSmartCollection(row, slug) : null;
|
|
190
|
+
})
|
|
191
|
+
.filter((row): row is SmartCollection => row !== null);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Append this smart collection's row to the collections directory.
|
|
196
|
+
*
|
|
197
|
+
* The directory is one ordered list shared with manual collections, dividers,
|
|
198
|
+
* and links; the row is what gives a smart collection a position in it. The
|
|
199
|
+
* position rule itself lives in `collection-directory-position`, so both
|
|
200
|
+
* writers append the same way.
|
|
201
|
+
*
|
|
202
|
+
* @param id - The smart collection to place
|
|
203
|
+
* @param timestamp - Creation time, shared with the smart collection row
|
|
204
|
+
*/
|
|
205
|
+
async function placeInDirectory(
|
|
206
|
+
id: string,
|
|
207
|
+
timestamp: number,
|
|
208
|
+
): Promise<void> {
|
|
209
|
+
for (
|
|
210
|
+
let attempt = 0;
|
|
211
|
+
attempt < DIRECTORY_POSITION_RETRY_ATTEMPTS;
|
|
212
|
+
attempt += 1
|
|
213
|
+
) {
|
|
214
|
+
try {
|
|
215
|
+
await db.insert(directoryItemsTable).values({
|
|
216
|
+
id: createEntityId("collectionDirectoryItem"),
|
|
217
|
+
siteId,
|
|
218
|
+
type: "smart_collection",
|
|
219
|
+
collectionId: null,
|
|
220
|
+
smartCollectionId: id,
|
|
221
|
+
label: null,
|
|
222
|
+
url: null,
|
|
223
|
+
position: await getAppendDirectoryPosition(
|
|
224
|
+
db,
|
|
225
|
+
directoryItemsTable,
|
|
226
|
+
siteId,
|
|
227
|
+
),
|
|
228
|
+
createdAt: timestamp,
|
|
229
|
+
updatedAt: timestamp,
|
|
230
|
+
});
|
|
231
|
+
return;
|
|
232
|
+
} catch (err) {
|
|
233
|
+
if (
|
|
234
|
+
!isUniqueConstraintError(err) ||
|
|
235
|
+
attempt === DIRECTORY_POSITION_RETRY_ATTEMPTS - 1
|
|
236
|
+
) {
|
|
237
|
+
throw err;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Validate and normalize the fields a create or update may set. */
|
|
244
|
+
function normalizeInput(
|
|
245
|
+
data: CreateSmartCollection | UpdateSmartCollection,
|
|
246
|
+
): {
|
|
247
|
+
slug?: string;
|
|
248
|
+
title?: string;
|
|
249
|
+
description?: string | null;
|
|
250
|
+
selection?: PostFilterSelection;
|
|
251
|
+
sort?: SmartCollection["sort"];
|
|
252
|
+
layout?: SmartCollection["layout"];
|
|
253
|
+
} {
|
|
254
|
+
return {
|
|
255
|
+
...(data.slug !== undefined
|
|
256
|
+
? { slug: parseValidated(CollectionSlugSchema, data.slug) }
|
|
257
|
+
: {}),
|
|
258
|
+
...(data.title !== undefined
|
|
259
|
+
? { title: parseValidated(CollectionTitleSchema, data.title) }
|
|
260
|
+
: {}),
|
|
261
|
+
...(data.description !== undefined
|
|
262
|
+
? {
|
|
263
|
+
description:
|
|
264
|
+
data.description === null || data.description === ""
|
|
265
|
+
? null
|
|
266
|
+
: parseValidated(
|
|
267
|
+
CollectionDescriptionValueSchema,
|
|
268
|
+
data.description,
|
|
269
|
+
),
|
|
270
|
+
}
|
|
271
|
+
: {}),
|
|
272
|
+
...(data.selection !== undefined
|
|
273
|
+
? {
|
|
274
|
+
selection: parseValidated(
|
|
275
|
+
PostFilterSelectionSchema,
|
|
276
|
+
data.selection,
|
|
277
|
+
),
|
|
278
|
+
}
|
|
279
|
+
: {}),
|
|
280
|
+
...(data.sort !== undefined ? { sort: data.sort } : {}),
|
|
281
|
+
...(data.layout !== undefined ? { layout: data.layout } : {}),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Is this address free, ignoring the one this smart collection already holds? */
|
|
286
|
+
async function isSlugFree(
|
|
287
|
+
slug: string,
|
|
288
|
+
excludeId?: string,
|
|
289
|
+
): Promise<boolean> {
|
|
290
|
+
const resolved = await resolvedPaths.resolve(toCollectionPath(slug));
|
|
291
|
+
if (!resolved) return true;
|
|
292
|
+
return Boolean(
|
|
293
|
+
excludeId &&
|
|
294
|
+
resolved.kind === "slug" &&
|
|
295
|
+
resolved.smartCollectionId === excludeId,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Every collection the selection names must exist.
|
|
301
|
+
*
|
|
302
|
+
* The registry validates the *shape* of a collection id; only the database
|
|
303
|
+
* can say whether one is real. A condition pointing at nothing would render
|
|
304
|
+
* as a smart collection that silently matches no post.
|
|
305
|
+
*/
|
|
306
|
+
async function assertSelectionResolvable(
|
|
307
|
+
selection: PostFilterSelection,
|
|
308
|
+
): Promise<void> {
|
|
309
|
+
const ids = selection.collection ?? [];
|
|
310
|
+
if (ids.length === 0) return;
|
|
311
|
+
|
|
312
|
+
const { collections } = databaseSchema;
|
|
313
|
+
const rows = await db
|
|
314
|
+
.select({ id: collections.id })
|
|
315
|
+
.from(collections)
|
|
316
|
+
.where(
|
|
317
|
+
and(eq(collections.siteId, siteId), inArray(collections.id, [...ids])),
|
|
318
|
+
);
|
|
319
|
+
if (rows.length !== ids.length) {
|
|
320
|
+
throw new NotFoundError("Collection");
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function buildPostFilters(
|
|
325
|
+
smartCollection: SmartCollection,
|
|
326
|
+
viewer: SmartCollectionViewer,
|
|
327
|
+
): PostFilters {
|
|
328
|
+
// The same axes a manual collection sorts on, so the two kinds of
|
|
329
|
+
// collection cannot rank the same Thread differently: `newest` and the
|
|
330
|
+
// rating order's tie-break read Thread activity — when the Thread last
|
|
331
|
+
// gained an announced post — while `oldest` reads publication, which is
|
|
332
|
+
// the only reading of "oldest" that does not move when a reply lands.
|
|
333
|
+
const axis =
|
|
334
|
+
smartCollection.sort === "oldest"
|
|
335
|
+
? ("published" as const)
|
|
336
|
+
: ("activity" as const);
|
|
337
|
+
return {
|
|
338
|
+
lang: viewer.lang,
|
|
339
|
+
status: "published",
|
|
340
|
+
excludeReplies: true,
|
|
341
|
+
// The floor, always. A smart collection is public, but the posts inside
|
|
342
|
+
// it are only as visible as they are anywhere else.
|
|
343
|
+
excludePrivate: !viewer.isAuthenticated,
|
|
344
|
+
excludeLatestHidden: false,
|
|
345
|
+
// Membership never depends on presentation, so the year condition is
|
|
346
|
+
// pinned to the publication axis whatever the chosen order is.
|
|
347
|
+
...toPostFilters(smartCollection.selection, { yearAxis: "published" }),
|
|
348
|
+
sortBy: axis,
|
|
349
|
+
sortOrder: smartCollection.sort,
|
|
350
|
+
ignorePinnedSort: true,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return {
|
|
355
|
+
async getById(id) {
|
|
356
|
+
const rows = await db
|
|
357
|
+
.select()
|
|
358
|
+
.from(smartCollections)
|
|
359
|
+
.where(
|
|
360
|
+
and(eq(smartCollections.siteId, siteId), eq(smartCollections.id, id)),
|
|
361
|
+
)
|
|
362
|
+
.limit(1);
|
|
363
|
+
return hydrate(rows[0]);
|
|
364
|
+
},
|
|
365
|
+
|
|
366
|
+
async getBySlug(slug) {
|
|
367
|
+
const resolved = await resolvedPaths.resolve(toCollectionPath(slug));
|
|
368
|
+
if (
|
|
369
|
+
!resolved ||
|
|
370
|
+
resolved.kind !== "slug" ||
|
|
371
|
+
!resolved.smartCollectionId
|
|
372
|
+
) {
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
return this.getById(resolved.smartCollectionId);
|
|
376
|
+
},
|
|
377
|
+
|
|
378
|
+
async list() {
|
|
379
|
+
const rows = await db
|
|
380
|
+
.select()
|
|
381
|
+
.from(smartCollections)
|
|
382
|
+
.where(eq(smartCollections.siteId, siteId))
|
|
383
|
+
.orderBy(asc(smartCollections.createdAt));
|
|
384
|
+
return hydrateMany(rows);
|
|
385
|
+
},
|
|
386
|
+
|
|
387
|
+
async listDirectoryEntries(viewer) {
|
|
388
|
+
const entries = await this.list();
|
|
389
|
+
if (entries.length === 0) return [];
|
|
390
|
+
|
|
391
|
+
// One pass for every smart collection on the page, not one query each.
|
|
392
|
+
// Same predicate builder the pages use, so the number in the directory
|
|
393
|
+
// and the number on the page it links to agree.
|
|
394
|
+
const measured = await posts.aggregateMany(
|
|
395
|
+
entries.map((entry) => toPostFilters(entry.selection, {})),
|
|
396
|
+
{
|
|
397
|
+
status: "published",
|
|
398
|
+
excludeReplies: true,
|
|
399
|
+
excludePrivate: !viewer.isAuthenticated,
|
|
400
|
+
excludeLatestHidden: false,
|
|
401
|
+
lang: viewer.lang,
|
|
402
|
+
},
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
return entries.map((entry, index) => ({
|
|
406
|
+
...entry,
|
|
407
|
+
threadCount: measured[index]?.count ?? 0,
|
|
408
|
+
// Nothing matched — no thread to date it by — so the collection dates
|
|
409
|
+
// itself, exactly as an empty manual collection does.
|
|
410
|
+
recentActivityAt: measured[index]?.recentActivityAt ?? entry.updatedAt,
|
|
411
|
+
}));
|
|
412
|
+
},
|
|
413
|
+
|
|
414
|
+
async create(data) {
|
|
415
|
+
const normalized = normalizeInput(data);
|
|
416
|
+
if (!normalized.slug || !normalized.title) {
|
|
417
|
+
throw new ValidationError(
|
|
418
|
+
"A smart collection needs a title and an address.",
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
const selection = normalized.selection ?? {};
|
|
422
|
+
await assertSelectionResolvable(selection);
|
|
423
|
+
|
|
424
|
+
const id = createEntityId("smartCollection");
|
|
425
|
+
const timestamp = now();
|
|
426
|
+
|
|
427
|
+
// The row goes in before its path, because the path row's foreign key
|
|
428
|
+
// points at it. The address is what can still fail — it shares one
|
|
429
|
+
// namespace with posts and collections — so a clash rolls the row back
|
|
430
|
+
// rather than leaving a smart collection nothing can reach.
|
|
431
|
+
await db.insert(smartCollections).values({
|
|
432
|
+
id,
|
|
433
|
+
siteId,
|
|
434
|
+
title: normalized.title,
|
|
435
|
+
description: normalized.description ?? null,
|
|
436
|
+
...selectionToColumns(selection),
|
|
437
|
+
sort: normalized.sort ?? "newest",
|
|
438
|
+
layout: normalized.layout ?? null,
|
|
439
|
+
createdAt: timestamp,
|
|
440
|
+
updatedAt: timestamp,
|
|
441
|
+
} as typeof smartCollections.$inferInsert);
|
|
442
|
+
|
|
443
|
+
try {
|
|
444
|
+
// A smart collection takes its place in the directory the way a manual
|
|
445
|
+
// one does — appended, with a row of its own. The directory would list
|
|
446
|
+
// it either way, but only a row can hold a position, so without this it
|
|
447
|
+
// sits below every manual collection and cannot be dragged out of there.
|
|
448
|
+
await placeInDirectory(id, timestamp);
|
|
449
|
+
await resolvedPaths.createSmartCollectionSlug(id, normalized.slug);
|
|
450
|
+
} catch (err) {
|
|
451
|
+
await db
|
|
452
|
+
.delete(directoryItemsTable)
|
|
453
|
+
.where(
|
|
454
|
+
and(
|
|
455
|
+
eq(directoryItemsTable.siteId, siteId),
|
|
456
|
+
eq(directoryItemsTable.smartCollectionId, id),
|
|
457
|
+
),
|
|
458
|
+
);
|
|
459
|
+
await db
|
|
460
|
+
.delete(smartCollections)
|
|
461
|
+
.where(
|
|
462
|
+
and(
|
|
463
|
+
eq(smartCollections.siteId, siteId),
|
|
464
|
+
eq(smartCollections.id, id),
|
|
465
|
+
),
|
|
466
|
+
);
|
|
467
|
+
if (err instanceof ConflictError) {
|
|
468
|
+
throw new ConflictError(
|
|
469
|
+
`Slug "${normalized.slug}" is already in use`,
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
throw err;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const created = await this.getById(id);
|
|
476
|
+
if (!created) throw new NotFoundError("Smart collection");
|
|
477
|
+
return created;
|
|
478
|
+
},
|
|
479
|
+
|
|
480
|
+
async update(id, data) {
|
|
481
|
+
const existing = await this.getById(id);
|
|
482
|
+
if (!existing) return null;
|
|
483
|
+
|
|
484
|
+
const normalized = normalizeInput(data);
|
|
485
|
+
if (normalized.selection) {
|
|
486
|
+
await assertSelectionResolvable(normalized.selection);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const timestamp = now();
|
|
490
|
+
const slugChanged =
|
|
491
|
+
normalized.slug !== undefined && normalized.slug !== existing.slug;
|
|
492
|
+
|
|
493
|
+
if (slugChanged && normalized.slug) {
|
|
494
|
+
try {
|
|
495
|
+
await resolvedPaths.updateSmartCollectionSlug(id, normalized.slug);
|
|
496
|
+
} catch (err) {
|
|
497
|
+
if (err instanceof ConflictError) {
|
|
498
|
+
throw new ConflictError(
|
|
499
|
+
`Slug "${normalized.slug}" is already in use`,
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
throw err;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// A nav item's URL is stored, unlike its label, so moving the address
|
|
506
|
+
// has to move the link with it — the same rewrite a renamed collection
|
|
507
|
+
// performs.
|
|
508
|
+
await db
|
|
509
|
+
.update(navItems)
|
|
510
|
+
.set({
|
|
511
|
+
url: getCollectionPagePath(normalized.slug),
|
|
512
|
+
updatedAt: timestamp,
|
|
513
|
+
})
|
|
514
|
+
.where(
|
|
515
|
+
and(
|
|
516
|
+
eq(navItems.siteId, siteId),
|
|
517
|
+
eq(navItems.smartCollectionId, id),
|
|
518
|
+
),
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const updates: Record<string, unknown> = { updatedAt: timestamp };
|
|
523
|
+
if (normalized.title !== undefined) updates.title = normalized.title;
|
|
524
|
+
if (normalized.description !== undefined) {
|
|
525
|
+
updates.description = normalized.description;
|
|
526
|
+
}
|
|
527
|
+
if (normalized.sort !== undefined) updates.sort = normalized.sort;
|
|
528
|
+
if (normalized.layout !== undefined) updates.layout = normalized.layout;
|
|
529
|
+
if (normalized.selection !== undefined) {
|
|
530
|
+
// Every dimension is written, including the ones with no value —
|
|
531
|
+
// removing a condition has to clear its column, not leave it behind.
|
|
532
|
+
Object.assign(updates, selectionToColumns(normalized.selection));
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
await db
|
|
536
|
+
.update(smartCollections)
|
|
537
|
+
.set(updates as Partial<typeof smartCollections.$inferInsert>)
|
|
538
|
+
.where(
|
|
539
|
+
and(eq(smartCollections.siteId, siteId), eq(smartCollections.id, id)),
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
return this.getById(id);
|
|
543
|
+
},
|
|
544
|
+
|
|
545
|
+
async delete(id) {
|
|
546
|
+
await db
|
|
547
|
+
.delete(directoryItemsTable)
|
|
548
|
+
.where(
|
|
549
|
+
and(
|
|
550
|
+
eq(directoryItemsTable.siteId, siteId),
|
|
551
|
+
eq(directoryItemsTable.smartCollectionId, id),
|
|
552
|
+
),
|
|
553
|
+
);
|
|
554
|
+
await db
|
|
555
|
+
.delete(navItems)
|
|
556
|
+
.where(
|
|
557
|
+
and(eq(navItems.siteId, siteId), eq(navItems.smartCollectionId, id)),
|
|
558
|
+
);
|
|
559
|
+
const result = await db
|
|
560
|
+
.delete(smartCollections)
|
|
561
|
+
.where(
|
|
562
|
+
and(eq(smartCollections.siteId, siteId), eq(smartCollections.id, id)),
|
|
563
|
+
)
|
|
564
|
+
.returning();
|
|
565
|
+
return result.length > 0;
|
|
566
|
+
},
|
|
567
|
+
|
|
568
|
+
async suggestSlug(input) {
|
|
569
|
+
return generatePostSlug({
|
|
570
|
+
title: input.title,
|
|
571
|
+
idLength: slugIdLength,
|
|
572
|
+
isAvailable: (candidate) => isSlugFree(candidate, input.excludeId),
|
|
573
|
+
});
|
|
574
|
+
},
|
|
575
|
+
|
|
576
|
+
async checkSlugAvailability(slug, excludeId) {
|
|
577
|
+
const issue = getSlugValidationIssue(slug);
|
|
578
|
+
if (issue === "invalid") {
|
|
579
|
+
throw new ValidationError("Slug contains invalid characters");
|
|
580
|
+
}
|
|
581
|
+
if (issue === "reserved") {
|
|
582
|
+
throw new ValidationError("Slug is reserved");
|
|
583
|
+
}
|
|
584
|
+
return isSlugFree(slug, excludeId);
|
|
585
|
+
},
|
|
586
|
+
|
|
587
|
+
async preview(selection, viewer) {
|
|
588
|
+
const parsed = parseValidated(PostFilterSelectionSchema, selection);
|
|
589
|
+
await assertSelectionResolvable(parsed);
|
|
590
|
+
|
|
591
|
+
const base: PostFilters = {
|
|
592
|
+
status: "published",
|
|
593
|
+
excludeReplies: true,
|
|
594
|
+
excludePrivate: !viewer.isAuthenticated,
|
|
595
|
+
excludeLatestHidden: false,
|
|
596
|
+
lang: viewer.lang,
|
|
597
|
+
};
|
|
598
|
+
// Both numbers in one round trip, and both through the same predicate
|
|
599
|
+
// builder the saved page will use — a preview that counted differently
|
|
600
|
+
// than the page would be worse than no preview.
|
|
601
|
+
const [matched, all] = await posts.aggregateMany(
|
|
602
|
+
[toPostFilters(parsed, {}), {}],
|
|
603
|
+
base,
|
|
604
|
+
);
|
|
605
|
+
return { count: matched?.count ?? 0, baseline: all?.count ?? 0 };
|
|
606
|
+
},
|
|
607
|
+
|
|
608
|
+
toPostFilters(smartCollection, viewer) {
|
|
609
|
+
return buildPostFilters(smartCollection, viewer);
|
|
610
|
+
},
|
|
611
|
+
|
|
612
|
+
async assertCollectionUnused(collectionId) {
|
|
613
|
+
const rows = await db
|
|
614
|
+
.select({ title: smartCollections.title })
|
|
615
|
+
.from(smartCollections)
|
|
616
|
+
.where(
|
|
617
|
+
and(
|
|
618
|
+
eq(smartCollections.siteId, siteId),
|
|
619
|
+
eq(smartCollections.collectionId, collectionId),
|
|
620
|
+
),
|
|
621
|
+
)
|
|
622
|
+
.orderBy(asc(smartCollections.createdAt));
|
|
623
|
+
|
|
624
|
+
if (rows.length === 0) return;
|
|
625
|
+
|
|
626
|
+
// Named, not counted: "used by 2 smart collections" leaves the author
|
|
627
|
+
// hunting for which ones.
|
|
628
|
+
const names = rows.map((row) => row.title).join(", ");
|
|
629
|
+
throw new ValidationError(
|
|
630
|
+
rows.length === 1
|
|
631
|
+
? `This collection is used by the smart collection ${names}. Change or delete that first.`
|
|
632
|
+
: `This collection is used by these smart collections: ${names}. Change or delete them first.`,
|
|
633
|
+
);
|
|
634
|
+
},
|
|
635
|
+
};
|
|
636
|
+
}
|
package/src/services/telegram.ts
CHANGED
|
@@ -483,28 +483,26 @@ export function createTelegramService(
|
|
|
483
483
|
)
|
|
484
484
|
.returning();
|
|
485
485
|
return rows
|
|
486
|
-
.map(
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
}),
|
|
507
|
-
)
|
|
486
|
+
.map((row): TelegramMediaGroupItem => ({
|
|
487
|
+
id: row.id,
|
|
488
|
+
siteId: row.siteId,
|
|
489
|
+
botId: row.botId,
|
|
490
|
+
telegramUserId: row.telegramUserId,
|
|
491
|
+
mediaGroupId: row.mediaGroupId,
|
|
492
|
+
chatId: row.chatId,
|
|
493
|
+
messageId: row.messageId,
|
|
494
|
+
updateId: row.updateId,
|
|
495
|
+
fileId: row.fileId,
|
|
496
|
+
mediaKind: row.mediaKind as TelegramMediaGroupKind,
|
|
497
|
+
mimeType: row.mimeType,
|
|
498
|
+
originalName: row.originalName,
|
|
499
|
+
captionMarkdown: row.captionMarkdown,
|
|
500
|
+
width: row.width,
|
|
501
|
+
height: row.height,
|
|
502
|
+
durationSeconds: row.durationSeconds,
|
|
503
|
+
posterFileId: row.posterFileId,
|
|
504
|
+
createdAt: row.createdAt,
|
|
505
|
+
}))
|
|
508
506
|
.sort((a, b) => a.messageId - b.messageId);
|
|
509
507
|
},
|
|
510
508
|
|
|
@@ -91,9 +91,7 @@ export interface PutTransport extends PresignedPutTarget {
|
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
export type UploadTransport =
|
|
94
|
-
|
|
|
95
|
-
| MultipartRelayTransport
|
|
96
|
-
| PutTransport;
|
|
94
|
+
RelayTransport | MultipartRelayTransport | PutTransport;
|
|
97
95
|
|
|
98
96
|
export interface InitiateUploadResult {
|
|
99
97
|
id: string;
|