[
  {
    "path": ".editorconfig",
    "content": "# Editor configuration, see http://editorconfig.org\nroot = true\n\n[*]\ncharset = utf-8\nindent_style = space\nindent_size = 2\ninsert_final_newline = true\ntrim_trailing_whitespace = true\n\n[*.md]\nmax_line_length = off\ntrim_trailing_whitespace = false\n"
  },
  {
    "path": ".eslintignore",
    "content": "# Add files here to ignore them in eslint\n\nnode_modules\n*.js\n*.jsx\n*.spec.ts \n*.spec.tsx\n"
  },
  {
    "path": ".eslintrc.js",
    "content": "// @ts-check\n/** @type {import('eslint').ESLint.ConfigData} */\nconst config = {\n  root: true,\n  ignorePatterns: ['**/*'],\n  extends: [\n    'airbnb',\n    'airbnb-typescript',\n    'plugin:jest/recommended',\n    'plugin:@nrwl/nx/typescript',\n    'eslint-config-prettier',\n  ],\n  plugins: ['@nrwl/nx', 'jest'],\n  parserOptions: { project: './tsconfig.base.json' },\n  overrides: [\n    {\n      files: ['*.tsx'],\n      plugins: ['testing-library'],\n      extends: ['plugin:testing-library/react', 'airbnb/hooks'],\n    },\n  ],\n  rules: {\n    '@nrwl/nx/enforce-module-boundaries': [\n      'error',\n      {\n        enforceBuildableLibDependency: true,\n\n        // We allow it because we need to use import from\n        // postybirb-ui to make jump-to definition in the\n        // FieldType.label\n        allow: [\n          '@postybirb/form-builder',\n          '@postybirb/translations',\n          '@postybirb/types',\n        ],\n\n        depConstraints: [{ sourceTag: '*', onlyDependOnLibsWithTags: ['*'] }],\n      },\n    ],\n    '@typescript-eslint/lines-between-class-members': 'off',\n    'no-plusplus': 'off',\n    'no-nested-ternary': 'off',\n    'no-continue': 'off',\n    'no-await-in-loop': 'off',\n    'no-restricted-syntax': 'off',\n    'class-methods-use-this': 'off',\n\n    'import/export': 'off',\n    'import/no-cycle': 'off',\n    'import/prefer-default-export': 'off',\n\n    'react/jsx-props-no-spreading': 'off',\n    'react/react-in-jsx-scope': 'off',\n    'react/no-unescaped-entities': 'off',\n    'react/sort-comp': 'off',\n    'react/prop-types': 'off',\n\n    '@typescript-eslint/ban-types': 'warn',\n    '@typescript-eslint/no-unused-vars': 'off',\n    '@typescript-eslint/no-use-before-define': 'off',\n\n    'import/extension': 'off',\n    'import/no-extraneous-dependencies': [\n      'warn',\n      {\n        // These dependencies will be considered\n        // as bundled and no warning will be shown\n        bundledDependencies: ['electron'],\n      },\n    ],\n    '@typescript-eslint/naming-convention': [\n      'error',\n      {\n        selector: 'variable',\n        format: ['camelCase', 'PascalCase', 'UPPER_CASE'],\n\n        // Allow const { _ } = useLingui()\n        leadingUnderscore: 'allow',\n      },\n      {\n        selector: 'function',\n        format: ['camelCase', 'PascalCase'],\n      },\n      {\n        selector: 'typeLike',\n        format: ['PascalCase'],\n      },\n    ],\n  },\n};\n\nmodule.exports = config;\n"
  },
  {
    "path": ".github/workflows/build.yml",
    "content": "name: Build/Release\n\non:\n  workflow_dispatch:\n\njobs:\n  build:\n    name: ${{ matrix.name }}\n    runs-on: ${{ matrix.os }}\n\n    strategy:\n      fail-fast: false\n      matrix:\n        include:\n          - name: macOS (x64)\n            os: macos-latest\n            platform: mac\n            arch: x64\n          - name: macOS (arm64)\n            os: macos-latest\n            platform: mac\n            arch: arm64\n          - name: Linux (x64)\n            os: ubuntu-latest\n            platform: linux\n            arch: x64\n          - name: Linux (arm64)\n            os: ubuntu-24.04-arm\n            platform: linux\n            arch: arm64\n          - name: Windows (x64)\n            os: windows-latest\n            platform: win\n            arch: x64\n\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - name: Setup Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: \"22\"\n\n      - name: Enable corepack to use Yarn v4\n        run: |\n          corepack enable\n          corepack prepare --activate\n\n      - name: Cache Electron binaries\n        uses: actions/cache@v4\n        with:\n          path: |\n            ~/.cache/electron\n            ~/.cache/electron-builder\n            node_modules/.cache/electron\n          key: ${{ runner.os }}-${{ matrix.arch }}-electron-cache-${{ hashFiles('**/yarn.lock') }}\n          restore-keys: |\n            ${{ runner.os }}-${{ matrix.arch }}-electron-cache-\n\n      - name: Install dependencies\n        run: yarn install --immutable\n        env:\n          CYPRESS_INSTALL_BINARY: 0\n          ELECTRON_CACHE: ~/.cache/electron\n          ELECTRON_BUILDER_CACHE: ~/.cache/electron-builder\n\n      # This is needed to prevent ids showing instead of non translated i18n texts in production builds\n      - name: Extract messages\n        run: yarn lingui:extract\n\n      - name: Patch electron-builder.yml for fork repositories\n        if: github.event.repository.fork\n        uses: actions/github-script@v8\n        with:\n          script: |\n            const fs = require('fs');\n            const yaml = require('js-yaml');\n\n            const configPath = './electron-builder.yml';\n            const configContent = fs.readFileSync(configPath, 'utf8');\n            let config = yaml.load(configContent);\n\n            config.publish = {\n              provider: 'github',\n              owner: context.repo.owner,\n              repo: context.repo.repo\n            };\n\n            if (config.linux && config.linux.target) {\n              config.linux.target = [{ target:\"AppImage\" }]\n            }\n\n            // Remove snap configuration entirely for forks\n            delete config.snap;\n\n            // Remove deb and rpm configurations for forks\n            delete config.deb;\n            delete config.rpm;\n\n            // Write the modified config back\n            fs.writeFileSync(configPath, yaml.dump(config));\n\n            console.log('✅ Patched electron-builder.yml for fork repository');\n            console.log(`📦 Publish target: ${context.repo.owner}/${context.repo.repo}`);\n            console.log(yaml.dump(config))\n\n      - name: Install snapcraft\n        if: matrix.platform == 'linux' && !github.event.repository.fork\n        run: |\n          sudo apt-get update\n          sudo apt-get install -y snapd\n          sudo snap install snapcraft --classic\n\n      - name: Install Ruby + fpm (system) for Linux packaging\n        if: matrix.platform == 'linux' && !github.event.repository.fork\n        run: |\n          sudo apt-get update\n          sudo apt-get install -y ruby ruby-dev build-essential rpm\n          sudo gem install --no-document fpm\n          # Ensure gem bindir is on PATH for subsequent steps\n          echo \"$(ruby -e 'print Gem.bindir')\" >> \"$GITHUB_PATH\"\n          echo \"Ruby:\"; ruby -v\n          echo \"fpm:\"; fpm --version\n\n      - name: Inject Application Insights connection string\n        env:\n          APP_INSIGHTS_KEY: ${{ secrets.APP_INSIGHTS_KEY }}\n        run: corepack yarn inject:app-insights\n\n      - name: Build application\n        run: corepack yarn build:prod\n        env:\n          NODE_ENV: production\n\n      - name: Build Electron app (fork)\n        if: github.event.repository.fork\n        run: corepack yarn electron-builder --${{ matrix.platform }} --${{ matrix.arch }} --publish=always\n        env:\n          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Build Electron app\n        if: (!github.event.repository.fork)\n        run: corepack yarn electron-builder --${{ matrix.platform }} --${{ matrix.arch }} --publish=always\n        env:\n          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n          APPLE_ID: ${{ secrets.APPLE_ID }}\n          APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}\n          APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}\n          CSC_LINK: ${{ secrets.CSC_LINK }}\n          CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}\n          CSC_IDENTITY_AUTO_DISCOVERY: true\n          SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}\n          SNAPCRAFT_BUILD_ENVIRONMENT: host\n          # ✅ Use system fpm for Linux only\n          USE_SYSTEM_FPM: ${{ matrix.platform == 'linux' && 'true' || '' }}\n\n      - name: Extract version from package.json for docker tag\n        uses: actions/github-script@v8\n        id: packageVersion\n        with:\n          script: |\n            const fs = require('fs');\n            const pkg = JSON.parse(fs.readFileSync(\"package.json\", \"utf8\"))\n            return pkg.version\n\n      - name: Build and publish a Docker image for '${{github.repository}}'\n        uses: macbre/push-to-ghcr@master\n        if: matrix.platform == 'linux' && matrix.arch == 'x64'\n        with:\n          image_name: \"${{github.repository}}\"\n          extra_args: --tag ghcr.io/${{github.repository}}:${{steps.packageVersion.outputs.result}}\n          github_token: ${{ secrets.GITHUB_TOKEN }}\n          dockerfile: \"./Dockerfile\"\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI\non:\n  workflow_dispatch:\n  pull_request:\n  push:\n    branches:\n      - main\n\n# Needed for nx-set-shas when run on the main branch\npermissions:\n  actions: read\n  contents: read\n\njobs:\n  ci:\n    name: ${{ matrix.ci.name }}\n    runs-on: ubuntu-latest\n    if: github.head_ref != 'weblate-postybirb-postybirb'\n    strategy:\n      matrix:\n        ci:\n          - name: Lint\n            command: \"yarn nx affected -t lint --cache --cache-strategy content\n              --cache-location .eslint\"\n\n          - name: TypeCheck\n            command: \"yarn nx affected -t typecheck\"\n\n          # xvfb-run is needed because electron still tries to get $DISPLAY env even if it does not launches the window\n          # so to run tests on electron runner we need to add this command\n          # grep filters out useless spam in console about not able to access display.\n          # pipefail needed because otherwise grep returns exit code 0 and failed tests don't display in ci as failed\n          - name: Test\n            command: \"set -o pipefail && xvfb-run --auto-servernum --server-args=\\\"-screen 0\n              1280x960x24\\\" -- yarn nx affected -t test | grep -v -E\n              \\\"ERROR:viz_main_impl\\\\.cc\\\\(183\\\\)|ERROR:object_proxy\\\\.cc\\\\(576\\\n              \\\\)|ERROR:bus\\\\.cc\\\\(408\\\\)|ERROR:gles2_cmd_decoder_passthrough\\\\\\\n              .cc\\\\(1094\\\\)|ERROR:gl_utils\\\\.cc\\\\(431\\\\)|ERROR:browser_main_loop\\\n              \\\\.cc\\\\(276\\\\)\\\"\"\n\n    steps:\n      - name: Check out Git repository\n        uses: actions/checkout@v4\n        with:\n          # We need to fetch all branches and commits so that Nx affected has a base to compare against.\n          fetch-depth: 0\n\n      - name: Use eslint, jest and tsc cache\n        uses: actions/cache@v4\n        with:\n          # out-tsc needed because we use incremental in typecheck which uses files from tsc output\n          path: |\n            .eslint\n            .jest\n            dist/out-tsc\n\n          key: ${{ runner.os }}-ci\n        # This is needed to be run before node setup, because node setup uses yarn to get cache dir\n      - name: Enable corepack to use Yarn v4\n        run: corepack enable\n\n      - name: Install Node.js and Yarn\n        uses: actions/setup-node@v4\n        with:\n          node-version: \"22\"\n          cache: yarn\n        # These flags needed to speed up ci\n      - name: Install dependencies\n        run: yarn install --immutable\n        env:\n          CYPRESS_INSTALL_BINARY: 0 # Skip downloading Cypress binary (not needed for CI)\n        # Used to calculate affected\n      - name: Setup SHAs\n        uses: nrwl/nx-set-shas@v4\n\n      - name: Run ${{ matrix.ci.name }}\n        run: ${{ matrix.ci.command }}\n        timeout-minutes: 30\n"
  },
  {
    "path": ".github/workflows/i18n.yml.disabled",
    "content": "name: i18n CI\non:\n  push:\n    branches:\n      - main\n\npermissions:\n  actions: read\n  contents: write\n  checks: write\n  pull-requests: write\n\njobs:\n  main:\n    runs-on: ubuntu-latest\n    if: github.head_ref != 'weblate-postybirb-postybirb'\n    steps:\n      - name: Check out Git repository\n        uses: actions/checkout@v4\n\n      - name: Install Node.js, NPM and Yarn\n        uses: actions/setup-node@v4\n        with:\n          cache: yarn\n\n        # Note: These flags needed to speed up ci\n      - name: Install dependencies\n        run: yarn install --frozen-lockfile --prefer-offline\n\n        # Extracts i18n strings from code\n      - name: Extract i18n keys\n        run: yarn lingui:extract\n\n        # It commits extracted i18n string and eslint/prettier fixes\n      - name: Commit changes\n        run: |\n          git config --local user.email \"41898282+github-actions[bot]@users.noreply.github.com\"\n          git config --local user.name \"github-actions[bot]\"   \n          # pull only from main branch so it will not fetch all tags\n          git pull origin main\n          \n          git add lang/*\n          # ignore when nothing to commit\n          yarn exitzero \"git commit -m 'ci: extract i18n messages'\"\n\n      - name: Push changes\n        uses: ad-m/github-push-action@master\n        with:\n          github_token: ${{ secrets.GITHUB_TOKEN }}\n          branch: ${{ github.ref }}\n"
  },
  {
    "path": ".gitignore",
    "content": "# See http://help.github.com/ignore-files/ for more about ignoring files.\n\n# yarn\n.yarn/*\n!.yarn/patches\n!.yarn/plugins\n!.yarn/releases\n!.yarn/sdks\n!.yarn/versions\n\n# compiled output\n/release\n/dist\n/tmp\n/out-tsc\n/test\n/.swc\n/.jest\n/.eslint\n.jest\n.swc\n\n# dependencies\n/node_modules\nscripts/node_modules\n\n# IDEs and editors\n/.idea\n.project\n.classpath\n.c9/\n*.launch\n.settings/\n*.sublime-workspace\n\n# IDE - VSCode\n.vscode/*\n!.vscode/settings.json\n!.vscode/tasks.json\n!.vscode/launch.json\n!.vscode/extensions.json\n\n# misc\n/.sass-cache\n/connect.lock\n/coverage\n/libpeerconnection.log\nnpm-debug.log\nyarn-error.log\ntestem.log\n/typings\n\n# System Files\n.DS_Store\nThumbs.db\n\n# Public UI dependencies\n.nx/cache\n.nx/workspace-data\n"
  },
  {
    "path": ".husky/commit-msg",
    "content": "npx --no-install commitlint --edit \"$1\"\n"
  },
  {
    "path": ".husky/post-merge",
    "content": "yarn\n"
  },
  {
    "path": ".prettierignore",
    "content": "# Add files here to ignore them from prettier formatting\n\n/dist/**\n/coverage/**\n/.nx/cache/**\n/.nx/workspace-data/**\n\n*.hbs"
  },
  {
    "path": ".prettierrc",
    "content": "{\n  \"singleQuote\": true,\n  \"endOfLine\": \"crlf\"\n}\n"
  },
  {
    "path": ".vscode/extensions.json",
    "content": "{\n  \"recommendations\": [\n    \"nrwl.angular-console\",\n    \"esbenp.prettier-vscode\",\n    \"firsttris.vscode-jest-runner\",\n    \"dbaeumer.vscode-eslint\"\n  ]\n}\n"
  },
  {
    "path": ".vscode/launch.json",
    "content": "{\n  \"version\": \"0.2.0\",\n  \"configurations\": [\n    {\n      \"name\": \"Debug Node App\",\n      \"type\": \"node\",\n      \"request\": \"attach\",\n      \"port\": 9229\n    },\n    {\n      \"name\": \"Debug Jest Tests\",\n      \"type\": \"node\",\n      \"request\": \"launch\",\n      \"program\": \"${workspaceFolder}/node_modules/.bin/jest\",\n      \"args\": [\n        \"--runInBand\",\n        \"--no-coverage\",\n        \"--no-cache\",\n        \"${relativeFile}\"\n      ],\n      \"console\": \"integratedTerminal\",\n      \"internalConsoleOptions\": \"neverOpen\",\n      \"disableOptimisticBPs\": true,\n      \"windows\": {\n        \"program\": \"${workspaceFolder}/node_modules/jest/bin/jest\"\n      },\n      \"sourceMaps\": true,\n      \"outFiles\": [\n        \"${workspaceFolder}/**/*.js\",\n        \"!**/node_modules/**\"\n      ],\n      \"skipFiles\": [\n        \"<node_internals>/**\",\n        \"**/node_modules/**\"\n      ],\n      \"env\": {\n        \"NODE_ENV\": \"test\"\n      }\n    },\n    {\n      \"name\": \"Debug Current Jest Test\",\n      \"type\": \"node\",\n      \"request\": \"launch\",\n      \"program\": \"${workspaceFolder}/node_modules/.bin/jest\",\n      \"args\": [\n        \"--runInBand\",\n        \"--no-coverage\",\n        \"--no-cache\",\n        \"--testNamePattern=${input:testNamePattern}\",\n        \"${relativeFile}\"\n      ],\n      \"console\": \"integratedTerminal\",\n      \"internalConsoleOptions\": \"neverOpen\",\n      \"disableOptimisticBPs\": true,\n      \"windows\": {\n        \"program\": \"${workspaceFolder}/node_modules/jest/bin/jest\"\n      },\n      \"sourceMaps\": true,\n      \"outFiles\": [\n        \"${workspaceFolder}/**/*.js\",\n        \"!**/node_modules/**\"\n      ],\n      \"skipFiles\": [\n        \"<node_internals>/**\",\n        \"**/node_modules/**\"\n      ],\n      \"env\": {\n        \"NODE_ENV\": \"test\"\n      }\n    }\n  ],\n  \"inputs\": [\n    {\n      \"id\": \"testNamePattern\",\n      \"description\": \"Test name pattern\",\n      \"default\": \".*\",\n      \"type\": \"promptString\"\n    }\n  ]\n}\n"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n  \"editor.codeActionsOnSave\": {\n    \"source.organizeImports\": \"always\"\n  }\n}\n\n"
  },
  {
    "path": ".yarn/patches/@handlewithcare-prosemirror-inputrules-npm-0.1.3-897e37b56f.patch",
    "content": "diff --git a/package.json b/package.json\nindex 20bc41090c166b7b27756b8d8e0fb67525d48f38..a539a8e91499f831bd77b4fa43e98f3970ea57e8 100644\n--- a/package.json\n+++ b/package.json\n@@ -9,6 +9,7 @@\n   \"exports\": {\n     \".\": {\n       \"types\": \"./dist/index.d.ts\",\n+      \"require\": \"./dist/index.js\",\n       \"import\": \"./dist/index.js\"\n     }\n   },\n"
  },
  {
    "path": ".yarn/patches/@tiptap-html-npm-3.15.3-a9641901db.patch",
    "content": "diff --git a/package.json b/package.json\nindex 1d1aa65a70e7f3e898be608610693a9b98152b8c..e27af24e756e7c6b76651d504304f28e7de4e084 100644\n--- a/package.json\n+++ b/package.json\n@@ -12,28 +12,6 @@\n     \"type\": \"github\",\n     \"url\": \"https://github.com/sponsors/ueberdosis\"\n   },\n-  \"exports\": {\n-    \".\": {\n-      \"types\": {\n-        \"import\": \"./dist/index.d.ts\",\n-        \"require\": \"./dist/index.d.cts\"\n-      },\n-      \"import\": {\n-        \"browser\": \"./dist/index.js\",\n-        \"node\": \"./dist/server/index.js\",\n-        \"default\": \"./dist/index.js\"\n-      },\n-      \"require\": \"./dist/index.cjs\"\n-    },\n-    \"./server\": {\n-      \"types\": {\n-        \"import\": \"./dist/server/index.d.ts\",\n-        \"require\": \"./dist/server/index.d.cts\"\n-      },\n-      \"import\": \"./dist/server/index.js\",\n-      \"require\": \"./dist/server/index.cjs\"\n-    }\n-  },\n   \"main\": \"dist/index.cjs\",\n   \"module\": \"dist/index.js\",\n   \"types\": \"dist/index.d.ts\",\n"
  },
  {
    "path": ".yarn/patches/jest-snapshot-npm-29.7.0-15ef0a4ad6.patch",
    "content": "diff --git a/build/InlineSnapshots.js b/build/InlineSnapshots.js\nindex 3481ad99885c847156afdef148d3075dcc9c68ca..44c91da106e6111f95c75b37bc41e5cadebcc145 100644\n--- a/build/InlineSnapshots.js\n+++ b/build/InlineSnapshots.js\n@@ -149,6 +149,7 @@ const saveSnapshotsForFile = (snapshots, sourceFilePath, rootDir, prettier) => {\n       filename: sourceFilePath,\n       plugins,\n       presets,\n+      configFile: path.join(process.cwd(),'./babel.config.js'),\n       root: rootDir\n     });\n   } catch (error) {\n"
  },
  {
    "path": ".yarn/patches/strong-log-transformer-npm-2.1.0-45addd9278.patch",
    "content": "diff --git a/lib/logger.js b/lib/logger.js\nindex 69218a870e6022289a73c27cb2d4f166bf1691d9..8d6554bce9dd8892c945ef0740b0e9d6ba56798c 100644\n--- a/lib/logger.js\n+++ b/lib/logger.js\n@@ -29,7 +29,7 @@ var formatters = {\n \n function Logger(options) {\n   var defaults = JSON.parse(JSON.stringify(Logger.DEFAULTS));\n-  options = util._extend(defaults, options || {});\n+  options = Object.assign(defaults, options || {});\n   var catcher = deLiner();\n   var emitter = catcher;\n   var transforms = [\n"
  },
  {
    "path": ".yarnrc.yml",
    "content": "logFilters:\n  # NX packages peer dependencies make a lot of warnings that does not affect code in any way\n  - code: YN0086\n    level: discard\n\n  # This warning doesn't make sense. This package is not referenced in code at all and wasn't updated in 2 years\n  - code: YN0002\n    level: discard\n    text: doesn't provide @mantine/utils (p2df71b), requested by @blocknote/mantine.\n\nnodeLinker: node-modules\n\nsupportedArchitectures:\n  cpu:\n    - current\n    - x64\n    - arm64\n"
  },
  {
    "path": "Dockerfile",
    "content": "# Multi stage build to make image smaller\nFROM node:24-bookworm-slim AS builder\n\n# For ca-certificates\nRUN apt-get update && apt-get install -y curl \n\nWORKDIR /source\n\nCOPY . .\n\n# Conditional build - only build if release/linux-unpacked doesn't exist\nRUN if [ -d \"./release/linux-unpacked\" ]; then \\\n        echo \"Found existing build, copying...\"; \\\n        cp -r ./release/linux-unpacked/ /app;\\\n    else \\\n        echo \"Building from source...\"; rm -rf .nx && \\\n        CYPRESS_INSTALL_BINARY=0 corepack yarn install --inline-builds && \\\n        corepack yarn dist:linux --dir && \\\n        cp -r ./release/linux-unpacked/ /app;\\\n    fi \n    \nFROM node:24-bookworm-slim\n\nCOPY --from=builder /app /app\n\n# Install dependencies for Electron and headless display\nRUN apt-get update && apt-get install -y \\\n    libgtk-3-0 \\\n    libnss3 \\\n    libasound2 \\\n    libxss1 \\\n    libgbm-dev \\\n    libxshmfence-dev \\\n    libdrm-dev \\\n    # For ca-certificates and healthcheck\n    curl \\ \n    xvfb \\\n    && rm -rf /var/lib/apt/lists/*\n\n\nWORKDIR /app\n\n# Contains database, submissions, tags etc\nVOLUME [ \"/root/PostyBirb\" ]\n# Contains startup options, remote config, partitions etc\nVOLUME [ \"/root/.config/postybirb\" ]\n\nENV DISPLAY=:99\n\nEXPOSE 8080\n\nHEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=5 \\\n    CMD curl http://127.0.0.1:8080 || [ $? -eq 52 ] && exit 0 || exit 1\n\nCOPY ./entrypoint.sh .\n\nENTRYPOINT [ \"bash\" ]\n\nCMD [ \"entrypoint.sh\" ]\n"
  },
  {
    "path": "LICENSE",
    "content": "BSD 3-Clause License\n\nCopyright (c) 2024, Michael DiCarlo\nAll rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are met:\n\n1. Redistributions of source code must retain the above copyright notice, this\n   list of conditions and the following disclaimer.\n\n2. Redistributions in binary form must reproduce the above copyright notice,\n   this list of conditions and the following disclaimer in the documentation\n   and/or other materials provided with the distribution.\n\n3. Neither the name of the copyright holder nor the names of its\n   contributors may be used to endorse or promote products derived from\n   this software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\nAND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\nIMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE\nDISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE\nFOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL\nDAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR\nSERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER\nCAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,\nOR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n"
  },
  {
    "path": "README.md",
    "content": "# Postybirb\n\n<div style='flex: 1'>\n<a href=\"https://discord.com/invite/FUdN7JCr2f\">\n<img alt=\"Static Badge\" src=\"https://img.shields.io/badge/discord-%2323272a?logo=discord\">\n</a>\n<a href=\"https://github.com/mvdicarlo/postybirb/releases/latest\">\n<img alt=\"GitHub Downloads (all assets, latest release)\" src=\"https://img.shields.io/github/downloads/mvdicarlo/postybirb/latest/total\">\n</a>\n<a href=\"https://hosted.weblate.org/engage/postybirb/\">\n<img src=\"https://hosted.weblate.org/widget/postybirb/svg-badge.svg\" alt=\"Translation status\" />\n</a>\n<img alt=\"GitHub Actions Workflow Status\" src=\"https://img.shields.io/github/actions/workflow/status/mvdicarlo/postybirb/build.yml\">\n</div>\n\n## About\n\nPostyBirb is an application that helps artists post art and other multimedia to\nmultiple websites more quickly.The overall goal of PostyBirb is to cut down on\nthe time it takes to post submissions to multiple websites.\n\n## V4 Initiative\n\nv4 sets out to be more flexible for adding new features and updates not easily\nsupported on v3. It also aims to be more contributor friendly and ease the\nimplementation of websites where possible.\n\n## Looking for v3 (PostyBirb+)?\n\nYou can find v3 [here](https://github.com/mvdicarlo/postybirb-plus).\n\n## Translation\n\n![Translation status badge](https://hosted.weblate.org/widget/postybirb/postybirb/287x66-black.png)\n\nPostyBirb uses [Weblate](https://hosted.weblate.org/projects/postybirb/postybirb/) as transltion service\n\nLearn more: [Translation guide](./TRANSLATION.md)\n\n## Project Setup\n\n1. Ensure your NodeJS version is 24.6.0 or higher\n2. Clone project using git\n3. `corepack enable` Make NodeJS use the yarn version specific to the project (from package.json)\n4. `yarn install` Installs dependencies\n5. If it fails with `➤ YN0009: │ better-sqlite3@npm:11.8.0 couldn't be built successfully`: <summary>\n\n  <details>\n       If you're on windows run\n\n```\nwinget install -e --id Microsoft.VisualStudio.2022.BuildTools --override \"--passive --wait --add Microsoft.VisualStudio.Workload.VCTools;includeRecommended\"\n```\n\nIf you're on linux or other OS please create an issue with log from the unsucessfull build. It will have instructions on which packages are required and we will add them there. But generally it should work out of box if you have C++ compiler installed\n\n  </details>\n\n</summary>\n\n6. `yarn run setup` Installs hooks/husky\n7. `yarn start` Starts app\n\n### Recommended Plugins (VSCode)\n\n- Nx Console\n- Jest Runner\n- Prettier\n\n## Contributing\n\nPlease write clean code.\n\nFollow [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/)\n\nTo add new website [see this guide](./contributing/add-a-website)\n\n## Primary Modules (/apps)\n\n### Client-Server\n\nThe \"Back end\" of the application. This houses all data models, user settings,\nposting logic, etc.\n\n#### Primary Technologies Used\n\n- NestJS\n- Drizzle (sqlite3)\n\n### Postybirb\n\nThe Electron part of the application that contains initialization logic and\napp setup.\n\n#### Primary Technologies Used\n\n- Electron\n\n### PostyBirb-UI\n\nThe user interface for the application that talks with Client-Server through\nweb-socket and https.\n\n#### Primary Technologies Used\n\n- React\n- Blocknote/TipTap (Text editor)\n\n---\n\nThis project was generated using [Nx](https://nx.dev).\n"
  },
  {
    "path": "TRANSLATION.md",
    "content": "# Translation guide\n\n## How to contribute to translation\n\nGo to [PostyBirb site](https://hosted.weblate.org/projects/postybirb/postybirb/), create account and suggest translation!\n\n## Add new language\n\nTo add new language you need to add [language 2-letter code](https://www.loc.gov/standards/iso639-2/php/code_list.php) to these files:\n\n- [lingui.config.ts](./lingui.config.ts)\n- [languages.tsx](./apps/postybirb-ui/src/app/languages.tsx)\n"
  },
  {
    "path": "apps/client-server/.eslintrc.json",
    "content": "{\n  \"extends\": [\"../../.eslintrc.js\"],\n  \"ignorePatterns\": [\"!**/*\"],\n  \"overrides\": [\n    {\n      \"files\": [\"*.ts\", \"*.tsx\", \"*.js\", \"*.jsx\"],\n      \"rules\": {}\n    },\n    {\n      \"files\": [\"*.ts\", \"*.tsx\"],\n      \"rules\": {}\n    },\n    {\n      \"files\": [\"*.js\", \"*.jsx\"],\n      \"rules\": {}\n    }\n  ]\n}\n"
  },
  {
    "path": "apps/client-server/jest.config.ts",
    "content": "/* eslint-disable */\nexport default {\n  displayName: 'client-server',\n  preset: '../../jest.preset.js',\n  globals: {},\n  testEnvironment: 'node',\n  moduleFileExtensions: ['ts', 'js', 'html'],\n  coverageDirectory: '../../coverage/apps/client-server',\n  runner: '@kayahr/jest-electron-runner/main',\n};\n"
  },
  {
    "path": "apps/client-server/project.json",
    "content": "{\n  \"name\": \"client-server\",\n  \"$schema\": \"../../node_modules/nx/schemas/project-schema.json\",\n  \"sourceRoot\": \"apps/client-server/src\",\n  \"projectType\": \"application\",\n  \"targets\": {\n    \"build\": {\n      \"executor\": \"nx-electron:build\",\n      \"outputs\": [\"{options.outputPath}\"],\n      \"options\": {\n        \"outputPath\": \"dist/apps/client-server\",\n        \"main\": \"apps/client-server/src/main.ts\",\n        \"tsConfig\": \"apps/client-server/tsconfig.app.json\",\n        \"assets\": [\"apps/client-server/src/assets\"]\n      },\n      \"configurations\": {\n        \"production\": {\n          \"optimization\": true,\n          \"extractLicenses\": true,\n          \"inspect\": false,\n          \"fileReplacements\": [\n            {\n              \"replace\": \"apps/client-server/src/environments/environment.ts\",\n              \"with\": \"apps/client-server/src/environments/environment.prod.ts\"\n            }\n          ]\n        }\n      }\n    },\n    \"serve\": {\n      \"executor\": \"@nx/js:node\",\n      \"options\": {\n        \"buildTarget\": \"client-server:build\",\n        \"inspect\": true,\n        \"port\": 9229\n      }\n    },\n    \"lint\": {\n      \"executor\": \"@nx/eslint:lint\",\n      \"outputs\": [\"{options.outputFile}\"]\n    },\n    \"test\": {\n      \"executor\": \"@nx/jest:jest\",\n      \"outputs\": [\"{workspaceRoot}/coverage/apps/client-server\"],\n      \"options\": {\n        \"jestConfig\": \"apps/client-server/jest.config.ts\"\n      }\n    },\n    \"typecheck\": {\n      \"executor\": \"nx:run-commands\",\n      \"options\": {\n        \"command\": \"tsc -b {projectRoot}/tsconfig.json --incremental --pretty\"\n      }\n    }\n  },\n  \"tags\": []\n}\n"
  },
  {
    "path": "apps/client-server/src/app/account/account.controller.ts",
    "content": "import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';\nimport {\n  ApiBadRequestResponse,\n  ApiNotFoundResponse,\n  ApiOkResponse,\n  ApiTags,\n} from '@nestjs/swagger';\nimport { AccountId } from '@postybirb/types';\nimport { PostyBirbController } from '../common/controller/postybirb-controller';\nimport { AccountService } from './account.service';\nimport { CreateAccountDto } from './dtos/create-account.dto';\nimport { SetWebsiteDataRequestDto } from './dtos/set-website-data-request.dto';\nimport { UpdateAccountDto } from './dtos/update-account.dto';\n\n/**\n * CRUD operations on Account data.\n * @class AccountController\n */\n@ApiTags('account')\n@Controller('account')\nexport class AccountController extends PostyBirbController<'AccountSchema'> {\n  constructor(readonly service: AccountService) {\n    super(service);\n  }\n\n  @Post()\n  @ApiOkResponse({ description: 'Account created.' })\n  @ApiBadRequestResponse({ description: 'Bad request made.' })\n  create(@Body() createAccountDto: CreateAccountDto) {\n    return this.service\n      .create(createAccountDto)\n      .then((account) => account.toDTO());\n  }\n\n  @Post('/clear/:id')\n  @ApiOkResponse({ description: 'Account data cleared.' })\n  @ApiBadRequestResponse({ description: 'Bad request made.' })\n  async clear(@Param('id') id: AccountId) {\n    await this.service.clearAccountData(id);\n    try {\n      this.service.manuallyExecuteOnLogin(id);\n    } catch {\n      // For some reason throws error that crashes app when deleting account\n    }\n  }\n\n  @Get('/refresh/:id')\n  @ApiOkResponse({ description: 'Account login check queued.' })\n  async refresh(@Param('id') id: AccountId) {\n    this.service.manuallyExecuteOnLogin(id);\n  }\n\n  @Patch(':id')\n  @ApiOkResponse({ description: 'Account updated.', type: Boolean })\n  @ApiNotFoundResponse({ description: 'Account Id not found.' })\n  update(\n    @Body() updateAccountDto: UpdateAccountDto,\n    @Param('id') id: AccountId,\n  ) {\n    return this.service\n      .update(id, updateAccountDto)\n      .then((account) => account.toDTO());\n  }\n\n  @Post('/account-data')\n  @ApiOkResponse({ description: 'Account data set.' })\n  setWebsiteData(@Body() oauthRequestDto: SetWebsiteDataRequestDto) {\n    return this.service.setAccountData(oauthRequestDto);\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/account/account.events.ts",
    "content": "import { ACCOUNT_UPDATES } from '@postybirb/socket-events';\nimport { IAccountDto } from '@postybirb/types';\nimport { WebsocketEvent } from '../web-socket/models/web-socket-event';\n\nexport type AccountEventTypes = AccountUpdateEvent;\n\nclass AccountUpdateEvent implements WebsocketEvent<IAccountDto[]> {\n  event: string = ACCOUNT_UPDATES;\n\n  data: IAccountDto[];\n}\n"
  },
  {
    "path": "apps/client-server/src/app/account/account.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { WebsitesModule } from '../websites/websites.module';\nimport { AccountController } from './account.controller';\nimport { AccountService } from './account.service';\n\n@Module({\n  imports: [WebsitesModule],\n  providers: [AccountService],\n  controllers: [AccountController],\n  exports: [AccountService],\n})\nexport class AccountModule {}\n"
  },
  {
    "path": "apps/client-server/src/app/account/account.service.spec.ts",
    "content": "import { Test, TestingModule } from '@nestjs/testing';\nimport { clearDatabase } from '@postybirb/database';\nimport { NULL_ACCOUNT_ID } from '@postybirb/types';\nimport { Account } from '../drizzle/models';\nimport { waitUntil } from '../utils/wait.util';\nimport { WebsiteImplProvider } from '../websites/implementations/provider';\nimport { WebsiteRegistryService } from '../websites/website-registry.service';\nimport { AccountService } from './account.service';\nimport { CreateAccountDto } from './dtos/create-account.dto';\n\ndescribe('AccountsService', () => {\n  let service: AccountService;\n  let registryService: WebsiteRegistryService;\n  let module: TestingModule;\n\n  // Mock objects for deleteUnregisteredAccounts tests\n  let mockRepository: any;\n  let mockWebsiteRegistry: any;\n  let mockLogger: any;\n\n  const mockRegisteredAccount = {\n    id: 'account-1',\n    name: 'Test Account 1',\n    website: 'registered-website',\n    withWebsiteInstance(websiteInstance) {\n      return this;\n    },\n    toDTO: () => {},\n  } as Account;\n\n  const mockUnregisteredAccount = {\n    id: 'account-2',\n    name: 'Test Account 2',\n    website: 'unregistered-website',\n    withWebsiteInstance(websiteInstance) {\n      return this;\n    },\n    toDTO: () => {},\n  } as Account;\n\n  const mockAnotherUnregisteredAccount = {\n    id: 'account-3',\n    name: 'Test Account 3',\n    website: 'another-unregistered-website',\n    withWebsiteInstance(websiteInstance) {\n      return this;\n    },\n    toDTO: () => {},\n  } as Account;\n\n  beforeEach(async () => {\n    clearDatabase();\n    module = await Test.createTestingModule({\n      providers: [AccountService, WebsiteRegistryService, WebsiteImplProvider],\n    }).compile();\n\n    service = module.get<AccountService>(AccountService);\n    registryService = module.get<WebsiteRegistryService>(\n      WebsiteRegistryService,\n    );\n\n    await service.onModuleInit();\n  });\n\n  afterAll(async () => {\n    await module.close();\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n\n  it('should set and clear account data', async () => {\n    const dto = new CreateAccountDto();\n    dto.groups = ['test'];\n    dto.name = 'test';\n    dto.website = 'test';\n\n    const record = await service.create(dto);\n    const instance = registryService.findInstance(record);\n    expect(instance).toBeDefined();\n\n    await instance.login();\n    const websiteData = instance.getWebsiteData();\n    expect(websiteData).toEqual({\n      test: 'test-mode',\n    });\n\n    await service.setAccountData({\n      id: record.id,\n      data: { test: 'test-mode-2' },\n    });\n    expect(instance.getWebsiteData()).toEqual({\n      test: 'test-mode-2',\n    });\n\n    await service.clearAccountData(record.id);\n    expect(instance.getWebsiteData()).toEqual({});\n  }, 10000);\n\n  it('should create entities', async () => {\n    const dto = new CreateAccountDto();\n    dto.groups = ['test'];\n    dto.name = 'test';\n    dto.website = 'test';\n\n    const record = await service.create(dto);\n    expect(registryService.findInstance(record)).toBeDefined();\n\n    const groups = await service.findAll();\n    await waitUntil(() => !record.websiteInstance?.getLoginState().pending, 50);\n    expect(groups).toHaveLength(1);\n    expect(groups[0].name).toEqual(dto.name);\n    expect(groups[0].website).toEqual(dto.website);\n    expect(groups[0].groups).toEqual(dto.groups);\n    const recordDto = record.toDTO();\n    expect(recordDto).toEqual({\n      groups: dto.groups,\n      name: dto.name,\n      website: dto.website,\n      id: record.id,\n      createdAt: record.createdAt,\n      updatedAt: record.updatedAt,\n      state: {\n        isLoggedIn: true,\n        pending: false,\n        username: 'TestUser',\n        lastUpdated: expect.any(String),\n      },\n      data: {\n        test: 'test-mode',\n      },\n      websiteInfo: {\n        supports: ['MESSAGE', 'FILE'],\n        websiteDisplayName: 'Test',\n      },\n    });\n  }, 10000);\n\n  it('should support crud operations', async () => {\n    const createAccount: CreateAccountDto = new CreateAccountDto();\n    createAccount.name = 'test';\n    createAccount.website = 'test';\n\n    // Create\n    const account = await service.create(createAccount);\n    expect(account).toBeDefined();\n    expect(await service.findAll()).toHaveLength(1);\n    expect(await service.findById(account.id)).toBeDefined();\n\n    // Update\n    await service.update(account.id, { name: 'Updated', groups: [] });\n    expect(await (await service.findById(account.id)).name).toEqual('Updated');\n\n    // Remove\n    await service.remove(account.id);\n    expect(await service.findAll()).toHaveLength(0);\n  });\n\n  describe('deleteUnregisteredAccounts', () => {\n    beforeEach(() => {\n      // Setup mock objects for testing private method\n      mockRepository = {\n        find: jest.fn(),\n        deleteById: jest.fn(),\n        schemaEntity: { id: 'id' },\n      };\n\n      mockWebsiteRegistry = {\n        canCreate: jest.fn(),\n        create: jest.fn(),\n        findInstance: jest.fn(),\n        getAvailableWebsites: () => [],\n        markAsInitialized: jest.fn(),\n        emit: jest.fn(),\n      };\n\n      mockLogger = {\n        withMetadata: jest.fn().mockReturnThis(),\n        withError: jest.fn().mockReturnThis(),\n        warn: jest.fn(),\n        error: jest.fn(),\n      };\n\n      // Replace service dependencies with mocks\n      (service as any).repository = mockRepository;\n      (service as any).websiteRegistry = mockWebsiteRegistry;\n      (service as any).logger = mockLogger;\n\n      // Setup default mock behavior\n      mockRepository.find.mockResolvedValue([\n        mockRegisteredAccount,\n        mockUnregisteredAccount,\n        mockAnotherUnregisteredAccount,\n      ]);\n\n      mockWebsiteRegistry.canCreate.mockImplementation((website: string) => {\n        return website === 'registered-website';\n      });\n\n      mockRepository.deleteById.mockResolvedValue(undefined);\n    });\n\n    it('should delete accounts for unregistered websites', async () => {\n      await (service as any).deleteUnregisteredAccounts();\n\n      // Verify that find was called to get all accounts except NULL_ACCOUNT_ID\n      expect(mockRepository.find).toHaveBeenCalledWith({\n        where: expect.any(Object), // ne(schemaEntity.id, NULL_ACCOUNT_ID)\n      });\n\n      // Verify canCreate was called for each account's website\n      expect(mockWebsiteRegistry.canCreate).toHaveBeenCalledWith(\n        'registered-website',\n      );\n      expect(mockWebsiteRegistry.canCreate).toHaveBeenCalledWith(\n        'unregistered-website',\n      );\n      expect(mockWebsiteRegistry.canCreate).toHaveBeenCalledWith(\n        'another-unregistered-website',\n      );\n      expect(mockWebsiteRegistry.canCreate).toHaveBeenCalledTimes(3);\n\n      // Verify deleteById was called for unregistered accounts only\n      expect(mockRepository.deleteById).toHaveBeenCalledWith(['account-2']);\n      expect(mockRepository.deleteById).toHaveBeenCalledWith(['account-3']);\n      expect(mockRepository.deleteById).toHaveBeenCalledTimes(2);\n\n      // Verify logging\n      expect(mockLogger.withMetadata).toHaveBeenCalledWith(\n        mockUnregisteredAccount,\n      );\n      expect(mockLogger.withMetadata).toHaveBeenCalledWith(\n        mockAnotherUnregisteredAccount,\n      );\n      expect(mockLogger.warn).toHaveBeenCalledWith(\n        'Deleting unregistered account: account-2 (Test Account 2)',\n      );\n      expect(mockLogger.warn).toHaveBeenCalledWith(\n        'Deleting unregistered account: account-3 (Test Account 3)',\n      );\n    });\n\n    it('should not delete accounts for registered websites', async () => {\n      await (service as any).deleteUnregisteredAccounts();\n\n      // Verify the registered account was not deleted\n      expect(mockRepository.deleteById).not.toHaveBeenCalledWith(['account-1']);\n    });\n\n    it('should handle deletion errors gracefully', async () => {\n      const deleteError = new Error('Database deletion failed');\n      mockRepository.deleteById\n        .mockResolvedValueOnce(undefined) // First deletion succeeds\n        .mockRejectedValueOnce(deleteError); // Second deletion fails\n\n      await (service as any).deleteUnregisteredAccounts();\n\n      // Verify both deletions were attempted\n      expect(mockRepository.deleteById).toHaveBeenCalledTimes(2);\n\n      // Verify error was logged for the failed deletion\n      expect(mockLogger.withError).toHaveBeenCalledWith(deleteError);\n      expect(mockLogger.error).toHaveBeenCalledWith(\n        'Failed to delete unregistered account: account-3',\n      );\n    });\n\n    it('should handle empty accounts list', async () => {\n      mockRepository.find.mockResolvedValue([]);\n\n      await (service as any).deleteUnregisteredAccounts();\n\n      expect(mockWebsiteRegistry.canCreate).not.toHaveBeenCalled();\n      expect(mockRepository.deleteById).not.toHaveBeenCalled();\n      expect(mockLogger.warn).not.toHaveBeenCalled();\n    });\n\n    it('should handle case where all accounts are registered', async () => {\n      mockRepository.find.mockResolvedValue([mockRegisteredAccount]);\n\n      await (service as any).deleteUnregisteredAccounts();\n\n      expect(mockWebsiteRegistry.canCreate).toHaveBeenCalledWith(\n        'registered-website',\n      );\n      expect(mockRepository.deleteById).not.toHaveBeenCalled();\n      expect(mockLogger.warn).not.toHaveBeenCalled();\n    });\n\n    it('should exclude NULL_ACCOUNT_ID from deletion consideration', async () => {\n      const nullAccount = {\n        id: NULL_ACCOUNT_ID,\n        name: 'Null Account',\n        website: 'null',\n      } as Account;\n\n      // Mock the repository.find to only return non-NULL accounts (simulating the database query)\n      // The actual service uses ne(this.repository.schemaEntity.id, NULL_ACCOUNT_ID) to exclude it\n      mockRepository.find.mockResolvedValue([\n        mockUnregisteredAccount, // Only return the unregistered account, not the null account\n      ]);\n\n      // Even if null website is not registered, it shouldn't be considered for deletion\n      mockWebsiteRegistry.canCreate.mockImplementation((website: string) => {\n        return website !== 'null' && website !== 'unregistered-website';\n      });\n\n      await (service as any).deleteUnregisteredAccounts();\n\n      // Verify the query excludes NULL_ACCOUNT_ID (this is tested by the repository mock)\n      expect(mockRepository.find).toHaveBeenCalledWith({\n        where: expect.any(Object),\n      });\n\n      // Only the unregistered account should be deleted, not the null account\n      expect(mockRepository.deleteById).toHaveBeenCalledWith(['account-2']);\n      expect(mockRepository.deleteById).toHaveBeenCalledTimes(1);\n    });\n  });\n});\n"
  },
  {
    "path": "apps/client-server/src/app/account/account.service.ts",
    "content": "import {\n  BadRequestException,\n  Injectable,\n  OnModuleInit,\n  Optional,\n} from '@nestjs/common';\nimport { Cron, CronExpression } from '@nestjs/schedule';\nimport { ACCOUNT_UPDATES } from '@postybirb/socket-events';\nimport {\n  AccountId,\n  IWebsiteMetadata,\n  NULL_ACCOUNT_ID,\n  NullAccount,\n} from '@postybirb/types';\nimport { IsTestEnvironment } from '@postybirb/utils/electron';\nimport { ne } from 'drizzle-orm';\nimport { Class } from 'type-fest';\nimport { PostyBirbService } from '../common/service/postybirb-service';\nimport { Account } from '../drizzle/models';\nimport { FindOptions } from '../drizzle/postybirb-database/find-options.type';\nimport { WSGateway } from '../web-socket/web-socket-gateway';\nimport { UnknownWebsite } from '../websites/website';\nimport { WebsiteRegistryService } from '../websites/website-registry.service';\nimport { CreateAccountDto } from './dtos/create-account.dto';\nimport { SetWebsiteDataRequestDto } from './dtos/set-website-data-request.dto';\nimport { UpdateAccountDto } from './dtos/update-account.dto';\nimport { LoginStatePoller } from './login-state-poller';\n\n/**\n * Service responsible for returning Account data.\n * Also stores login refresh timers for initiating login checks.\n */\n@Injectable()\nexport class AccountService\n  extends PostyBirbService<'AccountSchema'>\n  implements OnModuleInit\n{\n  private readonly loginRefreshTimers: Record<\n    string,\n    {\n      timer: NodeJS.Timeout;\n      websites: Class<UnknownWebsite>[];\n    }\n  > = {};\n\n  private readonly loginStatePoller: LoginStatePoller;\n\n  constructor(\n    private readonly websiteRegistry: WebsiteRegistryService,\n    @Optional() webSocket?: WSGateway,\n  ) {\n    super('AccountSchema', webSocket);\n    this.repository.subscribe('AccountSchema', () => this.emit());\n    this.loginStatePoller = new LoginStatePoller(\n      this.websiteRegistry,\n      () => {\n        this.emit();\n        this.websiteRegistry.emit();\n      },\n    );\n  }\n\n  /**\n   * Initializes all website login timers and creates instances for known accounts.\n   * Heavy operations are deferred to avoid blocking application startup.\n   */\n  async onModuleInit() {\n    // Critical path: only populate null account to ensure database is ready\n    await this.populateNullAccount();\n\n    // Defer heavy operations to avoid blocking NestJS initialization\n    setImmediate(async () => {\n      await this.deleteUnregisteredAccounts();\n      await this.initWebsiteRegistry();\n      this.websiteRegistry.markAsInitialized();\n      this.initWebsiteLoginRefreshTimers();\n\n      this.emit();\n\n      Object.keys(this.loginRefreshTimers).forEach((interval) =>\n        this.executeOnLoginForInterval(interval),\n      );\n    });\n  }\n\n  /**\n   * CRON-driven poll for login state changes.\n   * Compares cached login states against live values and emits to UI on change.\n   */\n  @Cron(CronExpression.EVERY_SECOND)\n  private pollLoginStates() {\n    if (!IsTestEnvironment()) {\n      this.loginStatePoller.checkForChanges();\n    }\n  }\n\n  private async deleteUnregisteredAccounts() {\n    const accounts = await this.repository.find({\n      where: ne(this.repository.schemaEntity.id, NULL_ACCOUNT_ID),\n    });\n    const unregisteredAccounts = accounts.filter(\n      (account) => !this.websiteRegistry.canCreate(account.website),\n    );\n    for (const account of unregisteredAccounts) {\n      try {\n        this.logger\n          .withMetadata(account)\n          .warn(\n            `Deleting unregistered account: ${account.id} (${account.name})`,\n          );\n        await this.repository.deleteById([account.id]);\n      } catch (err) {\n        this.logger\n          .withError(err)\n          .withMetadata(account)\n          .error(`Failed to delete unregistered account: ${account.id}`);\n      }\n    }\n  }\n\n  /**\n   * Create the Nullable typed account.\n   */\n  private async populateNullAccount(): Promise<void> {\n    if (!(await this.repository.findById(NULL_ACCOUNT_ID))) {\n      await this.repository.insert(new NullAccount());\n    }\n  }\n\n  /**\n   * Loads accounts into website registry.\n   */\n  private async initWebsiteRegistry(): Promise<void> {\n    const accounts = await this.repository.find({\n      where: ne(this.repository.schemaEntity.id, NULL_ACCOUNT_ID),\n    });\n    await Promise.all(\n      accounts.map((account) => this.websiteRegistry.create(account)),\n    ).catch((err) => {\n      this.logger.error(err, 'onModuleInit');\n    });\n  }\n\n  /**\n   * Creates website login check timers.\n   */\n  private initWebsiteLoginRefreshTimers(): void {\n    const availableWebsites = this.websiteRegistry.getAvailableWebsites();\n    availableWebsites.forEach((website) => {\n      const interval: number =\n        (website.prototype.decoratedProps.metadata as IWebsiteMetadata)\n          .refreshInterval ?? 60_000 * 60;\n      if (!this.loginRefreshTimers[interval]) {\n        this.loginRefreshTimers[interval] = {\n          websites: [],\n          timer: setInterval(() => {\n            this.executeOnLoginForInterval(interval);\n          }, interval),\n        };\n      }\n\n      this.loginRefreshTimers[interval].websites.push(website);\n    });\n  }\n\n  public async emit() {\n    const dtos = await this.findAll().then((accounts) =>\n      accounts.map((a) => this.injectWebsiteInstance(a)),\n    );\n    super.emit({\n      event: ACCOUNT_UPDATES,\n      data: dtos.map((dto) => dto.toDTO()),\n    });\n  }\n\n  /**\n   * Runs login on all created website instances within a specific interval.\n   * The mutex inside website.login() ensures only one login runs at a time\n   * per instance; concurrent callers simply wait and get the fresh state.\n   *\n   * @param {string} interval\n   */\n  private async executeOnLoginForInterval(interval: string | number) {\n    const { websites } = this.loginRefreshTimers[interval];\n    websites.forEach((website) => {\n      this.websiteRegistry.getInstancesOf(website).forEach((instance) => {\n        // Fire-and-forget — the poller will detect state changes\n        instance.login().catch((e) => {\n          this.logger.withError(e).error(`Login failed for ${instance.id}`);\n        });\n      });\n    });\n  }\n\n  /**\n   * Logic that needs to be run after an account is created.\n   *\n   * @param {Account} account\n   * @param {UnknownWebsite} website\n   */\n  private afterCreate(account: Account, website: UnknownWebsite) {\n    // Fire-and-forget — poller picks up the state change\n    website.login().catch((e) => {\n      this.logger.withError(e).error(`Initial login failed for ${website.id}`);\n    });\n  }\n\n  /**\n   * Executes a login refresh initiated from an external source.\n   * Waits for the result so the caller knows when it's done.\n   *\n   * @param {AccountId} id\n   */\n  async manuallyExecuteOnLogin(id: AccountId): Promise<void> {\n    const account = await this.findById(id);\n    if (account) {\n      const instance = this.websiteRegistry.findInstance(account);\n      if (instance) {\n        await instance.login();\n        // Force an immediate UI update for this instance\n        this.loginStatePoller.checkInstance(instance);\n      }\n    }\n  }\n\n  /**\n   * Creates an Account.\n   * @param {CreateAccountDto} createDto\n   * @return {*}  {Promise<Account>}\n   */\n  async create(createDto: CreateAccountDto): Promise<Account> {\n    this.logger\n      .withMetadata(createDto)\n      .info(`Creating Account '${createDto.name}:${createDto.website}`);\n    if (!this.websiteRegistry.canCreate(createDto.website)) {\n      throw new BadRequestException(\n        `Website ${createDto.website} is not supported.`,\n      );\n    }\n    const account = await this.repository.insert(new Account(createDto));\n    const instance = await this.websiteRegistry.create(account);\n    this.afterCreate(account, instance);\n    return account.withWebsiteInstance(instance);\n  }\n\n  public findById(id: AccountId, options?: FindOptions) {\n    return this.repository\n      .findById(id, options)\n      .then((account) => this.injectWebsiteInstance(account));\n  }\n\n  public async findAll() {\n    return this.repository\n      .find({\n        where: ne(this.repository.schemaEntity.id, NULL_ACCOUNT_ID),\n      })\n      .then((accounts) =>\n        accounts.map((account) => this.injectWebsiteInstance(account)),\n      );\n  }\n\n  async update(id: AccountId, update: UpdateAccountDto) {\n    this.logger.withMetadata(update).info(`Updating Account '${id}'`);\n    return this.repository\n      .update(id, update)\n      .then((account) => this.injectWebsiteInstance(account));\n  }\n\n  async remove(id: AccountId) {\n    const account = await this.findById(id);\n    if (account) {\n      this.websiteRegistry.remove(account);\n    }\n    return super.remove(id);\n  }\n\n  /**\n   * Clears the data and login state associated with an account.\n   *\n   * @param {string} id\n   */\n  async clearAccountData(id: AccountId) {\n    this.logger.info(`Clearing Account data for '${id}'`);\n    const account = await this.findById(id);\n    if (account) {\n      const instance = this.websiteRegistry.findInstance(account);\n      await instance.clearLoginStateAndData();\n    }\n  }\n\n  /**\n   * Sets the data saved to an account's website.\n   *\n   * @param {SetWebsiteDataRequestDto} setWebsiteDataRequestDto\n   */\n  async setAccountData(setWebsiteDataRequestDto: SetWebsiteDataRequestDto) {\n    this.logger.info(\n      `Setting Account data for '${setWebsiteDataRequestDto.id}'`,\n    );\n    const account = await this.repository.findById(\n      setWebsiteDataRequestDto.id,\n      { failOnMissing: true },\n    );\n    const instance = this.websiteRegistry.findInstance(account);\n    await instance.setWebsiteData(setWebsiteDataRequestDto.data);\n  }\n\n  private injectWebsiteInstance(account?: Account): Account | null {\n    if (!account) {\n      return null;\n    }\n    return account.withWebsiteInstance(\n      this.websiteRegistry.findInstance(account),\n    );\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/account/dtos/create-account.dto.ts",
    "content": "import { ApiProperty } from '@nestjs/swagger';\nimport { ICreateAccountDto } from '@postybirb/types';\nimport { IsArray, IsString, Length } from 'class-validator';\n\n/**\n * Account creation request object.\n */\nexport class CreateAccountDto implements ICreateAccountDto {\n  @ApiProperty()\n  @IsString()\n  @Length(1, 64)\n  name: string;\n\n  @ApiProperty()\n  @IsString()\n  @Length(1)\n  website: string;\n\n  @ApiProperty()\n  @IsArray()\n  groups: string[];\n}\n"
  },
  {
    "path": "apps/client-server/src/app/account/dtos/set-website-data-request.dto.ts",
    "content": "import { ApiProperty } from '@nestjs/swagger';\nimport {\n  AccountId,\n  DynamicObject,\n  ISetWebsiteDataRequestDto,\n} from '@postybirb/types';\nimport { IsObject, IsString } from 'class-validator';\n\nexport class SetWebsiteDataRequestDto\n  implements ISetWebsiteDataRequestDto<DynamicObject>\n{\n  @ApiProperty()\n  @IsString()\n  id: AccountId;\n\n  @ApiProperty({\n    type: Object,\n  })\n  @IsObject()\n  data: DynamicObject;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/account/dtos/update-account.dto.ts",
    "content": "import { ApiProperty } from '@nestjs/swagger';\nimport { IUpdateAccountDto } from '@postybirb/types';\nimport { IsArray, IsString } from 'class-validator';\n\n/**\n * Account update request object.\n */\nexport class UpdateAccountDto implements IUpdateAccountDto {\n  @ApiProperty()\n  @IsString()\n  name: string;\n\n  @ApiProperty()\n  @IsArray()\n  groups: string[];\n}\n"
  },
  {
    "path": "apps/client-server/src/app/account/login-state-poller.ts",
    "content": "import { Logger } from '@postybirb/logger';\nimport { AccountId, ILoginState } from '@postybirb/types';\nimport { UnknownWebsite } from '../websites/website';\nimport { WebsiteRegistryService } from '../websites/website-registry.service';\n\n/**\n * Compares all website instances' login states against a cached snapshot\n * and triggers a callback when any state has changed.\n *\n * Designed to be driven externally (e.g. by a @Cron job) rather than\n * managing its own polling interval.\n */\nexport class LoginStatePoller {\n  private readonly logger = Logger(LoginStatePoller.name);\n\n  /**\n   * Cached snapshot of the last-known login state per account.\n   * Used for diffing to detect changes.\n   */\n  private lastKnownStates: Record<AccountId, ILoginState> = {};\n\n  constructor(\n    private readonly websiteRegistry: WebsiteRegistryService,\n    private readonly onStateChange: () => void,\n  ) {}\n\n  /**\n   * Compare current login states against the cached snapshot.\n   * If any account's state has changed, update the cache and fire the callback.\n   */\n  checkForChanges(): void {\n    try {\n      const instances = this.websiteRegistry.getAll();\n      let hasChanged = false;\n\n      const currentStates: Record<AccountId, ILoginState> = {};\n\n      for (const instance of instances) {\n        const { accountId } = instance;\n        const state = instance.getLoginState();\n        currentStates[accountId] = state;\n\n        const previous = this.lastKnownStates[accountId];\n        if (!previous || !this.statesEqual(previous, state)) {\n          hasChanged = true;\n        }\n      }\n\n      // Detect removed accounts\n      for (const accountId of Object.keys(this.lastKnownStates)) {\n        if (!(accountId in currentStates)) {\n          hasChanged = true;\n        }\n      }\n\n      if (hasChanged) {\n        this.lastKnownStates = currentStates;\n        this.onStateChange();\n      }\n    } catch (e) {\n      this.logger.withError(e).error('Error during login state poll');\n    }\n  }\n\n  /**\n   * Check a single website instance for login state changes.\n   * More efficient than checkForChanges() when you know exactly which instance changed.\n   *\n   * @param {UnknownWebsite} instance - The website instance to check\n   */\n  checkInstance(instance: UnknownWebsite): void {\n    try {\n      const { accountId } = instance;\n      const state = instance.getLoginState();\n      const previous = this.lastKnownStates[accountId];\n\n      if (!previous || !this.statesEqual(previous, state)) {\n        this.lastKnownStates[accountId] = state;\n        this.onStateChange();\n      }\n    } catch (e) {\n      this.logger.withError(e).error('Error during single instance login state check');\n    }\n  }\n\n  /**\n   * Shallow equality check for two ILoginState objects.\n   */\n  private statesEqual(a: ILoginState, b: ILoginState): boolean {\n    return (\n      a.isLoggedIn === b.isLoggedIn &&\n      a.pending === b.pending &&\n      a.username === b.username &&\n      a.lastUpdated === b.lastUpdated\n    );\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/app.controller.ts",
    "content": "import { Controller, Get } from '@nestjs/common';\n\nimport { AppService } from './app.service';\n\n@Controller()\nexport class AppController {\n  constructor(private readonly appService: AppService) {}\n\n  @Get()\n  getData() {\n    return this.appService.getData();\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/app.module.ts",
    "content": "import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';\n\nimport { ScheduleModule } from '@nestjs/schedule';\nimport { ServeStaticModule } from '@nestjs/serve-static';\nimport { join } from 'path';\nimport { AccountModule } from './account/account.module';\nimport { AppController } from './app.controller';\nimport { AppService } from './app.service';\nimport { CustomShortcutsModule } from './custom-shortcuts/custom-shortcuts.module';\nimport { DirectoryWatchersModule } from './directory-watchers/directory-watchers.module';\nimport { FileConverterModule } from './file-converter/file-converter.module';\nimport { FileModule } from './file/file.module';\nimport { FormGeneratorModule } from './form-generator/form-generator.module';\nimport { ImageProcessingModule } from './image-processing/image-processing.module';\nimport { LegacyDatabaseImporterModule } from './legacy-database-importer/legacy-database-importer.module';\nimport { LogsModule } from './logs/logs.module';\nimport { NotificationsModule } from './notifications/notifications.module';\nimport { PostParsersModule } from './post-parsers/post-parsers.module';\nimport { PostModule } from './post/post.module';\nimport { RemotePasswordMiddleware } from './remote/remote.middleware';\nimport { RemoteModule } from './remote/remote.module';\nimport { SettingsModule } from './settings/settings.module';\nimport { SubmissionModule } from './submission/submission.module';\nimport { TagConvertersModule } from './tag-converters/tag-converters.module';\nimport { TagGroupsModule } from './tag-groups/tag-groups.module';\nimport { UpdateModule } from './update/update.module';\nimport { UserConvertersModule } from './user-converters/user-converters.module';\nimport { UserSpecifiedWebsiteOptionsModule } from './user-specified-website-options/user-specified-website-options.module';\nimport { ValidationModule } from './validation/validation.module';\nimport { WebSocketModule } from './web-socket/web-socket.module';\nimport { WebsiteOptionsModule } from './website-options/website-options.module';\nimport { WebsitesModule } from './websites/websites.module';\n\n@Module({\n  imports: [\n    ScheduleModule.forRoot(),\n    ImageProcessingModule,\n    AccountModule,\n    WebSocketModule,\n    WebsitesModule,\n    FileModule,\n    SubmissionModule,\n    SettingsModule,\n    ServeStaticModule.forRoot({\n      rootPath: join(__dirname, '..', 'postybirb-ui'),\n      exclude: ['/api*'],\n    }),\n    FormGeneratorModule,\n    WebsiteOptionsModule,\n    TagGroupsModule,\n    TagConvertersModule,\n    UserConvertersModule,\n    DirectoryWatchersModule,\n    UserSpecifiedWebsiteOptionsModule,\n    UpdateModule,\n    PostModule,\n    PostParsersModule,\n    ValidationModule,\n    FileConverterModule,\n    NotificationsModule,\n    RemoteModule,\n    CustomShortcutsModule,\n    LegacyDatabaseImporterModule,\n    LogsModule,\n  ],\n  controllers: [AppController],\n  providers: [AppService],\n})\nexport class AppModule implements NestModule {\n  configure(consumer: MiddlewareConsumer) {\n    consumer.apply(RemotePasswordMiddleware).forRoutes('*');\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/app.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { app } from 'electron';\n\n@Injectable()\nexport class AppService {\n  getData(): Record<string, string> {\n    return {\n      message: 'pong',\n      version: app.getVersion(),\n      location: app.getPath('userData'),\n    };\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/common/controller/postybirb-controller.ts",
    "content": "import { Delete, Get, Param, Query } from '@nestjs/common';\nimport { ApiOkResponse } from '@nestjs/swagger';\nimport { SchemaKey } from '@postybirb/database';\nimport { EntityId } from '@postybirb/types';\nimport { PostyBirbService } from '../service/postybirb-service';\n\n/**\n * Base PostyBirb controller logic that should be good for most rest calls.\n *\n * @class PostyBirbController\n */\nexport abstract class PostyBirbController<T extends SchemaKey> {\n  constructor(protected readonly service: PostyBirbService<T>) {}\n\n  @Get(':id')\n  @ApiOkResponse({ description: 'Record by Id.' })\n  findOne(@Param('id') id: EntityId) {\n    return this.service\n      .findById(id, { failOnMissing: true })\n      .then((record) => record.toDTO());\n  }\n\n  @Get()\n  @ApiOkResponse({ description: 'A list of all records.' })\n  findAll() {\n    return this.service\n      .findAll()\n      .then((records) => records.map((record) => record.toDTO()));\n  }\n\n  @Delete()\n  @ApiOkResponse({\n    description: 'Records removed.',\n  })\n  async remove(@Query('ids') ids: EntityId | EntityId[]) {\n    return Promise.all(\n      (Array.isArray(ids) ? ids : [ids]).map((id) => this.service.remove(id)),\n    ).then(() => ({\n      success: true,\n    }));\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/common/service/postybirb-service.ts",
    "content": "import { BadRequestException, Injectable } from '@nestjs/common';\nimport { SchemaKey } from '@postybirb/database';\nimport { Logger } from '@postybirb/logger';\nimport { EntityId } from '@postybirb/types';\nimport { SQL } from 'drizzle-orm';\nimport { FindOptions } from '../../drizzle/postybirb-database/find-options.type';\nimport { PostyBirbDatabase } from '../../drizzle/postybirb-database/postybirb-database';\nimport { WSGateway } from '../../web-socket/web-socket-gateway';\nimport { WebSocketEvents } from '../../web-socket/web-socket.events';\n\n/**\n * Base class that implements simple CRUD logic\n *\n * @class PostyBirbService\n */\n@Injectable()\nexport abstract class PostyBirbService<TSchemaKey extends SchemaKey> {\n  protected readonly logger = Logger(this.constructor.name);\n\n  protected readonly repository: PostyBirbDatabase<TSchemaKey>;\n\n  constructor(\n    private readonly table: TSchemaKey | PostyBirbDatabase<TSchemaKey>,\n    private readonly webSocket?: WSGateway,\n  ) {\n    if (typeof table === 'string') {\n      this.repository = new PostyBirbDatabase(table);\n    } else {\n      this.repository = table;\n    }\n  }\n\n  /**\n   * Emits events onto the websocket\n   *\n   * @protected\n   * @param {WebSocketEvents} event\n   */\n  protected async emit(event: WebSocketEvents) {\n    try {\n      if (this.webSocket) {\n        this.webSocket.emit(event);\n      }\n    } catch (err) {\n      this.logger.error(`Error emitting websocket event: ${event.event}`, err);\n    }\n  }\n\n  protected get schema() {\n    return this.repository.schemaEntity;\n  }\n\n  /**\n   * Throws exception if a record matching the query already exists.\n   *\n   * @protected\n   * @param {FilterQuery<T>} where\n   */\n  protected async throwIfExists(where: SQL) {\n    const exists = await this.repository.select(where);\n    if (exists.length) {\n      this.logger\n        .withMetadata(exists)\n        .error(`A duplicate entity already exists`);\n      throw new BadRequestException(`A duplicate entity already exists`);\n    }\n  }\n\n  // Repository Wrappers\n\n  public findById(id: EntityId, options?: FindOptions) {\n    return this.repository.findById(id, options);\n  }\n\n  public findAll() {\n    return this.repository.findAll();\n  }\n\n  public remove(id: EntityId) {\n    this.logger.withMetadata({ id }).info(`Removing entity '${id}'`);\n    return this.repository.deleteById([id]);\n  }\n\n  // END Repository Wrappers\n}\n"
  },
  {
    "path": "apps/client-server/src/app/constants.ts",
    "content": "// Constant variables\nexport const WEBSITE_IMPLEMENTATIONS = 'WEBSITE_IMPLEMENTATIONS';\n"
  },
  {
    "path": "apps/client-server/src/app/custom-shortcuts/custom-shortcut.events.ts",
    "content": "import { CUSTOM_SHORTCUT_UPDATES } from '@postybirb/socket-events';\nimport { ICustomShortcut } from '@postybirb/types';\nimport { WebsocketEvent } from '../web-socket/models/web-socket-event';\n\nexport type CustomShortcutEventTypes = CustomShortcutEvent;\n\nclass CustomShortcutEvent implements WebsocketEvent<ICustomShortcut[]> {\n  event: string = CUSTOM_SHORTCUT_UPDATES;\n\n  data: ICustomShortcut[];\n}\n"
  },
  {
    "path": "apps/client-server/src/app/custom-shortcuts/custom-shortcuts.controller.ts",
    "content": "import { Body, Controller, Param, Patch, Post } from '@nestjs/common';\nimport { ApiOkResponse, ApiTags } from '@nestjs/swagger';\nimport { PostyBirbController } from '../common/controller/postybirb-controller';\nimport { CustomShortcutsService } from './custom-shortcuts.service';\nimport { CreateCustomShortcutDto } from './dtos/create-custom-shortcut.dto';\nimport { UpdateCustomShortcutDto } from './dtos/update-custom-shortcut.dto';\n\n@ApiTags('custom-shortcut')\n@Controller('custom-shortcut')\nexport class CustomShortcutsController extends PostyBirbController<'CustomShortcutSchema'> {\n  constructor(readonly service: CustomShortcutsService) {\n    super(service);\n  }\n\n  @Post()\n  @ApiOkResponse({ description: 'Custom shortcut created' })\n  async create(@Body() createCustomShortcutDto: CreateCustomShortcutDto) {\n    return this.service\n      .create(createCustomShortcutDto)\n      .then((shortcut) => shortcut.toDTO());\n  }\n\n  @Patch(':id')\n  @ApiOkResponse({ description: 'Custom shortcut updated' })\n  async update(\n    @Body() updateCustomShortcutDto: UpdateCustomShortcutDto,\n    @Param('id') id: string,\n  ) {\n    return this.service\n      .update(id, updateCustomShortcutDto)\n      .then((shortcut) => shortcut.toDTO());\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/custom-shortcuts/custom-shortcuts.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { CustomShortcutsController } from './custom-shortcuts.controller';\nimport { CustomShortcutsService } from './custom-shortcuts.service';\n\n@Module({\n  controllers: [CustomShortcutsController],\n  providers: [CustomShortcutsService],\n  exports: [CustomShortcutsService],\n})\nexport class CustomShortcutsModule {}\n"
  },
  {
    "path": "apps/client-server/src/app/custom-shortcuts/custom-shortcuts.service.ts",
    "content": "import { Injectable, Optional } from '@nestjs/common';\nimport { CUSTOM_SHORTCUT_UPDATES } from '@postybirb/socket-events';\nimport { EntityId } from '@postybirb/types';\nimport { eq } from 'drizzle-orm';\nimport { PostyBirbService } from '../common/service/postybirb-service';\nimport { CustomShortcut } from '../drizzle/models/custom-shortcut.entity';\nimport { WSGateway } from '../web-socket/web-socket-gateway';\nimport { CreateCustomShortcutDto } from './dtos/create-custom-shortcut.dto';\nimport { UpdateCustomShortcutDto } from './dtos/update-custom-shortcut.dto';\n\n@Injectable()\nexport class CustomShortcutsService extends PostyBirbService<'CustomShortcutSchema'> {\n  constructor(@Optional() webSocket?: WSGateway) {\n    super('CustomShortcutSchema', webSocket);\n    this.repository.subscribe('CustomShortcutSchema', () => this.emit());\n  }\n\n  public async emit() {\n    const dtos = await this.findAll();\n    super.emit({\n      event: CUSTOM_SHORTCUT_UPDATES,\n      data: dtos.map((dto) => dto.toDTO()),\n    });\n  }\n\n  public async create(\n    createCustomShortcutDto: CreateCustomShortcutDto,\n  ): Promise<CustomShortcut> {\n    this.logger\n      .withMetadata(createCustomShortcutDto)\n      .info('Creating custom shortcut');\n    await this.throwIfExists(\n      eq(this.schema.name, createCustomShortcutDto.name),\n    );\n    return this.repository.insert(createCustomShortcutDto);\n  }\n\n  public async update(\n    id: string,\n    updateCustomShortcutDto: UpdateCustomShortcutDto,\n  ): Promise<CustomShortcut> {\n    this.logger\n      .withMetadata(updateCustomShortcutDto)\n      .info('Updating custom shortcut');\n    const existing = await this.repository.findById(id, {\n      failOnMissing: true,\n    });\n\n    return this.repository.update(id, updateCustomShortcutDto);\n  }\n\n  public async remove(id: EntityId) {\n    const existing = await this.repository.findById(id, {\n      failOnMissing: true,\n    });\n    return super.remove(id);\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/custom-shortcuts/dtos/create-custom-shortcut.dto.ts",
    "content": "import { ICreateCustomShortcutDto } from '@postybirb/types';\nimport { IsString } from 'class-validator';\n\nexport class CreateCustomShortcutDto implements ICreateCustomShortcutDto {\n  @IsString()\n  name: string;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/custom-shortcuts/dtos/update-custom-shortcut.dto.ts",
    "content": "import { Description, IUpdateCustomShortcutDto } from '@postybirb/types';\nimport { IsObject, IsString } from 'class-validator';\n\nexport class UpdateCustomShortcutDto implements IUpdateCustomShortcutDto {\n  @IsString()\n  name: string;\n\n  @IsObject()\n  shortcut: Description;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/directory-watchers/directory-watcher.events.ts",
    "content": "import { DIRECTORY_WATCHER_UPDATES } from '@postybirb/socket-events';\nimport { DirectoryWatcherDto } from '@postybirb/types';\nimport { WebsocketEvent } from '../web-socket/models/web-socket-event';\n\nexport type DirectoryWatcherEventTypes = DirectoryWatcherUpdateEvent;\n\nclass DirectoryWatcherUpdateEvent\n  implements WebsocketEvent<DirectoryWatcherDto[]>\n{\n  event: string = DIRECTORY_WATCHER_UPDATES;\n\n  data: DirectoryWatcherDto[];\n}\n"
  },
  {
    "path": "apps/client-server/src/app/directory-watchers/directory-watchers.controller.ts",
    "content": "import { Body, Controller, Param, Patch, Post } from '@nestjs/common';\nimport {\n    ApiBadRequestResponse,\n    ApiNotFoundResponse,\n    ApiOkResponse,\n    ApiTags,\n} from '@nestjs/swagger';\nimport { EntityId } from '@postybirb/types';\nimport { PostyBirbController } from '../common/controller/postybirb-controller';\nimport { DirectoryWatchersService } from './directory-watchers.service';\nimport { CheckPathDto } from './dtos/check-path.dto';\nimport { CreateDirectoryWatcherDto } from './dtos/create-directory-watcher.dto';\nimport { UpdateDirectoryWatcherDto } from './dtos/update-directory-watcher.dto';\n\n/**\n * CRUD operations on DirectoryWatchers.\n * @class DirectoryWatchersController\n */\n@ApiTags('directory-watchers')\n@Controller('directory-watchers')\nexport class DirectoryWatchersController extends PostyBirbController<'DirectoryWatcherSchema'> {\n  constructor(readonly service: DirectoryWatchersService) {\n    super(service);\n  }\n\n  @Post()\n  @ApiOkResponse({ description: 'Entity created.' })\n  @ApiBadRequestResponse({ description: 'Bad request made.' })\n  create(@Body() createDto: CreateDirectoryWatcherDto) {\n    return this.service.create(createDto).then((entity) => entity.toDTO());\n  }\n\n  @Patch(':id')\n  @ApiOkResponse({ description: 'Entity updated.', type: Boolean })\n  @ApiNotFoundResponse({ description: 'Entity not found.' })\n  update(\n    @Body() updateDto: UpdateDirectoryWatcherDto,\n    @Param('id') id: EntityId,\n  ) {\n    return this.service.update(id, updateDto).then((entity) => entity.toDTO());\n  }\n\n  @Post('check-path')\n  @ApiOkResponse({ description: 'Path check result.' })\n  @ApiBadRequestResponse({ description: 'Bad request made.' })\n  checkPath(@Body() checkPathDto: CheckPathDto) {\n    return this.service.checkPath(checkPathDto.path);\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/directory-watchers/directory-watchers.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { NotificationsModule } from '../notifications/notifications.module';\nimport { SubmissionModule } from '../submission/submission.module';\nimport { DirectoryWatchersController } from './directory-watchers.controller';\nimport { DirectoryWatchersService } from './directory-watchers.service';\n\n@Module({\n  imports: [SubmissionModule, NotificationsModule],\n  controllers: [DirectoryWatchersController],\n  providers: [DirectoryWatchersService],\n})\nexport class DirectoryWatchersModule {}\n"
  },
  {
    "path": "apps/client-server/src/app/directory-watchers/directory-watchers.service.spec.ts",
    "content": "import { Test, TestingModule } from '@nestjs/testing';\nimport { clearDatabase } from '@postybirb/database';\nimport { DirectoryWatcherImportAction, SubmissionType } from '@postybirb/types';\nimport { mkdir, readdir, rename, writeFile } from 'fs/promises';\nimport { join } from 'path';\nimport { AccountService } from '../account/account.service';\nimport { NotificationsModule } from '../notifications/notifications.module';\nimport { CreateSubmissionDto } from '../submission/dtos/create-submission.dto';\nimport { SubmissionService } from '../submission/services/submission.service';\nimport { SubmissionModule } from '../submission/submission.module';\nimport { DirectoryWatchersService } from './directory-watchers.service';\nimport { CreateDirectoryWatcherDto } from './dtos/create-directory-watcher.dto';\nimport { UpdateDirectoryWatcherDto } from './dtos/update-directory-watcher.dto';\n\n// Mock fs/promises\njest.mock('fs/promises', () => ({\n  readdir: jest.fn(),\n  mkdir: jest.fn(),\n  rename: jest.fn(),\n  writeFile: jest.fn(),\n}));\n\ndescribe('DirectoryWatchersService', () => {\n  let service: DirectoryWatchersService;\n  let submissionService: SubmissionService;\n  let accountService: AccountService;\n  let module: TestingModule;\n\n  beforeEach(async () => {\n    clearDatabase();\n\n    // Setup mocks\n    (readdir as jest.Mock).mockResolvedValue([]);\n    (mkdir as jest.Mock).mockResolvedValue(undefined);\n    (rename as jest.Mock).mockResolvedValue(undefined);\n    (writeFile as jest.Mock).mockResolvedValue(undefined);\n\n    module = await Test.createTestingModule({\n      imports: [SubmissionModule, NotificationsModule],\n      providers: [DirectoryWatchersService],\n    }).compile();\n\n    service = module.get<DirectoryWatchersService>(DirectoryWatchersService);\n    submissionService = module.get<SubmissionService>(SubmissionService);\n    accountService = module.get<AccountService>(AccountService);\n\n    await accountService.onModuleInit();\n  });\n\n  async function createSubmission() {\n    const dto = new CreateSubmissionDto();\n    dto.name = 'test';\n    dto.type = SubmissionType.MESSAGE;\n    dto.isTemplate = true;\n\n    const record = await submissionService.create(dto);\n    return record;\n  }\n\n  afterEach(() => {\n    jest.clearAllMocks();\n  });\n\n  afterAll(async () => {\n    await module.close();\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n    expect(module).toBeDefined();\n  });\n\n  it('should create entities and setup directory structure', async () => {\n    const dto = new CreateDirectoryWatcherDto();\n    dto.importAction = DirectoryWatcherImportAction.NEW_SUBMISSION;\n    dto.path = 'path';\n\n    await service.create(dto);\n\n    // Verify directory structure was created\n    expect(mkdir).toHaveBeenCalledWith(join('path', 'processing'), {\n      recursive: true,\n    });\n    expect(mkdir).toHaveBeenCalledWith(join('path', 'completed'), {\n      recursive: true,\n    });\n    expect(mkdir).toHaveBeenCalledWith(join('path', 'failed'), {\n      recursive: true,\n    });\n\n    const entities = await service.findAll();\n    const record = entities[0];\n    expect(record.path).toBe(dto.path);\n    expect(record.importAction).toBe(dto.importAction);\n  });\n\n  it('should update entities', async () => {\n    const dto = new CreateDirectoryWatcherDto();\n    dto.importAction = DirectoryWatcherImportAction.NEW_SUBMISSION;\n    dto.path = 'path';\n\n    const record = await service.create(dto);\n    expect(record.path).toBe(dto.path);\n    expect(record.importAction).toBe(dto.importAction);\n\n    const updateDto = new UpdateDirectoryWatcherDto();\n    updateDto.path = 'updated-path';\n    const updatedRecord = await service.update(record.id, updateDto);\n    expect(updatedRecord.path).toBe(updateDto.path);\n\n    // Verify directory structure was created for new path\n    expect(mkdir).toHaveBeenCalledWith(join('updated-path', 'processing'), {\n      recursive: true,\n    });\n  });\n\n  it('should support templates', async () => {\n    const template = await createSubmission();\n    const dto = new CreateDirectoryWatcherDto();\n    dto.importAction = DirectoryWatcherImportAction.NEW_SUBMISSION;\n    dto.path = 'path';\n    const record = await service.create(dto);\n    expect(record.path).toBe(dto.path);\n    const updateDto = new UpdateDirectoryWatcherDto();\n    updateDto.templateId = template.id;\n\n    const updatedRecord = await service.update(record.id, updateDto);\n    expect(updatedRecord.templateId).toBe(template.id);\n    expect(updatedRecord.toDTO()).toEqual({\n      createdAt: updatedRecord.createdAt,\n      updatedAt: updatedRecord.updatedAt,\n      id: updatedRecord.id,\n      importAction: DirectoryWatcherImportAction.NEW_SUBMISSION,\n      path: 'path',\n      templateId: template.id,\n    });\n\n    await submissionService.remove(template.id);\n    const rec = await service.findById(updatedRecord.id);\n    expect(rec.templateId).toBe(null);\n  });\n\n  it('should throw error if path does not exist', async () => {\n    (readdir as jest.Mock).mockRejectedValue(new Error('Path not found'));\n\n    const dto = new CreateDirectoryWatcherDto();\n    dto.importAction = DirectoryWatcherImportAction.NEW_SUBMISSION;\n    dto.path = 'non-existent-path';\n\n    await expect(service.create(dto)).rejects.toThrow(\n      \"Path 'non-existent-path' does not exist or is not accessible\",\n    );\n  });\n\n  it('should create entity without path (UI flow)', async () => {\n    const dto = new CreateDirectoryWatcherDto();\n    dto.importAction = DirectoryWatcherImportAction.NEW_SUBMISSION;\n    // No path provided\n\n    const record = await service.create(dto);\n\n    // Verify entity was created without path\n    expect(record.importAction).toBe(dto.importAction);\n    expect(record.path).toBeNull();\n\n    // Verify no directory structure was created\n    expect(mkdir).not.toHaveBeenCalled();\n  });\n\n  it('should create directory structure when path is first set via update', async () => {\n    // Create without path\n    const dto = new CreateDirectoryWatcherDto();\n    dto.importAction = DirectoryWatcherImportAction.NEW_SUBMISSION;\n    const record = await service.create(dto);\n\n    expect(record.path).toBeNull();\n\n    // Clear mocks from create\n    jest.clearAllMocks();\n\n    // Update with path\n    const updateDto = new UpdateDirectoryWatcherDto();\n    updateDto.path = 'new-path';\n    const updatedRecord = await service.update(record.id, updateDto);\n\n    expect(updatedRecord.path).toBe('new-path');\n\n    // Verify directory structure was created\n    expect(mkdir).toHaveBeenCalledWith(join('new-path', 'processing'), {\n      recursive: true,\n    });\n    expect(mkdir).toHaveBeenCalledWith(join('new-path', 'completed'), {\n      recursive: true,\n    });\n    expect(mkdir).toHaveBeenCalledWith(join('new-path', 'failed'), {\n      recursive: true,\n    });\n  });\n});\n"
  },
  {
    "path": "apps/client-server/src/app/directory-watchers/directory-watchers.service.ts",
    "content": "import { BadRequestException, Injectable, Optional } from '@nestjs/common';\nimport { Cron, CronExpression } from '@nestjs/schedule';\nimport { DIRECTORY_WATCHER_UPDATES } from '@postybirb/socket-events';\nimport {\n  DirectoryWatcherImportAction,\n  EntityId,\n  SubmissionType,\n} from '@postybirb/types';\nimport { IsTestEnvironment } from '@postybirb/utils/electron';\nimport { mkdir, readdir, rename, writeFile } from 'fs/promises';\nimport { getType } from 'mime';\nimport { join } from 'path';\nimport { PostyBirbService } from '../common/service/postybirb-service';\nimport { DirectoryWatcher } from '../drizzle/models';\nimport { MulterFileInfo } from '../file/models/multer-file-info';\nimport { NotificationsService } from '../notifications/notifications.service';\nimport { SubmissionService } from '../submission/services/submission.service';\nimport { WSGateway } from '../web-socket/web-socket-gateway';\nimport { CreateDirectoryWatcherDto } from './dtos/create-directory-watcher.dto';\nimport { UpdateDirectoryWatcherDto } from './dtos/update-directory-watcher.dto';\n\n/**\n * Directory structure for file processing:\n *\n * {watch-path}/        <- Users drop files here\n *   ├── processing/    <- Files being actively processed\n *   ├── completed/     <- Successfully processed files\n *   └── failed/        <- Files that failed processing (with .error.txt)\n */\n\n/**\n * A directory watcher service that reads created watchers and checks\n * for new files added to the folder.\n *\n * Files are moved through different folders based on processing status,\n * eliminating the need for metadata tracking.\n *\n * @class DirectoryWatchersService\n * @extends {PostyBirbService<DirectoryWatcher>}\n */\n/**\n * Threshold for warning users about folders with many files.\n * If a folder contains more than this number of files, a confirmation will be required.\n */\nexport const FILE_COUNT_WARNING_THRESHOLD = 10;\n\n/**\n * Result of checking a directory path for file watcher usage.\n */\nexport interface CheckPathResult {\n  /** Whether the path is valid and accessible */\n  valid: boolean;\n  /** Number of files in the directory (excluding subfolders) */\n  count: number;\n  /** List of file names in the directory */\n  files: string[];\n  /** Error message if the path is invalid */\n  error?: string;\n}\n\n@Injectable()\nexport class DirectoryWatchersService extends PostyBirbService<'DirectoryWatcherSchema'> {\n  private runningWatchers = new Set<EntityId>();\n\n  private recoveredWatchers = new Set<EntityId>();\n\n  private readonly SUBFOLDER_PROCESSING = 'processing';\n\n  private readonly SUBFOLDER_COMPLETED = 'completed';\n\n  private readonly SUBFOLDER_FAILED = 'failed';\n\n  constructor(\n    private readonly submissionService: SubmissionService,\n    private readonly notificationService: NotificationsService,\n    @Optional() webSocket?: WSGateway,\n  ) {\n    super('DirectoryWatcherSchema', webSocket);\n    this.repository.subscribe('DirectoryWatcherSchema', () =>\n      this.emitUpdates(),\n    );\n  }\n\n  protected async emitUpdates() {\n    super.emit({\n      event: DIRECTORY_WATCHER_UPDATES,\n      data: (await this.repository.findAll()).map((entity) => entity.toDTO()),\n    });\n  }\n\n  /**\n   * CRON run read of paths.\n   */\n  @Cron(CronExpression.EVERY_30_SECONDS)\n  private async run() {\n    if (!IsTestEnvironment()) {\n      const entities = await this.repository.findAll();\n      entities\n        .filter((e) => !!e.path)\n        .forEach((e) => {\n          // Recover orphaned files on first run\n          if (!this.recoveredWatchers.has(e.id)) {\n            this.recoverOrphanedFiles(e);\n            this.recoveredWatchers.add(e.id);\n          }\n\n          // Process new files if not already running\n          if (!this.runningWatchers.has(e.id)) {\n            this.runningWatchers.add(e.id);\n            this.read(e).finally(() => this.runningWatchers.delete(e.id));\n          }\n        });\n    }\n  }\n\n  /**\n   * Ensures all required subdirectories exist.\n   * Skips if the base path doesn't exist.\n   *\n   * @param {string} basePath\n   */\n  private async ensureDirectoryStructure(basePath: string): Promise<void> {\n    // Check if base path exists, skip if it doesn't\n    try {\n      await readdir(basePath);\n    } catch {\n      return;\n    }\n\n    const subfolders = [\n      this.SUBFOLDER_PROCESSING,\n      this.SUBFOLDER_COMPLETED,\n      this.SUBFOLDER_FAILED,\n    ];\n\n    for (const folder of subfolders) {\n      await mkdir(join(basePath, folder), { recursive: true });\n    }\n  }\n\n  /**\n   * Recovers files that were left in the processing folder due to app crash/restart.\n   * Moves them back to the main watch folder for reprocessing.\n   *\n   * @param {DirectoryWatcher} watcher\n   */\n  private async recoverOrphanedFiles(watcher: DirectoryWatcher): Promise<void> {\n    try {\n      await this.ensureDirectoryStructure(watcher.path);\n\n      const processingPath = join(watcher.path, this.SUBFOLDER_PROCESSING);\n      const orphanedFiles = await readdir(processingPath);\n\n      if (orphanedFiles.length > 0) {\n        this.logger.info(\n          `Recovering ${orphanedFiles.length} orphaned files in ${watcher.path}`,\n        );\n\n        for (const file of orphanedFiles) {\n          const sourcePath = join(processingPath, file);\n          const targetPath = join(watcher.path, file);\n\n          try {\n            await rename(sourcePath, targetPath);\n            this.logger.info(`Recovered orphaned file: ${file}`);\n          } catch (err) {\n            this.logger.error(err, `Failed to recover orphaned file: ${file}`);\n          }\n        }\n\n        this.notificationService.create({\n          title: 'Directory Watcher Recovery',\n          message: `Recovered ${orphanedFiles.length} orphaned files in '${watcher.path}'`,\n          type: 'info',\n          tags: ['directory-watcher', 'recovery'],\n          data: {\n            recoveredFiles: orphanedFiles,\n            watcherId: watcher.id,\n          },\n        });\n      }\n    } catch (err) {\n      this.logger.error(\n        err,\n        `Failed to recover orphaned files for watcher ${watcher.id}`,\n      );\n    }\n  }\n\n  /**\n   * Reads directory for processable files.\n   *\n   * @param {DirectoryWatcher} watcher\n   */\n  private async read(watcher: DirectoryWatcher) {\n    try {\n      // Ensure directory structure exists\n      await this.ensureDirectoryStructure(watcher.path);\n\n      const allFiles = await readdir(watcher.path);\n      const filesInDirectory = allFiles.filter(\n        (file) =>\n          file !== this.SUBFOLDER_PROCESSING &&\n          file !== this.SUBFOLDER_COMPLETED &&\n          file !== this.SUBFOLDER_FAILED,\n      );\n\n      // Only process and notify if there are files\n      if (filesInDirectory.length === 0) {\n        return;\n      }\n\n      const results = { success: [], failed: [] };\n\n      // Process files sequentially\n      for (const file of filesInDirectory) {\n        try {\n          await this.processFileWithMove(watcher, file);\n          results.success.push(file);\n        } catch (err) {\n          this.logger.error(err, `Failed to process file ${file}`);\n          results.failed.push({ file, error: err.message });\n        }\n      }\n\n      // Create notification with success/failure breakdown\n      this.notificationService.create({\n        title: 'Directory Watcher',\n        message: `Processed ${results.success.length} of ${filesInDirectory.length} files in '${watcher.path}'`,\n        type: results.failed.length > 0 ? 'warning' : 'info',\n        tags: ['directory-watcher'],\n        data: {\n          successCount: results.success.length,\n          failedCount: results.failed.length,\n          successFiles: results.success,\n          failedFiles: results.failed,\n          watcherId: watcher.id,\n        },\n      });\n    } catch (e) {\n      this.logger.error(e, `Failed to read directory ${watcher.path}`);\n      this.notificationService.create({\n        title: 'Directory Watcher Error',\n        message: `Failed to read directory ${watcher.path}`,\n        type: 'error',\n        tags: ['directory-watcher'],\n        data: {\n          error: e.message,\n        },\n      });\n    }\n  }\n\n  /**\n   * Processes a file using the move/archive pattern.\n   * Files are moved through: main folder -> processing -> completed/failed\n   *\n   * @param {DirectoryWatcher} watcher\n   * @param {string} fileName\n   */\n  private async processFileWithMove(\n    watcher: DirectoryWatcher,\n    fileName: string,\n  ): Promise<void> {\n    const sourcePath = join(watcher.path, fileName);\n    const processingPath = join(\n      watcher.path,\n      this.SUBFOLDER_PROCESSING,\n      fileName,\n    );\n    const completedPath = join(\n      watcher.path,\n      this.SUBFOLDER_COMPLETED,\n      fileName,\n    );\n    const failedPath = join(watcher.path, this.SUBFOLDER_FAILED, fileName);\n\n    let currentLocation = sourcePath;\n    let submissionId: EntityId | null = null;\n\n    try {\n      // Step 1: Move to processing folder (atomic operation)\n      await rename(sourcePath, processingPath);\n      currentLocation = processingPath;\n      this.logger.info(`Processing file ${fileName}`);\n\n      // Step 2: Process the file\n      const multerInfo: MulterFileInfo = {\n        fieldname: '',\n        origin: 'directory-watcher',\n        originalname: fileName,\n        encoding: '',\n        mimetype: getType(fileName),\n        size: 0,\n        destination: '',\n        filename: fileName,\n        path: processingPath, // Use processing path\n      };\n\n      switch (watcher.importAction) {\n        case DirectoryWatcherImportAction.NEW_SUBMISSION: {\n          const submission = await this.submissionService.create(\n            {\n              name: fileName,\n              type: SubmissionType.FILE,\n            },\n            multerInfo,\n          );\n          submissionId = submission.id;\n\n          if (watcher.template) {\n            await this.submissionService.applyOverridingTemplate(\n              submission.id,\n              watcher.template?.id,\n            );\n          }\n          break;\n        }\n\n        default:\n          break;\n      }\n\n      // Step 3: Move to completed folder\n      await rename(processingPath, completedPath);\n      this.logger.info(\n        `Successfully processed file ${fileName} (submission: ${submissionId})`,\n      );\n    } catch (err) {\n      this.logger.error(err, `Failed to process file ${fileName}`);\n\n      // Cleanup submission if it was created\n      if (submissionId) {\n        await this.submissionService\n          .remove(submissionId)\n          .catch((cleanupErr) => {\n            this.logger.error(\n              cleanupErr,\n              `Failed to cleanup submission ${submissionId}`,\n            );\n          });\n      }\n\n      // Move to failed folder and create error file\n      try {\n        await rename(currentLocation, failedPath);\n\n        // Create error details file\n        const errorFilePath = join(\n          watcher.path,\n          this.SUBFOLDER_FAILED,\n          `${fileName}.error.txt`,\n        );\n        const errorDetails = [\n          `File: ${fileName}`,\n          `Failed at: ${new Date().toISOString()}`,\n          `Error: ${err.message}`,\n          `Stack: ${err.stack || 'N/A'}`,\n        ].join('\\n');\n\n        await writeFile(errorFilePath, errorDetails);\n      } catch (moveErr) {\n        this.logger.error(\n          moveErr,\n          `Failed to move file to failed folder: ${fileName}`,\n        );\n      }\n\n      throw err;\n    }\n  }\n\n  async create(\n    createDto: CreateDirectoryWatcherDto,\n  ): Promise<DirectoryWatcher> {\n    // Validate path exists and is accessible (only if path is provided)\n    if (createDto.path) {\n      try {\n        await readdir(createDto.path);\n      } catch (err) {\n        throw new BadRequestException(\n          `Path '${createDto.path}' does not exist or is not accessible`,\n        );\n      }\n\n      // Create directory structure\n      await this.ensureDirectoryStructure(createDto.path);\n    }\n\n    return this.repository.insert(createDto);\n  }\n\n  async update(id: EntityId, update: UpdateDirectoryWatcherDto) {\n    this.logger.withMetadata(update).info(`Updating DirectoryWatcher '${id}'`);\n    const entity = await this.repository.findById(id, { failOnMissing: true });\n\n    // Validate path if being updated\n    if (update.path && update.path !== entity.path) {\n      try {\n        await readdir(update.path);\n      } catch (err) {\n        throw new BadRequestException(\n          `Path '${update.path}' does not exist or is not accessible`,\n        );\n      }\n\n      // Create directory structure for new path\n      await this.ensureDirectoryStructure(update.path);\n    }\n\n    const template = update.templateId\n      ? await this.submissionService.findById(update.templateId, {\n          failOnMissing: true,\n        })\n      : null;\n    if (template && !template.isTemplate) {\n      throw new BadRequestException('Template Id provided is not a template.');\n    }\n\n    const updatedEntity = await this.repository.update(id, {\n      importAction: update.importAction ?? entity.importAction,\n      path: update.path ?? entity.path,\n      templateId: update.templateId ?? entity.templateId,\n    });\n\n    // If this is the first time setting a path (from null/empty to valid path), ensure directory structure\n    if (!entity.path && updatedEntity.path) {\n      await this.ensureDirectoryStructure(updatedEntity.path);\n    }\n\n    return updatedEntity;\n  }\n\n  /**\n   * Checks a directory path for validity and returns file count information.\n   * Used to warn users before selecting folders with many files.\n   *\n   * @param {string} path - The directory path to check\n   * @returns {Promise<CheckPathResult>} Information about the directory\n   */\n  async checkPath(path: string): Promise<CheckPathResult> {\n    try {\n      const allFiles = await readdir(path);\n      const files = allFiles.filter(\n        (file) =>\n          file !== this.SUBFOLDER_PROCESSING &&\n          file !== this.SUBFOLDER_COMPLETED &&\n          file !== this.SUBFOLDER_FAILED,\n      );\n\n      return {\n        valid: true,\n        count: files.length,\n        files,\n      };\n    } catch (err) {\n      return {\n        valid: false,\n        count: 0,\n        files: [],\n        error: `Path '${path}' does not exist or is not accessible`,\n      };\n    }\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/directory-watchers/dtos/check-path.dto.ts",
    "content": "import { ApiProperty } from '@nestjs/swagger';\nimport { IsNotEmpty, IsString } from 'class-validator';\n\nexport class CheckPathDto {\n  @ApiProperty({ description: 'The directory path to check' })\n  @IsNotEmpty()\n  @IsString()\n  path: string;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/directory-watchers/dtos/create-directory-watcher.dto.ts",
    "content": "import { ApiProperty } from '@nestjs/swagger';\nimport {\n  DirectoryWatcherImportAction,\n  ICreateDirectoryWatcherDto,\n} from '@postybirb/types';\nimport { IsEnum, IsOptional, IsString } from 'class-validator';\n\nexport class CreateDirectoryWatcherDto implements ICreateDirectoryWatcherDto {\n  @ApiProperty()\n  @IsOptional()\n  @IsString()\n  path: string;\n\n  @ApiProperty({\n    enum: DirectoryWatcherImportAction,\n  })\n  @IsEnum(DirectoryWatcherImportAction)\n  importAction: DirectoryWatcherImportAction;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/directory-watchers/dtos/update-directory-watcher.dto.ts",
    "content": "import { ApiProperty } from '@nestjs/swagger';\nimport {\n  DirectoryWatcherImportAction,\n  IUpdateDirectoryWatcherDto,\n  SubmissionId,\n} from '@postybirb/types';\nimport { IsEnum, IsOptional, IsString } from 'class-validator';\n\nexport class UpdateDirectoryWatcherDto implements IUpdateDirectoryWatcherDto {\n  @ApiProperty({\n    enum: DirectoryWatcherImportAction,\n    required: false,\n  })\n  @IsOptional()\n  @IsEnum(DirectoryWatcherImportAction)\n  importAction?: DirectoryWatcherImportAction;\n\n  @ApiProperty({ required: false })\n  @IsOptional()\n  @IsString()\n  path?: string;\n\n  @ApiProperty({ required: false })\n  @IsOptional()\n  @IsString()\n  templateId?: SubmissionId;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/drizzle/models/account.entity.ts",
    "content": "import { IAccount, IAccountDto } from '@postybirb/types';\nimport { Exclude, instanceToPlain, Type } from 'class-transformer';\nimport { UnknownWebsite } from '../../websites/website';\nimport { DatabaseEntity } from './database-entity';\nimport { WebsiteData } from './website-data.entity';\n\nexport class Account extends DatabaseEntity implements IAccount {\n  name: string;\n\n  website: string;\n\n  groups: string[] = [];\n\n  /**\n   * we don't want to pass this down to users unless filtered\n   * by the website instance.\n   */\n  @Exclude()\n  @Type(() => WebsiteData)\n  websiteData: WebsiteData;\n\n  @Exclude()\n  websiteInstance?: UnknownWebsite;\n\n  // eslint-disable-next-line @typescript-eslint/no-useless-constructor\n  constructor(entity: Partial<IAccount>) {\n    super(entity);\n    Object.assign(this, entity);\n  }\n\n  toObject(): IAccount {\n    return instanceToPlain(this, {\n      enableCircularCheck: true,\n    }) as IAccount;\n  }\n\n  toDTO(): IAccountDto {\n    const dto: IAccountDto = {\n      ...(this.toObject() as unknown as IAccountDto),\n      data: this.websiteInstance?.getWebsiteData() ?? {},\n      state: this.websiteInstance?.getLoginState() ?? {\n        isLoggedIn: false,\n        username: '',\n        pending: false,\n        lastUpdated: null,\n      },\n      websiteInfo: {\n        websiteDisplayName:\n          this.websiteInstance?.decoratedProps.metadata.displayName ?? '',\n        supports: this.websiteInstance?.getSupportedTypes() ?? [],\n      },\n    };\n    return dto;\n  }\n\n  withWebsiteInstance(websiteInstance: UnknownWebsite): this {\n    this.websiteInstance = websiteInstance;\n    return this;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/drizzle/models/custom-shortcut.entity.ts",
    "content": "import {\n  DefaultDescription,\n  Description,\n  ICustomShortcut,\n  ICustomShortcutDto,\n} from '@postybirb/types';\nimport { instanceToPlain } from 'class-transformer';\nimport { DatabaseEntity } from './database-entity';\n\nexport class CustomShortcut extends DatabaseEntity implements ICustomShortcut {\n  name: string;\n\n  shortcut: Description = DefaultDescription();\n\n  constructor(entity: Partial<ICustomShortcut>) {\n    super(entity);\n    Object.assign(this, entity);\n  }\n\n  public toObject(): ICustomShortcut {\n    return instanceToPlain(this, {\n      enableCircularCheck: true,\n    }) as ICustomShortcut;\n  }\n\n  public toDTO(): ICustomShortcutDto {\n    return instanceToPlain(this, {\n      enableCircularCheck: true,\n    }) as ICustomShortcutDto;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/drizzle/models/database-entity.spec.ts",
    "content": "import { IEntity, IEntityDto } from '@postybirb/types';\nimport { instanceToPlain } from 'class-transformer';\nimport 'reflect-metadata';\nimport { DatabaseEntity, fromDatabaseRecord } from './database-entity';\n\nclass TestEntity extends DatabaseEntity {\n  public testField: string;\n\n  constructor(entity: Partial<TestEntity>) {\n    super(entity);\n    Object.assign(this, entity);\n  }\n\n  toObject(): IEntity {\n    return instanceToPlain(this, {\n      enableCircularCheck: true,\n    }) as unknown as IEntity;\n  }\n\n  toDTO(): IEntityDto {\n    return this.toObject() as unknown as IEntityDto;\n  }\n}\n\ndescribe('DatabaseEntity', () => {\n  let entity: TestEntity;\n\n  beforeEach(() => {\n    entity = new TestEntity({\n      id: 'id',\n      testField: 'test',\n      createdAt: new Date().toISOString(),\n      updatedAt: new Date().toISOString(),\n    });\n  });\n\n  it('should create an instance', () => {\n    expect(entity).toBeTruthy();\n  });\n\n  it('should convert class to object', () => {\n    const obj = entity.toObject();\n    expect(obj).toBeTruthy();\n  });\n\n  it('should succeed fromDatabaseObject', () => {\n    const dbObj = {\n      id: 'id',\n      createdAt: new Date().toISOString(),\n      updatedAt: new Date().toISOString(),\n      testField: 'test',\n    };\n    const obj = fromDatabaseRecord(TestEntity, dbObj);\n    expect(obj).toBeTruthy();\n    expect(obj.id).toBe(dbObj.id);\n    expect(obj.createdAt).toEqual(dbObj.createdAt);\n    expect(obj.updatedAt).toEqual(dbObj.updatedAt);\n    expect(obj.testField).toBe(dbObj.testField);\n  });\n\n  it('should convert toObject', () => {\n    const obj = entity.toObject();\n    expect(obj).toBeTruthy();\n    expect(obj.id).toBe(entity.id);\n    expect(obj.createdAt).toBe(entity.createdAt);\n    expect(obj.updatedAt).toBe(entity.updatedAt);\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    expect((obj as any).testField).toBe(entity.testField);\n  });\n});\n"
  },
  {
    "path": "apps/client-server/src/app/drizzle/models/database-entity.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { EntityId, IEntity, IEntityDto } from '@postybirb/types';\nimport {\n  ClassConstructor,\n  plainToClass,\n  plainToInstance,\n} from 'class-transformer';\nimport { v4 } from 'uuid';\n\nexport function fromDatabaseRecord<TEntity>(\n  entity: ClassConstructor<TEntity>,\n  record: any[],\n): TEntity[];\nexport function fromDatabaseRecord<TEntity>(\n  entity: ClassConstructor<TEntity>,\n  record: any,\n): TEntity;\nexport function fromDatabaseRecord<TEntity>(\n  entity: ClassConstructor<TEntity>,\n  record: any | any[],\n): TEntity | TEntity[] {\n  if (Array.isArray(record)) {\n    return record.map((r) =>\n      plainToInstance(entity, r, { enableCircularCheck: true }),\n    ) as TEntity[];\n  }\n  return plainToClass(entity, record, {\n    enableCircularCheck: true,\n  }) as TEntity;\n}\n\nexport abstract class DatabaseEntity implements IEntity {\n  public readonly id: EntityId;\n\n  public createdAt: string;\n\n  public updatedAt: string;\n\n  constructor(entity: Partial<IEntity>) {\n    Object.assign(this, entity);\n    if (!this.id) {\n      this.id = v4();\n    }\n  }\n\n  public abstract toObject(): IEntity;\n\n  public abstract toDTO(): IEntityDto;\n\n  public toJSON(): string {\n    return JSON.stringify(this.toDTO());\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/drizzle/models/directory-watcher.entity.ts",
    "content": "import {\n  DirectoryWatcherDto,\n  DirectoryWatcherImportAction,\n  IDirectoryWatcher,\n  SubmissionId,\n} from '@postybirb/types';\nimport { instanceToPlain, Type } from 'class-transformer';\nimport { DatabaseEntity } from './database-entity';\nimport { Submission } from './submission.entity';\n\nexport class DirectoryWatcher\n  extends DatabaseEntity\n  implements IDirectoryWatcher\n{\n  path?: string;\n\n  templateId: SubmissionId;\n\n  importAction: DirectoryWatcherImportAction;\n\n  @Type(() => Submission)\n  template: Submission;\n\n  toObject(): IDirectoryWatcher {\n    return instanceToPlain(this, {\n      enableCircularCheck: true,\n    }) as IDirectoryWatcher;\n  }\n\n  toDTO(): DirectoryWatcherDto {\n    return this.toObject() as unknown as DirectoryWatcherDto;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/drizzle/models/file-buffer.entity.ts",
    "content": "import { EntityId, FileBufferDto, IFileBuffer } from '@postybirb/types';\nimport { Exclude, instanceToPlain, Type } from 'class-transformer';\nimport { DatabaseEntity } from './database-entity';\n\nexport class FileBuffer extends DatabaseEntity implements IFileBuffer {\n  submissionFileId: EntityId;\n\n  @Type(() => Buffer)\n  @Exclude({ toPlainOnly: true })\n  buffer: Buffer;\n\n  fileName: string;\n\n  mimeType: string;\n\n  size: number;\n\n  width: number;\n\n  height: number;\n\n  constructor(entity: Partial<FileBuffer>) {\n    super(entity);\n    Object.assign(this, entity);\n  }\n\n  toObject(): IFileBuffer {\n    return instanceToPlain(this, {\n      enableCircularCheck: true,\n    }) as IFileBuffer;\n  }\n\n  toDTO(): FileBufferDto {\n    return this.toObject() as unknown as FileBufferDto;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/drizzle/models/index.ts",
    "content": "export * from './account.entity';\nexport * from './database-entity';\nexport * from './directory-watcher.entity';\nexport * from './file-buffer.entity';\nexport * from './post-event.entity';\nexport * from './post-queue-record.entity';\nexport * from './post-record.entity';\nexport * from './settings.entity';\nexport * from './submission-file.entity';\nexport * from './submission.entity';\nexport * from './tag-converter.entity';\nexport * from './tag-group.entity';\nexport * from './user-converter.entity';\nexport * from './user-specified-website-options.entity';\nexport * from './website-data.entity';\nexport * from './website-options.entity';\n\n"
  },
  {
    "path": "apps/client-server/src/app/drizzle/models/notification.entity.ts",
    "content": "import { INotification } from '@postybirb/types';\nimport { instanceToPlain } from 'class-transformer';\nimport { DatabaseEntity } from './database-entity';\n\nexport class Notification extends DatabaseEntity implements INotification {\n  title: string;\n\n  message: string;\n\n  tags: string[];\n\n  data: Record<string, unknown>;\n\n  isRead: boolean;\n\n  hasEmitted: boolean;\n\n  type: 'warning' | 'error' | 'info' | 'success';\n\n  toObject(): INotification {\n    return instanceToPlain(this, {}) as INotification;\n  }\n\n  toDTO(): INotification {\n    return this.toObject() as unknown as INotification;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/drizzle/models/post-event.entity.ts",
    "content": "import {\n  AccountId,\n  EntityId,\n  IPostEvent,\n  IPostEventError,\n  IPostEventMetadata,\n  PostEventDto,\n  PostEventType,\n} from '@postybirb/types';\nimport { instanceToPlain, Type } from 'class-transformer';\nimport { Account } from './account.entity';\nimport { DatabaseEntity } from './database-entity';\nimport { PostRecord } from './post-record.entity';\n\nexport class PostEvent extends DatabaseEntity implements IPostEvent {\n  postRecordId: EntityId;\n\n  accountId?: AccountId;\n\n  eventType: PostEventType;\n\n  fileId?: EntityId;\n\n  sourceUrl?: string;\n\n  error?: IPostEventError;\n\n  metadata?: IPostEventMetadata;\n\n  @Type(() => PostRecord)\n  postRecord: PostRecord;\n\n  @Type(() => Account)\n  account?: Account;\n\n  constructor(entity: Partial<IPostEvent>) {\n    super(entity);\n    Object.assign(this, entity);\n  }\n\n  toObject(): IPostEvent {\n    return instanceToPlain(this, {\n      enableCircularCheck: true,\n    }) as IPostEvent;\n  }\n\n  toDTO(): PostEventDto {\n    const dto: PostEventDto = {\n      ...this.toObject(),\n      account: this.account?.toDTO(),\n    };\n    return dto;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/drizzle/models/post-queue-record.entity.ts",
    "content": "import {\n  EntityId,\n  IPostQueueRecord,\n  PostQueueRecordDto,\n  SubmissionId\n} from '@postybirb/types';\nimport { instanceToPlain, Type } from 'class-transformer';\nimport { DatabaseEntity } from './database-entity';\nimport { PostRecord } from './post-record.entity';\nimport { Submission } from './submission.entity';\n\nexport class PostQueueRecord\n  extends DatabaseEntity\n  implements IPostQueueRecord\n{\n  postRecordId: EntityId;\n\n  submissionId: SubmissionId;\n\n  @Type(() => PostRecord)\n  postRecord: PostRecord;\n\n  @Type(() => Submission)\n  submission: Submission;\n\n  toObject(): IPostQueueRecord {\n    return instanceToPlain(this, {\n      enableCircularCheck: true,\n    }) as IPostQueueRecord;\n  }\n\n  toDTO(): PostQueueRecordDto {\n    return this.toObject() as unknown as PostQueueRecordDto;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/drizzle/models/post-record.entity.ts",
    "content": "import {\n  EntityId,\n  IPostRecord,\n  PostRecordDto,\n  PostRecordResumeMode,\n  PostRecordState,\n  SubmissionId,\n} from '@postybirb/types';\nimport { instanceToPlain, Type } from 'class-transformer';\nimport { DatabaseEntity } from './database-entity';\nimport { PostEvent } from './post-event.entity';\nimport { PostQueueRecord } from './post-queue-record.entity';\nimport { Submission } from './submission.entity';\n\nexport class PostRecord extends DatabaseEntity implements IPostRecord {\n  postQueueRecordId: EntityId;\n\n  submissionId: SubmissionId;\n\n  @Type(() => Submission)\n  submission: Submission;\n\n  /**\n   * Reference to the originating NEW PostRecord for this chain.\n   * null for NEW records (they ARE the origin).\n   */\n  originPostRecordId?: EntityId;\n\n  /**\n   * The originating NEW PostRecord (resolved relation).\n   */\n  @Type(() => PostRecord)\n  origin?: PostRecord;\n\n  /**\n   * All CONTINUE/RETRY PostRecords that chain to this origin.\n   */\n  @Type(() => PostRecord)\n  chainedRecords?: PostRecord[];\n\n  completedAt?: string;\n\n  state: PostRecordState;\n\n  resumeMode: PostRecordResumeMode;\n\n  // eslint-disable-next-line @typescript-eslint/no-useless-constructor\n  constructor(entity: Partial<IPostRecord>) {\n    super(entity);\n  }\n\n  @Type(() => PostEvent)\n  events: PostEvent[];\n\n  @Type(() => PostQueueRecord)\n  postQueueRecord: PostQueueRecord;\n\n  toObject(): IPostRecord {\n    return instanceToPlain(this, {\n      enableCircularCheck: true,\n    }) as IPostRecord;\n  }\n\n  toDTO(): PostRecordDto {\n    const dto: PostRecordDto = {\n      ...this.toObject(),\n      events: this.events?.map((event) => event.toDTO()),\n      postQueueRecord: this.postQueueRecord?.toDTO(),\n    };\n    return dto;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/drizzle/models/settings.entity.ts",
    "content": "import {\n  ISettings,\n  ISettingsOptions,\n  SettingsConstants,\n  SettingsDto,\n} from '@postybirb/types';\nimport { instanceToPlain } from 'class-transformer';\nimport { DatabaseEntity } from './database-entity';\n\nexport class Settings extends DatabaseEntity implements ISettings {\n  profile: string;\n\n  settings: ISettingsOptions = { ...SettingsConstants.DEFAULT_SETTINGS };\n\n  toObject(): ISettings {\n    return instanceToPlain(this, {\n      enableCircularCheck: true,\n    }) as ISettings;\n  }\n\n  toDTO(): SettingsDto {\n    return this.toObject() as unknown as SettingsDto;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/drizzle/models/submission-file.entity.ts",
    "content": "import {\n  EntityId,\n  FileSubmissionMetadata,\n  ISubmissionFile,\n  ISubmissionFileDto,\n  SubmissionFileMetadata,\n} from '@postybirb/types';\nimport { instanceToPlain, Type } from 'class-transformer';\nimport { PostyBirbDatabase } from '../postybirb-database/postybirb-database';\nimport { DatabaseEntity } from './database-entity';\nimport { FileBuffer } from './file-buffer.entity';\nimport { Submission } from './submission.entity';\n\nexport class SubmissionFile extends DatabaseEntity implements ISubmissionFile {\n  submissionId: EntityId;\n\n  primaryFileId: EntityId;\n\n  altFileId: EntityId;\n\n  thumbnailId: EntityId;\n\n  @Type(() => Submission)\n  submission: Submission<FileSubmissionMetadata>;\n\n  fileName: string;\n\n  hash: string;\n\n  mimeType: string;\n\n  @Type(() => FileBuffer)\n  file: FileBuffer;\n\n  @Type(() => FileBuffer)\n  thumbnail?: FileBuffer;\n\n  @Type(() => FileBuffer)\n  altFile?: FileBuffer;\n\n  hasThumbnail: boolean;\n\n  hasAltFile: boolean;\n\n  hasCustomThumbnail: boolean;\n\n  size: number;\n\n  width: number;\n\n  height: number;\n\n  metadata: SubmissionFileMetadata;\n\n  order: number;\n\n  constructor(entity: Partial<SubmissionFile>) {\n    super(entity);\n    Object.assign(this, entity);\n  }\n\n  toObject(): ISubmissionFile {\n    return instanceToPlain(this, {\n      enableCircularCheck: true,\n    }) as ISubmissionFile;\n  }\n\n  toDTO(): ISubmissionFileDto {\n    return this.toObject() as unknown as ISubmissionFileDto;\n  }\n\n  /**\n   * Load the submission file from the database\n   * More of a workaround for the lack of proper ORM support with\n   * blob relations loading from nested with queries.\n   */\n  public async load(fileTarget?: 'file' | 'thumbnail' | 'alt') {\n    const db = new PostyBirbDatabase('FileBufferSchema');\n\n    if (fileTarget) {\n      switch (fileTarget) {\n        case 'file':\n          this.file = await db.findById(this.primaryFileId);\n          break;\n        case 'thumbnail':\n          this.thumbnail = await db.findById(this.thumbnailId);\n          break;\n        case 'alt':\n          this.altFile = await db.findById(this.altFileId);\n          break;\n        default:\n          throw new Error('Invalid file target');\n      }\n      return;\n    }\n\n    this.file = await db.findById(this.primaryFileId);\n    if (this.thumbnailId) {\n      this.thumbnail = await db.findById(this.thumbnailId);\n    }\n    if (this.altFileId) {\n      this.altFile = await db.findById(this.altFileId);\n    }\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/drizzle/models/submission.entity.ts",
    "content": "import {\n    ISubmission,\n    ISubmissionDto,\n    ISubmissionMetadata,\n    ISubmissionScheduleInfo,\n    ScheduleType,\n    SubmissionType,\n} from '@postybirb/types';\nimport { instanceToPlain, Type } from 'class-transformer';\nimport { DatabaseEntity } from './database-entity';\nimport { PostQueueRecord } from './post-queue-record.entity';\nimport { PostRecord } from './post-record.entity';\nimport { SubmissionFile } from './submission-file.entity';\nimport { WebsiteOptions } from './website-options.entity';\n\nexport class Submission<T extends ISubmissionMetadata = ISubmissionMetadata>\n  extends DatabaseEntity\n  implements ISubmission<T>\n{\n  type: SubmissionType;\n\n  @Type(() => WebsiteOptions)\n  options: WebsiteOptions[];\n\n  @Type(() => PostQueueRecord)\n  postQueueRecord?: PostQueueRecord;\n\n  isScheduled = false;\n\n  isTemplate = false;\n\n  isMultiSubmission = false;\n\n  isArchived = false;\n\n  isInitialized = false;\n\n  schedule: ISubmissionScheduleInfo = {\n    scheduleType: ScheduleType.NONE,\n  };\n\n  @Type(() => SubmissionFile)\n  files: SubmissionFile[];\n\n  metadata: T;\n\n  @Type(() => PostRecord)\n  posts: PostRecord[];\n\n  order: number;\n\n  constructor(entity: Partial<ISubmission<T>>) {\n    super(entity);\n    Object.assign(this, entity);\n  }\n\n  toObject(): ISubmission {\n    return instanceToPlain(this, {\n      enableCircularCheck: true,\n    }) as ISubmission;\n  }\n\n  toDTO(): ISubmissionDto {\n    const dto: ISubmissionDto = {\n      ...this.toObject(),\n      files: this.files?.map((file) => file.toDTO()),\n      options: this.options?.map((option) => option.toDTO()),\n      posts: this.posts?.map((post) => post.toDTO()),\n      postQueueRecord: this.postQueueRecord?.toDTO(),\n      validations: [],\n    };\n\n    return dto;\n  }\n\n  getSubmissionName(): string {\n    if (this.options?.length) {\n      return this.options.find((o) => o.isDefault)?.data.title;\n    }\n    return 'Unknown';\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/drizzle/models/tag-converter.entity.ts",
    "content": "import { ITagConverter, TagConverterDto } from '@postybirb/types';\nimport { instanceToPlain } from 'class-transformer';\nimport { DatabaseEntity } from './database-entity';\n\nexport class TagConverter extends DatabaseEntity implements ITagConverter {\n  public readonly tag: string;\n\n  public convertTo: Record<string, string> = {};\n\n  constructor(entity: ITagConverter) {\n    super(entity);\n    Object.assign(this, entity);\n  }\n\n  toObject(): ITagConverter {\n    return instanceToPlain(this, {\n      enableCircularCheck: true,\n    }) as ITagConverter;\n  }\n\n  toDTO(): TagConverterDto {\n    return this.toObject() as unknown as TagConverterDto;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/drizzle/models/tag-group.entity.ts",
    "content": "import { ITagGroup, TagGroupDto } from '@postybirb/types';\nimport { instanceToPlain } from 'class-transformer';\nimport { DatabaseEntity } from './database-entity';\n\nexport class TagGroup extends DatabaseEntity implements ITagGroup {\n  name: string;\n\n  tags: string[] = [];\n\n  toObject(): ITagGroup {\n    return instanceToPlain(this, {\n      enableCircularCheck: true,\n    }) as ITagGroup;\n  }\n\n  toDTO(): TagGroupDto {\n    return this.toObject() as unknown as TagGroupDto;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/drizzle/models/user-converter.entity.ts",
    "content": "import { IUserConverter, UserConverterDto } from '@postybirb/types';\nimport { instanceToPlain } from 'class-transformer';\nimport { DatabaseEntity } from './database-entity';\n\nexport class UserConverter extends DatabaseEntity implements IUserConverter {\n  public readonly username: string;\n\n  public convertTo: Record<string, string> = {};\n\n  constructor(entity: IUserConverter) {\n    super(entity);\n    Object.assign(this, entity);\n  }\n\n  toObject(): IUserConverter {\n    return instanceToPlain(this, {\n      enableCircularCheck: true,\n    }) as IUserConverter;\n  }\n\n  toDTO(): UserConverterDto {\n    return this.toObject() as unknown as UserConverterDto;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/drizzle/models/user-specified-website-options.entity.ts",
    "content": "import {\n  AccountId,\n  DynamicObject,\n  IUserSpecifiedWebsiteOptions,\n  SubmissionType,\n  UserSpecifiedWebsiteOptionsDto\n} from '@postybirb/types';\nimport { instanceToPlain, Type } from 'class-transformer';\nimport { Account } from './account.entity';\nimport { DatabaseEntity } from './database-entity';\n\nexport class UserSpecifiedWebsiteOptions\n  extends DatabaseEntity\n  implements IUserSpecifiedWebsiteOptions\n{\n  accountId: AccountId;\n\n  @Type(() => Account)\n  account: Account;\n\n  type: SubmissionType;\n\n  options: DynamicObject;\n\n  toObject(): IUserSpecifiedWebsiteOptions {\n    return instanceToPlain(this, {\n      enableCircularCheck: true,\n    }) as IUserSpecifiedWebsiteOptions;\n  }\n\n  toDTO(): UserSpecifiedWebsiteOptionsDto {\n    return this.toObject() as unknown as UserSpecifiedWebsiteOptionsDto;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/drizzle/models/website-data.entity.ts",
    "content": "import {\n  DynamicObject,\n  IWebsiteData,\n  IWebsiteDataDto\n} from '@postybirb/types';\nimport { instanceToPlain, Type } from 'class-transformer';\nimport { Account } from './account.entity';\nimport { DatabaseEntity } from './database-entity';\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport class WebsiteData<T extends DynamicObject = any>\n  extends DatabaseEntity\n  implements IWebsiteData<T>\n{\n  data: T = {} as T;\n\n  @Type(() => Account)\n  account: Account;\n\n  toObject(): IWebsiteData {\n    return instanceToPlain(this) as IWebsiteData;\n  }\n\n  toDTO(): IWebsiteDataDto {\n    return this.toObject() as unknown as IWebsiteDataDto;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/drizzle/models/website-options.entity.ts",
    "content": "import {\n  AccountId,\n  IWebsiteFormFields,\n  IWebsiteOptions,\n  SubmissionId,\n  WebsiteOptionsDto,\n} from '@postybirb/types';\nimport { instanceToPlain, Type } from 'class-transformer';\nimport { BaseWebsiteOptions } from '../../websites/models/base-website-options';\nimport { Account } from './account.entity';\nimport { DatabaseEntity } from './database-entity';\nimport { Submission } from './submission.entity';\n\nexport class WebsiteOptions extends DatabaseEntity implements IWebsiteOptions {\n  accountId: AccountId;\n\n  @Type(() => Account)\n  account: Account;\n\n  submissionId: SubmissionId;\n\n  @Type(() => Submission)\n  submission: Submission;\n\n  data: IWebsiteFormFields = new BaseWebsiteOptions();\n\n  isDefault: boolean;\n\n  constructor(entity: Partial<WebsiteOptions>) {\n    super(entity);\n    Object.assign(this, entity);\n  }\n\n  toObject(): IWebsiteOptions {\n    return instanceToPlain(this, {\n      enableCircularCheck: true,\n    }) as IWebsiteOptions;\n  }\n\n  toDTO(): WebsiteOptionsDto {\n    return {\n      ...this.toObject(),\n      submission: this.submission?.toDTO(),\n    } as unknown as WebsiteOptionsDto;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/drizzle/postybirb-database/find-options.type.ts",
    "content": "export type FindOptions = {\n  failOnMissing?: boolean;\n};\n"
  },
  {
    "path": "apps/client-server/src/app/drizzle/postybirb-database/postybirb-database.spec.ts",
    "content": "import { clearDatabase, Schemas } from '@postybirb/database';\nimport { eq as equals } from 'drizzle-orm';\nimport 'reflect-metadata';\nimport { PostyBirbDatabase } from './postybirb-database';\n\ndescribe('PostyBirbDatabase', () => {\n  let service: PostyBirbDatabase<'AccountSchema'>;\n\n  beforeEach(() => {\n    clearDatabase();\n    service = new PostyBirbDatabase('AccountSchema');\n  });\n\n  it('should be created', () => {\n    expect(service).toBeTruthy();\n  });\n\n  it('should insert and delete', async () => {\n    const account = await service.insert({\n      name: 'test',\n      website: 'test',\n      groups: [],\n    });\n\n    const accounts = await service.findAll();\n    expect(accounts).toHaveLength(1);\n    expect(accounts[0].id).toBe(account.id);\n    expect(accounts[0].name).toBe('test');\n    expect(accounts[0].website).toBe('test');\n\n    await service.deleteById([account.id]);\n\n    const accountsAfterDelete = await service.findAll();\n    expect(accountsAfterDelete).toHaveLength(0);\n  });\n\n  it('should find by id', async () => {\n    const account = await service.insert({\n      name: 'test',\n      website: 'test',\n      groups: [],\n    });\n\n    const foundAccount = await service.findById(account.id, {\n      failOnMissing: true,\n    });\n    expect(foundAccount).toBeTruthy();\n    expect(foundAccount.id).toBe(account.id);\n    expect(foundAccount.name).toBe('test');\n    expect(foundAccount.website).toBe('test');\n\n    const notFoundAccount = await service.findById('not-found', {\n      failOnMissing: false,\n    });\n    expect(notFoundAccount).toBeNull();\n  });\n\n  it('should throw on find by id not found', async () => {\n    await expect(\n      service.findById('not-found', { failOnMissing: true }),\n    ).rejects.toThrow('Record with id not-found not found');\n  });\n\n  it('should update', async () => {\n    const account = await service.insert({\n      name: 'test',\n      website: 'test',\n      groups: [],\n    });\n\n    await service.update(account.id, {\n      name: 'test2',\n    });\n\n    const updatedAccount = await service.findById(account.id, {\n      failOnMissing: true,\n    });\n    expect(updatedAccount).toBeTruthy();\n    expect(updatedAccount.id).toBe(account.id);\n    expect(updatedAccount.name).toBe('test2');\n  });\n\n  it('should find one', async () => {\n    await service.insert({\n      name: 'test',\n      website: 'test',\n      groups: [],\n    });\n\n    const foundAccount = await service.findOne({\n      where: (account, { eq }) => eq(account.name, 'test'),\n    });\n\n    expect(foundAccount).toBeTruthy();\n    expect(foundAccount?.name).toBe('test');\n  });\n\n  it('should find many', async () => {\n    await service.insert({\n      name: 'test',\n      website: 'test',\n      groups: [],\n    });\n\n    await service.insert({\n      name: 'test2',\n      website: 'test',\n      groups: [],\n    });\n\n    const foundAccounts = await service.find({\n      where: (account, { eq }) => eq(account.website, 'test'),\n    });\n\n    expect(foundAccounts).toHaveLength(2);\n  });\n\n  it('should return empty array on find many not found', async () => {\n    const foundAccounts = await service.find({\n      where: (account, { eq }) => eq(account.website, 'test'),\n    });\n\n    expect(foundAccounts).toHaveLength(0);\n  });\n\n  it('should notify subscribers on create', async () => {\n    const subscriber = jest.fn();\n    service.subscribe('AccountSchema', subscriber);\n\n    const entity = await service.insert({\n      name: 'test',\n      website: 'test',\n      groups: [],\n    });\n\n    expect(subscriber).toHaveBeenCalledWith([entity.id], 'insert');\n  });\n\n  it('should select', async () => {\n    await service.insert({\n      name: 'test',\n      website: 'test',\n      groups: [],\n    });\n\n    const foundAccounts = await service.select(\n      equals(Schemas.AccountSchema.name, 'test'),\n    );\n\n    expect(foundAccounts).toHaveLength(1);\n  });\n});\n"
  },
  {
    "path": "apps/client-server/src/app/drizzle/postybirb-database/postybirb-database.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { NotFoundException } from '@nestjs/common';\nimport {\n  getDatabase,\n  Insert,\n  PostyBirbDatabaseType,\n  SchemaKey,\n  Schemas,\n  Select,\n} from '@postybirb/database';\nimport { EntityId, NULL_ACCOUNT_ID } from '@postybirb/types';\nimport { eq, inArray, KnownKeysOnly, SQL } from 'drizzle-orm';\nimport {\n  DBQueryConfig,\n  ExtractTablesWithRelations,\n} from 'drizzle-orm/relations';\nimport { fromDatabaseRecord } from '../models';\nimport { FindOptions } from './find-options.type';\nimport {\n  DatabaseSchemaEntityMap,\n  DatabaseSchemaEntityMapConst,\n} from './schema-entity-map';\n\ntype ExtractedRelations = ExtractTablesWithRelations<typeof Schemas>;\n\ntype Relation<TSchemaKey extends SchemaKey> =\n  PostyBirbDatabaseType['_']['schema'][TSchemaKey];\n\nexport type Action = 'delete' | 'insert' | 'update';\n\ntype SubscribeCallback = (ids: EntityId[], action: Action) => void;\n\nexport class PostyBirbDatabase<\n  TSchemaKey extends SchemaKey,\n  TEntityClass = DatabaseSchemaEntityMap[TSchemaKey],\n> {\n  public readonly db: PostyBirbDatabaseType;\n\n  private static readonly subscribers: Record<\n    SchemaKey,\n    Array<SubscribeCallback>\n  > = {\n    AccountSchema: [],\n    DirectoryWatcherSchema: [],\n    FileBufferSchema: [],\n    PostEventSchema: [],\n    PostQueueRecordSchema: [],\n    PostRecordSchema: [],\n    SettingsSchema: [],\n    SubmissionFileSchema: [],\n    SubmissionSchema: [],\n    TagConverterSchema: [],\n    TagGroupSchema: [],\n    UserConverterSchema: [],\n    UserSpecifiedWebsiteOptionsSchema: [],\n    WebsiteDataSchema: [],\n    WebsiteOptionsSchema: [],\n    NotificationSchema: [],\n    CustomShortcutSchema: [],\n  };\n\n  constructor(\n    private readonly schemaKey: TSchemaKey,\n    private readonly load?: DBQueryConfig<\n      'many',\n      true,\n      ExtractedRelations,\n      Relation<TSchemaKey>\n    >['with'],\n  ) {\n    this.db = getDatabase();\n  }\n\n  public subscribe(key: SchemaKey[], callback: SubscribeCallback): this;\n  public subscribe(key: SchemaKey, callback: SubscribeCallback): this;\n  public subscribe(\n    key: SchemaKey | SchemaKey[],\n    callback: SubscribeCallback,\n  ): this {\n    if (Array.isArray(key)) {\n      key.forEach((k) => this.subscribe(k, callback));\n      return this;\n    }\n    if (!PostyBirbDatabase.subscribers[key]) {\n      PostyBirbDatabase.subscribers[key] = [];\n    }\n    PostyBirbDatabase.subscribers[key].push(callback);\n    return this;\n  }\n\n  private notify(ids: EntityId[], action: Action) {\n    PostyBirbDatabase.subscribers[this.schemaKey].forEach((callback) =>\n      callback(ids, action),\n    );\n  }\n\n  /**\n   * Forcefully calls notify.\n   * To be used where database updates occur outside of normal db calls\n   * such as during transaction and direct db mutations.\n   *\n   * @param {EntityId[]} ids\n   * @param {Action} action\n   */\n  public forceNotify(ids: EntityId[], action: Action) {\n    this.notify(ids, action);\n  }\n\n  /**\n   * Static method to notify subscribers for a given schema.\n   * Used by TransactionContext to trigger notifications after commit.\n   *\n   * @param {SchemaKey} schemaKey - The schema key to notify subscribers for\n   * @param {EntityId[]} ids - The entity IDs that were affected\n   * @param {Action} action - The action that occurred (insert, update, delete)\n   */\n  public static notifySubscribers(\n    schemaKey: SchemaKey,\n    ids: EntityId[],\n    action: Action,\n  ) {\n    PostyBirbDatabase.subscribers[schemaKey]?.forEach((callback) =>\n      callback(ids, action),\n    );\n  }\n\n  public get EntityClass() {\n    return DatabaseSchemaEntityMapConst[this.schemaKey];\n  }\n\n  public get schemaEntity() {\n    return Schemas[this.schemaKey];\n  }\n\n  private classConverter(value: any[]): TEntityClass[];\n  private classConverter(value: any): TEntityClass;\n  private classConverter(value: any | any[]): TEntityClass | TEntityClass[] {\n    if (Array.isArray(value)) {\n      return fromDatabaseRecord(this.EntityClass, value);\n    }\n    return fromDatabaseRecord(this.EntityClass, value);\n  }\n\n  public async insert(value: Insert<TSchemaKey>): Promise<TEntityClass>;\n  public async insert(value: Insert<TSchemaKey>[]): Promise<TEntityClass[]>;\n  public async insert(\n    value: Insert<TSchemaKey> | Insert<TSchemaKey>[],\n  ): Promise<TEntityClass | TEntityClass[]> {\n    const insertQuery = this.db\n      .insert(this.schemaEntity)\n      .values(value)\n      .returning();\n    // After calling .returning(), the result is always an array of the selected fields\n    const inserts = (await insertQuery) as Array<Select<TSchemaKey>>;\n    this.notify(\n      inserts.map((insert) => insert.id),\n      'insert',\n    );\n    const result = await Promise.all(\n      inserts.map((insert) => this.findById(insert.id)),\n    );\n    return Array.isArray(value) ? result : result[0];\n  }\n\n  public async deleteById(ids: EntityId[]) {\n    if (ids.find((id) => id === NULL_ACCOUNT_ID)) {\n      throw new Error('Cannot delete the null account');\n    }\n    const result = await this.db\n      .delete(this.schemaEntity)\n      .where(inArray(this.schemaEntity.id, ids));\n    this.notify(ids, 'delete');\n    return result;\n  }\n\n  public async findById(\n    id: EntityId,\n    options?: FindOptions,\n    load?: DBQueryConfig<\n      'many',\n      true,\n      ExtractedRelations,\n      Relation<TSchemaKey>\n    >['with'],\n  ): Promise<TEntityClass | null> {\n    const record = await this.db.query[this.schemaKey].findFirst({\n      where: eq(this.schemaEntity.id, id),\n      with: {\n        ...(load ?? this.load ?? {}),\n      },\n    });\n\n    if (!record && options?.failOnMissing) {\n      throw new NotFoundException(`Record with id ${id} not found`);\n    }\n\n    return record ? (this.classConverter(record) as TEntityClass) : null;\n  }\n\n  public async select(query: SQL): Promise<TEntityClass[]> {\n    const records = await this.db.select().from(this.schemaEntity).where(query);\n    return this.classConverter(records);\n  }\n\n  public async find<\n    TConfig extends DBQueryConfig<\n      'many',\n      true,\n      ExtractedRelations,\n      Relation<TSchemaKey>\n    >,\n  >(\n    query: KnownKeysOnly<\n      TConfig,\n      DBQueryConfig<'many', true, ExtractedRelations, Relation<TSchemaKey>>\n    >,\n  ): Promise<TEntityClass[]> {\n    const record: any[] =\n      (await this.db.query[\n        this.schemaKey as keyof PostyBirbDatabaseType\n      ].findMany({\n        ...query,\n        with: query.with\n          ? query.with\n          : {\n              ...(this.load ?? {}),\n            },\n      })) ?? [];\n    return this.classConverter(record);\n  }\n\n  public async findOne<\n    TSelection extends Omit<\n      DBQueryConfig<'many', true, ExtractedRelations, Relation<TSchemaKey>>,\n      'limit'\n    >,\n  >(\n    query: KnownKeysOnly<\n      TSelection,\n      Omit<\n        DBQueryConfig<'many', true, ExtractedRelations, Relation<TSchemaKey>>,\n        'limit'\n      >\n    >,\n  ): Promise<TEntityClass | null> {\n    const record = await this.db.query[\n      this.schemaKey as keyof PostyBirbDatabaseType\n    ].findFirst({\n      ...query,\n      with: query.with\n        ? query.with\n        : {\n            ...(this.load ?? {}),\n          },\n    });\n    return record ? this.classConverter(record) : null;\n  }\n\n  public async findAll(): Promise<TEntityClass[]> {\n    const records: object[] = await this.db.query[this.schemaKey].findMany({\n      with: {\n        ...(this.load ?? {}),\n      },\n    });\n    return this.classConverter(records);\n  }\n\n  public async update(\n    id: EntityId,\n    set: Partial<Select<TSchemaKey>>,\n  ): Promise<TEntityClass> {\n    await this.findById(id, { failOnMissing: true });\n\n    await this.db\n      // eslint-disable-next-line testing-library/await-async-query\n      .update(this.schemaEntity)\n      .set(set)\n      .where(eq(this.schemaEntity.id, id));\n    this.notify([id], 'update');\n    return this.findById(id);\n  }\n\n  public count(filter?: SQL): Promise<number> {\n    return this.db.$count(this.schemaEntity, filter);\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/drizzle/postybirb-database/postybirb-database.util.ts",
    "content": "/* eslint-disable no-param-reassign */\nimport { SchemaKey, Schemas } from '@postybirb/database';\nimport { DatabaseEntity } from '../models';\nimport { PostyBirbDatabase } from './postybirb-database';\n\nexport class PostyBirbDatabaseUtil {\n  static async saveFromEntity<T extends DatabaseEntity>(entity: T) {\n    const obj = entity.toObject();\n    let entitySchemaKey: SchemaKey | undefined;\n    for (const schemaKey of Object.keys(Schemas)) {\n      if (schemaKey === `${entity.constructor.name}Schema`) {\n        entitySchemaKey = schemaKey as SchemaKey;\n        break;\n      }\n    }\n\n    if (!entitySchemaKey) {\n      throw new Error(`Could not find schema for ${entity.constructor.name}`);\n    }\n\n    const db = new PostyBirbDatabase(entitySchemaKey);\n    const exists = await db.findById(entity.id);\n    if (exists) {\n      if (exists.updatedAt !== entity.updatedAt) {\n        throw new Error('Entity has been updated since last fetch');\n      }\n      const update = await db.update(entity.id, obj);\n      entity.updatedAt = update.updatedAt;\n    } else {\n      const insert = await db.insert(obj);\n      entity.createdAt = insert.createdAt;\n      entity.updatedAt = insert.updatedAt;\n    }\n\n    return entity;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/drizzle/postybirb-database/schema-entity-map.ts",
    "content": "import { SchemaKey } from '@postybirb/database';\nimport {\n    Account,\n    DirectoryWatcher,\n    FileBuffer,\n    PostEvent,\n    PostQueueRecord,\n    PostRecord,\n    Settings,\n    Submission,\n    SubmissionFile,\n    TagConverter,\n    TagGroup,\n    UserConverter,\n    UserSpecifiedWebsiteOptions,\n    WebsiteData,\n    WebsiteOptions,\n} from '../models';\nimport { CustomShortcut } from '../models/custom-shortcut.entity';\nimport { Notification } from '../models/notification.entity';\n\nexport type DatabaseSchemaEntityMap = {\n  AccountSchema: InstanceType<typeof Account>;\n  DirectoryWatcherSchema: InstanceType<typeof DirectoryWatcher>;\n  FileBufferSchema: InstanceType<typeof FileBuffer>;\n  PostEventSchema: InstanceType<typeof PostEvent>;\n  PostQueueRecordSchema: InstanceType<typeof PostQueueRecord>;\n  PostRecordSchema: InstanceType<typeof PostRecord>;\n  SettingsSchema: InstanceType<typeof Settings>;\n  SubmissionFileSchema: InstanceType<typeof SubmissionFile>;\n  SubmissionSchema: InstanceType<typeof Submission>;\n  TagConverterSchema: InstanceType<typeof TagConverter>;\n  TagGroupSchema: InstanceType<typeof TagGroup>;\n  UserConverterSchema: InstanceType<typeof UserConverter>;\n  UserSpecifiedWebsiteOptionsSchema: InstanceType<\n    typeof UserSpecifiedWebsiteOptions\n  >;\n  WebsiteDataSchema: InstanceType<typeof WebsiteData>;\n  WebsiteOptionsSchema: InstanceType<typeof WebsiteOptions>;\n  NotificationSchema: InstanceType<typeof Notification>;\n  CustomShortcutSchema: InstanceType<typeof CustomShortcut>;\n};\n\nexport const DatabaseSchemaEntityMapConst: Record<\n  SchemaKey,\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  InstanceType<any>\n> = {\n  AccountSchema: Account,\n  DirectoryWatcherSchema: DirectoryWatcher,\n  FileBufferSchema: FileBuffer,\n  PostEventSchema: PostEvent,\n  PostQueueRecordSchema: PostQueueRecord,\n  PostRecordSchema: PostRecord,\n  SettingsSchema: Settings,\n  SubmissionFileSchema: SubmissionFile,\n  SubmissionSchema: Submission,\n  TagConverterSchema: TagConverter,\n  TagGroupSchema: TagGroup,\n  UserConverterSchema: UserConverter,\n  UserSpecifiedWebsiteOptionsSchema: UserSpecifiedWebsiteOptions,\n  WebsiteDataSchema: WebsiteData,\n  WebsiteOptionsSchema: WebsiteOptions,\n  NotificationSchema: Notification,\n  CustomShortcutSchema: CustomShortcut,\n};\n"
  },
  {
    "path": "apps/client-server/src/app/drizzle/transaction-context.ts",
    "content": "import type { PostyBirbDatabaseType, SchemaKey } from '@postybirb/database';\nimport { Schemas } from '@postybirb/database';\nimport { Logger } from '@postybirb/logger';\nimport { EntityId } from '@postybirb/types';\nimport { eq } from 'drizzle-orm';\nimport { PostyBirbDatabase } from './postybirb-database/postybirb-database';\n\ninterface TrackedEntity {\n  schemaKey: SchemaKey;\n  id: EntityId;\n}\n\n/**\n * A transaction-like wrapper that tracks created entities and provides\n * automatic cleanup on failure. This works around drizzle-orm's synchronous\n * transaction requirement with better-sqlite3.\n */\nexport class TransactionContext {\n  private readonly logger = Logger();\n\n  private readonly createdEntities: TrackedEntity[] = [];\n\n  private readonly db: PostyBirbDatabaseType;\n\n  constructor(db: PostyBirbDatabaseType) {\n    this.db = db;\n  }\n\n  /**\n   * Track an entity that was created during this operation.\n   * If the operation fails, this entity will be deleted.\n   */\n  track(schemaKey: SchemaKey, id: EntityId): void {\n    this.createdEntities.push({ schemaKey, id });\n  }\n\n  /**\n   * Track multiple entities that were created during this operation.\n   */\n  trackMany(schemaKey: SchemaKey, ids: EntityId[]): void {\n    ids.forEach((id) => this.track(schemaKey, id));\n  }\n\n  /**\n   * Get the database instance for performing operations.\n   */\n  getDb(): PostyBirbDatabaseType {\n    return this.db;\n  }\n\n  /**\n   * Cleanup all tracked entities in reverse order (LIFO).\n   * This is called automatically on failure.\n   */\n  async cleanup(): Promise<void> {\n    this.logger.warn(\n      `Rolling back transaction: cleaning up ${this.createdEntities.length} entities`,\n    );\n\n    // Delete in reverse order (most recently created first)\n    const entities = [...this.createdEntities].reverse();\n\n    for (const { schemaKey, id } of entities) {\n      try {\n        const schema = Schemas[schemaKey];\n        await this.db.delete(schema).where(eq(schema.id, id));\n        this.logger.debug(`Cleaned up ${String(schemaKey)} entity: ${id}`);\n      } catch (err) {\n        this.logger.error(\n          `Failed to cleanup ${String(schemaKey)} entity ${id}: ${err.message}`,\n          err.stack,\n        );\n      }\n    }\n  }\n\n  /**\n   * Clear tracked entities and notify subscribers (called on successful completion).\n   *\n   * NOTE: Currently only tracks inserts. If update/delete tracking is needed in the future,\n   * add trackUpdate() and trackDelete() methods that store the action type alongside the entity.\n   */\n  commit(): void {\n    // Group tracked entities by schemaKey\n    const bySchema = new Map<SchemaKey, EntityId[]>();\n    for (const { schemaKey, id } of this.createdEntities) {\n      const existing = bySchema.get(schemaKey);\n      if (existing) {\n        existing.push(id);\n      } else {\n        bySchema.set(schemaKey, [id]);\n      }\n    }\n\n    // Notify subscribers for each schema\n    for (const [schemaKey, ids] of bySchema) {\n      PostyBirbDatabase.notifySubscribers(schemaKey, ids, 'insert');\n    }\n\n    this.createdEntities.length = 0;\n  }\n}\n\n/**\n * Execute an operation with automatic cleanup on failure.\n *\n * @example\n * ```typescript\n * const result = await withTransactionContext(\n *   this.fileRepository.db,\n *   async (ctx) => {\n *     const entity = await createEntity(...);\n *     ctx.track('SubmissionFileSchema', entity.id);\n *\n *     const buffer = await createBuffer(...);\n *     ctx.track('FileBufferSchema', buffer.id);\n *\n *     return entity;\n *   }\n * );\n * ```\n */\nexport async function withTransactionContext<T>(\n  db: PostyBirbDatabaseType,\n  operation: (ctx: TransactionContext) => Promise<T>,\n): Promise<T> {\n  const ctx = new TransactionContext(db);\n\n  try {\n    const result = await operation(ctx);\n    ctx.commit();\n    return result;\n  } catch (err) {\n    await ctx.cleanup();\n    throw err;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/file/file.controller.ts",
    "content": "import {\n  BadRequestException,\n  Controller,\n  Get,\n  Param,\n  Res,\n} from '@nestjs/common';\nimport { ApiNotFoundResponse, ApiOkResponse, ApiTags } from '@nestjs/swagger';\nimport { EntityId, IFileBuffer } from '@postybirb/types';\nimport { SubmissionFile } from '../drizzle/models';\nimport { FileService } from './file.service';\n\n@ApiTags('file')\n@Controller('file')\nexport class FileController {\n  constructor(private readonly service: FileService) {}\n\n  @Get(':fileTarget/:id')\n  @ApiOkResponse()\n  @ApiNotFoundResponse()\n  async getThumbnail(\n    @Param('fileTarget') fileTarget: 'file' | 'thumbnail' | 'alt',\n    @Param('id') id: EntityId,\n    @Res() response,\n  ) {\n    const submissionFile = await this.service.findFile(id);\n    await submissionFile.load(fileTarget);\n    const imageProvidingEntity = this.getFileBufferForTarget(\n      fileTarget,\n      submissionFile,\n    );\n    if (!imageProvidingEntity) {\n      throw new BadRequestException(`No ${fileTarget} found for file ${id}`);\n    }\n    response.contentType(imageProvidingEntity.mimeType);\n    response.send(imageProvidingEntity.buffer);\n  }\n\n  private getFileBufferForTarget(\n    fileTarget: 'file' | 'thumbnail' | 'alt',\n    submissionFile: SubmissionFile,\n  ): IFileBuffer | undefined {\n    switch (fileTarget) {\n      case 'file':\n        return submissionFile.file;\n      case 'thumbnail':\n        return submissionFile.thumbnail;\n      case 'alt':\n        return submissionFile.altFile;\n      default:\n        throw new BadRequestException('Invalid file target');\n    }\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/file/file.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { ImageProcessingModule } from '../image-processing/image-processing.module';\nimport { FileController } from './file.controller';\nimport { FileService } from './file.service';\nimport { CreateFileService } from './services/create-file.service';\nimport { UpdateFileService } from './services/update-file.service';\n\n@Module({\n  imports: [ImageProcessingModule],\n  controllers: [FileController],\n  providers: [FileService, CreateFileService, UpdateFileService],\n  exports: [FileService],\n})\nexport class FileModule {}\n"
  },
  {
    "path": "apps/client-server/src/app/file/file.service.spec.ts",
    "content": "import { Test, TestingModule } from '@nestjs/testing';\nimport { clearDatabase } from '@postybirb/database';\nimport { PostyBirbDirectories, writeSync } from '@postybirb/fs';\nimport { FileSubmission, SubmissionType } from '@postybirb/types';\nimport { readFileSync } from 'fs';\nimport { join } from 'path';\nimport { AccountService } from '../account/account.service';\nimport { CustomShortcutsService } from '../custom-shortcuts/custom-shortcuts.service';\nimport { SubmissionFile } from '../drizzle/models';\nimport { PostyBirbDatabase } from '../drizzle/postybirb-database/postybirb-database';\nimport { FileConverterService } from '../file-converter/file-converter.service';\nimport { FormGeneratorService } from '../form-generator/form-generator.service';\nimport { DescriptionParserService } from '../post-parsers/parsers/description-parser.service';\nimport { TagParserService } from '../post-parsers/parsers/tag-parser.service';\nimport { TitleParser } from '../post-parsers/parsers/title-parser';\nimport { PostParsersService } from '../post-parsers/post-parsers.service';\nimport { SettingsService } from '../settings/settings.service';\nimport { CreateSubmissionDto } from '../submission/dtos/create-submission.dto';\nimport { FileSubmissionService } from '../submission/services/file-submission.service';\nimport { MessageSubmissionService } from '../submission/services/message-submission.service';\nimport { SubmissionService } from '../submission/services/submission.service';\nimport { TagConvertersService } from '../tag-converters/tag-converters.service';\nimport { UserConvertersService } from '../user-converters/user-converters.service';\nimport { UserSpecifiedWebsiteOptionsService } from '../user-specified-website-options/user-specified-website-options.service';\nimport { ValidationService } from '../validation/validation.service';\nimport { WebsiteOptionsService } from '../website-options/website-options.service';\nimport { WebsiteImplProvider } from '../websites/implementations/provider';\nimport { WebsiteRegistryService } from '../websites/website-registry.service';\nimport { FileService } from './file.service';\nimport { MulterFileInfo } from './models/multer-file-info';\nimport { CreateFileService } from './services/create-file.service';\nimport { UpdateFileService } from './services/update-file.service';\nimport { SharpInstanceManager } from '../image-processing/sharp-instance-manager';\n\ndescribe('FileService', () => {\n  let testFile: Buffer | null = null;\n  let testFile2: Buffer | null = null;\n  let service: FileService;\n  let submissionService: SubmissionService;\n  let module: TestingModule;\n  let fileBufferRepository: PostyBirbDatabase<'FileBufferSchema'>;\n\n  async function createSubmission() {\n    const dto = new CreateSubmissionDto();\n    dto.name = 'test';\n    dto.type = SubmissionType.MESSAGE; // Use message submission just for the sake of insertion\n\n    const record = await submissionService.create(dto);\n    return record;\n  }\n\n  function createMulterData(path: string): MulterFileInfo {\n    return {\n      fieldname: 'file',\n      originalname: 'small_image.jpg',\n      encoding: '',\n      mimetype: 'image/jpeg',\n      size: testFile.length,\n      destination: '',\n      filename: 'small_image.jpg',\n      path,\n      origin: undefined,\n    };\n  }\n\n  function createMulterData2(path: string): MulterFileInfo {\n    return {\n      fieldname: 'file',\n      originalname: 'png_with_alpha.png',\n      encoding: '',\n      mimetype: 'image/png',\n      size: testFile2.length,\n      destination: '',\n      filename: 'png_with_alpha.jpg',\n      path,\n      origin: undefined,\n    };\n  }\n\n  function setup(): string[] {\n    const path = `${PostyBirbDirectories.DATA_DIRECTORY}/${Date.now()}.jpg`;\n    const path2 = `${PostyBirbDirectories.DATA_DIRECTORY}/${Date.now()}.png`;\n\n    writeSync(path, testFile);\n    writeSync(path2, testFile2);\n    return [path, path2];\n  }\n\n  beforeAll(() => {\n    testFile = readFileSync(\n      join(__dirname, '../../test-files/small_image.jpg'),\n    );\n    testFile2 = readFileSync(\n      join(__dirname, '../../test-files/png_with_alpha.png'),\n    );\n  });\n\n  beforeEach(async () => {\n    clearDatabase();\n    module = await Test.createTestingModule({\n      providers: [\n        UserSpecifiedWebsiteOptionsService,\n        SubmissionService,\n        CreateFileService,\n        UpdateFileService,\n        SharpInstanceManager,\n        FileService,\n        ValidationService,\n        SubmissionService,\n        FileSubmissionService,\n        MessageSubmissionService,\n        AccountService,\n        WebsiteRegistryService,\n        WebsiteOptionsService,\n        WebsiteImplProvider,\n        PostParsersService,\n        TagParserService,\n        DescriptionParserService,\n        TitleParser,\n        TagConvertersService,\n        SettingsService,\n        FormGeneratorService,\n        FileConverterService,\n        CustomShortcutsService,\n        UserConvertersService,\n      ],\n    }).compile();\n    fileBufferRepository = new PostyBirbDatabase('FileBufferSchema');\n    service = module.get<FileService>(FileService);\n    submissionService = module.get<SubmissionService>(SubmissionService);\n\n    const accountService = module.get<AccountService>(AccountService);\n    await accountService.onModuleInit();\n  });\n\n  async function loadBuffers(rec: SubmissionFile) {\n    // !bug - https://github.com/drizzle-team/drizzle-orm/issues/3497\n    // eslint-disable-next-line no-param-reassign\n    rec.file = await fileBufferRepository.findById(rec.primaryFileId);\n    // eslint-disable-next-line no-param-reassign\n    rec.thumbnail = rec.thumbnailId\n      ? await fileBufferRepository.findById(rec.thumbnailId)\n      : undefined;\n    // eslint-disable-next-line no-param-reassign\n    rec.altFile = rec.altFileId\n      ? await fileBufferRepository.findById(rec.altFileId)\n      : undefined;\n  }\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n\n  it('should create submission file', async () => {\n    const path = setup();\n    const submission = await createSubmission();\n    const fileInfo = createMulterData(path[0]);\n    const file = await service.create(\n      fileInfo,\n      submission as unknown as FileSubmission,\n    );\n    await loadBuffers(file);\n    expect(file.file).toBeDefined();\n    expect(file.thumbnail).toBeDefined();\n    expect(file.thumbnail.fileName.startsWith('thumbnail_')).toBe(true);\n    expect(file.fileName).toBe(fileInfo.originalname);\n    expect(file.size).toBe(fileInfo.size);\n    expect(file.hasThumbnail).toBe(true);\n    expect(file.hasCustomThumbnail).toBe(false);\n    expect(file.height).toBe(202);\n    expect(file.width).toBe(138);\n    expect(file.file.size).toBe(fileInfo.size);\n    expect(file.file.height).toBe(202);\n    expect(file.file.width).toBe(138);\n    expect(file.file.submissionFileId).toEqual(file.id);\n    expect(file.file.mimeType).toEqual(fileInfo.mimetype);\n    expect(file.file.buffer).toEqual(testFile);\n  });\n\n  it('should not update submission file when hash is same', async () => {\n    const path = setup();\n    const submission = await createSubmission();\n    const fileInfo = createMulterData(path[0]);\n    const file = await service.create(\n      fileInfo,\n      submission as unknown as FileSubmission,\n    );\n    await loadBuffers(file);\n    expect(file.file).toBeDefined();\n\n    const path2 = setup();\n    const updateFileInfo: MulterFileInfo = {\n      fieldname: 'file',\n      originalname: 'small_image.jpg',\n      encoding: '',\n      mimetype: 'image/png',\n      size: testFile.length,\n      destination: '',\n      filename: 'small_image.jpg',\n      path: path2[0],\n      origin: undefined,\n    };\n    const updatedFile = await service.update(updateFileInfo, file.id, false);\n    await loadBuffers(updatedFile);\n    expect(updatedFile.file).toBeDefined();\n    expect(updatedFile.thumbnail).toBeDefined();\n    expect(updatedFile.fileName).toBe(updateFileInfo.originalname);\n    expect(updatedFile.size).toBe(updateFileInfo.size);\n    expect(updatedFile.hasThumbnail).toBe(true);\n    expect(updatedFile.hasCustomThumbnail).toBe(false);\n    expect(updatedFile.height).toBe(202);\n    expect(updatedFile.width).toBe(138);\n    expect(updatedFile.file.size).toBe(updateFileInfo.size);\n    expect(updatedFile.file.height).toBe(202);\n    expect(updatedFile.file.width).toBe(138);\n    expect(updatedFile.file.submissionFileId).toEqual(file.id);\n    expect(updatedFile.file.mimeType).not.toEqual(updateFileInfo.mimetype);\n    expect(updatedFile.file.buffer).toEqual(testFile);\n  });\n\n  it('should update submission primary file', async () => {\n    const path = setup();\n    const submission = await createSubmission();\n    const fileInfo = createMulterData(path[0]);\n    const file = await service.create(\n      fileInfo,\n      submission as unknown as FileSubmission,\n    );\n    await loadBuffers(file);\n    expect(file.file).toBeDefined();\n\n    const path2 = setup();\n    const updateFileInfo = createMulterData2(path2[1]);\n    const updatedFile = await service.update(updateFileInfo, file.id, false);\n    await loadBuffers(updatedFile);\n    expect(updatedFile.file).toBeDefined();\n    expect(updatedFile.thumbnail).toBeDefined();\n    expect(updatedFile.fileName).toBe(updateFileInfo.filename);\n    expect(updatedFile.size).toBe(updateFileInfo.size);\n    expect(updatedFile.hasThumbnail).toBe(true);\n    expect(updatedFile.hasCustomThumbnail).toBe(false);\n    expect(updatedFile.height).toBe(600);\n    expect(updatedFile.width).toBe(600);\n    expect(updatedFile.file.size).toBe(updateFileInfo.size);\n    expect(updatedFile.file.height).toBe(600);\n    expect(updatedFile.file.width).toBe(600);\n    expect(updatedFile.file.submissionFileId).toEqual(file.id);\n    expect(updatedFile.file.mimeType).toEqual(updateFileInfo.mimetype);\n    expect(updatedFile.file.buffer).toEqual(testFile2);\n  });\n\n  it('should cleanup entities on transaction failure', async () => {\n    const path = setup();\n    const submission = await createSubmission();\n    const fileInfo = createMulterData(path[0]);\n\n    // Get initial count of entities\n    const initialFiles = await new PostyBirbDatabase(\n      'SubmissionFileSchema',\n    ).findAll();\n    const initialBuffers = await fileBufferRepository.findAll();\n    const initialFileCount = initialFiles.length;\n    const initialBufferCount = initialBuffers.length;\n\n    // Mock a method to throw an error partway through creation\n    const createFileService = module.get<CreateFileService>(CreateFileService);\n    const originalMethod = createFileService.createFileBufferEntity;\n    let callCount = 0;\n    jest\n      .spyOn(createFileService, 'createFileBufferEntity')\n      .mockImplementation(async (...args) => {\n        callCount++;\n        // Fail on the second buffer creation (thumbnail)\n        if (callCount === 2) {\n          throw new Error('Simulated error during buffer creation');\n        }\n        return originalMethod.apply(createFileService, args);\n      });\n\n    // Attempt to create a file, which should fail and trigger cleanup\n    await expect(\n      service.create(fileInfo, submission as unknown as FileSubmission),\n    ).rejects.toThrow('Simulated error during buffer creation');\n\n    // Verify that entities were cleaned up\n    const finalFiles = await new PostyBirbDatabase(\n      'SubmissionFileSchema',\n    ).findAll();\n    const finalBuffers = await fileBufferRepository.findAll();\n\n    expect(finalFiles.length).toBe(initialFileCount);\n    expect(finalBuffers.length).toBe(initialBufferCount);\n\n    // Restore the original method\n    jest.restoreAllMocks();\n  });\n});\n"
  },
  {
    "path": "apps/client-server/src/app/file/file.service.ts",
    "content": "/* eslint-disable no-param-reassign */\nimport { BadRequestException, Injectable } from '@nestjs/common';\nimport { read } from '@postybirb/fs';\nimport { Logger } from '@postybirb/logger';\nimport {\n  EntityId,\n  FileSubmission,\n  SubmissionFileMetadata,\n} from '@postybirb/types';\nimport type { queueAsPromised } from 'fastq';\nimport fastq from 'fastq';\nimport { readFile } from 'fs/promises';\nimport { cpus } from 'os';\nimport { SubmissionFile } from '../drizzle/models';\nimport { PostyBirbDatabase } from '../drizzle/postybirb-database/postybirb-database';\nimport { ReorderSubmissionFilesDto } from '../submission/dtos/reorder-submission-files.dto';\nimport { UpdateAltFileDto } from '../submission/dtos/update-alt-file.dto';\nimport { MulterFileInfo, TaskOrigin } from './models/multer-file-info';\nimport { CreateTask, Task, UpdateTask } from './models/task';\nimport { TaskType } from './models/task-type.enum';\nimport { CreateFileService } from './services/create-file.service';\nimport { UpdateFileService } from './services/update-file.service';\n\n/**\n * Service that handles storing file data into database.\n * @todo text encoding parsing and file name conversion (no periods)\n */\n@Injectable()\nexport class FileService {\n  private readonly logger = Logger();\n\n  private readonly queue: queueAsPromised<Task, SubmissionFile> = fastq.promise<\n    this,\n    Task\n  >(this, this.doTask, Math.min(cpus().length, 5));\n\n  private readonly fileBufferRepository = new PostyBirbDatabase(\n    'FileBufferSchema',\n  );\n\n  private readonly fileRepository = new PostyBirbDatabase(\n    'SubmissionFileSchema',\n  );\n\n  constructor(\n    private readonly createFileService: CreateFileService,\n    private readonly updateFileService: UpdateFileService,\n  ) {}\n\n  /**\n   * Deletes a file.\n   *\n   * @param {EntityId} id\n   * @return {*}\n   */\n  public async remove(id: EntityId) {\n    this.logger.info(id, `Removing entity '${id}'`);\n    return this.fileRepository.deleteById([id]);\n  }\n\n  /**\n   * Queues a file to create a database record.\n   *\n   * @param {MulterFileInfo} file\n   * @param {FileSubmission} submission\n   * @return {*}  {Promise<SubmissionFile>}\n   */\n  public async create(\n    file: MulterFileInfo,\n    submission: FileSubmission,\n  ): Promise<SubmissionFile> {\n    return this.queue.push({ type: TaskType.CREATE, file, submission });\n  }\n\n  /**\n   * Queues a file to update.\n   *\n   * @param {MulterFileInfo} file\n   * @param {EntityId} submissionFileId\n   * @param {boolean} forThumbnail\n   * @return {*}  {Promise<SubmissionFile>}\n   */\n  public async update(\n    file: MulterFileInfo,\n    submissionFileId: EntityId,\n    forThumbnail: boolean,\n  ): Promise<SubmissionFile> {\n    return this.queue.push({\n      type: TaskType.UPDATE,\n      file,\n      submissionFileId,\n      target: forThumbnail ? 'thumbnail' : undefined,\n    });\n  }\n\n  private async doTask(task: Task): Promise<SubmissionFile> {\n    task.file.originalname = this.sanitizeFilename(task.file.originalname);\n    const buf: Buffer = await this.getFile(task.file.path, task.file.origin);\n    this.logger.withMetadata(task).info('Reading File');\n    switch (task.type) {\n      case TaskType.CREATE:\n        // eslint-disable-next-line no-case-declarations\n        const ct = task as CreateTask;\n        return this.createFileService.create(ct.file, ct.submission, buf);\n      case TaskType.UPDATE:\n        // eslint-disable-next-line no-case-declarations\n        const ut = task as UpdateTask;\n        return this.updateFileService.update(\n          ut.file,\n          ut.submissionFileId,\n          buf,\n          ut.target,\n        );\n      default:\n        throw new BadRequestException(`Unknown TaskType '${task.type}'`);\n    }\n  }\n\n  /**\n   * Removes periods from the filename.\n   * There is some website that doesn't like them.\n   *\n   * @param {string} filename\n   * @return {*}  {string}\n   */\n  private sanitizeFilename(filename: string): string {\n    const nameParts = filename.split('.');\n    const ext = nameParts.pop();\n    return `${nameParts.join('_')}.${ext}`;\n  }\n\n  private async getFile(path: string, taskOrigin: TaskOrigin): Promise<Buffer> {\n    switch (taskOrigin) {\n      case 'directory-watcher':\n        return readFile(path);\n      default:\n        // Approved location\n        return read(path);\n    }\n  }\n\n  /**\n   * Returns file by Id.\n   *\n   * @param {EntityId} id\n   */\n  public async findFile(id: EntityId): Promise<SubmissionFile> {\n    return this.fileRepository.findById(id, { failOnMissing: true });\n  }\n\n  /**\n   * Gets the size of an alt text file without loading the buffer.\n   * @param {EntityId} id\n   */\n  async getAltFileSize(id: EntityId): Promise<number> {\n    const altFile = await this.fileBufferRepository.findById(id, {\n      failOnMissing: false,\n    });\n    return altFile?.size ?? 0;\n  }\n\n  /**\n   * Gets the raw text of an alt text file.\n   * @param {EntityId} id\n   */\n  async getAltText(id: EntityId): Promise<string> {\n    const altFile = await this.fileBufferRepository.findById(id, {\n      failOnMissing: true,\n    });\n    if (altFile.size) {\n      return altFile.buffer.toString();\n    }\n\n    return '';\n  }\n\n  /**\n   * Updates the raw text of an alt text file.\n   * @param {EntityId} id\n   * @param {UpdateAltFileDto} update\n   */\n  async updateAltText(id: EntityId, update: UpdateAltFileDto) {\n    const buffer = Buffer.from(update.text ?? '');\n    return this.fileBufferRepository.update(id, {\n      buffer,\n      mimeType: 'text/plain',\n      size: buffer.length,\n    });\n  }\n\n  async updateMetadata(id: string, update: SubmissionFileMetadata) {\n    const file = await this.findFile(id);\n    const merged = { ...file.metadata, ...update };\n    await this.fileRepository.update(id, { metadata: merged });\n  }\n\n  async reorderFiles(update: ReorderSubmissionFilesDto) {\n    await Promise.all(\n      Object.entries(update.order).map(([id, order]) =>\n        this.fileRepository.update(id, { order }),\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/file/models/multer-file-info.ts",
    "content": "/**\n * Matches a multer file info object.\n * TypeScript is not importing multer types correctly.\n *\n * @interface MulterFileInfo\n */\nexport interface MulterFileInfo {\n  fieldname: string;\n  originalname: string;\n  encoding: string;\n  mimetype: string;\n  size: number;\n  destination: string;\n  filename: string;\n  path: string;\n  buffer?: Buffer;\n  /**\n   * Internal origin, empty when external.\n   * @type {TaskOrigin}\n   */\n  origin?: TaskOrigin;\n}\n\nexport type TaskOrigin = 'directory-watcher';\n"
  },
  {
    "path": "apps/client-server/src/app/file/models/task-type.enum.ts",
    "content": "/**\n * Defines the requested TaskType\n */\nexport enum TaskType {\n  CREATE = 'CREATE', // Creating a new file entity\n  UPDATE = 'UPDATE', // Updating a file entity\n}\n"
  },
  {
    "path": "apps/client-server/src/app/file/models/task.ts",
    "content": "import { EntityId, FileSubmission } from '@postybirb/types';\nimport { MulterFileInfo } from './multer-file-info';\nimport { TaskType } from './task-type.enum';\n\n// Task Type\nexport type Task = CreateTask | UpdateTask;\n\n// Defines CreateTask Params\nexport type CreateTask = {\n  type: TaskType;\n  /**\n   * File for use.\n   * @type {MulterFileInfo}\n   */\n  file: MulterFileInfo;\n  /**\n   * Submission the entities will be attached to.\n   * @type {FileSubmission}\n   */\n  submission: FileSubmission;\n};\n\n// Defines UpdateTask Params\nexport type UpdateTask = {\n  type: TaskType;\n  /**\n   * Updating file.\n   *\n   * @type {MulterFileInfo}\n   */\n  file: MulterFileInfo;\n  /**\n   * SubmissionFile being updated.\n   * @type {EntityId}\n   */\n  submissionFileId: EntityId;\n  /**\n   * The target type being updates (primary on empty).\n   * @type {string}\n   */\n  target?: 'thumbnail';\n};\n"
  },
  {
    "path": "apps/client-server/src/app/file/services/create-file.service.ts",
    "content": "import * as rtf from '@iarna/rtf-to-html';\nimport { Injectable } from '@nestjs/common';\nimport { Insert, Select } from '@postybirb/database';\nimport { removeFile } from '@postybirb/fs';\nimport { Logger } from '@postybirb/logger';\nimport {\n    DefaultSubmissionFileMetadata,\n    FileSubmission,\n    FileType,\n    IFileBuffer,\n} from '@postybirb/types';\nimport { getFileType } from '@postybirb/utils/file-type';\nimport { eq } from 'drizzle-orm';\nimport { async as hash } from 'hasha';\nimport { htmlToText } from 'html-to-text';\nimport * as mammoth from 'mammoth';\nimport { parse } from 'path';\nimport { promisify } from 'util';\nimport { v4 as uuid } from 'uuid';\n\nimport {\n    FileBuffer,\n    fromDatabaseRecord,\n    SubmissionFile,\n} from '../../drizzle/models';\nimport { PostyBirbDatabase } from '../../drizzle/postybirb-database/postybirb-database';\nimport {\n    TransactionContext,\n    withTransactionContext,\n} from '../../drizzle/transaction-context';\nimport { SharpInstanceManager } from '../../image-processing/sharp-instance-manager';\nimport { MulterFileInfo } from '../models/multer-file-info';\nimport { ImageUtil } from '../utils/image.util';\n\n/**\n * A Service that defines operations for creating a SubmissionFile.\n * @class CreateFileService\n */\n@Injectable()\nexport class CreateFileService {\n  private readonly logger = Logger();\n\n  private readonly fileBufferRepository = new PostyBirbDatabase(\n    'FileBufferSchema',\n  );\n\n  private readonly fileRepository = new PostyBirbDatabase(\n    'SubmissionFileSchema',\n  );\n\n  constructor(\n    private readonly sharpInstanceManager: SharpInstanceManager,\n  ) {}\n\n  /**\n   * Creates file entity and stores it.\n   * @todo extra data (image resize per website)\n   * @todo figure out what to do about non-image\n   *\n   * @param {MulterFileInfo} file\n   * @param {MulterFileInfo} submission\n   * @param {Buffer} buf\n   * @return {*}  {Promise<SubmissionFile>}\n   */\n  public async create(\n    file: MulterFileInfo,\n    submission: FileSubmission,\n    buf: Buffer,\n  ): Promise<SubmissionFile> {\n    try {\n      this.logger.withMetadata(file).info(`Creating SubmissionFile entity`);\n\n      const newSubmission = await withTransactionContext(\n        this.fileRepository.db,\n        async (ctx) => {\n          let entity = await this.createSubmissionFile(\n            ctx,\n            file,\n            submission,\n            buf,\n          );\n\n          if (ImageUtil.isImage(file.mimetype, true)) {\n            this.logger.info('[Mutation] Populating as Image');\n            entity = await this.populateAsImageFile(ctx, entity, file, buf);\n          }\n\n          if (getFileType(file.originalname) === FileType.TEXT) {\n            await this.createSubmissionTextAltFile(ctx, entity, file, buf);\n          }\n\n          const primaryFile = await this.createFileBufferEntity(\n            ctx,\n            entity,\n            buf,\n          );\n          await ctx\n            .getDb()\n            .update(this.fileRepository.schemaEntity)\n            .set({ primaryFileId: primaryFile.id })\n            .where(eq(this.fileRepository.schemaEntity.id, entity.id));\n          this.logger\n            .withMetadata({ id: entity.id })\n            .info('SubmissionFile Created');\n\n          return entity;\n        },\n      );\n      return await this.fileRepository.findById(newSubmission.id);\n    } catch (err) {\n      this.logger.error(err.message, err.stack);\n      throw err;\n    } finally {\n      if (!file.origin) {\n        removeFile(file.path);\n      }\n    }\n  }\n\n  /**\n   * Populates an alt file containing text data extracted from a file.\n   * Currently supports docx, rtf, and plaintext.\n   *\n   * @param {SubmissionFile} entity\n   * @param {MulterFileInfo} file\n   * @param {Buffer} buf\n   */\n  async createSubmissionTextAltFile(\n    ctx: TransactionContext,\n    entity: SubmissionFile,\n    file: MulterFileInfo,\n    buf: Buffer,\n  ) {\n    // Default to empty string - all TEXT files get an alt file\n    let altText = '';\n\n    if (\n      file.mimetype ===\n        'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||\n      file.mimetype === 'application/msword' ||\n      file.originalname.endsWith('.docx') ||\n      file.originalname.endsWith('.doc')\n    ) {\n      this.logger.info('[Mutation] Creating Alt File for Text Document: DOCX');\n      altText = (await mammoth.extractRawText({ buffer: buf })).value;\n    } else if (\n      file.mimetype === 'application/rtf' ||\n      file.originalname.endsWith('.rtf')\n    ) {\n      this.logger.info('[Mutation] Creating Alt File for Text Document: RTF');\n      const promisifiedRtf = promisify(rtf.fromString);\n      const rtfHtml = await promisifiedRtf(buf.toString(), {\n        template(_, __, content: string) {\n          return content;\n        },\n      });\n      altText = htmlToText(rtfHtml, { wordwrap: false });\n    } else if (\n      file.mimetype === 'text/plain' ||\n      file.originalname.endsWith('.txt')\n    ) {\n      this.logger.info('[Mutation] Creating Alt File for Text Document: TXT');\n      altText = buf.toString();\n    } else {\n      this.logger.info(\n        `[Mutation] Creating empty Alt File for unsupported text format: ${file.mimetype}`,\n      );\n    }\n\n    const prettifiedBuf = Buffer.from(altText ?? '');\n    const altFile = await this.createFileBufferEntity(\n      ctx,\n      entity,\n      prettifiedBuf,\n      {\n        mimeType: 'text/plain',\n        fileName: `${entity.fileName}.txt`,\n      },\n    );\n    await ctx\n      .getDb()\n      .update(this.fileRepository.schemaEntity)\n      .set({\n        altFileId: altFile.id,\n        hasAltFile: true,\n      })\n      .where(eq(this.fileRepository.schemaEntity.id, entity.id));\n    this.logger.withMetadata({ id: altFile.id }).info('Alt File Created');\n  }\n\n  /**\n   * Creates a SubmissionFile with pre-populated fields.\n   *\n   * @param {MulterFileInfo} file\n   * @param {FileSubmission} submission\n   * @param {Buffer} buf\n   * @return {*}  {Promise<SubmissionFile>}\n   */\n  private async createSubmissionFile(\n    ctx: TransactionContext,\n    file: MulterFileInfo,\n    submission: FileSubmission,\n    buf: Buffer,\n  ): Promise<SubmissionFile> {\n    const { mimetype: mimeType, originalname, size } = file;\n    const submissionFile: Insert<'SubmissionFileSchema'> = {\n      submissionId: submission.id,\n      mimeType,\n      fileName: originalname,\n      size,\n      hash: await hash(buf, { algorithm: 'sha256' }),\n      width: 0,\n      height: 0,\n      hasThumbnail: false,\n      metadata: DefaultSubmissionFileMetadata(),\n      order: Date.now(),\n    };\n    const sf = fromDatabaseRecord(\n      SubmissionFile,\n      await ctx\n        .getDb()\n        .insert(this.fileRepository.schemaEntity)\n        .values(submissionFile)\n        .returning(),\n    );\n\n    const entity = sf[0];\n    ctx.track('SubmissionFileSchema', entity.id);\n    return entity;\n  }\n\n  /**\n   * Populates SubmissionFile with Image specific fields.\n   * Width, Height, Thumbnail.\n   *\n   * @param {SubmissionFile} entity\n   * @param {MulterFileInfo} file\n   * @param {Buffer} buf\n   * @return {*}  {Promise<void>}\n   */\n  private async populateAsImageFile(\n    ctx: TransactionContext,\n    entity: SubmissionFile,\n    file: MulterFileInfo,\n    buf: Buffer,\n  ): Promise<SubmissionFile> {\n    const meta = await this.sharpInstanceManager.getMetadata(buf);\n    const thumbnail = await this.createFileThumbnail(\n      ctx,\n      entity,\n      file,\n      buf,\n    );\n    const update: Select<typeof this.fileRepository.schemaEntity> = {\n      width: meta.width ?? 0,\n      height: meta.height ?? 0,\n      hasThumbnail: true,\n      thumbnailId: thumbnail.id,\n      metadata: {\n        ...entity.metadata,\n        dimensions: {\n          default: {\n            width: meta.width ?? 0,\n            height: meta.height ?? 0,\n          },\n        },\n      },\n    };\n\n    return fromDatabaseRecord(\n      SubmissionFile,\n      await ctx\n        .getDb()\n        .update(this.fileRepository.schemaEntity)\n        .set(update)\n        .where(eq(this.fileRepository.schemaEntity.id, entity.id))\n        .returning(),\n    )[0];\n  }\n\n  /**\n   * Returns a thumbnail entity for a file.\n   *\n   * @param {SubmissionFile} fileEntity\n   * @param {MulterFileInfo} file\n   * @param {Buffer} imageBuffer - The source image buffer\n   * @return {*}  {Promise<IFileBuffer>}\n   */\n  public async createFileThumbnail(\n    ctx: TransactionContext,\n    fileEntity: SubmissionFile,\n    file: MulterFileInfo,\n    imageBuffer: Buffer,\n  ): Promise<IFileBuffer> {\n    const {\n      buffer: thumbnailBuf,\n      height,\n      width,\n      mimeType: thumbnailMimeType,\n    } = await this.generateThumbnail(\n      imageBuffer,\n      file.mimetype,\n    );\n\n    // Remove existing extension and add the appropriate thumbnail extension\n    const fileNameWithoutExt = parse(fileEntity.fileName).name;\n    const thumbnailExt = thumbnailMimeType === 'image/jpeg' ? 'jpg' : 'png';\n\n    return this.createFileBufferEntity(ctx, fileEntity, thumbnailBuf, {\n      height,\n      width,\n      mimeType: thumbnailMimeType,\n      fileName: `thumbnail_${fileNameWithoutExt}.${thumbnailExt}`,\n    });\n  }\n\n  /**\n   * Generates a thumbnail for display at specific dimension requirements.\n   * Delegates to the sharp worker pool for crash isolation.\n   *\n   * @param {Buffer} imageBuffer - The source image buffer\n   * @param {string} sourceMimeType - The mimetype of the source image\n   * @param {number} [preferredDimension=400] - The preferred thumbnail dimension\n   * @return {*}  {Promise<{ width: number; height: number; buffer: Buffer; mimeType: string }>}\n   */\n  public async generateThumbnail(\n    imageBuffer: Buffer,\n    sourceMimeType: string,\n    preferredDimension = 400,\n  ): Promise<{\n    width: number;\n    height: number;\n    buffer: Buffer;\n    mimeType: string;\n  }> {\n    const result = await this.sharpInstanceManager.generateThumbnail(\n      imageBuffer,\n      sourceMimeType,\n      'thumbnail',\n      preferredDimension,\n    );\n\n    return {\n      buffer: result.buffer,\n      height: result.height,\n      width: result.width,\n      mimeType: result.mimeType,\n    };\n  }\n\n  /**\n   * Creates a file buffer entity for storing blob data of a file.\n   *\n   * @param {File} fileEntity\n   * @param {Buffer} buf\n   * @param {string} type - thumbnail/alt/primary\n   * @return {*}  {IFileBuffer}\n   */\n  public async createFileBufferEntity(\n    ctx: TransactionContext,\n    fileEntity: SubmissionFile,\n    buf: Buffer,\n    opts: Select<'FileBufferSchema'> = {} as Select<'FileBufferSchema'>,\n  ): Promise<FileBuffer> {\n    const { mimeType, height, width, fileName } = fileEntity;\n    const data: Insert<'FileBufferSchema'> = {\n      id: uuid(),\n      buffer: buf,\n      submissionFileId: fileEntity.id,\n      height,\n      width,\n      fileName,\n      mimeType,\n      size: buf.length,\n      ...opts,\n    };\n\n    const result = fromDatabaseRecord(\n      FileBuffer,\n      await ctx\n        .getDb()\n        .insert(this.fileBufferRepository.schemaEntity)\n        .values(data)\n        .returning(),\n    )[0];\n\n    ctx.track('FileBufferSchema', result.id);\n    return result;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/file/services/update-file.service.ts",
    "content": "/* eslint-disable no-param-reassign */\nimport * as rtf from '@iarna/rtf-to-html';\nimport {\n    BadRequestException,\n    Injectable,\n    NotFoundException,\n} from '@nestjs/common';\nimport { Logger } from '@postybirb/logger';\nimport { EntityId, FileType } from '@postybirb/types';\nimport { getFileType } from '@postybirb/utils/file-type';\nimport { eq } from 'drizzle-orm';\nimport { async as hash } from 'hasha';\nimport { htmlToText } from 'html-to-text';\nimport * as mammoth from 'mammoth';\nimport { parse } from 'path';\nimport { promisify } from 'util';\nimport { SubmissionFile } from '../../drizzle/models';\nimport { PostyBirbDatabase } from '../../drizzle/postybirb-database/postybirb-database';\nimport {\n    TransactionContext,\n    withTransactionContext,\n} from '../../drizzle/transaction-context';\nimport { SharpInstanceManager } from '../../image-processing/sharp-instance-manager';\nimport { MulterFileInfo } from '../models/multer-file-info';\nimport { ImageUtil } from '../utils/image.util';\nimport { CreateFileService } from './create-file.service';\n\n/**\n * A Service for updating existing SubmissionFile entities.\n */\n@Injectable()\nexport class UpdateFileService {\n  private readonly logger = Logger();\n\n  private readonly fileRepository = new PostyBirbDatabase(\n    'SubmissionFileSchema',\n  );\n\n  private readonly fileBufferRepository = new PostyBirbDatabase(\n    'FileBufferSchema',\n  );\n\n  constructor(\n    private readonly createFileService: CreateFileService,\n    private readonly sharpInstanceManager: SharpInstanceManager,\n  ) {}\n\n  /**\n   * Creates file entity and stores it.\n   *\n   * @param {MulterFileInfo} file\n   * @param {MulterFileInfo} submission\n   * @param {Buffer} buf\n   * @param {string} target\n   * @return {*}  {Promise<SubmissionFile>}\n   */\n  public async update(\n    file: MulterFileInfo,\n    submissionFileId: EntityId,\n    buf: Buffer,\n    target?: 'thumbnail',\n  ): Promise<SubmissionFile> {\n    const submissionFile = await this.findFile(submissionFileId);\n\n    await withTransactionContext(this.fileRepository.db, async (ctx) => {\n      if (target === 'thumbnail') {\n        await this.replaceFileThumbnail(ctx, submissionFile, file, buf);\n      } else {\n        await this.replacePrimaryFile(ctx, submissionFile, file, buf);\n      }\n    });\n\n    // Notify subscribers so SubmissionService emits a websocket update\n    this.fileRepository.forceNotify([submissionFileId], 'update');\n    this.fileBufferRepository.forceNotify([submissionFileId], 'update');\n\n    // return the latest\n    return this.findFile(submissionFileId);\n  }\n\n  private async replaceFileThumbnail(\n    ctx: TransactionContext,\n    submissionFile: SubmissionFile,\n    file: MulterFileInfo,\n    buf: Buffer,\n  ) {\n    const thumbnailDetails = await this.getImageDetails(file, buf);\n    let { thumbnailId } = submissionFile;\n\n    if (!thumbnailId) {\n      // Create a new thumbnail buffer entity\n      const thumbnail = await this.createFileService.createFileBufferEntity(\n        ctx,\n        submissionFile,\n        thumbnailDetails.buffer,\n        {\n          width: thumbnailDetails.width,\n          height: thumbnailDetails.height,\n          mimeType: file.mimetype,\n        },\n      );\n      thumbnailId = thumbnail.id;\n    } else {\n      // Update existing thumbnail buffer\n      await ctx\n        .getDb()\n        .update(this.fileBufferRepository.schemaEntity)\n        .set({\n          buffer: thumbnailDetails.buffer,\n          size: thumbnailDetails.buffer.length,\n          mimeType: file.mimetype,\n          width: thumbnailDetails.width,\n          height: thumbnailDetails.height,\n        })\n        .where(eq(this.fileBufferRepository.schemaEntity.id, thumbnailId));\n    }\n\n    // Recompute hash from thumbnail buffer so the frontend cache-buster updates\n    const thumbnailHash = await hash(thumbnailDetails.buffer, {\n      algorithm: 'sha256',\n    });\n\n    await ctx\n      .getDb()\n      .update(this.fileRepository.schemaEntity)\n      .set({\n        thumbnailId,\n        hasCustomThumbnail: true,\n        hasThumbnail: true,\n        hash: thumbnailHash,\n      })\n      .where(eq(this.fileRepository.schemaEntity.id, submissionFile.id));\n  }\n\n  async replacePrimaryFile(\n    ctx: TransactionContext,\n    submissionFile: SubmissionFile,\n    file: MulterFileInfo,\n    buf: Buffer,\n  ) {\n    return this.updateFileEntity(ctx, submissionFile, file, buf);\n  }\n\n  private async updateFileEntity(\n    ctx: TransactionContext,\n    submissionFile: SubmissionFile,\n    file: MulterFileInfo,\n    buf: Buffer,\n  ) {\n    const fileHash = await hash(buf, { algorithm: 'sha256' });\n\n    // Only need to replace when unique file is given\n    if (submissionFile.hash !== fileHash) {\n      const fileType = getFileType(file.filename);\n      if (fileType === FileType.IMAGE) {\n        await this.updateImageFileProps(ctx, submissionFile, file, buf);\n      }\n\n      // Update submission file entity\n      await ctx\n        .getDb()\n        .update(this.fileRepository.schemaEntity)\n        .set({\n          hash: fileHash,\n          size: buf.length,\n          fileName: file.filename,\n          mimeType: file.mimetype,\n        })\n        .where(eq(this.fileRepository.schemaEntity.id, submissionFile.id));\n\n      // Just to get the latest data\n\n      // Duplicate props to primary file\n      await ctx\n        .getDb()\n        .update(this.fileBufferRepository.schemaEntity)\n        .set({\n          buffer: buf,\n          size: buf.length,\n          fileName: file.filename,\n          mimeType: file.mimetype,\n        })\n        .where(\n          eq(\n            this.fileBufferRepository.schemaEntity.id,\n            submissionFile.primaryFileId,\n          ),\n        );\n\n      if (\n        getFileType(file.originalname) === FileType.TEXT &&\n        submissionFile.hasAltFile\n      ) {\n        const altFileText =\n          (await this.repopulateTextFile(file, buf)) ||\n          submissionFile?.altFile?.buffer;\n        if (altFileText) {\n          await ctx\n            .getDb()\n            .update(this.fileBufferRepository.schemaEntity)\n            .set({\n              buffer: altFileText,\n              size: altFileText.length,\n            })\n            .where(\n              eq(\n                this.fileBufferRepository.schemaEntity.id,\n                submissionFile.altFile.id,\n              ),\n            );\n          await ctx\n            .getDb()\n            .update(this.fileRepository.schemaEntity)\n            .set({\n              hasAltFile: true,\n            })\n            .where(eq(this.fileRepository.schemaEntity.id, submissionFile.id));\n        }\n      }\n    }\n  }\n\n  async repopulateTextFile(\n    file: MulterFileInfo,\n    buf: Buffer,\n  ): Promise<Buffer | null> {\n    let altText: string;\n    if (\n      file.mimetype ===\n        'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||\n      file.originalname.endsWith('.docx')\n    ) {\n      this.logger.info('[Mutation] Updating Alt File for Text Document: DOCX');\n      altText = (await mammoth.extractRawText({ buffer: buf })).value;\n    }\n\n    if (\n      file.mimetype === 'application/rtf' ||\n      file.originalname.endsWith('.rtf')\n    ) {\n      this.logger.info('[Mutation] Updating Alt File for Text Document: RTF');\n      const promisifiedRtf = promisify(rtf.fromString);\n      const rtfHtml = await promisifiedRtf(buf.toString(), {\n        template(_, __, content: string) {\n          return content;\n        },\n      });\n      altText = htmlToText(rtfHtml, { wordwrap: false });\n    }\n\n    if (file.mimetype === 'text/plain' || file.originalname.endsWith('.txt')) {\n      this.logger.info('[Mutation] Updating Alt File for Text Document: TXT');\n      altText = buf.toString();\n    }\n\n    return altText\n      ? Buffer.from(altText)\n      : null;\n  }\n\n  private async updateImageFileProps(\n    ctx: TransactionContext,\n    submissionFile: SubmissionFile,\n    file: MulterFileInfo,\n    buf: Buffer,\n  ) {\n    const { width, height } = await this.getImageDetails(\n      file,\n      buf,\n    );\n    await ctx\n      .getDb()\n      .update(this.fileRepository.schemaEntity)\n      .set({\n        width,\n        height,\n      })\n      .where(eq(this.fileRepository.schemaEntity.id, submissionFile.id));\n\n    await ctx\n      .getDb()\n      .update(this.fileBufferRepository.schemaEntity)\n      .set({\n        width,\n        height,\n      })\n      .where(\n        eq(\n          this.fileBufferRepository.schemaEntity.id,\n          submissionFile.primaryFileId,\n        ),\n      );\n\n    // Reset metadata dimensions so they don't reference the old file size\n    const updatedMetadata = { ...submissionFile.metadata };\n    if (updatedMetadata.dimensions) {\n      updatedMetadata.dimensions = {\n        ...updatedMetadata.dimensions,\n        default: { width, height },\n      };\n    }\n    await ctx\n      .getDb()\n      .update(this.fileRepository.schemaEntity)\n      .set({ metadata: updatedMetadata })\n      .where(eq(this.fileRepository.schemaEntity.id, submissionFile.id));\n\n    if (submissionFile.hasThumbnail && !submissionFile.hasCustomThumbnail) {\n      // Regenerate auto-thumbnail;\n      const {\n        buffer: thumbnailBuf,\n        width: thumbnailWidth,\n        height: thumbnailHeight,\n        mimeType: thumbnailMimeType,\n      } = await this.createFileService.generateThumbnail(\n        buf,\n        file.mimetype,\n      );\n\n      const fileNameWithoutExt = parse(file.filename).name;\n      const thumbnailExt = thumbnailMimeType === 'image/jpeg' ? 'jpg' : 'png';\n\n      await ctx\n        .getDb()\n        .update(this.fileBufferRepository.schemaEntity)\n        .set({\n          buffer: thumbnailBuf,\n          width: thumbnailWidth,\n          height: thumbnailHeight,\n          size: thumbnailBuf.length,\n          mimeType: thumbnailMimeType,\n          fileName: `thumbnail_${fileNameWithoutExt}.${thumbnailExt}`,\n        })\n        .where(\n          eq(\n            this.fileBufferRepository.schemaEntity.id,\n            submissionFile.thumbnailId,\n          ),\n        );\n    }\n  }\n\n  /**\n   * Details of a multer file.\n   *\n   * @param {MulterFileInfo} file\n   */\n  private async getImageDetails(file: MulterFileInfo, buf: Buffer) {\n    if (ImageUtil.isImage(file.mimetype, false)) {\n      const { height, width } = await this.sharpInstanceManager.getMetadata(buf);\n      return { buffer: buf, width, height };\n    }\n\n    throw new BadRequestException('File is not an image');\n  }\n\n  /**\n   * Returns file by Id.\n   *\n   * @param {EntityId} id\n   */\n  private async findFile(id: EntityId): Promise<SubmissionFile> {\n    try {\n      const entity = await this.fileRepository.findOne({\n        where: (f, { eq: equals }) => equals(f.id, id),\n        // !bug - https://github.com/drizzle-team/drizzle-orm/issues/3497\n        // with: {\n        //   thumbnail: true,\n        //   primaryFile: true,\n        //   altFile: true,\n        // },\n      });\n\n      return entity;\n    } catch (e) {\n      this.logger.error(e.message, e.stack);\n      throw new NotFoundException(id);\n    }\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/file/utils/image.util.ts",
    "content": "/**\n * Utility class for image-related checks.\n *\n * NOTE: Sharp image processing has been moved to SharpInstanceManager\n * which runs sharp in isolated worker threads for crash protection.\n * The load() and getMetadata() methods have been removed.\n * Use SharpInstanceManager.getMetadata() or SharpInstanceManager.resizeForPost() instead.\n */\nexport class ImageUtil {\n  static isImage(mimetype: string, includeGIF = false): boolean {\n    if (includeGIF && mimetype === 'image/gif') {\n      return true;\n    }\n\n    return mimetype.startsWith('image/') && mimetype !== 'image/gif';\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/file-converter/converters/file-converter.ts",
    "content": "import { IFileBuffer } from '@postybirb/types';\n\nexport interface IFileConverter {\n  /**\n   * Determines if the file can be converted to any of the allowable output mime types.\n   *\n   * @param {IFileBuffer} file\n   * @param {string[]} allowableOutputMimeTypes\n   * @return {*}  {boolean}\n   */\n  canConvert(file: IFileBuffer, allowableOutputMimeTypes: string[]): boolean;\n\n  /**\n   * Converts the file to one of the allowable output mime types.\n   *\n   * @param {IFileBuffer} file\n   * @param {string[]} allowableOutputMimeTypes\n   * @return {*}  {Promise<IFileBuffer>}\n   */\n  convert(\n    file: IFileBuffer,\n    allowableOutputMimeTypes: string[],\n  ): Promise<IFileBuffer>;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/file-converter/converters/text-file-converter.ts",
    "content": "import { IFileBuffer } from '@postybirb/types';\nimport { htmlToText } from 'html-to-text';\nimport { TurndownService } from 'turndown';\nimport { IFileConverter } from './file-converter';\n\nconst supportedInputMimeTypes = ['text/html', 'text/plain'] as const;\nconst supportedOutputMimeTypes = [\n  'text/plain',\n  'text/html',\n  'text/markdown',\n] as const;\n\ntype SupportedInputMimeTypes = (typeof supportedInputMimeTypes)[number];\ntype SupportedOutputMimeTypes = (typeof supportedOutputMimeTypes)[number];\n\ntype ConversionMap = {\n  [inputMimeType in SupportedInputMimeTypes]: {\n    [outputMimeType in SupportedOutputMimeTypes]: (\n      file: IFileBuffer,\n    ) => Promise<IFileBuffer>;\n  };\n};\n\ntype ConversionWeights = {\n  [outputMimeType in SupportedOutputMimeTypes]: number;\n};\n\n/**\n * A class that converts text files to other text formats.\n * Largely for use when converting AltFiles (text/plain or text/html) to other desirable formats.\n * @class TextFileConverter\n * @implements {IFileConverter}\n */\nexport class TextFileConverter implements IFileConverter {\n  private passThrough = async (file: IFileBuffer): Promise<IFileBuffer> => ({\n    ...file,\n  });\n\n  private convertHtmlToPlaintext = async (\n    file: IFileBuffer,\n  ): Promise<IFileBuffer> => {\n    const text = htmlToText(file.buffer.toString(), {\n      wordwrap: 120,\n    });\n    return this.toMergedBuffer(file, text, 'text/plain');\n  };\n\n  private convertHtmlToMarkdown = async (\n    file: IFileBuffer,\n  ): Promise<IFileBuffer> => {\n    const turndownService = new TurndownService();\n    const markdown = turndownService.turndown(file.buffer.toString());\n    return this.toMergedBuffer(file, markdown, 'text/markdown');\n  };\n\n  /**\n   * Converts plain text to HTML by wrapping lines in <p> tags.\n   */\n  private convertPlaintextToHtml = async (\n    file: IFileBuffer,\n  ): Promise<IFileBuffer> => {\n    const lines = file.buffer.toString().split(/\\n/);\n    const html = lines\n      .map((line) => `<p>${line || '<br>'}</p>`)\n      .join('\\n');\n    return this.toMergedBuffer(file, html, 'text/html');\n  };\n\n  /**\n   * Plain text is valid markdown, so this is a passthrough with mime type change.\n   */\n  private convertPlaintextToMarkdown = async (\n    file: IFileBuffer,\n  ): Promise<IFileBuffer> => this.toMergedBuffer(file, file.buffer.toString(), 'text/markdown');\n\n  private readonly supportConversionMappers: ConversionMap = {\n    'text/html': {\n      'text/html': this.passThrough,\n      'text/plain': this.convertHtmlToPlaintext,\n      'text/markdown': this.convertHtmlToMarkdown,\n    },\n    'text/plain': {\n      'text/plain': this.passThrough,\n      'text/html': this.convertPlaintextToHtml,\n      'text/markdown': this.convertPlaintextToMarkdown,\n    },\n  };\n\n  /**\n   * Defines the preference of conversion, trying to convert to the most preferred format first.\n   */\n  private readonly conversionWeights: ConversionWeights = {\n    'text/plain': Number.MAX_SAFE_INTEGER,\n    'text/html': 1,\n    'text/markdown': 2,\n  };\n\n  canConvert(file: IFileBuffer, allowableOutputMimeTypes: string[]): boolean {\n    return (\n      supportedInputMimeTypes.includes(\n        file.mimeType as SupportedInputMimeTypes,\n      ) &&\n      supportedOutputMimeTypes.some((m) => allowableOutputMimeTypes.includes(m))\n    );\n  }\n\n  async convert(\n    file: IFileBuffer,\n    allowableOutputMimeTypes: string[],\n  ): Promise<IFileBuffer> {\n    const conversionMap =\n      this.supportConversionMappers[file.mimeType as SupportedInputMimeTypes];\n\n    const sortedOutputMimeTypes = allowableOutputMimeTypes\n      .filter((mimeType) => mimeType in conversionMap)\n      .sort(\n        (a, b) =>\n          this.conversionWeights[a as SupportedOutputMimeTypes] -\n          this.conversionWeights[b as SupportedOutputMimeTypes],\n      );\n\n    for (const outputMimeType of sortedOutputMimeTypes) {\n      const conversionFunction =\n        conversionMap[outputMimeType as SupportedOutputMimeTypes];\n      if (conversionFunction) {\n        return conversionFunction(file);\n      }\n    }\n\n    throw new Error(\n      `Cannot convert file ${file.fileName} with mime type: ${file.mimeType}`,\n    );\n  }\n\n  private toMergedBuffer(\n    fb: IFileBuffer,\n    str: string,\n    mimeType: string,\n  ): IFileBuffer {\n    return {\n      ...fb,\n      buffer: Buffer.from(str),\n      mimeType,\n    };\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/file-converter/file-converter.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { FileConverterService } from './file-converter.service';\n\n@Module({\n  providers: [FileConverterService],\n  exports: [FileConverterService],\n})\nexport class FileConverterModule {}\n"
  },
  {
    "path": "apps/client-server/src/app/file-converter/file-converter.service.spec.ts",
    "content": "import { Test, TestingModule } from '@nestjs/testing';\nimport { clearDatabase } from '@postybirb/database';\nimport { FileConverterService } from './file-converter.service';\n\ndescribe('FileConverterService', () => {\n  let service: FileConverterService;\n\n  beforeEach(async () => {\n    clearDatabase();\n    const module: TestingModule = await Test.createTestingModule({\n      providers: [FileConverterService],\n    }).compile();\n\n    service = module.get<FileConverterService>(FileConverterService);\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/client-server/src/app/file-converter/file-converter.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { IFileBuffer } from '@postybirb/types';\nimport { IFileConverter } from './converters/file-converter';\nimport { TextFileConverter } from './converters/text-file-converter';\n\n@Injectable()\nexport class FileConverterService {\n  private readonly converters: IFileConverter[] = [new TextFileConverter()];\n\n  public async convert<T extends IFileBuffer>(\n    file: T,\n    allowableOutputMimeTypes: string[],\n  ): Promise<IFileBuffer> {\n    const converter = this.converters.find((c) =>\n      c.canConvert(file, allowableOutputMimeTypes),\n    );\n\n    if (!converter) {\n      throw new Error('No converter found for file');\n    }\n\n    return converter.convert(file, allowableOutputMimeTypes);\n  }\n\n  public async canConvert(\n    mimeType: string,\n    allowableOutputMimeTypes: string[],\n  ): Promise<boolean> {\n    return this.converters.some((c) =>\n      c.canConvert({ mimeType } as IFileBuffer, allowableOutputMimeTypes),\n    );\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/form-generator/dtos/form-generation-request.dto.ts",
    "content": "import { ApiProperty } from '@nestjs/swagger';\nimport {\n  AccountId,\n  IFormGenerationRequestDto,\n  SubmissionType,\n} from '@postybirb/types';\nimport { IsBoolean, IsEnum, IsOptional, IsString } from 'class-validator';\n\nexport class FormGenerationRequestDto implements IFormGenerationRequestDto {\n  @ApiProperty()\n  @IsString()\n  accountId: AccountId;\n\n  @ApiProperty({ enum: SubmissionType })\n  @IsEnum(SubmissionType)\n  type: SubmissionType;\n\n  @ApiProperty()\n  @IsOptional()\n  @IsBoolean()\n  isMultiSubmission?: boolean;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/form-generator/form-generator.controller.ts",
    "content": "import { Body, Controller, Post } from '@nestjs/common';\nimport { ApiResponse, ApiTags } from '@nestjs/swagger';\nimport { NULL_ACCOUNT_ID } from '@postybirb/types';\nimport { FormGenerationRequestDto } from './dtos/form-generation-request.dto';\nimport { FormGeneratorService } from './form-generator.service';\n\n@ApiTags('form-generator')\n@Controller('form-generator')\nexport class FormGeneratorController {\n  constructor(private readonly service: FormGeneratorService) {}\n\n  @Post()\n  @ApiResponse({\n    status: 200,\n    description: 'Returns the generated form with default',\n  })\n  @ApiResponse({ status: 404, description: 'Website instance not found.' })\n  @ApiResponse({\n    status: 500,\n    description: 'An error occurred while performing operation.',\n  })\n  getFormForWebsite(@Body() request: FormGenerationRequestDto) {\n    return request.accountId === NULL_ACCOUNT_ID\n      ? this.service.getDefaultForm(request.type, request.isMultiSubmission)\n      : this.service.generateForm(request);\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/form-generator/form-generator.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { AccountModule } from '../account/account.module';\nimport { UserSpecifiedWebsiteOptionsModule } from '../user-specified-website-options/user-specified-website-options.module';\nimport { WebsitesModule } from '../websites/websites.module';\nimport { FormGeneratorController } from './form-generator.controller';\nimport { FormGeneratorService } from './form-generator.service';\n\n@Module({\n  imports: [WebsitesModule, UserSpecifiedWebsiteOptionsModule, AccountModule],\n  providers: [FormGeneratorService],\n  controllers: [FormGeneratorController],\n  exports: [FormGeneratorService],\n})\nexport class FormGeneratorModule {}\n"
  },
  {
    "path": "apps/client-server/src/app/form-generator/form-generator.service.spec.ts",
    "content": "import { NotFoundException } from '@nestjs/common';\nimport { Test, TestingModule } from '@nestjs/testing';\nimport { clearDatabase } from '@postybirb/database';\nimport {\n  NullAccount,\n  SubmissionRating,\n  SubmissionType,\n} from '@postybirb/types';\nimport { AccountModule } from '../account/account.module';\nimport { AccountService } from '../account/account.service';\nimport { CreateUserSpecifiedWebsiteOptionsDto } from '../user-specified-website-options/dtos/create-user-specified-website-options.dto';\nimport { UserSpecifiedWebsiteOptionsModule } from '../user-specified-website-options/user-specified-website-options.module';\nimport { UserSpecifiedWebsiteOptionsService } from '../user-specified-website-options/user-specified-website-options.service';\nimport { WebsitesModule } from '../websites/websites.module';\nimport { FormGeneratorService } from './form-generator.service';\n\ndescribe('FormGeneratorService', () => {\n  let service: FormGeneratorService;\n  let userSpecifiedService: UserSpecifiedWebsiteOptionsService;\n  let accountService: AccountService;\n  let module: TestingModule;\n\n  beforeEach(async () => {\n    clearDatabase();\n    module = await Test.createTestingModule({\n      imports: [\n        AccountModule,\n        WebsitesModule,\n        UserSpecifiedWebsiteOptionsModule,\n      ],\n      providers: [FormGeneratorService],\n    }).compile();\n\n    service = module.get<FormGeneratorService>(FormGeneratorService);\n    accountService = module.get<AccountService>(AccountService);\n    userSpecifiedService = module.get<UserSpecifiedWebsiteOptionsService>(\n      UserSpecifiedWebsiteOptionsService\n    );\n\n    await accountService.onModuleInit();\n  });\n\n  afterAll(async () => {\n    await module.close();\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n\n  it('should fail on missing account', async () => {\n    await expect(\n      service.generateForm({ accountId: 'fake', type: SubmissionType.MESSAGE })\n    ).rejects.toThrow(NotFoundException);\n  });\n\n  it('should return user specific defaults', async () => {\n    const userSpecifiedDto = new CreateUserSpecifiedWebsiteOptionsDto();\n    userSpecifiedDto.accountId = new NullAccount().id;\n    userSpecifiedDto.type = SubmissionType.MESSAGE;\n    userSpecifiedDto.options = { rating: SubmissionRating.ADULT };\n    await userSpecifiedService.create(userSpecifiedDto);\n\n    const messageForm = await service.getDefaultForm(SubmissionType.MESSAGE);\n    expect(messageForm).toMatchInlineSnapshot(`\n      {\n        \"contentWarning\": {\n          \"defaultValue\": \"\",\n          \"formField\": \"input\",\n          \"hidden\": false,\n          \"label\": \"contentWarning\",\n          \"order\": 5,\n          \"responsive\": {\n            \"xs\": 12,\n          },\n          \"section\": \"common\",\n          \"span\": 12,\n          \"type\": \"text\",\n        },\n        \"description\": {\n          \"defaultValue\": {\n            \"description\": {\n              \"content\": [],\n              \"type\": \"doc\",\n            },\n            \"overrideDefault\": false,\n          },\n          \"descriptionType\": \"html\",\n          \"formField\": \"description\",\n          \"label\": \"description\",\n          \"order\": 4,\n          \"required\": true,\n          \"responsive\": {\n            \"xs\": 12,\n          },\n          \"section\": \"common\",\n          \"span\": 12,\n          \"type\": \"description\",\n        },\n        \"rating\": {\n          \"defaultValue\": \"ADULT\",\n          \"formField\": \"rating\",\n          \"label\": \"rating\",\n          \"layout\": \"horizontal\",\n          \"options\": [\n            {\n              \"label\": \"General\",\n              \"value\": \"GENERAL\",\n            },\n            {\n              \"label\": \"Mature\",\n              \"value\": \"MATURE\",\n            },\n            {\n              \"label\": \"Adult\",\n              \"value\": \"ADULT\",\n            },\n            {\n              \"label\": \"Extreme\",\n              \"value\": \"EXTREME\",\n            },\n          ],\n          \"order\": 1,\n          \"required\": true,\n          \"responsive\": {\n            \"xs\": 12,\n          },\n          \"section\": \"common\",\n          \"span\": 12,\n          \"type\": \"rating\",\n        },\n        \"tags\": {\n          \"defaultValue\": {\n            \"overrideDefault\": false,\n            \"tags\": [],\n          },\n          \"formField\": \"tag\",\n          \"label\": \"tags\",\n          \"minTagLength\": 1,\n          \"order\": 3,\n          \"responsive\": {\n            \"xs\": 12,\n          },\n          \"section\": \"common\",\n          \"spaceReplacer\": \"_\",\n          \"span\": 12,\n          \"type\": \"tag\",\n        },\n        \"title\": {\n          \"defaultValue\": \"\",\n          \"expectedInDescription\": false,\n          \"formField\": \"input\",\n          \"label\": \"title\",\n          \"order\": 2,\n          \"required\": true,\n          \"responsive\": {\n            \"xs\": 12,\n          },\n          \"section\": \"common\",\n          \"span\": 12,\n          \"type\": \"title\",\n        },\n      }\n    `);\n  });\n\n  it('should return standard form', async () => {\n    const messageForm = await service.getDefaultForm(SubmissionType.MESSAGE);\n    expect(messageForm).toMatchInlineSnapshot(`\n      {\n        \"contentWarning\": {\n          \"defaultValue\": \"\",\n          \"formField\": \"input\",\n          \"hidden\": false,\n          \"label\": \"contentWarning\",\n          \"order\": 5,\n          \"responsive\": {\n            \"xs\": 12,\n          },\n          \"section\": \"common\",\n          \"span\": 12,\n          \"type\": \"text\",\n        },\n        \"description\": {\n          \"defaultValue\": {\n            \"description\": {\n              \"content\": [],\n              \"type\": \"doc\",\n            },\n            \"overrideDefault\": false,\n          },\n          \"descriptionType\": \"html\",\n          \"formField\": \"description\",\n          \"label\": \"description\",\n          \"order\": 4,\n          \"required\": true,\n          \"responsive\": {\n            \"xs\": 12,\n          },\n          \"section\": \"common\",\n          \"span\": 12,\n          \"type\": \"description\",\n        },\n        \"rating\": {\n          \"defaultValue\": \"GENERAL\",\n          \"formField\": \"rating\",\n          \"label\": \"rating\",\n          \"layout\": \"horizontal\",\n          \"options\": [\n            {\n              \"label\": \"General\",\n              \"value\": \"GENERAL\",\n            },\n            {\n              \"label\": \"Mature\",\n              \"value\": \"MATURE\",\n            },\n            {\n              \"label\": \"Adult\",\n              \"value\": \"ADULT\",\n            },\n            {\n              \"label\": \"Extreme\",\n              \"value\": \"EXTREME\",\n            },\n          ],\n          \"order\": 1,\n          \"required\": true,\n          \"responsive\": {\n            \"xs\": 12,\n          },\n          \"section\": \"common\",\n          \"span\": 12,\n          \"type\": \"rating\",\n        },\n        \"tags\": {\n          \"defaultValue\": {\n            \"overrideDefault\": false,\n            \"tags\": [],\n          },\n          \"formField\": \"tag\",\n          \"label\": \"tags\",\n          \"minTagLength\": 1,\n          \"order\": 3,\n          \"responsive\": {\n            \"xs\": 12,\n          },\n          \"section\": \"common\",\n          \"spaceReplacer\": \"_\",\n          \"span\": 12,\n          \"type\": \"tag\",\n        },\n        \"title\": {\n          \"defaultValue\": \"\",\n          \"expectedInDescription\": false,\n          \"formField\": \"input\",\n          \"label\": \"title\",\n          \"order\": 2,\n          \"required\": true,\n          \"responsive\": {\n            \"xs\": 12,\n          },\n          \"section\": \"common\",\n          \"span\": 12,\n          \"type\": \"title\",\n        },\n      }\n    `);\n\n    const fileForm = await service.getDefaultForm(SubmissionType.FILE);\n    expect(fileForm).toMatchInlineSnapshot(`\n      {\n        \"contentWarning\": {\n          \"defaultValue\": \"\",\n          \"formField\": \"input\",\n          \"hidden\": false,\n          \"label\": \"contentWarning\",\n          \"order\": 5,\n          \"responsive\": {\n            \"xs\": 12,\n          },\n          \"section\": \"common\",\n          \"span\": 12,\n          \"type\": \"text\",\n        },\n        \"description\": {\n          \"defaultValue\": {\n            \"description\": {\n              \"content\": [],\n              \"type\": \"doc\",\n            },\n            \"overrideDefault\": false,\n          },\n          \"descriptionType\": \"html\",\n          \"formField\": \"description\",\n          \"label\": \"description\",\n          \"order\": 4,\n          \"required\": true,\n          \"responsive\": {\n            \"xs\": 12,\n          },\n          \"section\": \"common\",\n          \"span\": 12,\n          \"type\": \"description\",\n        },\n        \"rating\": {\n          \"defaultValue\": \"GENERAL\",\n          \"formField\": \"rating\",\n          \"label\": \"rating\",\n          \"layout\": \"horizontal\",\n          \"options\": [\n            {\n              \"label\": \"General\",\n              \"value\": \"GENERAL\",\n            },\n            {\n              \"label\": \"Mature\",\n              \"value\": \"MATURE\",\n            },\n            {\n              \"label\": \"Adult\",\n              \"value\": \"ADULT\",\n            },\n            {\n              \"label\": \"Extreme\",\n              \"value\": \"EXTREME\",\n            },\n          ],\n          \"order\": 1,\n          \"required\": true,\n          \"responsive\": {\n            \"xs\": 12,\n          },\n          \"section\": \"common\",\n          \"span\": 12,\n          \"type\": \"rating\",\n        },\n        \"tags\": {\n          \"defaultValue\": {\n            \"overrideDefault\": false,\n            \"tags\": [],\n          },\n          \"formField\": \"tag\",\n          \"label\": \"tags\",\n          \"minTagLength\": 1,\n          \"order\": 3,\n          \"responsive\": {\n            \"xs\": 12,\n          },\n          \"section\": \"common\",\n          \"spaceReplacer\": \"_\",\n          \"span\": 12,\n          \"type\": \"tag\",\n        },\n        \"title\": {\n          \"defaultValue\": \"\",\n          \"expectedInDescription\": false,\n          \"formField\": \"input\",\n          \"label\": \"title\",\n          \"order\": 2,\n          \"required\": true,\n          \"responsive\": {\n            \"xs\": 12,\n          },\n          \"section\": \"common\",\n          \"span\": 12,\n          \"type\": \"title\",\n        },\n      }\n    `);\n  });\n});\n"
  },
  {
    "path": "apps/client-server/src/app/form-generator/form-generator.service.ts",
    "content": "import {\n  BadRequestException,\n  Injectable,\n  NotFoundException,\n} from '@nestjs/common';\nimport { FormBuilderMetadata, formBuilder } from '@postybirb/form-builder';\nimport {\n  AccountId,\n  IWebsiteFormFields,\n  NullAccount,\n  SubmissionType,\n} from '@postybirb/types';\nimport { AccountService } from '../account/account.service';\nimport { UserSpecifiedWebsiteOptionsService } from '../user-specified-website-options/user-specified-website-options.service';\nimport { DefaultWebsiteOptions } from '../websites/models/default-website-options';\nimport { isFileWebsite } from '../websites/models/website-modifiers/file-website';\nimport { isMessageWebsite } from '../websites/models/website-modifiers/message-website';\nimport { WebsiteRegistryService } from '../websites/website-registry.service';\nimport { FormGenerationRequestDto } from './dtos/form-generation-request.dto';\n\n@Injectable()\nexport class FormGeneratorService {\n  constructor(\n    private readonly websiteRegistryService: WebsiteRegistryService,\n    private readonly userSpecifiedWebsiteOptionsService: UserSpecifiedWebsiteOptionsService,\n    private readonly accountService: AccountService,\n  ) {}\n\n  /**\n   * Generates the form properties for a submission option.\n   * Form properties are used for form generation in UI.\n   *\n   * @param {FormGenerationRequestDto} request\n   */\n  async generateForm(\n    request: FormGenerationRequestDto,\n  ): Promise<FormBuilderMetadata> {\n    const account = await this.accountService.findById(request.accountId, {\n      failOnMissing: true,\n    });\n\n    // Get instance for creation\n    const instance = await this.websiteRegistryService.findInstance(account);\n\n    if (!instance) {\n      throw new NotFoundException('Unable to find website instance');\n    }\n\n    // Get data for inserting into form\n    const data = instance.getFormProperties();\n\n    // Get form model\n    let formModel: IWebsiteFormFields = null;\n    if (request.type === SubmissionType.MESSAGE && isMessageWebsite(instance)) {\n      formModel = instance.createMessageModel();\n    }\n\n    if (request.type === SubmissionType.FILE && isFileWebsite(instance)) {\n      formModel = instance.createFileModel();\n    }\n\n    if (!formModel) {\n      throw new BadRequestException(\n        `Website instance does not support ${request.type}`,\n      );\n    }\n\n    const form = formBuilder(formModel, data);\n    const formWithPopulatedDefaults = await this.populateUserDefaults(\n      form,\n      request.accountId,\n      request.type,\n    );\n\n    if (request.isMultiSubmission) {\n      delete formWithPopulatedDefaults.title; // Having title here just causes confusion for multi this flow\n    }\n\n    return formWithPopulatedDefaults;\n  }\n\n  /**\n   * Returns the default fields form.\n   * @param {SubmissionType} type\n   */\n  async getDefaultForm(type: SubmissionType, isMultiSubmission = false) {\n    const form = await this.populateUserDefaults(\n      formBuilder(new DefaultWebsiteOptions(), {}),\n      new NullAccount().id,\n      type,\n    );\n    if (isMultiSubmission) {\n      delete form.title; // Having title here just causes confusion for multi this flow\n    }\n    return form;\n  }\n\n  private async populateUserDefaults(\n    form: FormBuilderMetadata,\n    accountId: AccountId,\n    type: SubmissionType,\n  ): Promise<FormBuilderMetadata> {\n    const userSpecifiedDefaults =\n      await this.userSpecifiedWebsiteOptionsService.findByAccountAndSubmissionType(\n        accountId,\n        type,\n      );\n\n    if (userSpecifiedDefaults) {\n      Object.entries(userSpecifiedDefaults.options).forEach(([key, value]) => {\n        const field = form[key];\n        if (field) {\n          field.defaultValue = value ?? field.defaultValue;\n        }\n      });\n    }\n\n    return form;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/image-processing/image-processing.module.ts",
    "content": "import { Global, Module } from '@nestjs/common';\nimport { SharpInstanceManager } from './sharp-instance-manager';\n\n/**\n * Global module providing the SharpInstanceManager to all modules.\n * Sharp image processing is isolated in worker threads to protect\n * the main process from native libvips crashes.\n */\n@Global()\n@Module({\n  providers: [SharpInstanceManager],\n  exports: [SharpInstanceManager],\n})\nexport class ImageProcessingModule {}\n"
  },
  {
    "path": "apps/client-server/src/app/image-processing/index.ts",
    "content": "export { ImageProcessingModule } from './image-processing.module';\nexport { SharpInstanceManager } from './sharp-instance-manager';\nexport type { SharpWorkerInput, SharpWorkerResult } from './sharp-instance-manager';\n"
  },
  {
    "path": "apps/client-server/src/app/image-processing/sharp-instance-manager.ts",
    "content": "import { Injectable, OnModuleDestroy } from '@nestjs/common';\nimport { Logger } from '@postybirb/logger';\nimport { ImageResizeProps } from '@postybirb/types';\nimport { IsTestEnvironment } from '@postybirb/utils/electron';\nimport { existsSync } from 'fs';\nimport { cpus } from 'os';\nimport { join, resolve } from 'path';\nimport Piscina from 'piscina';\n\n/**\n * Input sent to the sharp worker thread.\n */\nexport interface SharpWorkerInput {\n  operation: 'resize' | 'metadata' | 'thumbnail' | 'healthcheck';\n  buffer: Buffer;\n  resize?: ImageResizeProps;\n  mimeType: string;\n  fileName?: string;\n  fileId?: string;\n  fileWidth?: number;\n  fileHeight?: number;\n  thumbnailBuffer?: Buffer;\n  thumbnailMimeType?: string;\n  thumbnailPreferredDimension?: number;\n  generateThumbnail?: boolean;\n}\n\n/**\n * Result returned from the sharp worker thread.\n */\nexport interface SharpWorkerResult {\n  buffer?: Buffer;\n  mimeType?: string;\n  width?: number;\n  height?: number;\n  format?: string;\n  fileName?: string;\n  modified: boolean;\n  thumbnailBuffer?: Buffer;\n  thumbnailMimeType?: string;\n  thumbnailWidth?: number;\n  thumbnailHeight?: number;\n  thumbnailFileName?: string;\n}\n\n/**\n * Manages a pool of worker threads that run sharp image processing.\n *\n * This isolates sharp/libvips native code from the main process so that\n * if libvips segfaults (e.g. after long idle periods), only the worker\n * dies — the main process survives and piscina spawns a replacement.\n *\n * @class SharpInstanceManager\n */\n@Injectable()\nexport class SharpInstanceManager implements OnModuleDestroy {\n  private readonly logger = Logger();\n\n  private pool: Piscina | null = null;\n\n  /**\n   * In test mode, we call the worker function directly in-process to avoid\n   * thread contention issues with the electron test runner. The sharp logic\n   * is still fully exercised — only the threading is bypassed.\n   */\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  private workerFn: ((input: any) => Promise<any>) | null = null;\n\n  constructor() {\n    const workerPath = this.resolveWorkerPath();\n\n    if (IsTestEnvironment()) {\n      // In tests: call the worker function directly (no threads)\n      // eslint-disable-next-line @typescript-eslint/no-require-imports, import/no-dynamic-require, global-require\n      this.workerFn = require(workerPath);\n    } else {\n      // Production: use piscina for crash isolation\n      const maxThreads = Math.min(cpus().length, 4);\n\n      this.logger\n        .withMetadata({ workerPath, maxThreads })\n        .info('Initializing sharp worker pool');\n\n      this.pool = new Piscina({\n        filename: workerPath,\n        maxThreads,\n        minThreads: 0, // Allow ALL workers to be reaped after idle\n        idleTimeout: 60_000, // Kill idle workers after 60s\n      });\n    }\n\n    // Run health check asynchronously — don't block construction,\n    // but log warnings if sharp is broken on this system.\n    // The .catch() is a safety net to prevent unhandled rejection\n    // if something unexpected escapes the try/catch inside runHealthCheck.\n    this.runHealthCheck().catch((err) => {\n      this.logger\n        .withError(err)\n        .error('Unexpected error during sharp health check');\n    });\n  }\n\n  /**\n   * Probe the worker with a trivial sharp operation to detect\n   * missing native bindings, glibc issues, or sandbox restrictions\n   * at startup rather than failing silently during posting.\n   */\n  private async runHealthCheck(): Promise<void> {\n    try {\n      const result = await this.processImage({\n        operation: 'healthcheck',\n        buffer: Buffer.alloc(0),\n        mimeType: '',\n      });\n\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n      const { diagnostics } = result as any;\n      if (diagnostics) {\n        this.logger\n          .withMetadata(diagnostics)\n          .info('Sharp health check passed');\n      }\n    } catch (error) {\n      this.logger\n        .withError(error)\n        .error(\n          'Sharp health check FAILED — image processing will not work. ' +\n          'This usually means native sharp bindings failed to load. ' +\n          'On Linux, ensure glibc >= 2.17 is installed. ' +\n          'On Snap/Flatpak, the sandbox may prevent loading native modules.',\n        );\n    }\n  }\n\n  async onModuleDestroy() {\n    if (this.pool) {\n      this.logger.info('Destroying sharp worker pool');\n      try {\n        await this.pool.destroy();\n      } catch {\n        // pool.destroy() throws for in-flight tasks — safe to ignore\n      }\n    }\n  }\n\n  /**\n   * Cached resolved path — avoids repeated filesystem probing.\n   */\n  private static resolvedWorkerPath: string | null = null;\n\n  /**\n   * Resolve the path to the sharp-worker.js file.\n   * Works in:\n   * - Production build (webpack output): __dirname/assets/sharp-worker.js\n   * - Development (nx serve): relative to source tree\n   * - Test mode: also relative to source tree (for require())\n   */\n  private resolveWorkerPath(): string {\n    if (SharpInstanceManager.resolvedWorkerPath) {\n      return SharpInstanceManager.resolvedWorkerPath;\n    }\n\n    const candidates = [\n      // Production build: assets sit next to the bundled main.js\n      join(__dirname, 'assets', 'sharp-worker.js'),\n      // Source tree: __dirname = apps/client-server/src/app/image-processing\n      resolve(__dirname, '..', '..', '..', 'assets', 'sharp-worker.js'),\n      // cwd-based (nx serve, standalone)\n      join(\n        process.cwd(),\n        'apps',\n        'client-server',\n        'src',\n        'assets',\n        'sharp-worker.js',\n      ),\n      // cwd-based (jest may cd into apps/client-server)\n      join(process.cwd(), 'src', 'assets', 'sharp-worker.js'),\n    ];\n\n    for (const candidate of candidates) {\n      if (existsSync(candidate)) {\n        SharpInstanceManager.resolvedWorkerPath = candidate;\n        return candidate;\n      }\n    }\n\n    this.logger.warn(\n      `Sharp worker not found in any candidate path. Checked: ${candidates.join(', ')}`,\n    );\n    return candidates[0];\n  }\n\n  /**\n   * Process an image using the worker pool.\n   *\n   * Buffers are copied to the worker via structured clone — the caller's\n   * original buffers are NOT detached and remain usable after the call.\n   * This means peak memory for a single operation is roughly\n   * 2× the input size (original + worker copy).\n   */\n  async processImage(input: SharpWorkerInput): Promise<SharpWorkerResult> {\n    try {\n      // In test mode, call the worker function directly (no threads)\n      if (this.workerFn) {\n        return (await this.workerFn(input)) as SharpWorkerResult;\n      }\n\n      const result = await this.pool.run(input);\n\n      // Structured clone across worker threads converts Node.js Buffers\n      // into plain Uint8Arrays. Re-wrap them so downstream consumers\n      // (e.g. form-data) that rely on Buffer methods/stream semantics work.\n      if (result.buffer && !Buffer.isBuffer(result.buffer)) {\n        result.buffer = Buffer.from(result.buffer);\n      }\n      if (result.thumbnailBuffer && !Buffer.isBuffer(result.thumbnailBuffer)) {\n        result.thumbnailBuffer = Buffer.from(result.thumbnailBuffer);\n      }\n\n      return result as SharpWorkerResult;\n    } catch (error) {\n      this.logger\n        .withError(error)\n        .error('Sharp worker error — worker may have crashed');\n      throw error;\n    }\n  }\n\n  /**\n   * Get metadata for an image buffer.\n   * @param buffer - The image buffer\n   * @returns width, height, format, mimeType\n   */\n  async getMetadata(buffer: Buffer): Promise<{\n    width: number;\n    height: number;\n    format: string;\n    mimeType: string;\n  }> {\n    const result = await this.processImage({\n      operation: 'metadata',\n      buffer,\n      mimeType: '',\n    });\n\n    return {\n      width: result.width ?? 0,\n      height: result.height ?? 0,\n      format: result.format ?? 'unknown',\n      mimeType: result.mimeType ?? 'image/unknown',\n    };\n  }\n\n  /**\n   * Generate a thumbnail from an image buffer.\n   * Used by CreateFileService and UpdateFileService during file upload.\n   */\n  async generateThumbnail(\n    buffer: Buffer,\n    mimeType: string,\n    fileName: string,\n    preferredDimension = 400,\n  ): Promise<{\n    buffer: Buffer;\n    width: number;\n    height: number;\n    mimeType: string;\n    fileName: string;\n  }> {\n    const result = await this.processImage({\n      operation: 'thumbnail',\n      buffer,\n      mimeType,\n      fileName,\n      thumbnailPreferredDimension: preferredDimension,\n    });\n\n    return {\n      buffer: result.buffer ?? buffer,\n      width: result.width ?? 0,\n      height: result.height ?? 0,\n      mimeType: result.mimeType ?? mimeType,\n      fileName: result.fileName ?? fileName,\n    };\n  }\n\n  /**\n   * Resize an image for posting. Handles format conversion, dimensional\n   * resize, maxBytes scaling, and optional thumbnail generation.\n   */\n  async resizeForPost(input: {\n    buffer: Buffer;\n    resize?: ImageResizeProps;\n    mimeType: string;\n    fileName: string;\n    fileId: string;\n    fileWidth: number;\n    fileHeight: number;\n    thumbnailBuffer?: Buffer;\n    thumbnailMimeType?: string;\n    generateThumbnail: boolean;\n    thumbnailPreferredDimension?: number;\n  }): Promise<SharpWorkerResult> {\n    return this.processImage({\n      operation: 'resize',\n      buffer: input.buffer,\n      resize: input.resize,\n      mimeType: input.mimeType,\n      fileName: input.fileName,\n      fileId: input.fileId,\n      fileWidth: input.fileWidth,\n      fileHeight: input.fileHeight,\n      thumbnailBuffer: input.thumbnailBuffer,\n      thumbnailMimeType: input.thumbnailMimeType,\n      generateThumbnail: input.generateThumbnail,\n      thumbnailPreferredDimension: input.thumbnailPreferredDimension ?? 500,\n    });\n  }\n\n  /**\n   * Get pool statistics for monitoring.\n   */\n  getStats() {\n    if (!this.pool) return null;\n    return {\n      completed: this.pool.completed,\n      duration: this.pool.duration,\n      utilization: this.pool.utilization,\n      queueSize: this.pool.queueSize,\n    };\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/legacy-database-importer/converters/legacy-converter.ts",
    "content": "/* eslint-disable no-underscore-dangle */\nimport { SchemaKey } from '@postybirb/database';\nimport { Logger } from '@postybirb/logger';\nimport { join } from 'path';\nimport { Class } from 'type-fest';\nimport { PostyBirbDatabase } from '../../drizzle/postybirb-database/postybirb-database';\nimport { LegacyConverterEntity } from '../legacy-entities/legacy-converter-entity';\nimport { NdjsonParser } from '../utils/ndjson-parser';\n\nexport abstract class LegacyConverter {\n  abstract readonly modernSchemaKey: SchemaKey;\n\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  abstract readonly LegacyEntityConstructor: Class<LegacyConverterEntity<any>>;\n\n  abstract readonly legacyFileName: string;\n\n  constructor(protected readonly databasePath: string) {}\n\n  private getEntityFilePath(): string {\n    return join(this.databasePath, 'data', `${this.legacyFileName}.db`);\n  }\n\n  private getModernDatabase() {\n    return new PostyBirbDatabase(this.modernSchemaKey);\n  }\n\n  public async import(): Promise<void> {\n    const logger = Logger(`LegacyConverter:${this.legacyFileName}`);\n    logger.info(`Starting import for ${this.legacyFileName}...`);\n\n    const filePath = this.getEntityFilePath();\n    logger.info(`Reading legacy data from ${filePath}...`);\n    const parser = new NdjsonParser();\n    const result = await parser.parseFile(\n      filePath,\n      this.LegacyEntityConstructor,\n    );\n\n    logger.info(\n      `Parsed ${filePath}: ${result.records.length} records, ${result.errors.length} errors`,\n    );\n    if (result.errors.length > 0) {\n      throw new Error(\n        `Errors occurred while parsing ${this.LegacyEntityConstructor.name} data: ${result.errors\n          .map((err) => `Line ${err.line}: ${err.error}`)\n          .join('; ')}`,\n      );\n    }\n    const modernDb = this.getModernDatabase();\n\n    let skippedCount = 0;\n    for (const legacyEntity of result.records) {\n      const exists = await modernDb.findById(legacyEntity._id);\n      if (exists) {\n        logger.warn(\n          `Entity with ID ${legacyEntity._id} already exists in modern database. Skipping.`,\n        );\n        continue;\n      }\n\n      const modernEntity = await legacyEntity.convert();\n\n      // Skip null conversions (e.g., deprecated websites)\n      if (modernEntity === null) {\n        skippedCount++;\n        continue;\n      }\n\n      await modernDb.insert(modernEntity);\n    }\n\n    if (skippedCount > 0) {\n      logger.info(`Skipped ${skippedCount} records during conversion`);\n    }\n\n    logger.info(`Import for ${this.legacyFileName} completed successfully.`);\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/legacy-database-importer/converters/legacy-custom-shortcut.converter.spec.ts",
    "content": "import { clearDatabase } from '@postybirb/database';\nimport { ensureDirSync, PostyBirbDirectories, writeSync } from '@postybirb/fs';\nimport { readFileSync } from 'fs';\nimport { join } from 'path';\nimport { v4 } from 'uuid';\nimport { PostyBirbDatabase } from '../../drizzle/postybirb-database/postybirb-database';\nimport { LegacyCustomShortcut } from '../legacy-entities/legacy-custom-shortcut';\nimport { LegacyCustomShortcutConverter } from './legacy-custom-shortcut.converter';\n\ndescribe('LegacyCustomShortcutConverter', () => {\n  let converter: LegacyCustomShortcutConverter;\n  let testDataPath: string;\n  let repository: PostyBirbDatabase<'CustomShortcutSchema'>;\n  const ts = Date.now();\n\n  beforeEach(async () => {\n    clearDatabase();\n\n    // Setup test data directory\n    testDataPath = `${PostyBirbDirectories.DATA_DIRECTORY}/legacy-db/${ts}/CustomShortcut-${v4()}`;\n\n    // Copy test data to temp directory\n    const testDataDir = join(testDataPath, 'data');\n    ensureDirSync(testDataDir);\n\n    const sourceFile = join(__dirname, '../test-files/data/custom-shortcut.db');\n    const testFile = readFileSync(sourceFile);\n    const destFile = join(testDataDir, 'custom-shortcut.db');\n\n    writeSync(destFile, testFile);\n\n    converter = new LegacyCustomShortcutConverter(testDataPath);\n    repository = new PostyBirbDatabase('CustomShortcutSchema');\n  });\n\n  it('should be defined', () => {\n    expect(LegacyCustomShortcutConverter).toBeDefined();\n  });\n\n  describe('System Shortcuts Conversion', () => {\n    it('should convert {title} to titleShortcut', async () => {\n      const legacyShortcut = new LegacyCustomShortcut({\n        _id: 'test-title-shortcut',\n        created: '2023-10-01T12:00:00Z',\n        lastUpdated: '2023-10-01T12:00:00Z',\n        shortcut: 'titletest',\n        content: '<p>Artwork: {title}</p>',\n        isDynamic: false,\n      });\n\n      const result = await legacyShortcut.convert();\n\n      expect(result.shortcut).toMatchObject({\n        type: 'doc',\n        content: [\n          {\n            type: 'paragraph',\n            content: [\n              { type: 'text', text: 'Artwork: ' },\n              { type: 'titleShortcut', attrs: {} },\n            ],\n          },\n        ],\n      });\n    });\n\n    it('should convert {tags} to tagsShortcut', async () => {\n      const legacyShortcut = new LegacyCustomShortcut({\n        _id: 'test-tags-shortcut',\n        created: '2023-10-01T12:00:00Z',\n        lastUpdated: '2023-10-01T12:00:00Z',\n        shortcut: 'tagstest',\n        content: '<p>Tags: {tags}</p>',\n        isDynamic: false,\n      });\n\n      const result = await legacyShortcut.convert();\n\n      expect(result.shortcut).toMatchObject({\n        type: 'doc',\n        content: [\n          {\n            type: 'paragraph',\n            content: [\n              { type: 'text', text: 'Tags: ' },\n              { type: 'tagsShortcut', attrs: {} },\n            ],\n          },\n        ],\n      });\n    });\n\n    it('should convert {cw} to contentWarningShortcut', async () => {\n      const legacyShortcut = new LegacyCustomShortcut({\n        _id: 'test-cw-shortcut',\n        created: '2023-10-01T12:00:00Z',\n        lastUpdated: '2023-10-01T12:00:00Z',\n        shortcut: 'cwtest',\n        content: '<p>Content Warning: {cw}</p>',\n        isDynamic: false,\n      });\n\n      const result = await legacyShortcut.convert();\n\n      expect(result.shortcut).toMatchObject({\n        type: 'doc',\n        content: [\n          {\n            type: 'paragraph',\n            content: [\n              { type: 'text', text: 'Content Warning: ' },\n              { type: 'contentWarningShortcut', attrs: {} },\n            ],\n          },\n        ],\n      });\n    });\n\n    it('should convert multiple system shortcuts in a single block', async () => {\n      const legacyShortcut = new LegacyCustomShortcut({\n        _id: 'test-multi-system',\n        created: '2023-10-01T12:00:00Z',\n        lastUpdated: '2023-10-01T12:00:00Z',\n        shortcut: 'multisystem',\n        content: '<p>{title} ({cw})</p><p>{tags}</p>',\n        isDynamic: false,\n      });\n\n      const result = await legacyShortcut.convert();\n\n      expect(result.shortcut).toMatchObject({\n        type: 'doc',\n        content: [\n          {\n            type: 'paragraph',\n            content: [\n              { type: 'titleShortcut', attrs: {} },\n              { type: 'text', text: ' (' },\n              { type: 'contentWarningShortcut', attrs: {} },\n              { type: 'text', text: ')' },\n            ],\n          },\n          {\n            type: 'paragraph',\n            content: [{ type: 'tagsShortcut', attrs: {} }],\n          },\n        ],\n      });\n    });\n\n    it('should convert system shortcuts alongside username shortcuts', async () => {\n      const legacyShortcut = new LegacyCustomShortcut({\n        _id: 'test-mixed-shortcuts',\n        created: '2023-10-01T12:00:00Z',\n        lastUpdated: '2023-10-01T12:00:00Z',\n        shortcut: 'mixedtest',\n        content: '<p>{title} by {fa:myusername}</p><p>{cw}</p><p>{tags}</p>',\n        isDynamic: false,\n      });\n\n      const result = await legacyShortcut.convert();\n\n      expect(result.shortcut).toMatchObject({\n        type: 'doc',\n        content: [\n          {\n            type: 'paragraph',\n            content: [\n              { type: 'titleShortcut', attrs: {} },\n              { type: 'text', text: ' by ' },\n              {\n                type: 'username',\n                attrs: expect.objectContaining({\n                  shortcut: 'furaffinity',\n                  only: '',\n                  username: 'myusername',\n                }),\n              },\n            ],\n          },\n          {\n            type: 'paragraph',\n            content: [{ type: 'contentWarningShortcut', attrs: {} }],\n          },\n          {\n            type: 'paragraph',\n            content: [{ type: 'tagsShortcut', attrs: {} }],\n          },\n        ],\n      });\n    });\n\n    it('should handle case-insensitive system shortcuts', async () => {\n      const legacyShortcut = new LegacyCustomShortcut({\n        _id: 'test-case-insensitive',\n        created: '2023-10-01T12:00:00Z',\n        lastUpdated: '2023-10-01T12:00:00Z',\n        shortcut: 'casetest',\n        content: '<p>{TITLE} {CW} {TAGS}</p>',\n        isDynamic: false,\n      });\n\n      const result = await legacyShortcut.convert();\n\n      expect(result.shortcut).toMatchObject({\n        type: 'doc',\n        content: [\n          {\n            type: 'paragraph',\n            content: [\n              { type: 'titleShortcut', attrs: {} },\n              { type: 'text', text: ' ' },\n              { type: 'contentWarningShortcut', attrs: {} },\n              { type: 'text', text: ' ' },\n              { type: 'tagsShortcut', attrs: {} },\n            ],\n          },\n        ],\n      });\n    });\n  });\n\n  it('should import and convert legacy custom shortcut data', async () => {\n    await converter.import();\n\n    const records = await repository.findAll();\n    expect(records).toHaveLength(2);\n\n    // Verify first shortcut\n    const shortcut1 = records.find(\n      (r) => r.id === 'cs123456-1234-1234-1234-123456789abc',\n    );\n    expect(shortcut1).toBeDefined();\n    expect(shortcut1!.name).toBe('myshortcut');\n\n    // Verify TipTap format\n    expect(shortcut1!.shortcut).toMatchObject({\n      type: 'doc',\n      content: [\n        {\n          type: 'paragraph',\n          content: [\n            {\n              type: 'text',\n              text: 'This is my custom shortcut content',\n            },\n          ],\n        },\n      ],\n    });\n\n    // Verify second shortcut (dynamic with HTML)\n    const shortcut2 = records.find(\n      (r) => r.id === 'cs234567-2345-2345-2345-234567890bcd',\n    );\n    expect(shortcut2).toBeDefined();\n    expect(shortcut2!.name).toBe('dynamicshortcut');\n\n    // Verify HTML is converted to TipTap format with bold formatting\n    const textContent2 = JSON.stringify(shortcut2!.shortcut);\n    expect(textContent2).toContain('Dynamic content');\n    expect(textContent2).toContain('bold');\n  });\n\n  it('should handle custom shortcut with empty content', async () => {\n    // Create test data with empty content\n    const emptyContentData = {\n      _id: 'test-empty-id',\n      created: '2023-10-01T12:00:00Z',\n      lastUpdated: '2023-10-01T12:00:00Z',\n      shortcut: 'empty',\n      content: '',\n      isDynamic: false,\n    };\n\n    const testDataDir = join(testDataPath, 'data');\n    const emptyFile = join(testDataDir, 'custom-shortcut.db');\n    writeSync(emptyFile, Buffer.from(JSON.stringify(emptyContentData) + '\\n'));\n\n    await converter.import();\n\n    const records = await repository.findAll();\n    expect(records).toHaveLength(1);\n\n    const record = records[0];\n    expect(record.name).toBe('empty');\n\n    // Empty content should create a doc with a single empty paragraph\n    expect(record.shortcut).toMatchObject({\n      type: 'doc',\n      content: [\n        {\n          type: 'paragraph',\n        },\n      ],\n    });\n  });\n\n  it('should preserve shortcut name as the modern name field', async () => {\n    await converter.import();\n\n    const records = await repository.findAll();\n    expect(records).toHaveLength(2);\n\n    // All records should have name field matching legacy shortcut\n    const names = records.map((r) => r.name);\n    expect(names).toContain('myshortcut');\n    expect(names).toContain('dynamicshortcut');\n  });\n\n  it('should convert legacy shortcuts in content to TipTap format', async () => {\n    // Create test data with legacy shortcut syntax\n    const shortcutData = {\n      _id: 'test-shortcuts-id',\n      created: '2023-10-01T12:00:00Z',\n      lastUpdated: '2023-10-01T12:00:00Z',\n      shortcut: 'testshortcut',\n      content:\n        '<p>Hello {default} and {fa:myusername} with {customshortcut} text</p>',\n      isDynamic: false,\n    };\n\n    const testDataDir = join(testDataPath, 'data');\n    const shortcutFile = join(testDataDir, 'custom-shortcut.db');\n    writeSync(shortcutFile, Buffer.from(JSON.stringify(shortcutData) + '\\n'));\n\n    await converter.import();\n\n    const records = await repository.findAll();\n    expect(records).toHaveLength(1);\n\n    const record = records[0];\n    expect(record.name).toBe('testshortcut');\n\n    const blocks = (record.shortcut as any).content;\n\n    // Should have 2 blocks: defaultShortcut block + paragraph with content\n    expect(blocks).toHaveLength(2);\n\n    // First block should be the defaultShortcut block\n    expect(blocks[0]).toMatchObject({\n      type: 'defaultShortcut',\n      attrs: {},\n    });\n\n    // Second block should be paragraph with username shortcut and customShortcut\n    expect(blocks[1]).toMatchObject({\n      type: 'paragraph',\n      content: [\n        { type: 'text', text: 'Hello ' },\n        { type: 'text', text: ' and ' },\n        {\n          type: 'username',\n          attrs: expect.objectContaining({\n            shortcut: 'furaffinity',\n            only: '',\n            username: 'myusername',\n          }),\n        },\n        { type: 'text', text: ' with ' },\n        {\n          type: 'customShortcut',\n          attrs: { id: 'customshortcut' },\n          content: [{ type: 'text', text: '' }],\n        },\n        { type: 'text', text: ' text' },\n      ],\n    });\n  });\n\n  it('should convert multiple username shortcuts to modern format', async () => {\n    // Create test data with multiple username shortcuts\n    const shortcutData = {\n      _id: 'test-multi-username-id',\n      created: '2023-10-01T12:00:00Z',\n      lastUpdated: '2023-10-01T12:00:00Z',\n      shortcut: 'multiusername',\n      content:\n        '<p>Follow me on {fa:furuser}, {tw:twitterhandle}, and {da:deviantartist}</p>',\n      isDynamic: false,\n    };\n\n    const testDataDir = join(testDataPath, 'data');\n    const shortcutFile = join(testDataDir, 'custom-shortcut.db');\n    writeSync(shortcutFile, Buffer.from(JSON.stringify(shortcutData) + '\\n'));\n\n    await converter.import();\n\n    const records = await repository.findAll();\n    expect(records).toHaveLength(1);\n\n    const record = records[0];\n\n    // Verify the complete structure with all username shortcuts\n    expect(record.shortcut).toMatchObject({\n      type: 'doc',\n      content: [\n        {\n          type: 'paragraph',\n          content: [\n            { type: 'text', text: 'Follow me on ' },\n            {\n              type: 'username',\n              attrs: expect.objectContaining({\n                shortcut: 'furaffinity',\n                only: '',\n                username: 'furuser',\n              }),\n            },\n            { type: 'text', text: ', ' },\n            {\n              type: 'username',\n              attrs: expect.objectContaining({\n                shortcut: 'twitter',\n                only: '',\n                username: 'twitterhandle',\n              }),\n            },\n            { type: 'text', text: ', and ' },\n            {\n              type: 'username',\n              attrs: expect.objectContaining({\n                shortcut: 'deviantart',\n                only: '',\n                username: 'deviantartist',\n              }),\n            },\n          ],\n        },\n      ],\n    });\n  });\n\n  it('should convert {default} to block-level when alone in paragraph', async () => {\n    const shortcutData = {\n      _id: 'test-default-alone',\n      created: '2023-10-01T12:00:00Z',\n      lastUpdated: '2023-10-01T12:00:00Z',\n      shortcut: 'defaultalone',\n      content: '<p>{default}</p>',\n      isDynamic: false,\n    };\n\n    const testDataDir = join(testDataPath, 'data');\n    const shortcutFile = join(testDataDir, 'custom-shortcut.db');\n    writeSync(shortcutFile, Buffer.from(JSON.stringify(shortcutData) + '\\n'));\n\n    await converter.import();\n\n    const records = await repository.findAll();\n    expect(records).toHaveLength(1);\n\n    const record = records[0];\n\n    // Should have a single defaultShortcut block\n    expect(record.shortcut).toMatchObject({\n      type: 'doc',\n      content: [\n        {\n          type: 'defaultShortcut',\n          attrs: {},\n        },\n      ],\n    });\n  });\n\n  it('should insert defaultShortcut block before paragraph when {default} is with other content', async () => {\n    const shortcutData = {\n      _id: 'test-default-with-content',\n      created: '2023-10-01T12:00:00Z',\n      lastUpdated: '2023-10-01T12:00:00Z',\n      shortcut: 'defaultwithcontent',\n      content: '<p>Hello {default} World</p>',\n      isDynamic: false,\n    };\n\n    const testDataDir = join(testDataPath, 'data');\n    const shortcutFile = join(testDataDir, 'custom-shortcut.db');\n    writeSync(shortcutFile, Buffer.from(JSON.stringify(shortcutData) + '\\n'));\n\n    await converter.import();\n\n    const records = await repository.findAll();\n    expect(records).toHaveLength(1);\n\n    const record = records[0];\n\n    // Should have 2 blocks: defaultShortcut block + paragraph with remaining text\n    expect(record.shortcut).toMatchObject({\n      type: 'doc',\n      content: [\n        {\n          type: 'defaultShortcut',\n          attrs: {},\n        },\n        {\n          type: 'paragraph',\n          content: [\n            { type: 'text', text: 'Hello ' },\n            { type: 'text', text: ' World' },\n          ],\n        },\n      ],\n    });\n  });\n\n  it('should handle multiple {default} tags correctly', async () => {\n    const shortcutData = {\n      _id: 'test-multiple-defaults',\n      created: '2023-10-01T12:00:00Z',\n      lastUpdated: '2023-10-01T12:00:00Z',\n      shortcut: 'multipledefaults',\n      content: '<p>{default}</p><p>Some text {default} here</p>',\n      isDynamic: false,\n    };\n\n    const testDataDir = join(testDataPath, 'data');\n    const shortcutFile = join(testDataDir, 'custom-shortcut.db');\n    writeSync(shortcutFile, Buffer.from(JSON.stringify(shortcutData) + '\\n'));\n\n    await converter.import();\n\n    const records = await repository.findAll();\n    expect(records).toHaveLength(1);\n\n    const record = records[0];\n\n    // Should have 3 blocks: defaultShortcut (from first para), defaultShortcut (inserted), paragraph (remaining content)\n    expect(record.shortcut).toMatchObject({\n      type: 'doc',\n      content: [\n        {\n          type: 'defaultShortcut',\n          attrs: {},\n        },\n        {\n          type: 'defaultShortcut',\n          attrs: {},\n        },\n        {\n          type: 'paragraph',\n          content: [\n            { type: 'text', text: 'Some text ' },\n            { type: 'text', text: ' here' },\n          ],\n        },\n      ],\n    });\n  });\n\n  it('should handle and strip modifier blocks from shortcuts', async () => {\n    const shortcutData = {\n      _id: 'test-modifiers',\n      created: '2023-10-01T12:00:00Z',\n      lastUpdated: '2023-10-01T12:00:00Z',\n      shortcut: 'modifiertest',\n      content:\n        '<p>Test {fa[only=furaffinity]:testuser} and {customshortcut[modifier]} text</p>',\n      isDynamic: false,\n    };\n\n    const testDataDir = join(testDataPath, 'data');\n    const shortcutFile = join(testDataDir, 'custom-shortcut.db');\n    writeSync(shortcutFile, Buffer.from(JSON.stringify(shortcutData) + '\\n'));\n\n    await converter.import();\n\n    const records = await repository.findAll();\n    expect(records).toHaveLength(1);\n\n    const record = records[0];\n\n    // Verify the structure - modifiers should be stripped\n    expect(record.shortcut).toMatchObject({\n      type: 'doc',\n      content: [\n        {\n          type: 'paragraph',\n          content: [\n            { type: 'text', text: 'Test ' },\n            {\n              type: 'username',\n              attrs: expect.objectContaining({\n                shortcut: 'furaffinity',\n                only: '',\n                username: 'testuser',\n              }),\n            },\n            { type: 'text', text: ' and ' },\n            {\n              type: 'customShortcut',\n              attrs: { id: 'customshortcut' },\n              content: [{ type: 'text', text: '' }],\n            },\n            { type: 'text', text: ' text' },\n          ],\n        },\n      ],\n    });\n  });\n});\n"
  },
  {
    "path": "apps/client-server/src/app/legacy-database-importer/converters/legacy-custom-shortcut.converter.ts",
    "content": "import { SchemaKey } from '@postybirb/database';\nimport { LegacyCustomShortcut } from '../legacy-entities/legacy-custom-shortcut';\nimport { LegacyConverter } from './legacy-converter';\n\nexport class LegacyCustomShortcutConverter extends LegacyConverter {\n  modernSchemaKey: SchemaKey = 'CustomShortcutSchema';\n\n  LegacyEntityConstructor = LegacyCustomShortcut;\n\n  legacyFileName = 'custom-shortcut';\n}\n"
  },
  {
    "path": "apps/client-server/src/app/legacy-database-importer/converters/legacy-tag-converter.converter.spec.ts",
    "content": "import { clearDatabase } from '@postybirb/database';\nimport { ensureDirSync, PostyBirbDirectories, writeSync } from '@postybirb/fs';\nimport { readFileSync } from 'fs';\nimport { join } from 'path';\nimport { v4 } from 'uuid';\nimport { PostyBirbDatabase } from '../../drizzle/postybirb-database/postybirb-database';\nimport { LegacyTagConverterConverter } from './legacy-tag-converter.converter';\n\ndescribe('LegacyTagConverterConverter', () => {\n  let converter: LegacyTagConverterConverter;\n  let testDataPath: string;\n  let repository: PostyBirbDatabase<'TagConverterSchema'>;\n  const ts = Date.now();\n\n  beforeEach(async () => {\n    clearDatabase();\n\n    // Setup test data directory\n    testDataPath = `${PostyBirbDirectories.DATA_DIRECTORY}/legacy-db/${ts}/TagConverter-${v4()}`;\n\n    // Copy test data to temp directory\n    const testDataDir = join(testDataPath, 'data');\n    ensureDirSync(testDataDir);\n\n    const sourceFile = join(__dirname, '../test-files/data/tag-converter.db');\n    const testFile = readFileSync(sourceFile);\n    const destFile = join(testDataDir, 'tag-converter.db');\n\n    writeSync(destFile, testFile);\n\n    converter = new LegacyTagConverterConverter(testDataPath);\n    repository = new PostyBirbDatabase('TagConverterSchema');\n  });\n\n  it('should import and convert legacy tag converter data', async () => {\n    await converter.import();\n\n    const records = await repository.findAll();\n    expect(records).toHaveLength(1);\n\n    const record = records[0];\n    // Verify ID is preserved\n    expect(record.id).toBe('f499b833-b465-462a-bb3c-0983b35b3475');\n    // Verify tag is preserved\n    expect(record.tag).toBe('converter');\n    // Verify legacy website IDs are mapped to modern ones (FurAffinity -> fur-affinity)\n    expect(record.convertTo).toHaveProperty('fur-affinity');\n    expect(record.convertTo['fur-affinity']).toBe('converted');\n  });\n\n  it('should handle empty conversions object', async () => {\n    // Create test data with empty conversions\n    const emptyConversionData = {\n      _id: 'test-empty-id',\n      created: '2023-10-01T12:00:00Z',\n      lastUpdated: '2023-10-01T12:00:00Z',\n      tag: 'empty-tag',\n      conversions: {},\n    };\n\n    const testDataDir = join(testDataPath, 'data');\n    const emptyTestFile = join(testDataDir, 'tag-converter.db');\n    writeSync(\n      emptyTestFile,\n      Buffer.from(JSON.stringify(emptyConversionData) + '\\n'),\n    );\n\n    await converter.import();\n\n    const records = await repository.findAll();\n    expect(records).toHaveLength(1);\n\n    const record = records[0];\n    expect(record.tag).toBe('empty-tag');\n    expect(record.convertTo).toEqual({});\n  });\n\n  it('should map legacy website names to modern IDs', async () => {\n    // Create test data with multiple legacy website names\n    const multiWebsiteData = {\n      _id: 'test-multi-id',\n      created: '2023-10-01T12:00:00Z',\n      lastUpdated: '2023-10-01T12:00:00Z',\n      tag: 'multi-tag',\n      conversions: {\n        FurAffinity: 'fa-tag',\n        DeviantArt: 'da-tag',\n      },\n    };\n\n    const testDataDir = join(testDataPath, 'data');\n    const multiTestFile = join(testDataDir, 'tag-converter.db');\n    writeSync(\n      multiTestFile,\n      Buffer.from(JSON.stringify(multiWebsiteData) + '\\n'),\n    );\n\n    await converter.import();\n\n    const records = await repository.findAll();\n    expect(records).toHaveLength(1);\n\n    const record = records[0];\n    expect(record.convertTo['fur-affinity']).toBe('fa-tag');\n    expect(record.convertTo['deviant-art']).toBe('da-tag');\n  });\n});\n"
  },
  {
    "path": "apps/client-server/src/app/legacy-database-importer/converters/legacy-tag-converter.converter.ts",
    "content": "import { SchemaKey } from '@postybirb/database';\nimport { LegacyTagConverter } from '../legacy-entities/legacy-tag-converter';\nimport { LegacyConverter } from './legacy-converter';\n\nexport class LegacyTagConverterConverter extends LegacyConverter {\n  modernSchemaKey: SchemaKey = 'TagConverterSchema';\n\n  LegacyEntityConstructor = LegacyTagConverter;\n\n  legacyFileName = 'tag-converter';\n}\n"
  },
  {
    "path": "apps/client-server/src/app/legacy-database-importer/converters/legacy-tag-group.converter.spec.ts",
    "content": "import { clearDatabase } from '@postybirb/database';\nimport { ensureDirSync, PostyBirbDirectories, writeSync } from '@postybirb/fs';\nimport { readFileSync } from 'fs';\nimport { join } from 'path';\nimport { v4 } from 'uuid';\nimport { PostyBirbDatabase } from '../../drizzle/postybirb-database/postybirb-database';\nimport { LegacyTagGroupConverter } from './legacy-tag-group.converter';\n\ndescribe('LegacyTagGroupConverter', () => {\n  let converter: LegacyTagGroupConverter;\n  let testDataPath: string;\n  let repository: PostyBirbDatabase<'TagGroupSchema'>;\n  const ts = Date.now();\n\n  beforeEach(async () => {\n    clearDatabase();\n\n    // Setup test data directory\n    testDataPath = `${PostyBirbDirectories.DATA_DIRECTORY}/legacy-db/${ts}/TagGroup-${v4()}`;\n\n    // Copy test data to temp directory\n    const testDataDir = join(testDataPath, 'data');\n    ensureDirSync(testDataDir);\n\n    const sourceFile = join(__dirname, '../test-files/data/tag-group.db');\n    const testFile = readFileSync(sourceFile);\n    const destFile = join(testDataDir, 'tag-group.db');\n\n    writeSync(destFile, testFile);\n\n    converter = new LegacyTagGroupConverter(testDataPath);\n    repository = new PostyBirbDatabase('TagGroupSchema');\n  });\n\n  it('should import and convert legacy tag group data', async () => {\n    await converter.import();\n\n    const records = await repository.findAll();\n    expect(records).toHaveLength(1);\n\n    const record = records[0];\n    // Verify ID is preserved\n    expect(record.id).toBe('f499b833-b465-462a-bb3c-0983b35b3475');\n    // Verify alias is converted to name\n    expect(record.name).toBe('converter');\n    // Verify tags array is preserved\n    expect(record.tags).toEqual(['tag1', 'tag2']);\n  });\n\n  it('should handle tag group with empty tags array', async () => {\n    // Create test data with empty tags\n    const emptyTagsData = {\n      _id: 'test-empty-tags-id',\n      created: '2023-10-01T12:00:00Z',\n      lastUpdated: '2023-10-01T12:00:00Z',\n      alias: 'empty-group',\n      tags: [],\n    };\n\n    const testDataDir = join(testDataPath, 'data');\n    const emptyTagsFile = join(testDataDir, 'tag-group.db');\n    writeSync(emptyTagsFile, Buffer.from(JSON.stringify(emptyTagsData) + '\\n'));\n\n    await converter.import();\n\n    const records = await repository.findAll();\n    expect(records).toHaveLength(1);\n\n    const record = records[0];\n    expect(record.name).toBe('empty-group');\n    expect(record.tags).toEqual([]);\n  });\n\n  it('should handle tag group with single tag', async () => {\n    // Create test data with single tag\n    const singleTagData = {\n      _id: 'test-single-tag-id',\n      created: '2023-10-01T12:00:00Z',\n      lastUpdated: '2023-10-01T12:00:00Z',\n      alias: 'single-tag-group',\n      tags: ['lonely-tag'],\n    };\n\n    const testDataDir = join(testDataPath, 'data');\n    const singleTagFile = join(testDataDir, 'tag-group.db');\n    writeSync(singleTagFile, Buffer.from(JSON.stringify(singleTagData) + '\\n'));\n\n    await converter.import();\n\n    const records = await repository.findAll();\n    expect(records).toHaveLength(1);\n\n    const record = records[0];\n    expect(record.name).toBe('single-tag-group');\n    expect(record.tags).toHaveLength(1);\n    expect(record.tags[0]).toBe('lonely-tag');\n  });\n\n  it('should handle tag group with special characters in name', async () => {\n    // Create test data with special characters\n    const specialCharsData = {\n      _id: 'test-special-chars-id',\n      created: '2023-10-01T12:00:00Z',\n      lastUpdated: '2023-10-01T12:00:00Z',\n      alias: 'group-with-special!@#$%',\n      tags: ['tag1', 'tag2'],\n    };\n\n    const testDataDir = join(testDataPath, 'data');\n    const specialCharsFile = join(testDataDir, 'tag-group.db');\n    writeSync(\n      specialCharsFile,\n      Buffer.from(JSON.stringify(specialCharsData) + '\\n'),\n    );\n\n    await converter.import();\n\n    const records = await repository.findAll();\n    expect(records).toHaveLength(1);\n\n    const record = records[0];\n    expect(record.name).toBe('group-with-special!@#$%');\n  });\n});\n"
  },
  {
    "path": "apps/client-server/src/app/legacy-database-importer/converters/legacy-tag-group.converter.ts",
    "content": "import { SchemaKey } from '@postybirb/database';\nimport { LegacyTagGroup } from '../legacy-entities/legacy-tag-group';\nimport { LegacyConverter } from './legacy-converter';\n\nexport class LegacyTagGroupConverter extends LegacyConverter {\n  modernSchemaKey: SchemaKey = 'TagGroupSchema';\n\n  LegacyEntityConstructor = LegacyTagGroup;\n\n  legacyFileName = 'tag-group';\n}\n"
  },
  {
    "path": "apps/client-server/src/app/legacy-database-importer/converters/legacy-user-account.converter.spec.ts",
    "content": "import { clearDatabase } from '@postybirb/database';\nimport { ensureDirSync, PostyBirbDirectories, writeSync } from '@postybirb/fs';\nimport { readFileSync } from 'fs';\nimport { join } from 'path';\nimport { v4 } from 'uuid';\nimport { PostyBirbDatabase } from '../../drizzle/postybirb-database/postybirb-database';\nimport { LegacyUserAccountConverter } from './legacy-user-account.converter';\n\ndescribe('LegacyUserAccountConverter', () => {\n  let converter: LegacyUserAccountConverter;\n  let testDataPath: string;\n  let repository: PostyBirbDatabase<'AccountSchema'>;\n  const ts = Date.now();\n\n  beforeEach(async () => {\n    clearDatabase();\n\n    // Setup test data directory\n    testDataPath = `${PostyBirbDirectories.DATA_DIRECTORY}/legacy-db/${ts}/Account-${v4()}`;\n\n    // Copy test data to temp directory\n    const testDataDir = join(testDataPath, 'data');\n    ensureDirSync(testDataDir);\n\n    const sourceFile = join(__dirname, '../test-files/data/accounts.db');\n    const testFile = readFileSync(sourceFile);\n    const destFile = join(testDataDir, 'accounts.db');\n\n    writeSync(destFile, testFile);\n\n    converter = new LegacyUserAccountConverter(testDataPath);\n    repository = new PostyBirbDatabase('AccountSchema');\n  });\n\n  it('should import and convert legacy user account data', async () => {\n    await converter.import();\n\n    const records = await repository.findAll();\n    expect(records).toHaveLength(2);\n\n    // Verify FurAffinity account\n    const faAccount = records.find(\n      (r) => r.id === 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',\n    );\n    expect(faAccount).toBeDefined();\n    expect(faAccount!.name).toBe('FurAffinity Main');\n    expect(faAccount!.website).toBe('fur-affinity');\n\n    // Verify DeviantArt account\n    const daAccount = records.find(\n      (r) => r.id === 'b2c3d4e5-f6a7-8901-bcde-f12345678901',\n    );\n    expect(daAccount).toBeDefined();\n    expect(daAccount!.name).toBe('DeviantArt Account');\n    expect(daAccount!.website).toBe('deviant-art');\n  });\n\n  it('should skip accounts for deprecated websites', async () => {\n    // Create test data with deprecated website\n    const deprecatedData = {\n      _id: 'deprecated-id',\n      created: '2023-10-01T12:00:00Z',\n      lastUpdated: '2023-10-01T12:00:00Z',\n      alias: 'FurryNetwork Account',\n      website: 'FurryNetwork',\n      data: { username: 'test' },\n    };\n\n    const testDataDir = join(testDataPath, 'data');\n    const deprecatedFile = join(testDataDir, 'accounts.db');\n    writeSync(\n      deprecatedFile,\n      Buffer.from(JSON.stringify(deprecatedData) + '\\n'),\n    );\n\n    await converter.import();\n\n    const records = await repository.findAll();\n    // Should be empty because FurryNetwork is deprecated\n    expect(records).toHaveLength(0);\n  });\n});\n"
  },
  {
    "path": "apps/client-server/src/app/legacy-database-importer/converters/legacy-user-account.converter.ts",
    "content": "import { SchemaKey } from '@postybirb/database';\nimport { LegacyUserAccount } from '../legacy-entities/legacy-user-account';\nimport { LegacyConverter } from './legacy-converter';\n\nexport class LegacyUserAccountConverter extends LegacyConverter {\n  modernSchemaKey: SchemaKey = 'AccountSchema';\n\n  LegacyEntityConstructor = LegacyUserAccount;\n\n  legacyFileName = 'accounts';\n}\n"
  },
  {
    "path": "apps/client-server/src/app/legacy-database-importer/converters/legacy-website-data.converter.spec.ts",
    "content": "import { clearDatabase } from '@postybirb/database';\nimport { ensureDirSync, PostyBirbDirectories, writeSync } from '@postybirb/fs';\nimport { readFileSync } from 'fs';\nimport { join } from 'path';\nimport { v4 } from 'uuid';\nimport { PostyBirbDatabase } from '../../drizzle/postybirb-database/postybirb-database';\nimport { LegacyUserAccountConverter } from './legacy-user-account.converter';\nimport { LegacyWebsiteDataConverter } from './legacy-website-data.converter';\n\ndescribe('LegacyWebsiteDataConverter', () => {\n  let accountConverter: LegacyUserAccountConverter;\n  let websiteDataConverter: LegacyWebsiteDataConverter;\n  let testDataPath: string;\n  let accountRepository: PostyBirbDatabase<'AccountSchema'>;\n  let websiteDataRepository: PostyBirbDatabase<'WebsiteDataSchema'>;\n  const ts = Date.now();\n\n  beforeEach(async () => {\n    clearDatabase();\n\n    // Setup test data directory\n    testDataPath = `${PostyBirbDirectories.DATA_DIRECTORY}/legacy-db/${ts}/WebsiteData-${v4()}`;\n\n    // Copy test data to temp directory\n    const testDataDir = join(testDataPath, 'data');\n    ensureDirSync(testDataDir);\n\n    // Use the accounts-with-websitedata.db test file\n    const sourceFile = join(\n      __dirname,\n      '../test-files/data/accounts-with-websitedata.db',\n    );\n    const testFile = readFileSync(sourceFile);\n    const destFile = join(testDataDir, 'accounts.db');\n    writeSync(destFile, testFile);\n\n    accountConverter = new LegacyUserAccountConverter(testDataPath);\n    websiteDataConverter = new LegacyWebsiteDataConverter(testDataPath);\n    accountRepository = new PostyBirbDatabase('AccountSchema');\n    websiteDataRepository = new PostyBirbDatabase('WebsiteDataSchema');\n  });\n\n  /**\n   * Helper to run both converters in the correct order.\n   * Account converter must run first due to foreign key dependency.\n   */\n  async function runConverters() {\n    // Accounts must be created first (WebsiteData has FK reference)\n    await accountConverter.import();\n    await websiteDataConverter.import();\n  }\n\n  it('should import Twitter WebsiteData with transformed credentials', async () => {\n    await runConverters();\n\n    const websiteData = await websiteDataRepository.findById(\n      'twitter-test-id-001',\n    );\n    expect(websiteData).toBeDefined();\n    expect(websiteData!.data).toEqual({\n      apiKey: 'consumer_key_123',\n      apiSecret: 'consumer_secret_456',\n      accessToken: 'access_token_789',\n      accessTokenSecret: 'access_secret_012',\n      screenName: 'test_user',\n      userId: '123456789',\n    });\n  });\n\n  it('should import Discord WebsiteData with webhook config', async () => {\n    await runConverters();\n\n    const websiteData = await websiteDataRepository.findById(\n      'discord-test-id-002',\n    );\n    expect(websiteData).toBeDefined();\n    expect(websiteData!.data).toEqual({\n      webhook: 'https://discord.com/api/webhooks/123456789/abcdefghijklmnop',\n      serverLevel: 2,\n      isForum: true,\n    });\n  });\n\n  it('should import Telegram WebsiteData with app credentials', async () => {\n    await runConverters();\n\n    const websiteData = await websiteDataRepository.findById(\n      'telegram-test-id-003',\n    );\n    expect(websiteData).toBeDefined();\n    expect(websiteData!.data).toEqual({\n      appId: 12345678, // Converted from string to number\n      appHash: 'abcdef0123456789abcdef0123456789',\n      phoneNumber: '+1234567890',\n      session: undefined,\n      channels: [],\n    });\n  });\n\n  it('should import Mastodon WebsiteData with normalized instance URL', async () => {\n    await runConverters();\n\n    const websiteData = await websiteDataRepository.findById(\n      'mastodon-test-id-004',\n    );\n    expect(websiteData).toBeDefined();\n    expect(websiteData!.data).toMatchObject({\n      accessToken: 'mastodon_access_token_xyz',\n      instanceUrl: 'mastodon.social', // Normalized (protocol stripped)\n      username: 'mastodon_user',\n    });\n  });\n\n  it('should import Bluesky WebsiteData with username and password', async () => {\n    await runConverters();\n\n    const websiteData = await websiteDataRepository.findById(\n      'bluesky-test-id-005',\n    );\n    expect(websiteData).toBeDefined();\n    expect(websiteData!.data).toEqual({\n      username: 'bluesky.user.bsky.social',\n      password: 'app_password_123',\n    });\n  });\n\n  it('should import e621 WebsiteData with API key', async () => {\n    await runConverters();\n\n    const websiteData = await websiteDataRepository.findById('e621-test-id-007');\n    expect(websiteData).toBeDefined();\n    expect(websiteData!.data).toEqual({\n      username: 'e621_user',\n      key: 'api_key_xyz789',\n    });\n  });\n\n  it('should import Custom webhook with fixed typo', async () => {\n    await runConverters();\n\n    const websiteData = await websiteDataRepository.findById(\n      'custom-test-id-009',\n    );\n    expect(websiteData).toBeDefined();\n    // Verify typo fix: thumbnaiField -> thumbnailField\n    expect(websiteData!.data).toMatchObject({\n      fileUrl: 'https://example.com/upload',\n      descriptionField: 'description',\n      headers: [{ name: 'Authorization', value: 'Bearer token123' }],\n      thumbnailField: 'thumbnail', // Fixed from thumbnaiField\n    });\n  });\n\n  it('should import Pleroma using MegalodonDataTransformer', async () => {\n    await runConverters();\n\n    const websiteData = await websiteDataRepository.findById(\n      'pleroma-test-id-010',\n    );\n    expect(websiteData).toBeDefined();\n    expect(websiteData!.data).toMatchObject({\n      accessToken: 'pleroma_token_abc',\n      instanceUrl: 'pleroma.example.com',\n      username: 'pleroma_user',\n    });\n  });\n\n  it('should import Pixelfed and normalize instance URL', async () => {\n    await runConverters();\n\n    const websiteData = await websiteDataRepository.findById(\n      'pixelfed-test-id-011',\n    );\n    expect(websiteData).toBeDefined();\n    expect(websiteData!.data).toMatchObject({\n      accessToken: 'pixelfed_token_def',\n      instanceUrl: 'pixelfed.social', // Protocol and trailing slash stripped\n      username: 'pixelfed_user',\n    });\n  });\n\n  it('should NOT create WebsiteData for browser-cookie websites (FurAffinity)', async () => {\n    await runConverters();\n\n    // FurAffinity uses browser cookies, not WebsiteData - no transformer exists\n    const websiteData = await websiteDataRepository.findById(\n      'furaffinity-test-id-012',\n    );\n    expect(websiteData).toBeNull();\n\n    // But the account should exist\n    const account = await accountRepository.findById('furaffinity-test-id-012');\n    expect(account).toBeDefined();\n    expect(account!.website).toBe('fur-affinity');\n  });\n\n  it('should skip deprecated websites', async () => {\n    // Create test data with deprecated website\n    const testDataDir = join(testDataPath, 'data');\n    const deprecatedData = {\n      _id: 'deprecated-id',\n      created: '2023-10-01T12:00:00Z',\n      lastUpdated: '2023-10-01T12:00:00Z',\n      alias: 'FurryNetwork Account',\n      website: 'FurryNetwork',\n      data: { username: 'test' },\n    };\n    const deprecatedFile = join(testDataDir, 'accounts.db');\n    writeSync(\n      deprecatedFile,\n      Buffer.from(JSON.stringify(deprecatedData) + '\\n'),\n    );\n\n    // Run just the website data converter (account doesn't exist)\n    await websiteDataConverter.import();\n\n    // Should be empty because FurryNetwork is deprecated\n    const records = await websiteDataRepository.findAll();\n    expect(records).toHaveLength(0);\n  });\n\n  it('should handle accounts with transformer but missing data', async () => {\n    // Create test data with Twitter account but no data field\n    const testDataDir = join(testDataPath, 'data');\n    const noDataAccount = {\n      _id: 'twitter-no-data',\n      created: '2023-10-01T12:00:00Z',\n      lastUpdated: '2023-10-01T12:00:00Z',\n      alias: 'Twitter No Data',\n      website: 'Twitter',\n      // data field is missing\n    };\n    const testFile = join(testDataDir, 'accounts.db');\n    writeSync(testFile, Buffer.from(JSON.stringify(noDataAccount) + '\\n'));\n\n    // Create the account first\n    await accountConverter.import();\n    await websiteDataConverter.import();\n\n    // WebsiteData should not be created\n    const websiteData = await websiteDataRepository.findById('twitter-no-data');\n    expect(websiteData).toBeNull();\n  });\n});\n"
  },
  {
    "path": "apps/client-server/src/app/legacy-database-importer/converters/legacy-website-data.converter.ts",
    "content": "import { SchemaKey } from '@postybirb/database';\nimport { LegacyWebsiteData } from '../legacy-entities/legacy-website-data';\nimport { LegacyConverter } from './legacy-converter';\n\n/**\n * Converter for importing website-specific data (OAuth tokens, API keys, credentials)\n * from legacy PostyBirb Plus accounts.\n *\n * IMPORTANT: This converter must run AFTER LegacyUserAccountConverter because\n * WebsiteData records have a foreign key reference to Account records.\n * The Account must exist before its associated WebsiteData can be created.\n *\n * Only websites with registered transformers in WebsiteDataTransformerRegistry\n * will produce records. Websites using browser cookies for authentication\n * (e.g., FurAffinity, DeviantArt) will be skipped.\n */\nexport class LegacyWebsiteDataConverter extends LegacyConverter {\n  modernSchemaKey: SchemaKey = 'WebsiteDataSchema';\n\n  LegacyEntityConstructor = LegacyWebsiteData;\n\n  /**\n   * Reads from the same 'accounts' file as LegacyUserAccountConverter,\n   * but only extracts and transforms the website-specific data.\n   */\n  legacyFileName = 'accounts';\n}\n"
  },
  {
    "path": "apps/client-server/src/app/legacy-database-importer/dtos/legacy-import.dto.ts",
    "content": "import { IsBoolean, IsOptional, IsString } from 'class-validator';\n\nexport class LegacyImportDto {\n  @IsBoolean()\n  customShortcuts: boolean;\n\n  @IsBoolean()\n  tagGroups: boolean;\n\n  @IsBoolean()\n  accounts: boolean;\n\n  @IsBoolean()\n  tagConverters: boolean;\n\n  @IsOptional()\n  @IsString()\n  customPath?: string;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/legacy-database-importer/legacy-database-importer.controller.ts",
    "content": "import { Body, Controller, Post } from '@nestjs/common';\nimport { LegacyImportDto } from './dtos/legacy-import.dto';\nimport { LegacyDatabaseImporterService } from './legacy-database-importer.service';\n\n@Controller('legacy-database-importer')\nexport class LegacyDatabaseImporterController {\n  constructor(\n    private readonly legacyDatabaseImporterService: LegacyDatabaseImporterService,\n  ) {}\n\n  @Post('import')\n  async import(@Body() importRequest: LegacyImportDto) {\n    return this.legacyDatabaseImporterService.import(importRequest);\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/legacy-database-importer/legacy-database-importer.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { AccountModule } from '../account/account.module';\nimport { LegacyDatabaseImporterController } from './legacy-database-importer.controller';\nimport { LegacyDatabaseImporterService } from './legacy-database-importer.service';\n\n@Module({\n  imports: [AccountModule],\n  providers: [LegacyDatabaseImporterService],\n  controllers: [LegacyDatabaseImporterController],\n  exports: [],\n})\nexport class LegacyDatabaseImporterModule {}\n"
  },
  {
    "path": "apps/client-server/src/app/legacy-database-importer/legacy-database-importer.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { Logger } from '@postybirb/logger';\nimport { app } from 'electron';\nimport { join } from 'path';\nimport { AccountService } from '../account/account.service';\nimport { LegacyConverter } from './converters/legacy-converter';\nimport { LegacyCustomShortcutConverter } from './converters/legacy-custom-shortcut.converter';\nimport { LegacyTagConverterConverter } from './converters/legacy-tag-converter.converter';\nimport { LegacyTagGroupConverter } from './converters/legacy-tag-group.converter';\nimport { LegacyUserAccountConverter } from './converters/legacy-user-account.converter';\nimport { LegacyWebsiteDataConverter } from './converters/legacy-website-data.converter';\nimport { LegacyImportDto } from './dtos/legacy-import.dto';\n\n@Injectable()\nexport class LegacyDatabaseImporterService {\n  private readonly logger = Logger(LegacyDatabaseImporterService.name);\n\n  protected readonly LEGACY_POSTYBIRB_PLUS_PATH = join(\n    app.getPath('documents'),\n    'PostyBirb',\n  );\n\n  constructor(private readonly accountService: AccountService) {}\n\n  async import(importRequest: LegacyImportDto): Promise<{ errors: Error[] }> {\n    const path = importRequest.customPath || this.LEGACY_POSTYBIRB_PLUS_PATH;\n\n    const errors: Error[] = [];\n    if (importRequest.accounts) {\n      // Import user accounts\n      const result = await this.processImport(\n        new LegacyUserAccountConverter(path),\n      );\n      if (result.error) {\n        errors.push(result.error);\n      }\n\n      // IMPORTANT: WebsiteData must be imported AFTER accounts because\n      // WebsiteData records have a foreign key reference to Account records.\n      // The Account must exist before its associated WebsiteData can be created.\n      const websiteDataResult = await this.processImport(\n        new LegacyWebsiteDataConverter(path),\n      );\n      if (websiteDataResult.error) {\n        errors.push(websiteDataResult.error);\n      }\n\n      const allAccounts = await this.accountService.findAll();\n      allAccounts.forEach((account) => {\n        this.accountService.manuallyExecuteOnLogin(account.id);\n      });\n    }\n\n    if (importRequest.tagGroups) {\n      // Import tag groups\n      const result = await this.processImport(\n        new LegacyTagGroupConverter(path),\n      );\n      if (result.error) {\n        errors.push(result.error);\n      }\n    }\n\n    if (importRequest.tagConverters) {\n      // Import tag converters\n      const result = await this.processImport(\n        new LegacyTagConverterConverter(path),\n      );\n      if (result.error) {\n        errors.push(result.error);\n      }\n    }\n\n    if (importRequest.customShortcuts) {\n      // Import custom shortcuts\n      const result = await this.processImport(\n        new LegacyCustomShortcutConverter(path),\n      );\n      if (result.error) {\n        errors.push(result.error);\n      }\n    }\n\n    return { errors };\n  }\n\n  private async processImport(\n    converter: LegacyConverter,\n  ): Promise<{ error?: Error }> {\n    try {\n      this.logger.info(`Starting import for ${converter.legacyFileName}...`);\n      await converter.import();\n      return {};\n    } catch (error) {\n      this.logger.error(\n        `Import for ${converter.legacyFileName} failed.`,\n        error,\n      );\n      return { error };\n    }\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/legacy-database-importer/legacy-entities/legacy-converter-entity.ts",
    "content": "import { IEntity } from '@postybirb/types';\n\nexport type MinimalEntity<T extends IEntity> = Omit<\n  T,\n  'createdAt' | 'updatedAt'\n>;\n\nexport interface LegacyConverterEntity<T extends IEntity> {\n  _id: string;\n\n  convert(): Promise<MinimalEntity<T> | null>;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/legacy-database-importer/legacy-entities/legacy-custom-shortcut.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\n/* eslint-disable import/no-extraneous-dependencies */\nimport { Description, ICustomShortcut, TipTapNode } from '@postybirb/types';\nimport { Blockquote } from '@tiptap/extension-blockquote';\nimport { Bold } from '@tiptap/extension-bold';\nimport { Code } from '@tiptap/extension-code';\nimport { Document } from '@tiptap/extension-document';\nimport { HardBreak } from '@tiptap/extension-hard-break';\nimport { Heading } from '@tiptap/extension-heading';\nimport { HorizontalRule } from '@tiptap/extension-horizontal-rule';\nimport { Italic } from '@tiptap/extension-italic';\nimport { Link } from '@tiptap/extension-link';\nimport { Paragraph } from '@tiptap/extension-paragraph';\nimport { Strike } from '@tiptap/extension-strike';\nimport { Text } from '@tiptap/extension-text';\nimport { Underline } from '@tiptap/extension-underline';\nimport { generateJSON } from '@tiptap/html/dist/server';\nimport {\n    LegacyConverterEntity,\n    MinimalEntity,\n} from './legacy-converter-entity';\n\nconst tiptapExtensions = [\n  Text,\n  Document,\n  Paragraph,\n  Bold,\n  Italic,\n  Strike,\n  Underline,\n  Code,\n  HardBreak,\n  Blockquote,\n  Heading,\n  HorizontalRule,\n  Link.configure({\n    openOnClick: false,\n  }),\n];\n\nexport class LegacyCustomShortcut implements LegacyConverterEntity<ICustomShortcut> {\n  _id: string;\n\n  created: string;\n\n  lastUpdated: string;\n\n  shortcut: string;\n\n  content: string;\n\n  isDynamic: boolean;\n\n  constructor(data: Partial<LegacyCustomShortcut>) {\n    Object.assign(this, data);\n  }\n\n  async convert(): Promise<MinimalEntity<ICustomShortcut>> {\n    // Convert legacy format to new format\n    // Legacy: { shortcut: string, content: string, isDynamic: boolean }\n    // New: { name: string, shortcut: Description (TipTap format) }\n\n    // Step 1: Wrap legacy shortcuts in code tags to preserve them during HTML parsing\n    const contentWithWrappedShortcuts = this.wrapLegacyShortcuts(this.content);\n\n    // Step 2: Parse HTML with wrapped shortcuts to TipTap JSON format\n    const doc = generateJSON(\n      contentWithWrappedShortcuts || '<p></p>',\n      tiptapExtensions,\n    ) as Description;\n\n    // Step 3: Convert legacy shortcuts to modern format\n    let blocks: TipTapNode[] = doc.content ?? [];\n    blocks = this.convertLegacyToModernShortcut(blocks);\n\n    // Step 4: Convert default shortcuts to block-level elements\n    blocks = this.convertDefaultToBlock(blocks);\n\n    const shortcut: Description = { type: 'doc', content: blocks };\n\n    return {\n      // eslint-disable-next-line no-underscore-dangle\n      id: this._id,\n      name: this.shortcut, // Legacy shortcut name becomes the name\n      shortcut,\n    };\n  }\n\n  /**\n   * Recursively traverses TipTap tree to find code-marked text nodes that are\n   * legacy shortcuts and converts them to modern format in place.\n   */\n  private convertLegacyToModernShortcut(blocks: TipTapNode[]): TipTapNode[] {\n    // Pattern matches:\n    // {word} or {word:text} or {word[modifier]:text} or {word[modifier]}\n    // Captures: (1) shortcut key, (2) optional modifier (ignored), (3) optional value\n    const shortcutPattern =\n      /^\\{([a-zA-Z0-9]+)(?:\\[([^\\]]+)\\])?(?::([^}]+))?\\}$/;\n\n    // Mapping of legacy system shortcuts to new inline shortcut types\n    const systemShortcutMapping: Record<string, string> = {\n      cw: 'contentWarningShortcut',\n      title: 'titleShortcut',\n      tags: 'tagsShortcut',\n    };\n\n    // Mapping of legacy username shortcut keys to modern IDs\n    const usernameShortcutMapping: Record<string, string> = {\n      ac: 'artconomy',\n      bsky: 'bluesky',\n      da: 'deviantart',\n      db: 'derpibooru',\n      e6: 'e621',\n      fa: 'furaffinity',\n      furb: 'furbooru',\n      hf: 'h-foundry',\n      ib: 'inkbunny',\n      it: 'itaku',\n      mb: 'manebooru',\n      ng: 'newgrounds',\n      pa: 'patreon',\n      pf: 'pillowfort',\n      ptv: 'picarto',\n      pz: 'piczel',\n      sf: 'sofurry',\n      ss: 'subscribe-star',\n      tu: 'tumblr',\n      tw: 'twitter',\n      ws: 'weasyl',\n    };\n\n    const hasCodeMark = (item: any): boolean =>\n      Array.isArray(item.marks) &&\n      item.marks.some((m: any) => m.type === 'code');\n\n    const processInlineContent = (content: any[]): any[] => {\n      const result: any[] = [];\n\n      content.forEach((item: any) => {\n        // Check if this is a code-marked text node (legacy shortcut)\n        if (\n          item.type === 'text' &&\n          hasCodeMark(item) &&\n          typeof item.text === 'string'\n        ) {\n          const match = item.text.match(shortcutPattern);\n\n          if (match) {\n            const shortcutKey = match[1];\n            const shortcutKeyLower = shortcutKey.toLowerCase();\n\n            // Check if this is a system shortcut (cw, title, tags)\n            if (systemShortcutMapping[shortcutKeyLower]) {\n              result.push({\n                type: systemShortcutMapping[shortcutKeyLower],\n                attrs: {},\n              });\n              return;\n            }\n            // match[2] is the modifier block - we ignore it\n            const shortcutValue = match[3]; // Value is now in capture group 3\n\n            // Check if this is a username shortcut (has a value after colon)\n            if (shortcutValue) {\n              const modernId =\n                usernameShortcutMapping[shortcutKey.toLowerCase()];\n\n              if (modernId) {\n                // Convert to username shortcut format\n                result.push({\n                  type: 'username',\n                  attrs: {\n                    shortcut: modernId,\n                    only: '',\n                    username: shortcutValue,\n                  },\n                });\n                return;\n              }\n\n              // Has a colon but not a username shortcut - convert to customShortcut\n              result.push({\n                type: 'customShortcut',\n                attrs: { id: shortcutKey },\n                content: [{ type: 'text', text: shortcutValue }],\n              });\n              return;\n            }\n\n            // Simple shortcut without colon - convert to customShortcut\n            result.push({\n              type: 'customShortcut',\n              attrs: { id: shortcutKey },\n              content: [{ type: 'text', text: '' }],\n            });\n            return;\n          }\n\n          // If it doesn't match shortcut pattern, return as is\n          result.push(item);\n          return;\n        }\n\n        result.push(item);\n      });\n\n      return result;\n    };\n\n    return blocks.map((block: any) => {\n      if (!Array.isArray(block.content)) {\n        return block;\n      }\n\n      // Check if content contains inline nodes (text) or block nodes (paragraph, etc.)\n      const hasInlineContent = block.content.some(\n        (c: any) => c.type === 'text',\n      );\n\n      if (hasInlineContent) {\n        // Process inline content for shortcut conversion\n        // eslint-disable-next-line no-param-reassign\n        block.content = processInlineContent(block.content);\n      } else {\n        // Recursively process nested block content (e.g. blockquote > paragraph)\n        // eslint-disable-next-line no-param-reassign\n        block.content = this.convertLegacyToModernShortcut(block.content);\n      }\n\n      return block;\n    });\n  }\n\n  /**\n   * Converts {default} shortcuts to block-level elements.\n   * - If default is alone in a block, convert the block to type: 'defaultShortcut'\n   * - If default is with other content, remove it and insert a defaultShortcut block before\n   */\n  private convertDefaultToBlock(blocks: TipTapNode[]): TipTapNode[] {\n    const result: any[] = [];\n\n    blocks.forEach((block: any) => {\n      // Recursively process nested block content (e.g. blockquote)\n      if (\n        Array.isArray(block.content) &&\n        !block.content.some((c: any) => c.type === 'text')\n      ) {\n        // eslint-disable-next-line no-param-reassign\n        block.content = this.convertDefaultToBlock(block.content);\n      }\n\n      // Check if this block has content with a default customShortcut\n      if (Array.isArray(block.content)) {\n        const defaultShortcutIndex = block.content.findIndex(\n          (item: any) =>\n            item.type === 'customShortcut' && item.attrs?.id === 'default',\n        );\n\n        if (defaultShortcutIndex !== -1) {\n          // Found a default shortcut in this block's content\n          const otherContent = block.content.filter(\n            (item: any, idx: number) => idx !== defaultShortcutIndex,\n          );\n\n          // Check if there are other content items (text, links, etc.)\n          const hasOtherContent = otherContent.some((item: any) =>\n            item.type === 'text'\n              ? item.text.trim().length > 0\n              : item.type !== 'customShortcut' || item.attrs?.id !== 'default',\n          );\n\n          if (hasOtherContent) {\n            // Default was with other content - insert defaultShortcut block before this one\n            result.push({\n              type: 'defaultShortcut',\n              attrs: {},\n            });\n\n            // Add the current block without the default shortcut\n            result.push({\n              ...block,\n              content: otherContent,\n            });\n          } else {\n            // Default was alone - convert this block to defaultShortcut type\n            result.push({\n              type: 'defaultShortcut',\n              attrs: {},\n            });\n          }\n          return;\n        }\n      }\n\n      // No default shortcut found, keep block as is\n      result.push(block);\n    });\n\n    return result;\n  }\n\n  /**\n   * Wraps legacy shortcuts in code tags to preserve them during HTML parsing.\n   * Supports:\n   * - Simple shortcuts: {default}, {customshortcut}\n   * - Username shortcuts: {fa:username}, {tw:handle}\n   * - Dynamic shortcuts: {myshortcut:text}\n   * - Modifiers (stripped): {fa[only=furaffinity]:username}, {shortcut[modifier]}\n   *\n   * Ignores deprecated shortcuts: {cw}, {title}, {tags}\n   *\n   * Uses <code> tags to mark shortcuts so TipTap will preserve them as\n   * code-marked text nodes that can be detected and converted.\n   */\n  private wrapLegacyShortcuts(content: string): string {\n    // Pattern matches:\n    // {word} or {word:text} or {word[modifier]:text} or {word[modifier]}\n    // where word is alphanumeric, modifier is anything except ], and text can contain anything except }\n    const shortcutPattern = /\\{([a-zA-Z0-9]+)(?:\\[([^\\]]+)\\])?(?::([^}]+))?\\}/g;\n\n    return content.replace(\n      shortcutPattern,\n      (match, key, modifier, additionalText) =>\n        // Use <code> tag which TipTap preserves as a code mark on text nodes\n        // This will create a text node with a code mark that we can identify\n        `<code data-shortcut=\"true\">${match}</code>`,\n    );\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/legacy-database-importer/legacy-entities/legacy-tag-converter.ts",
    "content": "import { ITagConverter } from '@postybirb/types';\nimport { WebsiteNameMapper } from '../utils/website-name-mapper';\nimport {\n    LegacyConverterEntity,\n    MinimalEntity,\n} from './legacy-converter-entity';\n\n/**\n * Legacy tag converter entity from PostyBirb Plus\n * Converts a tag to website-specific tags\n */\nexport class LegacyTagConverter\n  implements LegacyConverterEntity<ITagConverter>\n{\n  _id: string;\n\n  created: number;\n\n  lastUpdated: number;\n\n  tag: string;\n\n  conversions: Record<string, string>; // Legacy website ID -> converted tag\n\n  constructor(data: Partial<LegacyTagConverter>) {\n    Object.assign(this, data);\n  }\n\n  async convert(): Promise<MinimalEntity<ITagConverter>> {\n    const conversionsMap: Record<string, string> = {};\n    for (const [legacyWebsiteId, convertedTag] of Object.entries(\n      this.conversions,\n    )) {\n      const newWebsiteId = WebsiteNameMapper.map(legacyWebsiteId);\n      if (newWebsiteId) {\n        conversionsMap[newWebsiteId] = convertedTag;\n      }\n    }\n\n    return {\n      // eslint-disable-next-line no-underscore-dangle\n      id: this._id,\n      tag: this.tag,\n      convertTo: conversionsMap,\n    };\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/legacy-database-importer/legacy-entities/legacy-tag-group.ts",
    "content": "import { ITagGroup } from '@postybirb/types';\nimport {\n    LegacyConverterEntity,\n    MinimalEntity,\n} from './legacy-converter-entity';\n\n/**\n * Legacy tag group entity from PostyBirb Plus\n * Represents a group of tags that can be applied together\n */\nexport class LegacyTagGroup implements LegacyConverterEntity<ITagGroup> {\n  _id: string;\n\n  created: number;\n\n  lastUpdated: number;\n\n  alias: string;\n\n  tags: string[];\n\n  constructor(data: Partial<LegacyTagGroup>) {\n    Object.assign(this, data);\n  }\n\n  async convert(): Promise<MinimalEntity<ITagGroup>> {\n    return {\n      // eslint-disable-next-line no-underscore-dangle\n      id: this._id,\n      name: this.alias,\n      tags: this.tags,\n    };\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/legacy-database-importer/legacy-entities/legacy-user-account.ts",
    "content": "import { IAccount } from '@postybirb/types';\nimport { WebsiteNameMapper } from '../utils/website-name-mapper';\nimport {\n  LegacyConverterEntity,\n  MinimalEntity,\n} from './legacy-converter-entity';\n\n/**\n * Legacy user account entity from PostyBirb Plus\n * Represents a user's account on a specific website\n */\nexport class LegacyUserAccount implements LegacyConverterEntity<IAccount> {\n  _id: string;\n\n  created: number;\n\n  lastUpdated: number;\n\n  alias: string;\n\n  website: string;\n\n  data: unknown; // Website-specific data (handled by LegacyWebsiteData converter)\n\n  constructor(data: Partial<LegacyUserAccount>) {\n    Object.assign(this, data);\n  }\n\n  async convert(): Promise<MinimalEntity<IAccount> | null> {\n    const newWebsiteId = WebsiteNameMapper.map(this.website);\n\n    // Skip accounts for deprecated websites\n    if (!newWebsiteId) {\n      return null;\n    }\n\n    return {\n      // eslint-disable-next-line no-underscore-dangle\n      id: this._id,\n      name: this.alias,\n      website: newWebsiteId,\n      groups: [], // Groups weren't part of legacy accounts\n    };\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/legacy-database-importer/legacy-entities/legacy-website-data.ts",
    "content": "import { Logger } from '@postybirb/logger';\nimport { IWebsiteData } from '@postybirb/types';\nimport { WebsiteDataTransformerRegistry } from '../transformers';\nimport { WebsiteNameMapper } from '../utils/website-name-mapper';\nimport { LegacyConverterEntity, MinimalEntity } from './legacy-converter-entity';\n\nconst logger = Logger('LegacyWebsiteData');\n\n/**\n * Legacy website data entity from PostyBirb Plus.\n * This reads the same legacy account records but extracts and transforms\n * the website-specific data (OAuth tokens, API keys, credentials) into\n * WebsiteData records.\n *\n * Only websites with registered transformers will produce records.\n * Websites using browser cookies for authentication (e.g., FurAffinity)\n * will return null and be skipped.\n */\nexport class LegacyWebsiteData implements LegacyConverterEntity<IWebsiteData> {\n  _id: string;\n\n  created: number;\n\n  lastUpdated: number;\n\n  alias: string;\n\n  website: string;\n\n  data: unknown;\n\n  constructor(data: Partial<LegacyWebsiteData>) {\n    Object.assign(this, data);\n  }\n\n  async convert(): Promise<MinimalEntity<IWebsiteData> | null> {\n    const newWebsiteId = WebsiteNameMapper.map(this.website);\n\n    // Skip accounts for deprecated websites\n    if (!newWebsiteId) {\n      return null;\n    }\n\n    // Only process websites that have a data transformer\n    const transformer = WebsiteDataTransformerRegistry.getTransformer(\n      this.website,\n    );\n\n    if (!transformer) {\n      logger.debug(\n        `No transformer for website \"${this.website}\" (account: ${this.alias}). ` +\n          'This website likely uses browser cookies for authentication.',\n      );\n      return null;\n    }\n\n    if (!this.data) {\n      logger.warn(\n        `Account \"${this.alias}\" (${this.website}) has transformer but no data to transform.`,\n      );\n      return null;\n    }\n\n    const transformedData = transformer.transform(this.data);\n\n    if (!transformedData) {\n      logger.warn(\n        `Transformer returned null for account \"${this.alias}\" (${this.website}).`,\n      );\n      return null;\n    }\n\n    return {\n      // WebsiteData uses the same ID as the Account (foreign key relationship)\n      // eslint-disable-next-line no-underscore-dangle\n      id: this._id,\n      data: transformedData as Record<string, unknown>,\n    };\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/legacy-database-importer/transformers/implementations/bluesky-data-transformer.ts",
    "content": "import { BlueskyAccountData } from '@postybirb/types';\nimport { LegacyWebsiteDataTransformer } from '../legacy-website-data-transformer';\n\n/**\n * Legacy Bluesky account data structure from PostyBirb Plus\n */\ninterface LegacyBlueskyAccountData {\n  username: string;\n  password: string;\n}\n\n/**\n * Transforms legacy Bluesky account data to modern format.\n * This is a direct passthrough as the structure is identical.\n *\n * Field mappings:\n * - username → username\n * - password → password\n */\nexport class BlueskyDataTransformer\n  implements LegacyWebsiteDataTransformer<LegacyBlueskyAccountData, BlueskyAccountData>\n{\n  transform(legacyData: LegacyBlueskyAccountData): BlueskyAccountData | null {\n    if (!legacyData) {\n      return null;\n    }\n\n    // Must have credentials to be useful\n    if (!legacyData.username || !legacyData.password) {\n      return null;\n    }\n\n    return {\n      username: legacyData.username,\n      password: legacyData.password,\n    };\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/legacy-database-importer/transformers/implementations/custom-data-transformer.ts",
    "content": "import { CustomAccountData } from '@postybirb/types';\nimport { LegacyWebsiteDataTransformer } from '../legacy-website-data-transformer';\n\n/**\n * Legacy Custom account data structure from PostyBirb Plus\n * Note: Legacy has a typo \"thumbnaiField\" instead of \"thumbnailField\"\n */\ninterface LegacyCustomAccountData {\n  descriptionField?: string;\n  descriptionType?: 'html' | 'text' | 'md' | 'bbcode';\n  fileField?: string;\n  fileUrl?: string;\n  headers: { name: string; value: string }[];\n  notificationUrl?: string;\n  ratingField?: string;\n  tagField?: string;\n  thumbnaiField?: string; // Typo in legacy\n  titleField?: string;\n  altTextField?: string;\n}\n\n/**\n * Transforms legacy Custom account data to modern format.\n *\n * Field mappings:\n * - All fields pass through directly\n * - thumbnaiField (typo) → thumbnailField\n */\nexport class CustomDataTransformer\n  implements LegacyWebsiteDataTransformer<LegacyCustomAccountData, CustomAccountData>\n{\n  transform(legacyData: LegacyCustomAccountData): CustomAccountData | null {\n    if (!legacyData) {\n      return null;\n    }\n\n    // Must have at least file URL to be useful\n    if (!legacyData.fileUrl && !legacyData.notificationUrl) {\n      return null;\n    }\n\n    return {\n      descriptionField: legacyData.descriptionField,\n      descriptionType: legacyData.descriptionType,\n      fileField: legacyData.fileField,\n      fileUrl: legacyData.fileUrl,\n      headers: legacyData.headers ?? [],\n      notificationUrl: legacyData.notificationUrl,\n      ratingField: legacyData.ratingField,\n      tagField: legacyData.tagField,\n      // Fix typo from legacy: thumbnaiField → thumbnailField\n      thumbnailField: legacyData.thumbnaiField,\n      titleField: legacyData.titleField,\n      altTextField: legacyData.altTextField,\n    };\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/legacy-database-importer/transformers/implementations/discord-data-transformer.ts",
    "content": "import { DiscordAccountData } from '@postybirb/types';\nimport { LegacyWebsiteDataTransformer } from '../legacy-website-data-transformer';\n\n/**\n * Legacy Discord account data structure from PostyBirb Plus\n */\ninterface LegacyDiscordAccountData {\n  webhook: string;\n  serverBoostLevel: number;\n  name: string;\n  forum: boolean;\n}\n\n/**\n * Transforms legacy Discord account data to modern format.\n *\n * Field mappings:\n * - webhook → webhook\n * - serverBoostLevel → serverLevel\n * - forum → isForum\n * - name is not used in modern (account name is stored separately)\n */\nexport class DiscordDataTransformer\n  implements LegacyWebsiteDataTransformer<LegacyDiscordAccountData, DiscordAccountData>\n{\n  transform(legacyData: LegacyDiscordAccountData): DiscordAccountData | null {\n    if (!legacyData) {\n      return null;\n    }\n\n    // Must have webhook URL to be useful\n    if (!legacyData.webhook) {\n      return null;\n    }\n\n    return {\n      webhook: legacyData.webhook,\n      serverLevel: legacyData.serverBoostLevel ?? 0,\n      isForum: legacyData.forum ?? false,\n    };\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/legacy-database-importer/transformers/implementations/e621-data-transformer.ts",
    "content": "import { E621AccountData } from '@postybirb/types';\nimport { LegacyWebsiteDataTransformer } from '../legacy-website-data-transformer';\n\n/**\n * Legacy e621 account data structure from PostyBirb Plus\n */\ninterface LegacyE621AccountData {\n  username: string;\n  key: string; // API key\n}\n\n/**\n * Transforms legacy e621 account data to modern format.\n * This is a direct passthrough as the structure is identical.\n *\n * Field mappings:\n * - username → username\n * - key → key\n */\nexport class E621DataTransformer\n  implements LegacyWebsiteDataTransformer<LegacyE621AccountData, E621AccountData>\n{\n  transform(legacyData: LegacyE621AccountData): E621AccountData | null {\n    if (!legacyData) {\n      return null;\n    }\n\n    // Must have API key to be useful\n    if (!legacyData.key) {\n      return null;\n    }\n\n    return {\n      username: legacyData.username ?? '',\n      key: legacyData.key,\n    };\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/legacy-database-importer/transformers/implementations/index.ts",
    "content": "export * from './bluesky-data-transformer';\nexport * from './custom-data-transformer';\nexport * from './discord-data-transformer';\nexport * from './e621-data-transformer';\nexport * from './inkbunny-data-transformer';\nexport * from './megalodon-data-transformer';\nexport * from './telegram-data-transformer';\nexport * from './twitter-data-transformer';\n\n"
  },
  {
    "path": "apps/client-server/src/app/legacy-database-importer/transformers/implementations/inkbunny-data-transformer.ts",
    "content": "import { InkbunnyAccountData } from '@postybirb/types';\nimport { LegacyWebsiteDataTransformer } from '../legacy-website-data-transformer';\n\n/**\n * Legacy Inkbunny account data structure from PostyBirb Plus\n */\ninterface LegacyInkbunnyAccountData {\n  username: string;\n  sid: string; // Session ID\n}\n\n/**\n * Transforms legacy Inkbunny account data to modern format.\n * This is mostly a direct passthrough.\n *\n * Field mappings:\n * - username → username\n * - sid → sid\n * - folders: undefined (will be fetched on login)\n */\nexport class InkbunnyDataTransformer\n  implements LegacyWebsiteDataTransformer<LegacyInkbunnyAccountData, InkbunnyAccountData>\n{\n  transform(legacyData: LegacyInkbunnyAccountData): InkbunnyAccountData | null {\n    if (!legacyData) {\n      return null;\n    }\n\n    // Must have session ID to be useful\n    if (!legacyData.sid) {\n      return null;\n    }\n\n    return {\n      username: legacyData.username,\n      sid: legacyData.sid,\n      folders: undefined, // Will be populated on login\n    };\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/legacy-database-importer/transformers/implementations/megalodon-data-transformer.ts",
    "content": "import { MegalodonAccountData } from '@postybirb/types';\nimport { LegacyWebsiteDataTransformer } from '../legacy-website-data-transformer';\n\n/**\n * Legacy Megalodon account data structure from PostyBirb Plus\n * Used by Mastodon, Pleroma, and Pixelfed\n */\ninterface LegacyMegalodonAccountData {\n  token: string; // Access token\n  website: string; // Instance URL (e.g., \"mastodon.social\")\n  username: string;\n}\n\n/**\n * Transforms legacy Megalodon-based account data to modern format.\n * This transformer is used for Mastodon, Pleroma, and Pixelfed.\n *\n * Field mappings:\n * - token → accessToken\n * - website → instanceUrl\n * - username → username\n *\n * Note: OAuth client credentials (clientId, clientSecret) are not\n * preserved from legacy. The token should still work, but users\n * may need to re-authenticate if the token expires.\n */\nexport class MegalodonDataTransformer\n  implements LegacyWebsiteDataTransformer<LegacyMegalodonAccountData, MegalodonAccountData>\n{\n  transform(legacyData: LegacyMegalodonAccountData): MegalodonAccountData | null {\n    if (!legacyData) {\n      return null;\n    }\n\n    // Must have token and instance to be useful\n    if (!legacyData.token || !legacyData.website) {\n      return null;\n    }\n\n    return {\n      accessToken: legacyData.token,\n      instanceUrl: this.normalizeInstanceUrl(legacyData.website),\n      username: legacyData.username,\n      // These are not available from legacy but may be populated on next login\n      clientId: undefined,\n      clientSecret: undefined,\n      displayName: undefined,\n      instanceType: undefined,\n    };\n  }\n\n  /**\n   * Normalize instance URL to consistent format (without protocol or trailing slash).\n   */\n  private normalizeInstanceUrl(url: string): string {\n    let normalized = url.trim().toLowerCase();\n    normalized = normalized.replace(/^(https?:\\/\\/)/, '');\n    normalized = normalized.replace(/\\/$/, '');\n    return normalized;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/legacy-database-importer/transformers/implementations/telegram-data-transformer.ts",
    "content": "import { TelegramAccountData } from '@postybirb/types';\nimport { LegacyWebsiteDataTransformer } from '../legacy-website-data-transformer';\n\n/**\n * Legacy Telegram account data structure from PostyBirb Plus\n */\ninterface LegacyTelegramAccountData {\n  appId: string; // Note: stored as string in legacy\n  appHash: string;\n  phoneNumber: string;\n}\n\n/**\n * Transforms legacy Telegram account data to modern format.\n *\n * Field mappings:\n * - appId (string) → appId (number)\n * - appHash → appHash\n * - phoneNumber → phoneNumber\n * - session: undefined (must be re-authenticated)\n * - channels: [] (must be re-fetched after login)\n *\n * Note: Legacy Telegram sessions cannot be migrated as they use\n * a different session storage mechanism. Users will need to\n * re-authenticate after import.\n */\nexport class TelegramDataTransformer\n  implements LegacyWebsiteDataTransformer<LegacyTelegramAccountData, TelegramAccountData>\n{\n  transform(legacyData: LegacyTelegramAccountData): TelegramAccountData | null {\n    if (!legacyData) {\n      return null;\n    }\n\n    // Must have app credentials to be useful\n    if (!legacyData.appId || !legacyData.appHash) {\n      return null;\n    }\n\n    return {\n      appId: parseInt(legacyData.appId, 10),\n      appHash: legacyData.appHash,\n      phoneNumber: legacyData.phoneNumber ?? '',\n      session: undefined, // Cannot migrate session, requires re-auth\n      channels: [], // Will be populated after re-authentication\n    };\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/legacy-database-importer/transformers/implementations/twitter-data-transformer.ts",
    "content": "import { TwitterAccountData } from '@postybirb/types';\nimport { LegacyWebsiteDataTransformer } from '../legacy-website-data-transformer';\n\n/**\n * Legacy Twitter account data structure from PostyBirb Plus\n */\ninterface LegacyTwitterAccountData {\n  key: string; // Consumer key (API key)\n  secret: string; // Consumer secret (API secret)\n  oauth_token: string; // Access token\n  oauth_token_secret: string; // Access token secret\n  screen_name: string; // Username\n  user_id: number; // User ID\n}\n\n/**\n * Transforms legacy Twitter account data to modern format.\n *\n * Field mappings:\n * - key → apiKey\n * - secret → apiSecret\n * - oauth_token → accessToken\n * - oauth_token_secret → accessTokenSecret\n * - screen_name → screenName\n * - user_id → userId (number to string)\n */\nexport class TwitterDataTransformer\n  implements LegacyWebsiteDataTransformer<LegacyTwitterAccountData, TwitterAccountData>\n{\n  transform(legacyData: LegacyTwitterAccountData): TwitterAccountData | null {\n    if (!legacyData) {\n      return null;\n    }\n\n    // Must have OAuth tokens to be useful\n    if (!legacyData.oauth_token || !legacyData.oauth_token_secret) {\n      return null;\n    }\n\n    return {\n      apiKey: legacyData.key,\n      apiSecret: legacyData.secret,\n      accessToken: legacyData.oauth_token,\n      accessTokenSecret: legacyData.oauth_token_secret,\n      screenName: legacyData.screen_name,\n      userId: legacyData.user_id?.toString(),\n    };\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/legacy-database-importer/transformers/index.ts",
    "content": "export * from './legacy-website-data-transformer';\nexport * from './website-data-transformer-registry';\n\n"
  },
  {
    "path": "apps/client-server/src/app/legacy-database-importer/transformers/legacy-website-data-transformer.ts",
    "content": "/**\n * Interface for transforming legacy website-specific account data\n * to modern WebsiteData format.\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport interface LegacyWebsiteDataTransformer<TLegacy = any, TModern = any> {\n  /**\n   * Transform legacy account data to modern WebsiteData format.\n   * @param legacyData The legacy website-specific data from account.data\n   * @returns The transformed data for modern WebsiteData.data, or null if transformation fails\n   */\n  transform(legacyData: TLegacy): TModern | null;\n}\n\n/**\n * Base transformer that passes through data unchanged.\n * Useful for websites where the data structure is already compatible.\n */\nexport class PassthroughTransformer<T = Record<string, unknown>>\n  implements LegacyWebsiteDataTransformer<T, T>\n{\n  transform(legacyData: T): T | null {\n    if (!legacyData) {\n      return null;\n    }\n    return { ...legacyData };\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/legacy-database-importer/transformers/website-data-transformer-registry.ts",
    "content": "import { BlueskyDataTransformer } from './implementations/bluesky-data-transformer';\nimport { CustomDataTransformer } from './implementations/custom-data-transformer';\nimport { DiscordDataTransformer } from './implementations/discord-data-transformer';\nimport { E621DataTransformer } from './implementations/e621-data-transformer';\nimport { InkbunnyDataTransformer } from './implementations/inkbunny-data-transformer';\nimport { MegalodonDataTransformer } from './implementations/megalodon-data-transformer';\nimport { TelegramDataTransformer } from './implementations/telegram-data-transformer';\nimport { TwitterDataTransformer } from './implementations/twitter-data-transformer';\nimport { LegacyWebsiteDataTransformer } from './legacy-website-data-transformer';\n\n/**\n * Registry that maps legacy website names to their data transformers.\n * Only websites with custom login flows that store credentials in WebsiteData\n * need transformers here.\n */\nexport class WebsiteDataTransformerRegistry {\n  private static readonly transformers: Record<\n    string,\n    LegacyWebsiteDataTransformer\n  > = {\n    // OAuth/API key websites\n    Twitter: new TwitterDataTransformer(),\n    Discord: new DiscordDataTransformer(),\n    Telegram: new TelegramDataTransformer(),\n\n    // Megalodon-based fediverse websites (all use same transformer)\n    Mastodon: new MegalodonDataTransformer(),\n    Pleroma: new MegalodonDataTransformer(),\n    Pixelfed: new MegalodonDataTransformer(),\n\n    // Direct credential websites\n    Bluesky: new BlueskyDataTransformer(),\n    Inkbunny: new InkbunnyDataTransformer(),\n    e621: new E621DataTransformer(),\n\n    // Custom webhook website\n    Custom: new CustomDataTransformer(),\n  };\n\n  /**\n   * Get the transformer for a legacy website name.\n   * @param legacyWebsiteName The legacy website name (e.g., \"Twitter\", \"Mastodon\")\n   * @returns The transformer instance, or undefined if no transformer exists\n   */\n  static getTransformer(\n    legacyWebsiteName: string,\n  ): LegacyWebsiteDataTransformer | undefined {\n    return this.transformers[legacyWebsiteName];\n  }\n\n  /**\n   * Check if a legacy website has a data transformer.\n   * Websites without transformers typically use browser cookies for auth.\n   * @param legacyWebsiteName The legacy website name\n   */\n  static hasTransformer(legacyWebsiteName: string): boolean {\n    return legacyWebsiteName in this.transformers;\n  }\n\n  /**\n   * Get all legacy website names that have transformers.\n   */\n  static getTransformableWebsites(): string[] {\n    return Object.keys(this.transformers);\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/legacy-database-importer/utils/ndjson-parser.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { Logger } from '@postybirb/logger';\nimport { promises as fs } from 'fs';\n\nexport interface ParseResult<T> {\n  records: T[];\n  errors: ParseError[];\n}\n\nexport interface ParseError {\n  line: number;\n  content: string;\n  error: string;\n}\n\n/**\n * Simple NDJSON (Newline Delimited JSON) parser for NeDB files.\n * Each line in a NeDB file is a separate JSON object.\n */\n@Injectable()\nexport class NdjsonParser {\n  private readonly logger = Logger(NdjsonParser.name);\n\n  /**\n   * Parse an NDJSON file and instantiate objects of the specified class\n   */\n  async parseFile<T>(\n    filePath: string,\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    EntityClass: new (data: any) => T,\n  ): Promise<ParseResult<T>> {\n    const records: T[] = [];\n    const errors: ParseError[] = [];\n\n    try {\n      const content = await fs.readFile(filePath, 'utf-8');\n      const lines = content.split('\\n');\n\n      for (let i = 0; i < lines.length; i++) {\n        const line = lines[i].trim();\n\n        // Skip empty lines\n        if (!line) {\n          continue;\n        }\n\n        try {\n          const parsed = JSON.parse(line);\n          const instance = new EntityClass(parsed);\n          records.push(instance);\n        } catch (error) {\n          errors.push({\n            line: i + 1,\n            content: line.substring(0, 100), // Truncate long lines\n            error: error instanceof Error ? error.message : String(error),\n          });\n        }\n      }\n\n      if (errors.length > 0) {\n        this.logger.warn(\n          `Parsed ${filePath}: ${records.length} records, ${errors.length} errors`,\n        );\n      } else {\n        this.logger.info(`Parsed ${filePath}: ${records.length} records`);\n      }\n\n      return { records, errors };\n    } catch (error) {\n      if ((error as NodeJS.ErrnoException).code === 'ENOENT') {\n        this.logger.warn(`File not found: ${filePath}`);\n        return { records: [], errors: [] };\n      }\n\n      this.logger.error(`Error reading ${filePath}: ${error}`);\n      throw error;\n    }\n  }\n\n  /**\n   * Check if a file exists\n   */\n  async fileExists(filePath: string): Promise<boolean> {\n    try {\n      await fs.access(filePath);\n      return true;\n    } catch {\n      return false;\n    }\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/legacy-database-importer/utils/website-name-mapper.ts",
    "content": "/**\n * Maps legacy PostyBirb Plus website names to new PostyBirb V4 website IDs.\n * The website ID in V4 is the value from the @WebsiteMetadata({ name }) decorator.\n * Legacy uses PascalCase names, V4 uses kebab-case IDs.\n */\nexport class WebsiteNameMapper {\n  private static readonly LEGACY_TO_NEW: Record<string, string> = {\n    // Direct mappings (kebab-case in V4)\n    Artconomy: 'artconomy',\n    Aryion: 'aryion',\n    Bluesky: 'bluesky',\n    Custom: 'custom',\n    Derpibooru: 'derpibooru',\n    DeviantArt: 'deviant-art',\n    Discord: 'discord',\n    FurAffinity: 'fur-affinity',\n    Furbooru: 'furbooru',\n    FurryNetwork: null, // Deprecated - no longer exists in V4\n    HentaiFoundry: 'hentai-foundry',\n    Inkbunny: 'inkbunny',\n    Itaku: 'itaku',\n    KoFi: 'ko-fi',\n    Manebooru: 'manebooru',\n    Mastodon: 'mastodon',\n    MissKey: 'misskey',\n    Newgrounds: 'newgrounds',\n    Patreon: 'patreon',\n    Picarto: 'picarto',\n    Piczel: 'piczel',\n    Pillowfort: 'pillowfort',\n    Pixelfed: 'pixelfed',\n    Pixiv: 'pixiv',\n    Pleroma: 'pleroma',\n    SoFurry: 'sofurry',\n    SubscribeStar: 'subscribe-star',\n    SubscribeStarAdult: 'subscribe-star', // Maps to same base (Adult variant handled differently in V4)\n    Telegram: 'telegram',\n    Tumblr: 'tumblr',\n    Twitter: 'twitter',\n    Weasyl: 'weasyl',\n    e621: 'e621',\n\n    // New websites in V4 that didn't exist in Plus:\n    // cara: 'cara',\n    // firefish: 'firefish',\n    // friendica: 'friendica',\n    // gotosocial: 'gotosocial',\n    // toyhouse: 'toyhouse',\n  };\n\n  /**\n   * Map a legacy website name to the new website name\n   */\n  static map(legacyName: string): string | null {\n    return this.LEGACY_TO_NEW[legacyName] || null;\n  }\n\n  /**\n   * Map multiple legacy website names\n   */\n  static mapMany(\n    legacyNames: string[],\n  ): Array<{ legacy: string; new: string | null }> {\n    return legacyNames.map((legacy) => ({\n      legacy,\n      new: this.map(legacy),\n    }));\n  }\n\n  /**\n   * Check if a legacy website name has a mapping\n   */\n  static hasMapping(legacyName: string): boolean {\n    return legacyName in this.LEGACY_TO_NEW;\n  }\n\n  /**\n   * Get all legacy website names that have mappings\n   */\n  static getAllLegacyNames(): string[] {\n    return Object.keys(this.LEGACY_TO_NEW);\n  }\n\n  /**\n   * Get all new website names\n   */\n  static getAllNewNames(): string[] {\n    return Array.from(new Set(Object.values(this.LEGACY_TO_NEW)));\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/logs/logs.controller.ts",
    "content": "import { Controller, Get, Res } from '@nestjs/common';\nimport { ApiOkResponse, ApiTags } from '@nestjs/swagger';\nimport { LogsService } from './logs.service';\n\n/**\n * Controller for log file operations.\n * Provides an endpoint to download all logs as a .tar.gz archive.\n */\n@ApiTags('logs')\n@Controller('logs')\nexport class LogsController {\n  constructor(private readonly service: LogsService) {}\n\n  @Get('download')\n  @ApiOkResponse({ description: 'Returns a .tar.gz archive of all log files.' })\n  download(@Res() response) {\n    const archive = this.service.getLogsArchive();\n    const date = new Date().toISOString().split('T')[0];\n    response.set({\n      'Content-Type': 'application/gzip',\n      'Content-Disposition': `attachment; filename=postybirb-logs-${date}.tar.gz`,\n      'Content-Length': archive.length,\n    });\n    response.send(archive);\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/logs/logs.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { LogsController } from './logs.controller';\nimport { LogsService } from './logs.service';\n\n@Module({\n  providers: [LogsService],\n  controllers: [LogsController],\n  exports: [LogsService],\n})\nexport class LogsModule {}\n"
  },
  {
    "path": "apps/client-server/src/app/logs/logs.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { PostyBirbDirectories } from '@postybirb/fs';\nimport { Logger } from '@postybirb/logger';\nimport { readdirSync, readFileSync } from 'fs';\nimport { join } from 'path';\nimport { gzipSync } from 'zlib';\n\n/**\n * Service for managing log file operations.\n * Provides the ability to bundle all log files into a downloadable .tar.gz archive.\n */\n@Injectable()\nexport class LogsService {\n  private readonly logger = Logger('LogsService');\n\n  /**\n   * Creates a gzipped tar archive of all files in the logs directory.\n   * Uses Node.js built-in zlib (no external dependencies).\n   *\n   * @returns A Buffer containing the .tar.gz archive\n   */\n  getLogsArchive(): Buffer {\n    const logsDir = PostyBirbDirectories.LOGS_DIRECTORY;\n    this.logger.info(`Creating logs archive from: ${logsDir}`);\n\n    const files = readdirSync(logsDir);\n    const tarBuffer = this.createTar(logsDir, files);\n    return gzipSync(tarBuffer);\n  }\n\n  /**\n   * Creates a tar archive buffer from the given files.\n   * Implements a minimal tar writer (POSIX ustar format) sufficient for\n   * bundling flat log files.\n   */\n  private createTar(dir: string, fileNames: string[]): Buffer {\n    const blocks: Buffer[] = [];\n\n    for (const fileName of fileNames) {\n      try {\n        const filePath = join(dir, fileName);\n        const content = readFileSync(filePath);\n        const header = this.createTarHeader(fileName, content.length);\n        blocks.push(header);\n        blocks.push(content);\n\n        // Pad content to 512-byte boundary\n        const remainder = content.length % 512;\n        if (remainder > 0) {\n          blocks.push(Buffer.alloc(512 - remainder));\n        }\n      } catch (err) {\n        this.logger.withError(err).warn(`Skipping file: ${fileName}`);\n      }\n    }\n\n    // End-of-archive marker: two 512-byte blocks of zeros\n    blocks.push(Buffer.alloc(1024));\n\n    return Buffer.concat(blocks);\n  }\n\n  /**\n   * Creates a 512-byte tar header for a single file entry.\n   */\n  private createTarHeader(fileName: string, fileSize: number): Buffer {\n    const header = Buffer.alloc(512);\n\n    // File name (0–99, 100 bytes)\n    header.write(fileName.slice(0, 100), 0, 100, 'utf-8');\n\n    // File mode (100–107, 8 bytes) — 0644\n    header.write('0000644\\0', 100, 8, 'utf-8');\n\n    // Owner UID (108–115, 8 bytes)\n    header.write('0000000\\0', 108, 8, 'utf-8');\n\n    // Group GID (116–123, 8 bytes)\n    header.write('0000000\\0', 116, 8, 'utf-8');\n\n    // File size in octal (124–135, 12 bytes)\n    header.write(`${fileSize.toString(8).padStart(11, '0')}\\0`, 124, 12, 'utf-8');\n\n    // Modification time in octal (136–147, 12 bytes)\n    const mtime = Math.floor(Date.now() / 1000);\n    header.write(`${mtime.toString(8).padStart(11, '0')}\\0`, 136, 12, 'utf-8');\n\n    // Type flag (156, 1 byte) — '0' for regular file\n    header.write('0', 156, 1, 'utf-8');\n\n    // USTAR magic (257–262, 6 bytes) + version (263–264, 2 bytes)\n    header.write('ustar\\0', 257, 6, 'utf-8');\n    header.write('00', 263, 2, 'utf-8');\n\n    // Checksum placeholder: fill with spaces first (148–155, 8 bytes)\n    header.write('        ', 148, 8, 'utf-8');\n\n    // Compute checksum (sum of all unsigned bytes in the header)\n    let checksum = 0;\n    for (let i = 0; i < 512; i++) {\n      checksum += header[i];\n    }\n    // Write checksum in octal, null-terminated, space-padded\n    header.write(`${checksum.toString(8).padStart(6, '0')}\\0 `, 148, 8, 'utf-8');\n\n    return header;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/notifications/dtos/create-notification.dto.ts",
    "content": "import { ApiProperty } from '@nestjs/swagger';\nimport { ICreateNotificationDto } from '@postybirb/types';\nimport { IsArray, IsObject, IsString } from 'class-validator';\n\nexport class CreateNotificationDto implements ICreateNotificationDto {\n  @ApiProperty()\n  @IsObject()\n  data: Record<string, unknown> = {};\n\n  @ApiProperty()\n  @IsString()\n  title: string;\n\n  @ApiProperty()\n  @IsString()\n  message: string;\n\n  @ApiProperty()\n  @IsArray()\n  @IsString({ each: true })\n  tags: string[] = [];\n\n  @ApiProperty()\n  @IsString()\n  type: 'warning' | 'error' | 'info' | 'success' = 'info';\n}\n"
  },
  {
    "path": "apps/client-server/src/app/notifications/dtos/update-notification.dto.ts",
    "content": "import { ApiProperty } from '@nestjs/swagger';\nimport { IUpdateNotificationDto } from '@postybirb/types';\nimport { IsBoolean } from 'class-validator';\n\nexport class UpdateNotificationDto implements IUpdateNotificationDto {\n  @ApiProperty()\n  @IsBoolean()\n  isRead: boolean;\n\n  @ApiProperty()\n  @IsBoolean()\n  hasEmitted: boolean;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/notifications/notification.events.ts",
    "content": "import { NOTIFICATION_UPDATES } from '@postybirb/socket-events';\nimport { INotification } from '@postybirb/types';\nimport { WebsocketEvent } from '../web-socket/models/web-socket-event';\n\nexport type NotificationEventTypes = NotificationEvent;\n\nclass NotificationEvent implements WebsocketEvent<INotification[]> {\n  event: string = NOTIFICATION_UPDATES;\n\n  data: INotification[];\n}\n"
  },
  {
    "path": "apps/client-server/src/app/notifications/notifications.controller.ts",
    "content": "import {\n  Body,\n  Controller,\n  Delete,\n  Get,\n  Param,\n  Patch,\n  Post,\n  Query,\n} from '@nestjs/common';\nimport {\n  ApiBadRequestResponse,\n  ApiNotFoundResponse,\n  ApiOkResponse,\n  ApiTags,\n} from '@nestjs/swagger';\nimport { EntityId } from '@postybirb/types';\nimport { CreateNotificationDto } from './dtos/create-notification.dto';\nimport { UpdateNotificationDto } from './dtos/update-notification.dto';\nimport { NotificationsService } from './notifications.service';\n\n/**\n * @class NotificationsController\n */\n@ApiTags('notifications')\n@Controller('notifications')\nexport class NotificationsController {\n  constructor(readonly service: NotificationsService) {}\n\n  @Post()\n  @ApiOkResponse({ description: 'Notification created.' })\n  @ApiBadRequestResponse({ description: 'Bad request made.' })\n  create(@Body() createDto: CreateNotificationDto) {\n    return this.service.create(createDto).then((entity) => entity.toDTO());\n  }\n\n  @Patch(':id')\n  @ApiOkResponse({ description: 'Notification updated.' })\n  @ApiNotFoundResponse({ description: 'Notification not found.' })\n  update(@Param('id') id: EntityId, @Body() updateDto: UpdateNotificationDto) {\n    return this.service.update(id, updateDto).then((entity) => entity.toDTO());\n  }\n\n  @Get()\n  @ApiOkResponse({ description: 'A list of all records.' })\n  findAll() {\n    return this.service\n      .findAll()\n      .then((records) => records.map((record) => record.toDTO()));\n  }\n\n  @Delete()\n  @ApiOkResponse({ description: 'Notification deleted.' })\n  @ApiNotFoundResponse({ description: 'Notification not found.' })\n  async remove(@Query('ids') ids: EntityId | EntityId[]) {\n    return Promise.all(\n      (Array.isArray(ids) ? ids : [ids]).map((id) => this.service.remove(id)),\n    ).then(() => ({\n      success: true,\n    }));\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/notifications/notifications.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { AccountModule } from '../account/account.module';\nimport { SettingsModule } from '../settings/settings.module';\nimport { UserSpecifiedWebsiteOptionsModule } from '../user-specified-website-options/user-specified-website-options.module';\nimport { WebsitesModule } from '../websites/websites.module';\nimport { NotificationsController } from './notifications.controller';\nimport { NotificationsService } from './notifications.service';\n\n@Module({\n  imports: [\n    WebsitesModule,\n    UserSpecifiedWebsiteOptionsModule,\n    AccountModule,\n    SettingsModule,\n  ],\n  providers: [NotificationsService],\n  controllers: [NotificationsController],\n  exports: [NotificationsService],\n})\nexport class NotificationsModule {}\n"
  },
  {
    "path": "apps/client-server/src/app/notifications/notifications.service.spec.ts",
    "content": "import { Test, TestingModule } from '@nestjs/testing';\nimport { clearDatabase } from '@postybirb/database';\nimport { SettingsService } from '../settings/settings.service';\nimport { WSGateway } from '../web-socket/web-socket-gateway';\nimport { CreateNotificationDto } from './dtos/create-notification.dto';\nimport { UpdateNotificationDto } from './dtos/update-notification.dto';\nimport { NotificationsService } from './notifications.service';\n\ndescribe('NotificationsService', () => {\n  let service: NotificationsService;\n  let module: TestingModule;\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  let webSocketMock: any;\n\n  beforeEach(async () => {\n    clearDatabase();\n    webSocketMock = {\n      emit: jest.fn(),\n    };\n\n    module = await Test.createTestingModule({\n      providers: [\n        NotificationsService,\n        SettingsService,\n        {\n          provide: WSGateway,\n          useValue: webSocketMock,\n        },\n      ],\n    }).compile();\n\n    service = module.get<NotificationsService>(NotificationsService);\n  });\n\n  afterAll(async () => {\n    await module?.close();\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n\n  it('should create a notification', async () => {\n    const dto = new CreateNotificationDto();\n    dto.title = 'Test Notification';\n    dto.message = 'This is a test notification';\n    dto.type = 'info';\n\n    const notification = await service.create(dto);\n    expect(notification).toBeDefined();\n    expect(notification.title).toBe(dto.title);\n    expect(notification.message).toBe(dto.message);\n    expect(notification.type).toBe(dto.type);\n\n    const notifications = await service.findAll();\n    expect(notifications).toHaveLength(1);\n    expect(notifications[0].id).toBe(notification.id);\n  });\n\n  it('should update a notification', async () => {\n    const createDto = new CreateNotificationDto();\n    createDto.title = 'Initial Title';\n    createDto.message = 'Initial Message';\n    createDto.type = 'info';\n\n    const notification = await service.create(createDto);\n\n    const updateDto = new UpdateNotificationDto();\n    updateDto.isRead = true;\n\n    await service.update(notification.id, updateDto);\n\n    const updatedNotification = await service.findById(notification.id);\n    expect(updatedNotification.message).toBe(createDto.message); // unchanged\n    expect(updatedNotification.isRead).toBe(true);\n  });\n\n  it('should emit notification updates when changes occur', async () => {\n    // Create a notification which should trigger an emit\n    const dto = new CreateNotificationDto();\n    dto.title = 'Test Notification';\n    dto.message = 'This is a test notification';\n    dto.type = 'info';\n\n    await service.create(dto);\n\n    // Verify websocket emit was called with the correct event\n    expect(webSocketMock.emit).toHaveBeenCalled();\n    const emitArgs = webSocketMock.emit.mock.calls[0];\n    expect(emitArgs[0].data[0].title).toBe(dto.title);\n  });\n\n  it('should initialize without websocket and not throw error', () => {\n    // @ts-expect-error Test case\n    const serviceWithoutWebsocket = new NotificationsService();\n    expect(serviceWithoutWebsocket).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/client-server/src/app/notifications/notifications.service.ts",
    "content": "import { Injectable, Optional } from '@nestjs/common';\nimport { Cron, CronExpression } from '@nestjs/schedule';\nimport { NOTIFICATION_UPDATES } from '@postybirb/socket-events';\nimport { EntityId } from '@postybirb/types';\nimport { Notification as ElectronNotification } from 'electron';\nimport { PostyBirbService } from '../common/service/postybirb-service';\nimport { Notification } from '../drizzle/models/notification.entity';\nimport { SettingsService } from '../settings/settings.service';\nimport { WSGateway } from '../web-socket/web-socket-gateway';\nimport { CreateNotificationDto } from './dtos/create-notification.dto';\nimport { UpdateNotificationDto } from './dtos/update-notification.dto';\n\n/**\n * Service responsible for managing application notifications.\n * Handles creation, updating, and removal of notifications, as well as\n * sending desktop notifications based on user settings.\n */\n@Injectable()\nexport class NotificationsService extends PostyBirbService<'NotificationSchema'> {\n  /**\n   * Creates a new instance of the NotificationsService.\n   *\n   * @param settingsService - Service for accessing application settings\n   * @param webSocket - Optional websocket gateway for emitting events\n   */\n  constructor(\n    private readonly settingsService: SettingsService,\n    @Optional() webSocket?: WSGateway,\n  ) {\n    super('NotificationSchema', webSocket);\n    this.repository.subscribe('NotificationSchema', () => this.emit());\n    this.removeStaleNotifications();\n  }\n\n  /**\n   * Removes notifications older than one month.\n   * Runs automatically every hour via cron job.\n   */\n  @Cron(CronExpression.EVERY_HOUR)\n  private async removeStaleNotifications() {\n    const notifications = await this.repository.findAll();\n\n    const aMonthAgo = new Date();\n    aMonthAgo.setMonth(aMonthAgo.getMonth() - 1);\n    const staleNotifications = notifications.filter(\n      (notification) =>\n        new Date(notification.createdAt).getTime() < aMonthAgo.getTime(),\n    );\n    if (staleNotifications.length) {\n      await this.repository.deleteById(staleNotifications.map((n) => n.id));\n    }\n  }\n\n  /**\n   * Creates a new notification and optionally sends a desktop notification.\n   *\n   * @param createDto - The notification data to create\n   * @param sendDesktopNotification - Whether to also send a desktop notification\n   * @returns The created notification entity\n   */\n  async create(\n    createDto: CreateNotificationDto,\n    sendDesktopNotification = false,\n  ): Promise<Notification> {\n    this.logger\n      .withMetadata(createDto)\n      .info(`Creating notification '${createDto.title}'`);\n    if (sendDesktopNotification) {\n      this.sendDesktopNotification(createDto);\n    }\n\n    return this.repository.insert(createDto);\n  }\n\n  /**\n   * Trims notifications to a maximum of 250, removing the oldest first.\n   * Runs every 5 minutes.\n   */\n  @Cron(CronExpression.EVERY_5_MINUTES)\n  private async trimNotifications() {\n    const notifications = await this.repository.findAll();\n    if (notifications.length <= 250) {\n      return;\n    }\n    const sorted = notifications.sort(\n      (a, b) =>\n        new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),\n    );\n    const toRemove = sorted.slice(0, notifications.length - 250);\n    await this.repository.deleteById(toRemove.map((n) => n.id));\n  }\n\n  /**\n   * Sends a desktop notification based on user settings and notification type.\n   *\n   * @param notification - The notification data to display\n   */\n  async sendDesktopNotification(\n    notification: CreateNotificationDto,\n  ): Promise<void> {\n    const { settings } = await this.settingsService.getDefaultSettings();\n    const { desktopNotifications } = settings;\n    const { tags, title, message, type } = notification;\n    if (!desktopNotifications.enabled) {\n      return;\n    }\n\n    if (\n      desktopNotifications.showOnDirectoryWatcherError &&\n      tags.includes('directory-watcher') &&\n      type === 'error'\n    ) {\n      new ElectronNotification({\n        title,\n        body: message,\n      }).show();\n    }\n\n    if (\n      desktopNotifications.showOnDirectoryWatcherSuccess &&\n      tags.includes('directory-watcher') &&\n      type === 'success'\n    ) {\n      new ElectronNotification({\n        title,\n        body: message,\n      }).show();\n    }\n\n    if (\n      desktopNotifications.showOnPostError &&\n      tags.includes('post') &&\n      type === 'error'\n    ) {\n      new ElectronNotification({\n        title,\n        body: message,\n      }).show();\n    }\n\n    if (\n      desktopNotifications.showOnPostSuccess &&\n      tags.includes('post') &&\n      type === 'success'\n    ) {\n      new ElectronNotification({\n        title,\n        body: message,\n      }).show();\n    }\n  }\n\n  /**\n   * Updates an existing notification.\n   *\n   * @param id - The ID of the notification to update\n   * @param update - The data to update\n   * @returns The updated notification\n   */\n  update(id: EntityId, update: UpdateNotificationDto) {\n    this.logger.withMetadata(update).info(`Updating notification '${id}'`);\n    return this.repository.update(id, update);\n  }\n\n  /**\n   * Emits notification updates to connected clients.\n   * Converts entities to DTOs before sending.\n   */\n  protected async emit() {\n    super.emit({\n      event: NOTIFICATION_UPDATES,\n      data: (await this.repository.findAll()).map((entity) => entity.toDTO()),\n    });\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/post/dtos/post-queue-action.dto.ts",
    "content": "import { ApiProperty } from '@nestjs/swagger';\nimport { IPostQueueActionDto, PostRecordResumeMode } from '@postybirb/types';\nimport { ArrayNotEmpty, IsArray, IsEnum, IsOptional } from 'class-validator';\n\nexport class PostQueueActionDto implements IPostQueueActionDto {\n  @ApiProperty()\n  @IsArray()\n  @ArrayNotEmpty()\n  submissionIds: string[];\n\n  @ApiProperty({ enum: PostRecordResumeMode, required: false })\n  @IsOptional()\n  @IsEnum(PostRecordResumeMode)\n  resumeMode?: PostRecordResumeMode;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/post/dtos/queue-post-record.dto.ts",
    "content": "import { ApiProperty } from '@nestjs/swagger';\nimport { IQueuePostRecordRequestDto, SubmissionId } from '@postybirb/types';\nimport { ArrayNotEmpty, IsArray } from 'class-validator';\n\n/** @inheritdoc */\nexport class QueuePostRecordRequestDto implements IQueuePostRecordRequestDto {\n  @ApiProperty()\n  @IsArray()\n  @ArrayNotEmpty()\n  ids: SubmissionId[];\n}\n"
  },
  {
    "path": "apps/client-server/src/app/post/errors/index.ts",
    "content": "export * from './invalid-post-chain.error';\n\n"
  },
  {
    "path": "apps/client-server/src/app/post/errors/invalid-post-chain.error.ts",
    "content": "import { EntityId, PostRecordResumeMode } from '@postybirb/types';\n\n/**\n * Error thrown when attempting to create a PostRecord in an invalid state.\n *\n * This indicates a bug in the calling code - the caller should have verified\n * the submission state before requesting a PostRecord creation.\n *\n * Error reasons:\n * - no_origin: CONTINUE/RETRY requested but no prior NEW PostRecord exists\n * - origin_done: CONTINUE/RETRY requested but the origin NEW record is DONE (chain closed)\n * - in_progress: A PostRecord for this submission is already PENDING or RUNNING\n */\nexport class InvalidPostChainError extends Error {\n  public readonly submissionId: EntityId;\n\n  public readonly requestedResumeMode: PostRecordResumeMode;\n\n  public readonly reason: 'no_origin' | 'origin_done' | 'in_progress';\n\n  constructor(\n    submissionId: EntityId,\n    requestedResumeMode: PostRecordResumeMode,\n    reason: 'no_origin' | 'origin_done' | 'in_progress',\n  ) {\n    let message: string;\n    // eslint-disable-next-line default-case\n    switch (reason) {\n      case 'no_origin':\n        message = `Cannot create ${requestedResumeMode} PostRecord for submission ${submissionId}: no prior NEW PostRecord found to chain from`;\n        break;\n      case 'origin_done':\n        message = `Cannot create ${requestedResumeMode} PostRecord for submission ${submissionId}: the origin NEW PostRecord is already DONE (chain is closed)`;\n        break;\n      case 'in_progress':\n        message = `Cannot create ${requestedResumeMode} PostRecord for submission ${submissionId}: a PostRecord is already PENDING or RUNNING`;\n        break;\n    }\n\n    super(message);\n    this.name = 'InvalidPostChainError';\n    this.submissionId = submissionId;\n    this.requestedResumeMode = requestedResumeMode;\n    this.reason = reason;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/post/models/cancellable-token.ts",
    "content": "import { CancellationError } from './cancellation-error';\n\n/**\n * CancellableToken is a simple class that can be used to cancel a task.\n * @class CancellableToken\n */\nexport class CancellableToken {\n  private cancelled = false;\n\n  public get isCancelled(): boolean {\n    return this.cancelled;\n  }\n\n  public cancel(): void {\n    this.cancelled = true;\n  }\n\n  public throwIfCancelled(): void {\n    if (this.cancelled) {\n      throw new CancellationError();\n    }\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/post/models/cancellation-error.ts",
    "content": "/**\n * CancellationError is thrown when a task is cancelled.\n * @class CancellationError\n */\nexport class CancellationError extends Error {\n  constructor(message = 'Task was cancelled.') {\n    super(message);\n    this.name = 'CancellationError';\n\n    // Maintains proper stack trace for where our error was thrown (only available on V8)\n    if (Error.captureStackTrace) {\n      Error.captureStackTrace(this, CancellationError);\n    }\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/post/models/posting-file.ts",
    "content": "import { FormFile } from '@postybirb/http';\nimport {\n  FileType,\n  IFileBuffer,\n  SubmissionFileId,\n  SubmissionFileMetadata,\n} from '@postybirb/types';\nimport { getFileType } from '@postybirb/utils/file-type';\nimport { parse } from 'path';\n\nexport type ThumbnailOptions = Pick<\n  IFileBuffer,\n  'buffer' | 'height' | 'width' | 'mimeType' | 'fileName'\n>;\n\nexport type FormDataFileFormat = {\n  value: Buffer;\n  options: {\n    contentType: string;\n    filename: string;\n  };\n};\n\nexport class PostingFile {\n  public readonly id: SubmissionFileId;\n\n  public readonly buffer: Buffer;\n\n  public readonly mimeType: string;\n\n  public readonly fileType: FileType;\n\n  public readonly fileName: string;\n\n  public readonly width: number;\n\n  public readonly height: number;\n\n  public metadata: SubmissionFileMetadata;\n\n  public readonly thumbnail?: ThumbnailOptions;\n\n  public constructor(\n    id: SubmissionFileId,\n    file: IFileBuffer,\n    thumbnail?: ThumbnailOptions,\n  ) {\n    this.id = id;\n    this.buffer = file.buffer;\n    this.mimeType = file.mimeType;\n    this.width = file.width;\n    this.height = file.height;\n    this.fileType = getFileType(file.fileName);\n    this.fileName = this.normalizeFileName(file);\n    this.thumbnail = thumbnail;\n  }\n\n  private normalizeFileName(file: IFileBuffer): string {\n    const { ext } = parse(file.fileName);\n    return `${file.id}${ext}`;\n  }\n\n  public withMetadata(metadata: SubmissionFileMetadata): PostingFile {\n    this.metadata = metadata;\n    return this;\n  }\n\n  public toPostFormat(): FormFile {\n    return new FormFile(this.buffer, {\n      contentType: this.mimeType,\n      filename: this.fileName,\n    });\n  }\n\n  public thumbnailToPostFormat(): FormFile | undefined {\n    if (!this.thumbnail) {\n      return undefined;\n    }\n\n    return new FormFile(this.thumbnail.buffer, {\n      contentType: this.thumbnail.mimeType,\n      filename: this.thumbnail.fileName,\n    });\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/post/post.controller.ts",
    "content": "import { Controller, Get, Param } from '@nestjs/common';\nimport { ApiOkResponse, ApiTags } from '@nestjs/swagger';\nimport { EntityId } from '@postybirb/types';\nimport { PostyBirbController } from '../common/controller/postybirb-controller';\nimport { PostService } from './post.service';\n\n/**\n * Queue operations for Post data.\n * @class PostController\n */\n@ApiTags('post')\n@Controller('post')\nexport class PostController extends PostyBirbController<'PostRecordSchema'> {\n  constructor(readonly service: PostService) {\n    super(service);\n  }\n\n  /**\n   * Get all events for a specific post record.\n   * Returns the immutable event ledger showing all posting actions.\n   *\n   * @param {EntityId} id - The post record ID\n   * @returns {Promise<PostEventDto[]>} Array of post events\n   */\n  @Get(':id/events')\n  @ApiOkResponse({ description: 'Events for the post record.' })\n  async getEvents(@Param('id') id: EntityId) {\n    return this.service.getEvents(id);\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/post/post.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { FileConverterModule } from '../file-converter/file-converter.module';\nimport { NotificationsModule } from '../notifications/notifications.module';\nimport { PostParsersModule } from '../post-parsers/post-parsers.module';\nimport { SettingsModule } from '../settings/settings.module';\nimport { SubmissionModule } from '../submission/submission.module';\nimport { ValidationModule } from '../validation/validation.module';\nimport { WebsiteOptionsModule } from '../website-options/website-options.module';\nimport { WebsiteImplProvider } from '../websites/implementations/provider';\nimport { WebsitesModule } from '../websites/websites.module';\nimport { PostController } from './post.controller';\nimport { PostService } from './post.service';\nimport { PostFileResizerService } from './services/post-file-resizer/post-file-resizer.service';\nimport {\n  FileSubmissionPostManager,\n  MessageSubmissionPostManager,\n  PostManagerRegistry,\n} from './services/post-manager-v2';\nimport { PostManagerController } from './services/post-manager/post-manager.controller';\nimport { PostQueueController } from './services/post-queue/post-queue.controller';\nimport { PostQueueService } from './services/post-queue/post-queue.service';\nimport {\n  PostEventRepository,\n  PostRecordFactory,\n} from './services/post-record-factory';\n\n@Module({\n  imports: [\n    WebsiteOptionsModule,\n    WebsitesModule,\n    PostParsersModule,\n    ValidationModule,\n    FileConverterModule,\n    SettingsModule,\n    SubmissionModule,\n    NotificationsModule,\n  ],\n  controllers: [PostController, PostQueueController, PostManagerController],\n  providers: [\n    PostService,\n    PostFileResizerService,\n    WebsiteImplProvider,\n    PostQueueService,\n    PostEventRepository,\n    PostRecordFactory,\n    FileSubmissionPostManager,\n    MessageSubmissionPostManager,\n    PostManagerRegistry,\n  ],\n  exports: [PostEventRepository, PostRecordFactory, PostManagerRegistry],\n})\nexport class PostModule {}\n"
  },
  {
    "path": "apps/client-server/src/app/post/post.service.ts",
    "content": "import { Injectable, Optional } from '@nestjs/common';\nimport { EntityId, PostEventDto } from '@postybirb/types';\nimport { PostyBirbService } from '../common/service/postybirb-service';\nimport { PostyBirbDatabase } from '../drizzle/postybirb-database/postybirb-database';\nimport { WSGateway } from '../web-socket/web-socket-gateway';\n\n/**\n * Simple entity service for post records.\n * @class PostService\n */\n@Injectable()\nexport class PostService extends PostyBirbService<'PostRecordSchema'> {\n  private readonly postEventRepository = new PostyBirbDatabase(\n    'PostEventSchema',\n  );\n\n  constructor(@Optional() webSocket?: WSGateway) {\n    super('PostRecordSchema', webSocket);\n  }\n\n  /**\n   * Get all events for a specific post record.\n   *\n   * @param {EntityId} postRecordId - The post record ID\n   * @returns {Promise<PostEventDto[]>} Array of post events\n   */\n  async getEvents(postRecordId: EntityId): Promise<PostEventDto[]> {\n    const events = await this.postEventRepository.find({\n      where: (event, { eq }) => eq(event.postRecordId, postRecordId),\n      orderBy: (event, { asc }) => asc(event.createdAt),\n      with: {\n        account: true,\n      },\n    });\n\n    return events.map((event) => event.toDTO());\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/post/services/post-file-resizer/post-file-resizer.service.spec.ts",
    "content": "import { Test, TestingModule } from '@nestjs/testing';\nimport {\n  DefaultSubmissionFileMetadata,\n  ISubmission,\n  ISubmissionFile,\n} from '@postybirb/types';\nimport { readFileSync } from 'fs';\nimport { join } from 'path';\nimport { SharpInstanceManager } from '../../../image-processing/sharp-instance-manager';\nimport { PostFileResizerService } from './post-file-resizer.service';\n\ndescribe('PostFileResizerService', () => {\n  let service: PostFileResizerService;\n  let sharpManager: SharpInstanceManager;\n  let module: TestingModule;\n  let testFile: Buffer;\n  let file: ISubmissionFile;\n\n  function createFile(\n    fileName: string,\n    mimeType: string,\n    height: number,\n    width: number,\n    buf: Buffer,\n  ): ISubmissionFile {\n    return {\n      id: 'test',\n      fileName,\n      hash: 'test',\n      mimeType,\n      size: buf.length,\n      hasThumbnail: false,\n      hasCustomThumbnail: false,\n      hasAltFile: false,\n      createdAt: new Date().toISOString(),\n      updatedAt: new Date().toISOString(),\n      submission: {} as ISubmission<never>,\n      width,\n      height,\n      primaryFileId: 'test',\n      submissionId: 'test',\n      metadata: DefaultSubmissionFileMetadata(),\n      order: 0,\n      file: {\n        submissionFileId: 'test',\n        fileName,\n        mimeType,\n        id: 'test',\n        buffer: buf,\n        size: buf.length,\n        width,\n        height,\n        createdAt: new Date().toISOString(),\n        updatedAt: new Date().toISOString(),\n      },\n    };\n  }\n\n  beforeAll(async () => {\n    testFile = readFileSync(\n      join(__dirname, '../../../../test-files/small_image.jpg'),\n    );\n    file = createFile('test.jpg', 'image/jpeg', 202, 138, testFile);\n\n    module = await Test.createTestingModule({\n      providers: [PostFileResizerService, SharpInstanceManager],\n    }).compile();\n\n    service = module.get<PostFileResizerService>(PostFileResizerService);\n    sharpManager = module.get<SharpInstanceManager>(SharpInstanceManager);\n  });\n\n  afterAll(async () => {\n    await module.close();\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n\n  it('should resize image', async () => {\n    const resized = await service.resize({ file, resize: { width: 100 } });\n    expect(resized.buffer.length).toBeLessThan(testFile.length);\n    const metadata = await sharpManager.getMetadata(resized.buffer);\n    expect(metadata.width).toBe(100);\n    expect(metadata.height).toBeLessThan(202);\n    expect(resized.fileName).toBe('test.jpeg');\n    expect(resized.thumbnail).toBeDefined();\n  });\n\n  it('should not resize image', async () => {\n    const resized = await service.resize({ file, resize: { width: 300 } });\n    expect(resized.buffer.length).toBe(testFile.length);\n    const metadata = await sharpManager.getMetadata(resized.buffer);\n    expect(metadata.width).toBe(file.width);\n    expect(metadata.height).toBe(file.height);\n    expect(resized.fileName).toBe('test.jpg');\n  });\n\n  it('should scale down image', async () => {\n    const resized = await service.resize({\n      file,\n      resize: { maxBytes: testFile.length - 1000 },\n    });\n    expect(resized.buffer.length).toBeLessThan(testFile.length);\n    expect(resized.thumbnail?.buffer.length).toBeLessThan(testFile.length);\n    expect(resized.fileName).toBe('test.jpeg');\n    expect(resized.thumbnail?.fileName).toBe('test.jpg');\n    expect(resized.mimeType).toBe('image/jpeg');\n  });\n\n  it('should fail to scale down image', async () => {\n    await expect(\n      service.resize({\n        file,\n        resize: { maxBytes: -1 },\n      }),\n    ).rejects.toThrow();\n  });\n\n  it('should not convert png thumbnail with alpha to jpeg', async () => {\n    const noAlphaFile = readFileSync(\n      join(__dirname, '../../../../test-files/png_with_alpha.png'),\n    );\n    const tf = createFile('test.png', 'image/png', 600, 600, noAlphaFile);\n    const resized = await service.resize({\n      file: tf,\n      resize: { maxBytes: noAlphaFile.length - 1000 },\n    });\n\n    expect(resized.buffer.length).toBeLessThan(noAlphaFile.length);\n    expect(resized.fileName).toBe('test.png');\n    expect(resized.thumbnail?.buffer.length).toBeLessThan(noAlphaFile.length);\n    expect(resized.thumbnail?.fileName).toBe('test.png');\n  });\n});\n"
  },
  {
    "path": "apps/client-server/src/app/post/services/post-file-resizer/post-file-resizer.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { Logger } from '@postybirb/logger';\nimport {\n  FileType,\n  IFileBuffer,\n  ImageResizeProps,\n  ISubmissionFile,\n} from '@postybirb/types';\nimport { getFileType } from '@postybirb/utils/file-type';\nimport { SharpInstanceManager } from '../../../image-processing/sharp-instance-manager';\nimport { PostingFile, ThumbnailOptions } from '../../models/posting-file';\n\ntype ResizeRequest = {\n  file: ISubmissionFile;\n  resize?: ImageResizeProps;\n};\n\n/**\n * Responsible for resizing an image file to a smaller size before\n * posting to a website.\n *\n * All sharp/libvips work is delegated to the SharpInstanceManager,\n * which runs sharp in isolated worker threads. If libvips crashes\n * (e.g. after long idle), only the worker dies — the main process\n * survives.\n *\n * @class PostFileResizer\n */\n@Injectable()\nexport class PostFileResizerService {\n  private readonly logger = Logger();\n\n  constructor(\n    private readonly sharpInstanceManager: SharpInstanceManager,\n  ) {}\n\n  public async resize(request: ResizeRequest): Promise<PostingFile> {\n    return this.process(request);\n  }\n\n  private async process(request: ResizeRequest): Promise<PostingFile> {\n    const { resize } = request;\n    const { file } = request;\n    this.logger.withMetadata({ resize }).info('Resizing image...');\n\n    if (!file.file) {\n      throw new Error('File buffer is missing');\n    }\n\n    const primaryFile = await this.processPrimaryFile(file, resize);\n    const thumbnail = await this.processThumbnailFile(file);\n\n    const newPostingFile = new PostingFile(\n      file.id,\n      primaryFile,\n      thumbnail,\n    );\n\n    newPostingFile.metadata = file.metadata;\n    return newPostingFile;\n  }\n\n  private async processPrimaryFile(\n    file: ISubmissionFile,\n    resize?: ImageResizeProps,\n  ): Promise<IFileBuffer> {\n    if (!resize) return file.file;\n\n    const result = await this.sharpInstanceManager.resizeForPost({\n      buffer: file.file.buffer,\n      resize,\n      mimeType: file.mimeType,\n      fileName: file.fileName,\n      fileId: file.id,\n      fileWidth: file.file.width,\n      fileHeight: file.file.height,\n      generateThumbnail: false,\n    });\n\n    if (result.modified && result.buffer) {\n      return {\n        ...file.file,\n        fileName: result.fileName || `${file.id}.${result.format}`,\n        buffer: result.buffer,\n        mimeType: result.mimeType || file.mimeType,\n        height: result.height || file.file.height,\n        width: result.width || file.file.width,\n      };\n    }\n\n    return file.file;\n  }\n\n  private async processThumbnailFile(\n    file: ISubmissionFile,\n  ): Promise<ThumbnailOptions | undefined> {\n    let thumb = file.thumbnail;\n    const shouldProcessThumbnail =\n      !!thumb || getFileType(file.fileName) === FileType.IMAGE;\n\n    if (!shouldProcessThumbnail) {\n      return undefined;\n    }\n\n    thumb = thumb ?? { ...file.file }; // Ensure file to process\n\n    const result = await this.sharpInstanceManager.generateThumbnail(\n      thumb.buffer,\n      thumb.mimeType,\n      thumb.fileName,\n      500,\n    );\n\n    return {\n      buffer: result.buffer,\n      fileName: thumb.fileName,\n      mimeType: thumb.mimeType,\n      height: result.height,\n      width: result.width,\n    };\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/post/services/post-manager/post-manager.controller.ts",
    "content": "import { Controller, Get, Param, Post } from '@nestjs/common';\nimport { ApiOkResponse, ApiTags } from '@nestjs/swagger';\nimport { SubmissionId, SubmissionType } from '@postybirb/types';\nimport { PostManagerRegistry } from '../post-manager-v2';\nimport { PostQueueService } from '../post-queue/post-queue.service';\n\n@ApiTags('post-manager')\n@Controller('post-manager')\nexport class PostManagerController {\n  constructor(\n    readonly service: PostManagerRegistry,\n    private readonly postQueueService: PostQueueService,\n  ) {}\n\n  @Post('cancel/:id')\n  @ApiOkResponse({ description: 'Post cancelled if running.' })\n  async cancelIfRunning(@Param('id') id: SubmissionId) {\n    // Use post-queue's dequeue which ensures all db records are properly handled\n    await this.postQueueService.dequeue([id]);\n    return true;\n  }\n\n  @Get('is-posting/:submissionType')\n  @ApiOkResponse({ description: 'Check if a post is in progress.' })\n  async isPosting(@Param('submissionType') submissionType: SubmissionType) {\n    return { isPosting: this.service.getManager(submissionType).isPosting() };\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/post/services/post-manager-v2/base-post-manager.service.ts",
    "content": "import {\n  Logger,\n  trackEvent,\n  trackException,\n  trackMetric,\n} from '@postybirb/logger';\nimport {\n  AccountId,\n  EntityId,\n  PostData,\n  PostEventType,\n  PostRecordState,\n  PostResponse,\n  SubmissionType,\n} from '@postybirb/types';\nimport {\n  PostRecord,\n  Submission,\n  WebsiteOptions,\n} from '../../../drizzle/models';\nimport { PostyBirbDatabase } from '../../../drizzle/postybirb-database/postybirb-database';\nimport { NotificationsService } from '../../../notifications/notifications.service';\nimport { PostParsersService } from '../../../post-parsers/post-parsers.service';\nimport { ValidationService } from '../../../validation/validation.service';\nimport { UnknownWebsite, Website } from '../../../websites/website';\nimport { WebsiteRegistryService } from '../../../websites/website-registry.service';\nimport { CancellableToken } from '../../models/cancellable-token';\nimport { CancellationError } from '../../models/cancellation-error';\nimport { PostEventRepository, ResumeContext } from '../post-record-factory';\n\n/**\n * Website info for posting order.\n */\ninterface WebsiteInfo {\n  accountId: AccountId;\n  instance: Website<unknown>;\n}\n\n/**\n * Abstract base class for PostManager implementations.\n * Handles common posting logic and event emission.\n * @abstract\n * @class BasePostManager\n */\nexport abstract class BasePostManager {\n  protected readonly logger = Logger(this.constructor.name);\n\n  protected readonly lastTimePostedToWebsite: Record<AccountId, Date> = {};\n\n  /**\n   * The current post being processed.\n   */\n  protected currentPost: PostRecord | null = null;\n\n  /**\n   * The current cancel token for the current post.\n   */\n  protected cancelToken: CancellableToken | null = null;\n\n  /**\n   * Resume context from prior attempts.\n   */\n  protected resumeContext: ResumeContext | null = null;\n\n  protected readonly postRepository: PostyBirbDatabase<'PostRecordSchema'>;\n\n  constructor(\n    protected readonly postEventRepository: PostEventRepository,\n    protected readonly websiteRegistry: WebsiteRegistryService,\n    protected readonly postParserService: PostParsersService,\n    protected readonly validationService: ValidationService,\n    protected readonly notificationService: NotificationsService,\n  ) {\n    this.postRepository = new PostyBirbDatabase('PostRecordSchema');\n  }\n\n  /**\n   * Get the submission type this manager handles.\n   * @abstract\n   * @returns {SubmissionType} The submission type\n   */\n  abstract getSupportedType(): SubmissionType;\n\n  /**\n   * Cancels the current post if it is running and matches the Id.\n   * @param {EntityId} submissionId - The submission ID to check\n   * @returns {Promise<boolean>} True if the post was cancelled\n   */\n  public async cancelIfRunning(submissionId: EntityId): Promise<boolean> {\n    if (this.currentPost && this.currentPost.submissionId === submissionId) {\n      this.logger.info(`Cancelling current post`);\n      this.cancelToken?.cancel();\n      return true;\n    }\n    return false;\n  }\n\n  /**\n   * Check if this manager is currently posting.\n   * @returns {boolean} True if posting\n   */\n  public isPosting(): boolean {\n    return !!this.currentPost;\n  }\n\n  /**\n   * Starts a post attempt.\n   * @param {PostRecord} entity - The post record to start\n   * @param {ResumeContext} [resumeContext] - Optional resume context from prior attempts\n   */\n  public async startPost(\n    entity: PostRecord,\n    resumeContext?: ResumeContext,\n  ): Promise<void> {\n    try {\n      if (this.currentPost) {\n        this.logger.warn(\n          `PostManager is already posting, cannot start new post`,\n        );\n        return;\n      }\n\n      this.cancelToken = new CancellableToken();\n      this.resumeContext = resumeContext || null;\n\n      this.logger.withMetadata(entity.toDTO()).info(`Initializing post`);\n      this.currentPost = entity;\n      await this.postRepository.update(entity.id, {\n        state: PostRecordState.RUNNING,\n      });\n\n      // Posts order occurs in batched groups\n      // Standard websites first, then websites that accept external source urls\n      this.logger.info(`Creating post order`);\n      const postOrderGroups = this.getPostOrder(entity);\n\n      this.logger.info(`Posting to websites`);\n      for (const websites of postOrderGroups) {\n        this.cancelToken.throwIfCancelled();\n        await Promise.allSettled(\n          websites.map((w) => this.postToWebsite(entity, w)),\n        );\n      }\n      this.logger.info(`Finished posting to websites`);\n    } catch (error) {\n      this.logger.withError(error).error(`Error posting`);\n    } finally {\n      await this.finishPost(entity);\n    }\n  }\n\n  /**\n   * Post to a single website.\n   * @protected\n   * @param {PostRecord} entity - The post record\n   * @param {WebsiteInfo} websiteInfo - The website information\n   */\n  protected async postToWebsite(\n    entity: PostRecord,\n    websiteInfo: WebsiteInfo,\n  ): Promise<void> {\n    const { submission } = entity;\n    const { accountId, instance } = websiteInfo;\n    let data: PostData | undefined;\n    const option = submission.options.find((o) => o.accountId === accountId);\n\n    try {\n      await this.ensureLoggedIn(instance);\n\n      const supportedTypes = instance.getSupportedTypes();\n      if (!supportedTypes.includes(submission.type)) {\n        throw new Error(\n          `Website '${instance.decoratedProps.metadata.displayName}' does not support ${submission.type}`,\n        );\n      }\n\n      this.logger.info('Preparing post data');\n      data = await this.preparePostData(submission, instance, option);\n      this.logger.withMetadata(data).info('Post data prepared');\n\n      // Emit POST_ATTEMPT_STARTED event with post data\n      await this.emitPostAttemptStarted(\n        entity.id,\n        accountId,\n        instance,\n        data,\n        option,\n      );\n\n      this.logger.info('Validating submission');\n      const validationResult =\n        await this.validationService.validateSubmission(submission);\n      if (validationResult.some((v) => v.errors.length > 0)) {\n        throw new Error('Submission contains validation errors');\n      }\n\n      await this.attemptToPost(entity, accountId, instance, data);\n\n      // Emit POST_ATTEMPT_COMPLETED event\n      await this.postEventRepository.insert({\n        postRecordId: entity.id,\n        accountId,\n        eventType: PostEventType.POST_ATTEMPT_COMPLETED,\n        metadata: {\n          accountSnapshot: {\n            name: instance.account.name,\n            website: instance.decoratedProps.metadata.name,\n          },\n        },\n      });\n\n      this.lastTimePostedToWebsite[accountId] = new Date();\n\n      // Track successful post in App Insights (detailed)\n      const websiteName = instance.decoratedProps.metadata.name;\n      trackEvent('PostSuccess', {\n        website: websiteName,\n        accountId,\n        submissionId: entity.submissionId,\n        submissionType: submission.type,\n        hasSourceUrl: 'unknown', // Individual managers track this\n        fileCount: '0',\n        options: this.redactPostDataForLogging(data),\n      });\n\n      // Track success metric per website\n      trackMetric(`post.success.${websiteName}`, 1, {\n        website: websiteName,\n        submissionType: submission.type,\n      });\n    } catch (error) {\n      this.logger\n        .withError(error)\n        .error(`Error posting to website: ${instance.id}`);\n\n      const errorResponse =\n        error instanceof PostResponse\n          ? error\n          : PostResponse.fromWebsite(instance)\n              .withException(error)\n              .withMessage(\n                `${\n                  instance.decoratedProps.metadata.displayName ||\n                  instance.decoratedProps.metadata.name\n                }: ${String(error)}`,\n              );\n\n      await this.handlePostFailure(\n        entity.id,\n        accountId,\n        instance,\n        errorResponse,\n        data,\n      );\n\n      // Track failure in App Insights (detailed)\n      const websiteName = instance.decoratedProps.metadata.name;\n\n      // Only track non-cancellation failures\n      if (!(error instanceof CancellationError)) {\n        trackEvent('PostFailure', {\n          website: websiteName,\n          accountId,\n          submissionId: entity.submissionId,\n          submissionType: submission.type,\n          errorMessage: errorResponse.message ?? 'unknown',\n          stage: errorResponse.stage ?? 'unknown',\n          hasException: errorResponse.exception ? 'true' : 'false',\n          fileCount: '0',\n          options: data ? this.redactPostDataForLogging(data) : '',\n        });\n\n        // Track failure metric per website\n        trackMetric(`post.failure.${websiteName}`, 1, {\n          website: websiteName,\n          submissionType: submission.type,\n        });\n\n        // Track the exception if present\n        if (errorResponse.exception) {\n          trackException(errorResponse.exception, {\n            website: websiteName,\n            accountId,\n            submissionId: entity.submissionId,\n            stage: errorResponse.stage ?? 'unknown',\n            errorMessage: errorResponse.message ?? 'unknown',\n          });\n        }\n      }\n    }\n  }\n\n  /**\n   * Emit POST_ATTEMPT_STARTED event.\n   * @protected\n   * @param {EntityId} postRecordId - The post record ID\n   * @param {AccountId} accountId - The account ID\n   * @param {Website<unknown>} instance - The website instance\n   * @param {PostData} [data] - The post data\n   * @param {WebsiteOptions} [option] - The website options\n   */\n  protected async emitPostAttemptStarted(\n    postRecordId: EntityId,\n    accountId: AccountId,\n    instance: Website<unknown>,\n    data?: PostData,\n    option?: WebsiteOptions,\n  ): Promise<void> {\n    await this.postEventRepository.insert({\n      postRecordId,\n      accountId,\n      eventType: PostEventType.POST_ATTEMPT_STARTED,\n      metadata: {\n        accountSnapshot: {\n          name: instance.account.name,\n          website: instance.decoratedProps.metadata.name,\n        },\n        postData: data\n          ? {\n              parsedOptions: data.options,\n              websiteOptions: option ? [] : [], // Blank for now; populate if needed later\n            }\n          : undefined,\n      },\n    });\n  }\n\n  /**\n   * Handle post failure and emit appropriate events.\n   * @protected\n   * @param {EntityId} postRecordId - The post record ID\n   * @param {AccountId} accountId - The account ID\n   * @param {Website<unknown>} instance - The website instance\n   * @param {PostResponse} errorResponse - The error response\n   * @param {PostData} [postData] - The post data\n   */\n  protected async handlePostFailure(\n    postRecordId: EntityId,\n    accountId: AccountId,\n    instance: Website<unknown>,\n    errorResponse: PostResponse,\n    postData?: PostData,\n  ): Promise<void> {\n    await this.postEventRepository.insert({\n      postRecordId,\n      accountId,\n      eventType: PostEventType.POST_ATTEMPT_FAILED,\n      error: {\n        message: errorResponse.message || 'Unknown error',\n        stack: errorResponse.exception?.stack,\n        stage: errorResponse.stage,\n        additionalInfo: errorResponse.additionalInfo,\n      },\n      metadata: {\n        accountSnapshot: {\n          name: instance.account.name,\n          website: instance.decoratedProps.metadata.name,\n        },\n      },\n    });\n\n    await this.notificationService.create({\n      type: 'error',\n      title: `Failed to post to ${instance.decoratedProps.metadata.displayName}`,\n      message: errorResponse.message || 'Unknown error',\n      tags: ['post-failure', instance.decoratedProps.metadata.name],\n      data: {},\n    });\n  }\n\n  /**\n   * Attempt to post to the website based on submission type.\n   * @abstract\n   * @protected\n   * @param {PostRecord} entity - The post record\n   * @param {AccountId} accountId - The account ID\n   * @param {UnknownWebsite} instance - The website instance\n   * @param {PostData} data - The post data\n   */\n  protected abstract attemptToPost(\n    entity: PostRecord,\n    accountId: AccountId,\n    instance: UnknownWebsite,\n    data: PostData,\n  ): Promise<void>;\n\n  /**\n   * Prepare post data for a website.\n   * @protected\n   * @param {Submission} submission - The submission\n   * @param {Website<unknown>} instance - The website instance\n   * @param {WebsiteOptions} [option] - The website options\n   * @returns {Promise<PostData>} The prepared post data\n   */\n  protected async preparePostData(\n    submission: Submission,\n    instance: Website<unknown>,\n    option?: WebsiteOptions,\n  ): Promise<PostData> {\n    return this.postParserService.parse(submission, instance, option);\n  }\n\n  /**\n   * Get post order for websites.\n   * Groups websites into batches - standard websites first, then websites that accept external sources.\n   * @protected\n   * @param {PostRecord} entity - The post record\n   * @returns {WebsiteInfo[][]} Batched website info\n   */\n  protected getPostOrder(entity: PostRecord): WebsiteInfo[][] {\n    const { submission } = entity;\n    const websiteInfos: WebsiteInfo[] = [];\n\n    for (const option of submission.options) {\n      const instance = this.websiteRegistry.findInstance(option.account);\n      if (!instance) {\n        this.logger.warn(`Website instance not found for ${option.accountId}`);\n        continue;\n      }\n\n      if (!instance.getSupportedTypes().includes(submission.type)) {\n        this.logger.warn(\n          `Website ${instance.id} does not support ${submission.type}`,\n        );\n        continue;\n      }\n\n      // Skip if account is completed (based on resume context)\n      if (\n        this.resumeContext &&\n        this.resumeContext.completedAccountIds.has(option.accountId)\n      ) {\n        this.logger.info(\n          `Skipping account ${option.accountId} - already completed`,\n        );\n        continue;\n      }\n\n      websiteInfos.push({\n        accountId: option.accountId,\n        instance,\n      });\n    }\n\n    // Split into batches: standard websites first, then websites that accept external sources\n    const standard: WebsiteInfo[] = [];\n    const acceptsExternal: WebsiteInfo[] = [];\n\n    for (const info of websiteInfos) {\n      // Check if website accepts source URLs by looking at fileOptions\n      const acceptsSourceUrls =\n        info.instance.decoratedProps.fileOptions?.acceptsExternalSourceUrls ??\n        false;\n      if (acceptsSourceUrls) {\n        acceptsExternal.push(info);\n      } else {\n        standard.push(info);\n      }\n    }\n\n    const batches: WebsiteInfo[][] = [];\n    if (standard.length > 0) batches.push(standard);\n    if (acceptsExternal.length > 0) batches.push(acceptsExternal);\n\n    return batches;\n  }\n\n  /**\n   * Finish the post and update the post record state.\n   * @protected\n   * @param {PostRecord} entity - The post record\n   */\n  protected async finishPost(entity: PostRecord): Promise<void> {\n    this.currentPost = null;\n    this.cancelToken = null;\n    this.resumeContext = null;\n\n    const entityInDb = await this.postRepository.findById(entity.id);\n    if (!entityInDb) {\n      this.logger.error(\n        `Entity ${entity.id} not found in database. It may have been deleted while posting.`,\n      );\n      return;\n    }\n\n    // Query events to determine if post was successful\n    const failedEvents = await this.postEventRepository.getFailedEvents(\n      entity.id,\n    );\n\n    // DONE only if there are zero failures; any failure (including partial) means FAILED\n    const state =\n      failedEvents.length > 0 ? PostRecordState.FAILED : PostRecordState.DONE;\n\n    await this.postRepository.update(entity.id, {\n      state,\n      completedAt: new Date().toISOString(),\n    });\n\n    trackMetric(\n      'Post Duration',\n      Date.now() - new Date(entity.createdAt).getTime(),\n      {\n        submissionType: entity.submission.type,\n        state,\n      },\n    );\n\n    this.logger.info(`Post ${entity.id} finished with state: ${state}`);\n  }\n\n  /**\n   * Wait for posting wait interval to avoid rate limiting.\n   * Uses the website's configured minimumPostWaitInterval.\n   * @protected\n   * @param {AccountId} accountId - The account ID\n   * @param {Website<unknown>} instance - The website instance\n   */\n  protected async waitForPostingWaitInterval(\n    accountId: AccountId,\n    instance: Website<unknown>,\n  ): Promise<void> {\n    const lastTime = this.lastTimePostedToWebsite[accountId];\n    if (!lastTime) return;\n\n    const waitInterval =\n      instance.decoratedProps.metadata.minimumPostWaitInterval ?? 0;\n    if (!waitInterval) return;\n\n    const now = new Date();\n    const timeSinceLastPost = now.getTime() - lastTime.getTime();\n\n    if (timeSinceLastPost < waitInterval) {\n      const waitTime = waitInterval - timeSinceLastPost;\n      this.logger.info(\n        `Waiting ${waitTime}ms before posting to ${instance.id}`,\n      );\n      await new Promise((resolve) => {\n        setTimeout(resolve, waitTime);\n      });\n    }\n  }\n\n  /**\n   * Ensures the website instance is logged in before posting.\n   * Delegates to website.login() which is mutex-guarded:\n   * - If a login is already in progress, waits for it to finish.\n   * - If not logged in and no login in progress, triggers a fresh login.\n   * @protected\n   * @param {Website<unknown>} instance - The website instance\n   * @throws {Error} If the website is still not logged in after the login attempt\n   */\n  protected async ensureLoggedIn(instance: Website<unknown>): Promise<void> {\n    const state = instance.getLoginState();\n    if (state.isLoggedIn) {\n      return;\n    }\n\n    this.logger.info(\n      `Not logged in for ${instance.id}, triggering login...`,\n    );\n\n    const result = await instance.login();\n\n    if (!result.isLoggedIn) {\n      throw new Error('Not logged in');\n    }\n\n    this.logger.info(`Login resolved for ${instance.id} — now logged in`);\n  }\n\n  /**\n   * Redact sensitive information from post data for logging.\n   * @protected\n   * @param {PostData} postData - The post data\n   * @returns {string} Redacted post data as JSON string\n   */\n  protected redactPostDataForLogging(postData: PostData): string {\n    const opts = { ...postData.options };\n    // Redact sensitive information\n    if (opts.description) {\n      opts.description = `[REDACTED ${opts.description.length}]`;\n    }\n    return JSON.stringify({ options: opts });\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/post/services/post-manager-v2/file-submission-post-manager.service.spec.ts",
    "content": "import { clearDatabase } from '@postybirb/database';\nimport {\n  AccountId,\n  DefaultSubmissionFileMetadata,\n  EntityId,\n  FileSubmissionMetadata,\n  FileType,\n  IPostResponse,\n  PostData,\n  PostEventType,\n  PostRecordState,\n  SubmissionRating,\n  SubmissionType,\n} from '@postybirb/types';\nimport 'reflect-metadata';\nimport {\n  FileBuffer,\n  PostRecord,\n  Submission,\n  SubmissionFile,\n} from '../../../drizzle/models';\nimport { FileConverterService } from '../../../file-converter/file-converter.service';\nimport { NotificationsService } from '../../../notifications/notifications.service';\nimport { PostParsersService } from '../../../post-parsers/post-parsers.service';\nimport { ValidationService } from '../../../validation/validation.service';\nimport {\n  FileWebsite,\n  ImplementedFileWebsite,\n  PostBatchData,\n} from '../../../websites/models/website-modifiers/file-website';\nimport { UnknownWebsite } from '../../../websites/website';\nimport { WebsiteRegistryService } from '../../../websites/website-registry.service';\nimport { CancellableToken } from '../../models/cancellable-token';\nimport { PostingFile } from '../../models/posting-file';\nimport { PostFileResizerService } from '../post-file-resizer/post-file-resizer.service';\nimport { PostEventRepository } from '../post-record-factory';\nimport { FileSubmissionPostManager } from './file-submission-post-manager.service';\n\ndescribe('FileSubmissionPostManager', () => {\n  let manager: FileSubmissionPostManager;\n  let postEventRepositoryMock: jest.Mocked<PostEventRepository>;\n  let websiteRegistryMock: jest.Mocked<WebsiteRegistryService>;\n  let postParserServiceMock: jest.Mocked<PostParsersService>;\n  let validationServiceMock: jest.Mocked<ValidationService>;\n  let notificationServiceMock: jest.Mocked<NotificationsService>;\n  let resizerServiceMock: jest.Mocked<PostFileResizerService>;\n  let fileConverterServiceMock: jest.Mocked<FileConverterService>;\n\n  beforeEach(() => {\n    clearDatabase();\n\n    postEventRepositoryMock = {\n      insert: jest.fn().mockResolvedValue({}),\n      findByPostRecordId: jest.fn().mockResolvedValue([]),\n      getCompletedAccounts: jest.fn().mockResolvedValue([]),\n      getFailedEvents: jest.fn().mockResolvedValue([]),\n      getSourceUrlsFromPost: jest.fn().mockResolvedValue([]),\n    } as unknown as jest.Mocked<PostEventRepository>;\n\n    websiteRegistryMock = {\n      findInstance: jest.fn(),\n    } as unknown as jest.Mocked<WebsiteRegistryService>;\n\n    postParserServiceMock = {\n      parse: jest.fn(),\n    } as unknown as jest.Mocked<PostParsersService>;\n\n    validationServiceMock = {\n      validateSubmission: jest.fn().mockResolvedValue([]),\n    } as unknown as jest.Mocked<ValidationService>;\n\n    notificationServiceMock = {\n      create: jest.fn(),\n      sendDesktopNotification: jest.fn(),\n    } as unknown as jest.Mocked<NotificationsService>;\n\n    resizerServiceMock = {\n      resize: jest.fn(),\n    } as unknown as jest.Mocked<PostFileResizerService>;\n\n    fileConverterServiceMock = {\n      convert: jest.fn(),\n      canConvert: jest.fn().mockResolvedValue(false),\n    } as unknown as jest.Mocked<FileConverterService>;\n\n    manager = new FileSubmissionPostManager(\n      postEventRepositoryMock,\n      websiteRegistryMock,\n      postParserServiceMock,\n      validationServiceMock,\n      notificationServiceMock,\n      resizerServiceMock,\n      fileConverterServiceMock,\n    );\n  });\n\n  function createFileBuffer(): FileBuffer {\n    return new FileBuffer({\n      id: 'test-file-buffer',\n      fileName: 'test.png',\n      mimeType: 'image/png',\n      buffer: Buffer.from('fake-image-data'),\n      size: 100,\n      width: 600,\n      height: 600,\n      createdAt: new Date().toISOString(),\n      updatedAt: new Date().toISOString(),\n    });\n  }\n\n  function createSubmissionFile(\n    id: string = 'test-file',\n    order: number = 1,\n  ): SubmissionFile {\n    const fileBuffer = createFileBuffer();\n    return new SubmissionFile({\n      id,\n      createdAt: new Date().toISOString(),\n      updatedAt: new Date().toISOString(),\n      fileName: 'test.png',\n      hash: 'fake-hash',\n      mimeType: 'image/png',\n      size: fileBuffer.size,\n      width: 600,\n      height: 600,\n      hasAltFile: false,\n      hasThumbnail: false,\n      hasCustomThumbnail: false,\n      file: fileBuffer,\n      order,\n      metadata: DefaultSubmissionFileMetadata(),\n    });\n  }\n\n  function createSubmission(\n    files: SubmissionFile[] = [],\n  ): Submission<FileSubmissionMetadata> {\n    return new Submission<FileSubmissionMetadata>({\n      id: 'test-submission',\n      createdAt: new Date().toISOString(),\n      updatedAt: new Date().toISOString(),\n      metadata: {},\n      type: SubmissionType.FILE,\n      files,\n      options: [],\n      isScheduled: false,\n      schedule: {} as never,\n      order: 1,\n      posts: [] as never,\n      isTemplate: false,\n      isMultiSubmission: false,\n      isArchived: false,\n    });\n  }\n\n  function createPostRecord(\n    submission: Submission<FileSubmissionMetadata>,\n  ): PostRecord {\n    return new PostRecord({\n      id: 'test-post-record' as EntityId,\n      submission,\n      state: PostRecordState.PENDING,\n      createdAt: new Date().toISOString(),\n      updatedAt: new Date().toISOString(),\n    });\n  }\n\n  function createMockFileWebsite(accountId: AccountId): UnknownWebsite {\n    return {\n      id: 'test-website',\n      accountId,\n      account: {\n        id: accountId,\n        name: 'test-account',\n      },\n      decoratedProps: {\n        metadata: {\n          name: 'Test Website',\n          displayName: 'Test Website',\n        },\n        fileOptions: {\n          acceptedMimeTypes: ['image/png', 'image/jpeg'],\n          fileBatchSize: 1,\n          supportedFileTypes: [FileType.IMAGE],\n        },\n      },\n      onPostFileSubmission: jest.fn(),\n      calculateImageResize: jest.fn().mockReturnValue(undefined),\n      supportsFile: true,\n    } as unknown as UnknownWebsite;\n  }\n\n  function createPostingFile(file: SubmissionFile): PostingFile {\n    const postingFile = new PostingFile(file.id, file.file, file.thumbnail);\n    postingFile.metadata = file.metadata;\n    return postingFile;\n  }\n\n  describe('getSupportedType', () => {\n    it('should return FILE submission type', () => {\n      expect(manager.getSupportedType()).toBe(SubmissionType.FILE);\n    });\n  });\n\n  describe('attemptToPost', () => {\n    let mockWebsite: UnknownWebsite;\n    let postRecord: PostRecord;\n    let accountId: AccountId;\n    let postData: PostData;\n    let cancelToken: CancellableToken;\n    let submissionFile: SubmissionFile;\n\n    beforeEach(() => {\n      accountId = 'test-account-id' as AccountId;\n      submissionFile = createSubmissionFile();\n      const submission = createSubmission([submissionFile]);\n      postRecord = createPostRecord(submission);\n      mockWebsite = createMockFileWebsite(accountId);\n\n      cancelToken = new CancellableToken();\n      (manager as any).cancelToken = cancelToken;\n      (manager as any).lastTimePostedToWebsite = {};\n\n      postData = {\n        submission,\n        options: {\n          title: 'Test Title',\n          description: 'Test Description',\n          rating: SubmissionRating.GENERAL,\n          tags: ['test'],\n        },\n      } as PostData;\n\n      // Setup resizer mock to return a PostingFile\n      resizerServiceMock.resize.mockImplementation(async (request) => {\n        const file = request.file as SubmissionFile;\n        return createPostingFile(file);\n      });\n    });\n\n    it('should throw error if website does not support file submissions', async () => {\n      const nonFileWebsite = {\n        ...mockWebsite,\n        supportsFile: false,\n        decoratedProps: {\n          ...mockWebsite.decoratedProps,\n          metadata: {\n            name: 'Message Only Website',\n            displayName: 'Message Only Website',\n          },\n        },\n      } as unknown as UnknownWebsite;\n      delete (nonFileWebsite as any).onPostFileSubmission;\n\n      await expect(\n        (manager as any).attemptToPost(\n          postRecord,\n          accountId,\n          nonFileWebsite,\n          postData,\n        ),\n      ).rejects.toThrow('does not support file submissions');\n    });\n\n    it('should successfully post a file and emit FILE_POSTED event', async () => {\n      const sourceUrl = 'https://example.com/file/123';\n      const responseMessage = 'File posted successfully';\n\n      (mockWebsite as unknown as FileWebsite).onPostFileSubmission = jest\n        .fn()\n        .mockResolvedValue({\n          instanceId: 'test-website',\n          sourceUrl,\n          message: responseMessage,\n        });\n\n      await (manager as any).attemptToPost(\n        postRecord,\n        accountId,\n        mockWebsite,\n        postData,\n      );\n\n      // Verify website method was called\n      expect(\n        (mockWebsite as unknown as FileWebsite).onPostFileSubmission,\n      ).toHaveBeenCalledWith(postData, expect.any(Array), cancelToken, {\n        index: 0,\n        totalBatches: 1,\n      } satisfies PostBatchData);\n\n      // Verify FILE_POSTED event was emitted\n      expect(postEventRepositoryMock.insert).toHaveBeenCalledWith(\n        expect.objectContaining({\n          postRecordId: postRecord.id,\n          accountId,\n          eventType: PostEventType.FILE_POSTED,\n          fileId: submissionFile.id,\n          sourceUrl,\n          metadata: expect.objectContaining({\n            batchNumber: 0,\n            accountSnapshot: {\n              name: 'test-account',\n              website: 'Test Website',\n            },\n            fileSnapshot: expect.objectContaining({\n              fileName: submissionFile.fileName,\n              mimeType: submissionFile.mimeType,\n            }),\n          }),\n        }),\n      );\n    });\n\n    it('should emit FILE_FAILED event and throw on error', async () => {\n      const errorMessage = 'Failed to upload file';\n      const exception = new Error('API Error');\n      const stage = 'upload';\n      const additionalInfo = { code: 'ERR_API' };\n\n      (mockWebsite as unknown as FileWebsite).onPostFileSubmission = jest\n        .fn()\n        .mockResolvedValue({\n          instanceId: 'test-website',\n          message: errorMessage,\n          exception,\n          stage,\n          additionalInfo,\n        } as IPostResponse);\n\n      await expect(\n        (manager as any).attemptToPost(\n          postRecord,\n          accountId,\n          mockWebsite,\n          postData,\n        ),\n      ).rejects.toMatchObject({\n        exception,\n        message: errorMessage,\n      });\n\n      // Verify FILE_FAILED event was emitted\n      expect(postEventRepositoryMock.insert).toHaveBeenCalledWith(\n        expect.objectContaining({\n          postRecordId: postRecord.id,\n          accountId,\n          eventType: PostEventType.FILE_FAILED,\n          fileId: submissionFile.id,\n          error: expect.objectContaining({\n            message: errorMessage,\n            stage,\n            additionalInfo,\n          }),\n        }),\n      );\n    });\n\n    it('should emit FILE_FAILED with unknown error when no message provided', async () => {\n      const exception = new Error('Unknown API Error');\n\n      (mockWebsite as unknown as FileWebsite).onPostFileSubmission = jest\n        .fn()\n        .mockResolvedValue({\n          instanceId: 'test-website',\n          exception,\n        } as IPostResponse);\n\n      await expect(\n        (manager as any).attemptToPost(\n          postRecord,\n          accountId,\n          mockWebsite,\n          postData,\n        ),\n      ).rejects.toMatchObject({\n        exception,\n      });\n\n      // Verify FILE_FAILED event was emitted with default message\n      expect(postEventRepositoryMock.insert).toHaveBeenCalledWith(\n        expect.objectContaining({\n          eventType: PostEventType.FILE_FAILED,\n          error: expect.objectContaining({\n            message: 'Unknown error',\n          }),\n        }),\n      );\n    });\n\n    it('should return early if no files to post', async () => {\n      const emptySubmission = createSubmission([]);\n      const emptyPostRecord = createPostRecord(emptySubmission);\n      postData.submission = emptySubmission;\n\n      await (manager as any).attemptToPost(\n        emptyPostRecord,\n        accountId,\n        mockWebsite,\n        postData,\n      );\n\n      // Website method should not have been called\n      expect(\n        (mockWebsite as unknown as FileWebsite).onPostFileSubmission,\n      ).not.toHaveBeenCalled();\n\n      // No events should have been emitted\n      expect(postEventRepositoryMock.insert).not.toHaveBeenCalled();\n    });\n\n    it('should check cancel token during posting', async () => {\n      // Cancel immediately\n      cancelToken.cancel();\n\n      await expect(\n        (manager as any).attemptToPost(\n          postRecord,\n          accountId,\n          mockWebsite,\n          postData,\n        ),\n      ).rejects.toThrow('Task was cancelled');\n\n      // Website method should not have been called\n      expect(\n        (mockWebsite as unknown as FileWebsite).onPostFileSubmission,\n      ).not.toHaveBeenCalled();\n    });\n\n    it('should filter out files ignored for this website', async () => {\n      const ignoredFile = createSubmissionFile('ignored-file', 1);\n      ignoredFile.metadata.ignoredWebsites = [accountId];\n\n      const validFile = createSubmissionFile('valid-file', 2);\n\n      const submission = createSubmission([ignoredFile, validFile]);\n      const postRecordWithIgnored = createPostRecord(submission);\n      postData.submission = submission;\n\n      (mockWebsite as unknown as FileWebsite).onPostFileSubmission = jest\n        .fn()\n        .mockResolvedValue({\n          instanceId: 'test-website',\n          sourceUrl: 'https://example.com/file/456',\n        });\n\n      await (manager as any).attemptToPost(\n        postRecordWithIgnored,\n        accountId,\n        mockWebsite,\n        postData,\n      );\n\n      // Should only post one file (the valid one)\n      expect(\n        (mockWebsite as unknown as FileWebsite).onPostFileSubmission,\n      ).toHaveBeenCalledTimes(1);\n\n      // Verify FILE_POSTED event only for valid file\n      expect(postEventRepositoryMock.insert).toHaveBeenCalledWith(\n        expect.objectContaining({\n          fileId: validFile.id,\n          eventType: PostEventType.FILE_POSTED,\n        }),\n      );\n    });\n\n    it('should sort files by order before posting', async () => {\n      const file1 = createSubmissionFile('file-1', 3);\n      const file2 = createSubmissionFile('file-2', 1);\n      const file3 = createSubmissionFile('file-3', 2);\n\n      const submission = createSubmission([file1, file2, file3]);\n      const postRecordWithMultiple = createPostRecord(submission);\n      postData.submission = submission;\n\n      // Set batch size to 3 to get all files in one batch\n      (\n        mockWebsite as unknown as ImplementedFileWebsite\n      ).decoratedProps.fileOptions.fileBatchSize = 3;\n\n      let capturedFiles: PostingFile[] = [];\n      (mockWebsite as unknown as FileWebsite).onPostFileSubmission = jest\n        .fn()\n        .mockImplementation((data, files) => {\n          capturedFiles = files;\n          return {\n            instanceId: 'test-website',\n            sourceUrl: 'https://example.com/file/all',\n          };\n        });\n\n      await (manager as any).attemptToPost(\n        postRecordWithMultiple,\n        accountId,\n        mockWebsite,\n        postData,\n      );\n\n      // Verify files are sorted by order\n      expect(capturedFiles.map((f) => f.id)).toEqual([\n        'file-2', // order 1\n        'file-3', // order 2\n        'file-1', // order 3\n      ]);\n    });\n  });\n\n  describe('file batching', () => {\n    let mockWebsite: UnknownWebsite;\n    let accountId: AccountId;\n    let postData: PostData;\n    let cancelToken: CancellableToken;\n\n    beforeEach(() => {\n      accountId = 'test-account-id' as AccountId;\n      mockWebsite = createMockFileWebsite(accountId);\n      cancelToken = new CancellableToken();\n      (manager as any).cancelToken = cancelToken;\n      (manager as any).lastTimePostedToWebsite = {};\n\n      resizerServiceMock.resize.mockImplementation(async (request) => {\n        const file = request.file as SubmissionFile;\n        return createPostingFile(file);\n      });\n    });\n\n    it('should batch files according to website batch size', async () => {\n      const files = [\n        createSubmissionFile('file-1', 1),\n        createSubmissionFile('file-2', 2),\n        createSubmissionFile('file-3', 3),\n      ];\n\n      const submission = createSubmission(files);\n      const postRecord = createPostRecord(submission);\n\n      postData = {\n        submission,\n        options: {\n          title: 'Test',\n          description: 'Test',\n          rating: SubmissionRating.GENERAL,\n          tags: [],\n        },\n      } as PostData;\n\n      // Set batch size to 2\n      (\n        mockWebsite as unknown as ImplementedFileWebsite\n      ).decoratedProps.fileOptions.fileBatchSize = 2;\n\n      const batchIndices: number[] = [];\n      (mockWebsite as unknown as FileWebsite).onPostFileSubmission = jest\n        .fn()\n        .mockImplementation((data, files, token, { index }) => {\n          batchIndices.push(index);\n          return {\n            instanceId: 'test-website',\n            sourceUrl: `https://example.com/batch/${index}`,\n          };\n        });\n\n      await (manager as any).attemptToPost(\n        postRecord,\n        accountId,\n        mockWebsite,\n        postData,\n      );\n\n      // Should have 2 batches: [file-1, file-2] and [file-3]\n      expect(\n        (mockWebsite as unknown as FileWebsite).onPostFileSubmission,\n      ).toHaveBeenCalledTimes(2);\n      expect(batchIndices).toEqual([0, 1]);\n    });\n\n    it('should use minimum batch size of 1', async () => {\n      const file = createSubmissionFile();\n      const submission = createSubmission([file]);\n      const postRecord = createPostRecord(submission);\n\n      postData = {\n        submission,\n        options: {\n          title: 'Test',\n          description: 'Test',\n          rating: SubmissionRating.GENERAL,\n          tags: [],\n        },\n      } as PostData;\n\n      // Set invalid batch size\n      (\n        mockWebsite as unknown as ImplementedFileWebsite\n      ).decoratedProps.fileOptions.fileBatchSize = 0;\n\n      (mockWebsite as unknown as FileWebsite).onPostFileSubmission = jest\n        .fn()\n        .mockResolvedValue({\n          instanceId: 'test-website',\n          sourceUrl: 'https://example.com/file',\n        });\n\n      await (manager as any).attemptToPost(\n        postRecord,\n        accountId,\n        mockWebsite,\n        postData,\n      );\n\n      // Should still work with batch size 1\n      expect(\n        (mockWebsite as unknown as FileWebsite).onPostFileSubmission,\n      ).toHaveBeenCalledTimes(1);\n    });\n\n    it('should stop posting if a batch fails', async () => {\n      const files = [\n        createSubmissionFile('file-1', 1),\n        createSubmissionFile('file-2', 2),\n        createSubmissionFile('file-3', 3),\n      ];\n\n      const submission = createSubmission(files);\n      const postRecord = createPostRecord(submission);\n\n      postData = {\n        submission,\n        options: {\n          title: 'Test',\n          description: 'Test',\n          rating: SubmissionRating.GENERAL,\n          tags: [],\n        },\n      } as PostData;\n\n      // Set batch size to 1 to have 3 separate batches\n      (\n        mockWebsite as unknown as ImplementedFileWebsite\n      ).decoratedProps.fileOptions.fileBatchSize = 1;\n\n      let callCount = 0;\n      (mockWebsite as unknown as FileWebsite).onPostFileSubmission = jest\n        .fn()\n        .mockImplementation(() => {\n          callCount++;\n          if (callCount === 2) {\n            return {\n              instanceId: 'test-website',\n              exception: new Error('Batch 2 failed'),\n              message: 'Failed on second batch',\n            };\n          }\n          return {\n            instanceId: 'test-website',\n            sourceUrl: `https://example.com/file/${callCount}`,\n          };\n        });\n\n      await expect(\n        (manager as any).attemptToPost(\n          postRecord,\n          accountId,\n          mockWebsite,\n          postData,\n        ),\n      ).rejects.toMatchObject({\n        message: 'Failed on second batch',\n      });\n\n      // Should have stopped at batch 2\n      expect(\n        (mockWebsite as unknown as FileWebsite).onPostFileSubmission,\n      ).toHaveBeenCalledTimes(2);\n\n      // Should have emitted FILE_POSTED for first file and FILE_FAILED for second\n      expect(postEventRepositoryMock.insert).toHaveBeenCalledWith(\n        expect.objectContaining({\n          fileId: 'file-1',\n          eventType: PostEventType.FILE_POSTED,\n        }),\n      );\n      expect(postEventRepositoryMock.insert).toHaveBeenCalledWith(\n        expect.objectContaining({\n          fileId: 'file-2',\n          eventType: PostEventType.FILE_FAILED,\n        }),\n      );\n    });\n  });\n\n  describe('source URL propagation', () => {\n    let mockWebsite: UnknownWebsite;\n    let accountId: AccountId;\n    let postData: PostData;\n    let cancelToken: CancellableToken;\n\n    beforeEach(() => {\n      accountId = 'test-account-id' as AccountId;\n      mockWebsite = createMockFileWebsite(accountId);\n      cancelToken = new CancellableToken();\n      (manager as any).cancelToken = cancelToken;\n      (manager as any).lastTimePostedToWebsite = {};\n\n      resizerServiceMock.resize.mockImplementation(async (request) => {\n        const file = request.file as SubmissionFile;\n        return createPostingFile(file);\n      });\n    });\n\n    it('should include source URLs from previous posts in current batch', async () => {\n      const file = createSubmissionFile();\n      const submission = createSubmission([file]);\n      const postRecord = createPostRecord(submission);\n\n      postData = {\n        submission,\n        options: {\n          title: 'Test',\n          description: 'Test',\n          rating: SubmissionRating.GENERAL,\n          tags: [],\n        },\n      } as PostData;\n\n      // Mock that another account has already posted\n      postEventRepositoryMock.getSourceUrlsFromPost.mockResolvedValue([\n        'https://other-site.com/post/1',\n        'https://other-site.com/post/2',\n      ]);\n\n      let capturedFiles: PostingFile[] = [];\n      (mockWebsite as unknown as FileWebsite).onPostFileSubmission = jest\n        .fn()\n        .mockImplementation((data, files) => {\n          capturedFiles = files;\n          return {\n            instanceId: 'test-website',\n            sourceUrl: 'https://example.com/file',\n          };\n        });\n\n      await (manager as any).attemptToPost(\n        postRecord,\n        accountId,\n        mockWebsite,\n        postData,\n      );\n\n      // Verify source URLs were included in file metadata\n      expect(capturedFiles[0].metadata.sourceUrls).toContain(\n        'https://other-site.com/post/1',\n      );\n      expect(capturedFiles[0].metadata.sourceUrls).toContain(\n        'https://other-site.com/post/2',\n      );\n    });\n\n    it('should include source URLs from resume context', async () => {\n      const file = createSubmissionFile();\n      const submission = createSubmission([file]);\n      const postRecord = createPostRecord(submission);\n\n      postData = {\n        submission,\n        options: {\n          title: 'Test',\n          description: 'Test',\n          rating: SubmissionRating.GENERAL,\n          tags: [],\n        },\n      } as PostData;\n\n      // Set resume context with source URLs from prior attempts\n      const resumeContext = {\n        sourceUrlsByAccount: new Map([\n          ['other-account' as AccountId, ['https://prior-site.com/post/1']],\n          [accountId, ['https://self-url.com/post/1']], // Should be excluded\n        ]),\n        postedFilesByAccount: new Map(),\n      };\n      (manager as any).resumeContext = resumeContext;\n\n      let capturedFiles: PostingFile[] = [];\n      (mockWebsite as unknown as FileWebsite).onPostFileSubmission = jest\n        .fn()\n        .mockImplementation((data, files) => {\n          capturedFiles = files;\n          return {\n            instanceId: 'test-website',\n            sourceUrl: 'https://example.com/file',\n          };\n        });\n\n      await (manager as any).attemptToPost(\n        postRecord,\n        accountId,\n        mockWebsite,\n        postData,\n      );\n\n      // Verify source URLs from prior attempts were included (excluding self)\n      expect(capturedFiles[0].metadata.sourceUrls).toContain(\n        'https://prior-site.com/post/1',\n      );\n      expect(capturedFiles[0].metadata.sourceUrls).not.toContain(\n        'https://self-url.com/post/1',\n      );\n    });\n\n    it('should deduplicate source URLs', async () => {\n      const file = createSubmissionFile();\n      const submission = createSubmission([file]);\n      const postRecord = createPostRecord(submission);\n\n      postData = {\n        submission,\n        options: {\n          title: 'Test',\n          description: 'Test',\n          rating: SubmissionRating.GENERAL,\n          tags: [],\n        },\n      } as PostData;\n\n      // Same URL from both sources\n      postEventRepositoryMock.getSourceUrlsFromPost.mockResolvedValue([\n        'https://duplicate.com/post/1',\n      ]);\n\n      const resumeContext = {\n        sourceUrlsByAccount: new Map([\n          ['other-account' as AccountId, ['https://duplicate.com/post/1']],\n        ]),\n        postedFilesByAccount: new Map(),\n      };\n      (manager as any).resumeContext = resumeContext;\n\n      let capturedFiles: PostingFile[] = [];\n      (mockWebsite as unknown as FileWebsite).onPostFileSubmission = jest\n        .fn()\n        .mockImplementation((data, files) => {\n          capturedFiles = files;\n          return {\n            instanceId: 'test-website',\n            sourceUrl: 'https://example.com/file',\n          };\n        });\n\n      await (manager as any).attemptToPost(\n        postRecord,\n        accountId,\n        mockWebsite,\n        postData,\n      );\n\n      // Verify URL appears only once\n      const duplicateCount = capturedFiles[0].metadata.sourceUrls.filter(\n        (url) => url === 'https://duplicate.com/post/1',\n      ).length;\n      expect(duplicateCount).toBe(1);\n    });\n  });\n\n  describe('resume context handling', () => {\n    let mockWebsite: UnknownWebsite;\n    let accountId: AccountId;\n    let postData: PostData;\n    let cancelToken: CancellableToken;\n\n    beforeEach(() => {\n      accountId = 'test-account-id' as AccountId;\n      mockWebsite = createMockFileWebsite(accountId);\n      cancelToken = new CancellableToken();\n      (manager as any).cancelToken = cancelToken;\n      (manager as any).lastTimePostedToWebsite = {};\n\n      resizerServiceMock.resize.mockImplementation(async (request) => {\n        const file = request.file as SubmissionFile;\n        return createPostingFile(file);\n      });\n    });\n\n    it('should skip files that were already posted according to resume context', async () => {\n      const alreadyPostedFile = createSubmissionFile('already-posted', 1);\n      const newFile = createSubmissionFile('new-file', 2);\n\n      const submission = createSubmission([alreadyPostedFile, newFile]);\n      const postRecord = createPostRecord(submission);\n\n      postData = {\n        submission,\n        options: {\n          title: 'Test',\n          description: 'Test',\n          rating: SubmissionRating.GENERAL,\n          tags: [],\n        },\n      } as PostData;\n\n      // Set resume context indicating one file was already posted\n      const resumeContext = {\n        sourceUrlsByAccount: new Map(),\n        postedFilesByAccount: new Map([\n          [accountId, new Set(['already-posted'])],\n        ]),\n      };\n      (manager as any).resumeContext = resumeContext;\n\n      let capturedFiles: PostingFile[] = [];\n      (mockWebsite as unknown as FileWebsite).onPostFileSubmission = jest\n        .fn()\n        .mockImplementation((data, files) => {\n          capturedFiles = files;\n          return {\n            instanceId: 'test-website',\n            sourceUrl: 'https://example.com/file',\n          };\n        });\n\n      await (manager as any).attemptToPost(\n        postRecord,\n        accountId,\n        mockWebsite,\n        postData,\n      );\n\n      // Should only post the new file\n      expect(capturedFiles).toHaveLength(1);\n      expect(capturedFiles[0].id).toBe('new-file');\n    });\n  });\n\n  describe('file verification', () => {\n    let mockWebsite: UnknownWebsite;\n    let accountId: AccountId;\n    let postData: PostData;\n    let cancelToken: CancellableToken;\n\n    beforeEach(() => {\n      accountId = 'test-account-id' as AccountId;\n      mockWebsite = createMockFileWebsite(accountId);\n      cancelToken = new CancellableToken();\n      (manager as any).cancelToken = cancelToken;\n      (manager as any).lastTimePostedToWebsite = {};\n    });\n\n    it('should throw error if file type is not supported after processing', async () => {\n      const unsupportedFile = createSubmissionFile();\n\n      const submission = createSubmission([unsupportedFile]);\n      const postRecord = createPostRecord(submission);\n\n      postData = {\n        submission,\n        options: {\n          title: 'Test',\n          description: 'Test',\n          rating: SubmissionRating.GENERAL,\n          tags: [],\n        },\n      } as PostData;\n\n      // Website only accepts JPEG\n      (\n        mockWebsite as unknown as ImplementedFileWebsite\n      ).decoratedProps.fileOptions.acceptedMimeTypes = ['image/jpeg'];\n\n      // Resizer returns PNG (which website doesn't accept)\n      resizerServiceMock.resize.mockImplementation(async (request) => {\n        const file = request.file as SubmissionFile;\n        return createPostingFile(file); // Returns PNG\n      });\n\n      await expect(\n        (manager as any).attemptToPost(\n          postRecord,\n          accountId,\n          mockWebsite,\n          postData,\n        ),\n      ).rejects.toThrow('does not support the file type image/png');\n    });\n\n    it('should not verify if no accepted mime types are specified', async () => {\n      const file = createSubmissionFile();\n      const submission = createSubmission([file]);\n      const postRecord = createPostRecord(submission);\n\n      postData = {\n        submission,\n        options: {\n          title: 'Test',\n          description: 'Test',\n          rating: SubmissionRating.GENERAL,\n          tags: [],\n        },\n      } as PostData;\n\n      // Website accepts all file types\n      (\n        mockWebsite as unknown as ImplementedFileWebsite\n      ).decoratedProps.fileOptions.acceptedMimeTypes = [];\n\n      resizerServiceMock.resize.mockImplementation(async (request) => {\n        const f = request.file as SubmissionFile;\n        return createPostingFile(f);\n      });\n\n      (mockWebsite as unknown as FileWebsite).onPostFileSubmission = jest\n        .fn()\n        .mockResolvedValue({\n          instanceId: 'test-website',\n          sourceUrl: 'https://example.com/file',\n        });\n\n      await expect(\n        (manager as any).attemptToPost(\n          postRecord,\n          accountId,\n          mockWebsite,\n          postData,\n        ),\n      ).resolves.not.toThrow();\n    });\n  });\n\n  describe('event metadata structure', () => {\n    it('should create events with proper file snapshot metadata', async () => {\n      const accountId = 'metadata-account' as AccountId;\n      const submissionFile = createSubmissionFile();\n      submissionFile.hash = 'test-hash-123';\n\n      const submission = createSubmission([submissionFile]);\n      const postRecord = createPostRecord(submission);\n      const mockWebsite = createMockFileWebsite(accountId);\n\n      (mockWebsite as unknown as FileWebsite).onPostFileSubmission = jest\n        .fn()\n        .mockResolvedValue({\n          instanceId: 'test-website',\n          sourceUrl: 'https://example.com/file/456',\n          message: 'Successfully posted file',\n        });\n\n      resizerServiceMock.resize.mockImplementation(async (request) => {\n        const file = request.file as SubmissionFile;\n        return createPostingFile(file);\n      });\n\n      const cancelToken = new CancellableToken();\n      (manager as any).cancelToken = cancelToken;\n      (manager as any).lastTimePostedToWebsite = {};\n\n      const postData = {\n        submission,\n        options: {\n          title: 'Test',\n          description: 'Test',\n          rating: SubmissionRating.GENERAL,\n          tags: [],\n        },\n      } as PostData;\n\n      await (manager as any).attemptToPost(\n        postRecord,\n        accountId,\n        mockWebsite,\n        postData,\n      );\n\n      expect(postEventRepositoryMock.insert).toHaveBeenCalledWith({\n        postRecordId: postRecord.id,\n        accountId,\n        eventType: PostEventType.FILE_POSTED,\n        fileId: submissionFile.id,\n        sourceUrl: 'https://example.com/file/456',\n        metadata: {\n          batchNumber: 0,\n          accountSnapshot: {\n            name: 'test-account',\n            website: 'Test Website',\n          },\n          fileSnapshot: {\n            fileName: submissionFile.fileName,\n            mimeType: submissionFile.mimeType,\n            size: submissionFile.size,\n            hash: submissionFile.hash,\n          },\n          responseMessage: 'Successfully posted file',\n        },\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "apps/client-server/src/app/post/services/post-manager-v2/file-submission-post-manager.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { Logger } from '@postybirb/logger';\nimport {\n  AccountId,\n  FileSubmission,\n  FileSubmissionMetadata,\n  FileType,\n  ImageResizeProps,\n  PostData,\n  PostEventType,\n  SubmissionFileMetadata,\n  SubmissionType,\n} from '@postybirb/types';\nimport { getFileType } from '@postybirb/utils/file-type';\nimport { chunk } from 'lodash';\nimport {\n  FileBuffer,\n  PostRecord,\n  Submission,\n  SubmissionFile,\n} from '../../../drizzle/models';\nimport { FileConverterService } from '../../../file-converter/file-converter.service';\nimport { NotificationsService } from '../../../notifications/notifications.service';\nimport { PostParsersService } from '../../../post-parsers/post-parsers.service';\nimport { ValidationService } from '../../../validation/validation.service';\nimport { getSupportedFileSize } from '../../../websites/decorators/supports-files.decorator';\nimport {\n  ImplementedFileWebsite,\n  isFileWebsite,\n} from '../../../websites/models/website-modifiers/file-website';\nimport { UnknownWebsite } from '../../../websites/website';\nimport { WebsiteRegistryService } from '../../../websites/website-registry.service';\nimport { PostingFile } from '../../models/posting-file';\nimport { PostFileResizerService } from '../post-file-resizer/post-file-resizer.service';\nimport { PostEventRepository } from '../post-record-factory';\nimport { BasePostManager } from './base-post-manager.service';\n\n/**\n * Returns true if `mimeType` is accepted by any entry in `patterns`.\n * Handles exact matches, prefix patterns (\"image/\"), and wildcard patterns (\"image/*\").\n */\nfunction mimeTypeIsAccepted(mimeType: string, patterns: string[]): boolean {\n  return patterns.some((pattern) => {\n    if (pattern === mimeType) return true;\n    if (pattern.endsWith('/*')) {\n      return mimeType.startsWith(pattern.slice(0, -1)); // 'image/*' → prefix 'image/'\n    }\n    if (pattern.endsWith('/')) {\n      return mimeType.startsWith(pattern); // 'image/' → matches 'image/jpeg'\n    }\n    return false;\n  });\n}\n\n/**\n * PostManager for file submissions.\n * Handles file batching, resizing, and conversion.\n * @class FileSubmissionPostManager\n */\n@Injectable()\nexport class FileSubmissionPostManager extends BasePostManager {\n  protected readonly logger = Logger(this.constructor.name);\n\n  constructor(\n    postEventRepository: PostEventRepository,\n    websiteRegistry: WebsiteRegistryService,\n    postParserService: PostParsersService,\n    validationService: ValidationService,\n    notificationService: NotificationsService,\n    private readonly resizerService: PostFileResizerService,\n    private readonly fileConverterService: FileConverterService,\n  ) {\n    super(\n      postEventRepository,\n      websiteRegistry,\n      postParserService,\n      validationService,\n      notificationService,\n    );\n  }\n\n  getSupportedType(): SubmissionType {\n    return SubmissionType.FILE;\n  }\n\n  protected async attemptToPost(\n    entity: PostRecord,\n    accountId: AccountId,\n    instance: UnknownWebsite,\n    data: PostData,\n  ): Promise<void> {\n    if (!isFileWebsite(instance)) {\n      throw new Error(\n        `Website '${instance.decoratedProps.metadata.displayName}' does not support file submissions`,\n      );\n    }\n\n    const submission = entity.submission as Submission<FileSubmissionMetadata>;\n\n    // Order files based on submission order\n    const fileBatchSize = Math.max(\n      instance.decoratedProps.fileOptions.fileBatchSize ?? 1,\n      1,\n    );\n\n    const files = this.getFilesToPost(submission, accountId, instance);\n\n    if (files.length === 0) {\n      this.logger.info(`No files to post for account ${accountId}`);\n      return;\n    }\n\n    // Split files into batches based on instance file batch size\n    const batches = chunk(files, fileBatchSize);\n\n    for (const [batchIndex, batch] of batches.entries()) {\n      this.cancelToken.throwIfCancelled();\n\n      // Get source URLs from other accounts for cross-website propagation\n      // 1. From current post attempt (other accounts that have already posted)\n      const currentPostUrls =\n        await this.postEventRepository.getSourceUrlsFromPost(\n          entity.id,\n          accountId,\n        );\n\n      // 2. From prior attempts (resume context, excluding self)\n      const priorUrls: string[] = [];\n      if (this.resumeContext) {\n        for (const [\n          priorAccountId,\n          urls,\n        ] of this.resumeContext.sourceUrlsByAccount.entries()) {\n          if (priorAccountId !== accountId) {\n            priorUrls.push(...urls);\n          }\n        }\n      }\n\n      // Merge and deduplicate\n      const allSourceUrls = [...new Set([...currentPostUrls, ...priorUrls])];\n\n      // Resize files if necessary\n      const processedFiles: PostingFile[] = (\n        await Promise.all(\n          batch.map((submissionFile) =>\n            this.resizeOrModifyFile(submissionFile, submission, instance),\n          ),\n        )\n      ).map((f) => {\n        const fileWithMetadata = f.withMetadata(f.metadata);\n        fileWithMetadata.metadata.sourceUrls = [\n          ...(fileWithMetadata.metadata.sourceUrls ?? []),\n          ...allSourceUrls,\n        ].filter((s) => !!s?.trim());\n\n        return fileWithMetadata;\n      });\n\n      // Verify files are supported by the website after all processing\n      this.verifyPostingFiles(instance, processedFiles);\n\n      // Post\n      this.cancelToken.throwIfCancelled();\n      const fileIds = batch.map((f) => f.id);\n      this.logger\n        .withMetadata({\n          batchIndex,\n          totalBatches: batches.length,\n          totalFiles: files.length,\n          totalFilesInBatch: batch.length,\n          fileIds,\n        })\n        .info(`Posting file batch to ${instance.id}`);\n\n      await this.waitForPostingWaitInterval(accountId, instance);\n      this.cancelToken.throwIfCancelled();\n\n      const result = await instance.onPostFileSubmission(\n        data,\n        processedFiles,\n        this.cancelToken,\n        { totalBatches: batch.length, index: batchIndex },\n      );\n\n      if (result.exception) {\n        // Emit FILE_FAILED events for each file in the batch\n        const storedBatchIndex = batchIndex; // Capture for closure\n        await Promise.all(\n          batch.map((file) =>\n            this.postEventRepository.insert({\n              postRecordId: entity.id,\n              accountId,\n              eventType: PostEventType.FILE_FAILED,\n              fileId: file.id,\n              error: {\n                message: result.message || 'Unknown error',\n                stack: result.exception?.stack,\n                stage: result.stage,\n                additionalInfo: result.additionalInfo,\n              },\n              metadata: {\n                batchNumber: storedBatchIndex,\n                accountSnapshot: {\n                  name: instance.account.name,\n                  website: instance.decoratedProps.metadata.name,\n                },\n                fileSnapshot: {\n                  fileName: file.fileName,\n                  mimeType: file.mimeType,\n                  size: file.size,\n                  hash: file.hash,\n                },\n              },\n            }),\n          ),\n        );\n\n        // Behavior is to stop posting if a batch fails\n        // eslint-disable-next-line @typescript-eslint/no-throw-literal\n        throw result;\n      }\n\n      // Emit FILE_POSTED events for each file in the batch\n      const storedBatchIndex = batchIndex; // Capture for closure\n      await Promise.all(\n        batch.map((file) =>\n          this.postEventRepository.insert({\n            postRecordId: entity.id,\n            accountId,\n            eventType: PostEventType.FILE_POSTED,\n            fileId: file.id,\n            sourceUrl: result.sourceUrl,\n            metadata: {\n              batchNumber: storedBatchIndex,\n              accountSnapshot: {\n                name: instance.account.name,\n                website: instance.decoratedProps.metadata.name,\n              },\n              fileSnapshot: {\n                fileName: file.fileName,\n                mimeType: file.mimeType,\n                size: file.size,\n                hash: file.hash,\n              },\n              responseMessage: result.message,\n            },\n          }),\n        ),\n      );\n\n      this.logger\n        .withMetadata(result)\n        .info(`File batch posted to ${instance.id}`);\n    }\n  }\n\n  /**\n   * Get files to post, filtered by resume context and user settings.\n   * @private\n   * @param {Submission<FileSubmissionMetadata>} submission - The submission\n   * @param {AccountId} accountId - The account ID\n   * @param {ImplementedFileWebsite} instance - The website instance\n   * @returns {SubmissionFile[]} Files to post\n   */\n  private getFilesToPost(\n    submission: Submission<FileSubmissionMetadata>,\n    accountId: AccountId,\n    instance: ImplementedFileWebsite,\n  ): SubmissionFile[] {\n    return (\n      submission.files\n        ?.filter(\n          // Filter out files that have been marked by the user as ignored for this website\n          (f) => !f.metadata.ignoredWebsites?.includes(accountId),\n        )\n        .filter(\n          // Only post files that haven't been posted (based on resume context)\n          (f) => {\n            if (!this.resumeContext) return true;\n            const postedFiles =\n              this.resumeContext.postedFilesByAccount.get(accountId);\n            return !postedFiles?.has(f.id);\n          },\n        )\n        .sort((a, b) => {\n          const aOrder = a.order ?? Number.MAX_SAFE_INTEGER;\n          const bOrder = b.order ?? Number.MAX_SAFE_INTEGER;\n          return aOrder - bOrder;\n        }) ?? []\n    );\n  }\n\n  /**\n   * Verify that files are supported by the website.\n   * @private\n   * @param {UnknownWebsite} websiteInstance - The website instance\n   * @param {PostingFile[]} postingFiles - The posting files\n   */\n  private verifyPostingFiles(\n    websiteInstance: UnknownWebsite,\n    postingFiles: PostingFile[],\n  ): void {\n    const acceptedMimeTypes =\n      websiteInstance.decoratedProps.fileOptions.acceptedMimeTypes ?? [];\n    if (acceptedMimeTypes.length === 0) return;\n\n    postingFiles.forEach((f) => {\n      if (!mimeTypeIsAccepted(f.mimeType, acceptedMimeTypes)) {\n        throw new Error(\n          `Website '${websiteInstance.decoratedProps.metadata.displayName}' does not support the file type ${f.mimeType} and attempts to convert it did not resolve the issue`,\n        );\n      }\n    });\n  }\n\n  /**\n   * Resize or modify a file for posting.\n   * @private\n   * @param {SubmissionFile} file - The submission file\n   * @param {FileSubmission} submission - The file submission\n   * @param {ImplementedFileWebsite} instance - The website instance\n   * @returns {Promise<PostingFile>} The posting file\n   */\n  private async resizeOrModifyFile(\n    file: SubmissionFile,\n    submission: FileSubmission,\n    instance: ImplementedFileWebsite,\n  ): Promise<PostingFile> {\n    if (!file.file) {\n      await file.load();\n    }\n\n    const fileMetadata: SubmissionFileMetadata = file.metadata;\n    let resizeParams: ImageResizeProps | undefined;\n    const { fileOptions } = instance.decoratedProps;\n    const allowedMimeTypes = fileOptions.acceptedMimeTypes ?? [];\n    const fileType = getFileType(file.mimeType);\n\n    if (fileType === FileType.IMAGE) {\n      if (\n        await this.fileConverterService.canConvert(\n          file.mimeType,\n          allowedMimeTypes,\n        )\n      ) {\n        // eslint-disable-next-line no-param-reassign\n        file.file = new FileBuffer(\n          await this.fileConverterService.convert(file.file, allowedMimeTypes),\n        );\n      }\n\n      resizeParams = this.getResizeParameters(submission, instance, file);\n\n      // User defined dimensions\n      const userDefinedDimensions =\n        // eslint-disable-next-line @typescript-eslint/dot-notation\n        fileMetadata?.dimensions['default'] ??\n        fileMetadata?.dimensions[instance.accountId];\n\n      if (userDefinedDimensions) {\n        if (userDefinedDimensions.width && userDefinedDimensions.height) {\n          resizeParams = resizeParams ?? {};\n          if (\n            userDefinedDimensions.width > resizeParams.width &&\n            userDefinedDimensions.height > resizeParams.height\n          ) {\n            resizeParams = {\n              ...resizeParams,\n              width: userDefinedDimensions.width,\n              height: userDefinedDimensions.height,\n            };\n          }\n        }\n      }\n\n      // We pass to resize even if no resize parameters are set\n      // as it handles the bundling to PostingFile\n      return this.resizerService.resize({\n        file,\n        resize: resizeParams,\n      });\n    }\n\n    if (\n      fileType === FileType.TEXT &&\n      file.hasAltFile &&\n      !mimeTypeIsAccepted(file.mimeType, allowedMimeTypes)\n    ) {\n      // Use alt file if it exists and is a text file\n      if (\n        await this.fileConverterService.canConvert(\n          file.altFile.mimeType,\n          allowedMimeTypes,\n        )\n      ) {\n        // eslint-disable-next-line no-param-reassign\n        file.file = new FileBuffer(\n          await this.fileConverterService.convert(\n            file.altFile,\n            allowedMimeTypes,\n          ),\n        );\n      }\n    }\n\n    return new PostingFile(file.id, file.file, file.thumbnail).withMetadata(\n      file.metadata,\n    );\n  }\n\n  /**\n   * Get resize parameters for a file.\n   * @private\n   * @param {FileSubmission} submission - The file submission\n   * @param {ImplementedFileWebsite} instance - The website instance\n   * @param {SubmissionFile} file - The submission file\n   * @returns {ImageResizeProps | undefined} The resize parameters\n   */\n  private getResizeParameters(\n    submission: FileSubmission,\n    instance: ImplementedFileWebsite,\n    file: SubmissionFile,\n  ): ImageResizeProps | undefined {\n    // Use website's own calculation method\n    let resizeParams = instance.calculateImageResize(file);\n\n    // Apply user-defined dimensions if set\n    const fileParams = file.metadata.dimensions?.[instance.accountId];\n    if (fileParams) {\n      if (fileParams.width) {\n        if (!resizeParams) {\n          resizeParams = {};\n        }\n        resizeParams.width = Math.min(\n          file.width,\n          fileParams.width,\n          resizeParams.width ?? Infinity,\n        );\n      }\n      if (fileParams.height) {\n        if (!resizeParams) {\n          resizeParams = {};\n        }\n        resizeParams.height = Math.min(\n          file.height,\n          fileParams.height,\n          resizeParams.height ?? Infinity,\n        );\n      }\n    }\n\n    // Fall back to supported file size if needed\n    if (!resizeParams?.maxBytes) {\n      const supportedFileSize = getSupportedFileSize(instance, file);\n      if (supportedFileSize && file.size > supportedFileSize) {\n        if (!resizeParams) {\n          resizeParams = {};\n        }\n        resizeParams.maxBytes = supportedFileSize;\n      }\n    }\n\n    return resizeParams;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/post/services/post-manager-v2/index.ts",
    "content": "export * from './base-post-manager.service';\nexport * from './file-submission-post-manager.service';\nexport * from './message-submission-post-manager.service';\nexport * from './post-manager-registry.service';\n\n"
  },
  {
    "path": "apps/client-server/src/app/post/services/post-manager-v2/message-submission-post-manager.service.spec.ts",
    "content": "import { clearDatabase } from '@postybirb/database';\nimport {\n  AccountId,\n  EntityId,\n  IPostResponse,\n  PostData,\n  PostEventType,\n  PostRecordState,\n  SubmissionRating,\n  SubmissionType,\n} from '@postybirb/types';\nimport 'reflect-metadata';\nimport { PostRecord, Submission } from '../../../drizzle/models';\nimport { NotificationsService } from '../../../notifications/notifications.service';\nimport { PostParsersService } from '../../../post-parsers/post-parsers.service';\nimport { ValidationService } from '../../../validation/validation.service';\nimport { MessageWebsite } from '../../../websites/models/website-modifiers/message-website';\nimport { UnknownWebsite } from '../../../websites/website';\nimport { WebsiteRegistryService } from '../../../websites/website-registry.service';\nimport { CancellableToken } from '../../models/cancellable-token';\nimport { PostEventRepository } from '../post-record-factory';\nimport { MessageSubmissionPostManager } from './message-submission-post-manager.service';\n\ndescribe('MessageSubmissionPostManager', () => {\n  let manager: MessageSubmissionPostManager;\n  let postEventRepositoryMock: jest.Mocked<PostEventRepository>;\n  let websiteRegistryMock: jest.Mocked<WebsiteRegistryService>;\n  let postParserServiceMock: jest.Mocked<PostParsersService>;\n  let validationServiceMock: jest.Mocked<ValidationService>;\n  let notificationServiceMock: jest.Mocked<NotificationsService>;\n\n  beforeEach(() => {\n    clearDatabase();\n\n    postEventRepositoryMock = {\n      insert: jest.fn().mockResolvedValue({}),\n      findByPostRecordId: jest.fn().mockResolvedValue([]),\n      getCompletedAccounts: jest.fn().mockResolvedValue([]),\n      getFailedEvents: jest.fn().mockResolvedValue([]),\n    } as unknown as jest.Mocked<PostEventRepository>;\n\n    websiteRegistryMock = {\n      findInstance: jest.fn(),\n    } as unknown as jest.Mocked<WebsiteRegistryService>;\n\n    postParserServiceMock = {\n      parse: jest.fn(),\n    } as unknown as jest.Mocked<PostParsersService>;\n\n    validationServiceMock = {\n      validateSubmission: jest.fn().mockResolvedValue([]),\n    } as unknown as jest.Mocked<ValidationService>;\n\n    notificationServiceMock = {\n      create: jest.fn(),\n      sendDesktopNotification: jest.fn(),\n    } as unknown as jest.Mocked<NotificationsService>;\n\n    manager = new MessageSubmissionPostManager(\n      postEventRepositoryMock,\n      websiteRegistryMock,\n      postParserServiceMock,\n      validationServiceMock,\n      notificationServiceMock,\n    );\n  });\n\n  function createSubmission(): Submission {\n    return new Submission({\n      id: 'test-submission',\n      createdAt: new Date().toISOString(),\n      updatedAt: new Date().toISOString(),\n      metadata: {},\n      type: SubmissionType.MESSAGE,\n      files: [],\n      options: [],\n      isScheduled: false,\n      schedule: {} as never,\n      order: 1,\n      posts: [] as never,\n      isTemplate: false,\n      isMultiSubmission: false,\n      isArchived: false,\n    });\n  }\n\n  function createPostRecord(submission: Submission): PostRecord {\n    return new PostRecord({\n      id: 'test-post-record' as EntityId,\n      submission,\n      state: PostRecordState.PENDING,\n      createdAt: new Date().toISOString(),\n      updatedAt: new Date().toISOString(),\n    });\n  }\n\n  function createMockWebsite(accountId: AccountId): UnknownWebsite {\n    return {\n      id: 'test-website',\n      account: {\n        id: accountId,\n        name: 'test-account',\n      },\n      decoratedProps: {\n        metadata: {\n          name: 'Test Website',\n          displayName: 'Test Website',\n        },\n      },\n      onPostMessageSubmission: jest.fn(),\n    } as unknown as UnknownWebsite;\n  }\n\n  describe('getSupportedType', () => {\n    it('should return MESSAGE submission type', () => {\n      expect(manager.getSupportedType()).toBe(SubmissionType.MESSAGE);\n    });\n  });\n\n  describe('attemptToPost', () => {\n    let mockWebsite: UnknownWebsite;\n    let postRecord: PostRecord;\n    let accountId: AccountId;\n    let postData: PostData;\n    let cancelToken: CancellableToken;\n\n    beforeEach(() => {\n      accountId = 'test-account-id' as AccountId;\n      const submission = createSubmission();\n      postRecord = createPostRecord(submission);\n      mockWebsite = createMockWebsite(accountId);\n\n      cancelToken = new CancellableToken();\n      (manager as any).cancelToken = cancelToken;\n      (manager as any).lastTimePostedToWebsite = {};\n\n      postData = {\n        submission,\n        options: {\n          title: 'Test Title',\n          description: 'Test Description',\n          rating: SubmissionRating.GENERAL,\n          tags: ['test'],\n        },\n      } as PostData;\n    });\n\n    it('should successfully post a message and emit MESSAGE_POSTED event', async () => {\n      const sourceUrl = 'https://example.com/message/123';\n      const responseMessage = 'Message posted successfully';\n\n      (mockWebsite as unknown as MessageWebsite).onPostMessageSubmission = jest\n        .fn()\n        .mockResolvedValue({\n          instanceId: 'test-website',\n          sourceUrl,\n          message: responseMessage,\n        });\n\n      await (manager as any).attemptToPost(\n        postRecord,\n        accountId,\n        mockWebsite,\n        postData,\n      );\n\n      // Verify website method was called\n      expect(\n        (mockWebsite as unknown as MessageWebsite).onPostMessageSubmission,\n      ).toHaveBeenCalledWith(postData, cancelToken);\n\n      // Verify MESSAGE_POSTED event was emitted\n      expect(postEventRepositoryMock.insert).toHaveBeenCalledWith(\n        expect.objectContaining({\n          postRecordId: postRecord.id,\n          accountId,\n          eventType: PostEventType.MESSAGE_POSTED,\n          sourceUrl,\n          metadata: expect.objectContaining({\n            accountSnapshot: {\n              name: 'test-account',\n              website: 'Test Website',\n            },\n            responseMessage,\n          }),\n        }),\n      );\n    });\n\n    it('should emit MESSAGE_FAILED event and throw on error', async () => {\n      const errorMessage = 'Failed to post message';\n      const exception = new Error('API Error');\n      const stage = 'upload';\n      const additionalInfo = { code: 'ERR_API' };\n\n      (mockWebsite as unknown as MessageWebsite).onPostMessageSubmission = jest\n        .fn()\n        .mockResolvedValue({\n          instanceId: 'test-website',\n          message: errorMessage,\n          exception,\n          stage,\n          additionalInfo,\n        } as IPostResponse);\n\n      await expect(\n        (manager as any).attemptToPost(\n          postRecord,\n          accountId,\n          mockWebsite,\n          postData,\n        ),\n      ).rejects.toMatchObject({\n        exception,\n        message: errorMessage,\n      });\n\n      // Verify MESSAGE_FAILED event was emitted\n      expect(postEventRepositoryMock.insert).toHaveBeenCalledWith(\n        expect.objectContaining({\n          postRecordId: postRecord.id,\n          accountId,\n          eventType: PostEventType.MESSAGE_FAILED,\n          error: expect.objectContaining({\n            message: errorMessage,\n            stage,\n            additionalInfo,\n          }),\n        }),\n      );\n    });\n\n    it('should emit MESSAGE_FAILED event with unknown error when no message provided', async () => {\n      const exception = new Error('Unknown API Error');\n\n      (mockWebsite as unknown as MessageWebsite).onPostMessageSubmission = jest\n        .fn()\n        .mockResolvedValue({\n          instanceId: 'test-website',\n          exception,\n        } as IPostResponse);\n\n      await expect(\n        (manager as any).attemptToPost(\n          postRecord,\n          accountId,\n          mockWebsite,\n          postData,\n        ),\n      ).rejects.toMatchObject({\n        exception,\n      });\n\n      // Verify MESSAGE_FAILED event was emitted with default message\n      expect(postEventRepositoryMock.insert).toHaveBeenCalledWith(\n        expect.objectContaining({\n          eventType: PostEventType.MESSAGE_FAILED,\n          error: expect.objectContaining({\n            message: 'Unknown error',\n          }),\n        }),\n      );\n    });\n\n    it('should not wait if no previous post to account', async () => {\n      (mockWebsite as unknown as MessageWebsite).onPostMessageSubmission = jest\n        .fn()\n        .mockResolvedValue({\n          instanceId: 'test-website',\n          sourceUrl: 'https://example.com/message/123',\n        });\n\n      const startTime = Date.now();\n      await (manager as any).attemptToPost(\n        postRecord,\n        accountId,\n        mockWebsite,\n        postData,\n      );\n      const elapsed = Date.now() - startTime;\n\n      // Should not wait\n      expect(elapsed).toBeLessThan(1000);\n    });\n\n    it('should not wait if enough time has passed since last post', async () => {\n      (mockWebsite as unknown as MessageWebsite).onPostMessageSubmission = jest\n        .fn()\n        .mockResolvedValue({\n          instanceId: 'test-website',\n          sourceUrl: 'https://example.com/message/123',\n        });\n\n      // Set last time posted to 10 seconds ago (beyond min interval)\n      const now = new Date();\n      const lastTime = new Date(now.getTime() - 10000);\n      (manager as any).lastTimePostedToWebsite[accountId] = lastTime;\n\n      const startTime = Date.now();\n      await (manager as any).attemptToPost(\n        postRecord,\n        accountId,\n        mockWebsite,\n        postData,\n      );\n      const elapsed = Date.now() - startTime;\n\n      // Should not wait\n      expect(elapsed).toBeLessThan(1000);\n    });\n\n    it('should check cancel token during posting', async () => {\n      // Cancel immediately\n      cancelToken.cancel();\n\n      await expect(\n        (manager as any).attemptToPost(\n          postRecord,\n          accountId,\n          mockWebsite,\n          postData,\n        ),\n      ).rejects.toThrow('Task was cancelled');\n\n      // Website method should not have been called\n      expect(\n        (mockWebsite as unknown as MessageWebsite).onPostMessageSubmission,\n      ).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('event metadata structure', () => {\n    it('should create events with proper metadata structure', async () => {\n      const accountId = 'metadata-account' as AccountId;\n      const submission = createSubmission();\n      const postRecord = createPostRecord(submission);\n      const mockWebsite = createMockWebsite(accountId);\n\n      (mockWebsite as unknown as MessageWebsite).onPostMessageSubmission = jest\n        .fn()\n        .mockResolvedValue({\n          instanceId: 'test-website',\n          sourceUrl: 'https://example.com/message/456',\n          message: 'Successfully posted message',\n        });\n\n      const cancelToken = new CancellableToken();\n      (manager as any).cancelToken = cancelToken;\n      (manager as any).lastTimePostedToWebsite = {};\n\n      const postData = {\n        submission,\n        options: {\n          title: 'Test',\n          description: 'Test',\n          rating: SubmissionRating.GENERAL,\n          tags: [],\n        },\n      } as PostData;\n\n      await (manager as any).attemptToPost(\n        postRecord,\n        accountId,\n        mockWebsite,\n        postData,\n      );\n\n      expect(postEventRepositoryMock.insert).toHaveBeenCalledWith({\n        postRecordId: postRecord.id,\n        accountId,\n        eventType: PostEventType.MESSAGE_POSTED,\n        sourceUrl: 'https://example.com/message/456',\n        metadata: {\n          accountSnapshot: {\n            name: 'test-account',\n            website: 'Test Website',\n          },\n          responseMessage: 'Successfully posted message',\n        },\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "apps/client-server/src/app/post/services/post-manager-v2/message-submission-post-manager.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { Logger } from '@postybirb/logger';\nimport {\n    AccountId,\n    PostData,\n    PostEventType,\n    SubmissionType,\n} from '@postybirb/types';\nimport { PostRecord } from '../../../drizzle/models';\nimport { NotificationsService } from '../../../notifications/notifications.service';\nimport { PostParsersService } from '../../../post-parsers/post-parsers.service';\nimport { ValidationService } from '../../../validation/validation.service';\nimport { MessageWebsite } from '../../../websites/models/website-modifiers/message-website';\nimport { UnknownWebsite } from '../../../websites/website';\nimport { WebsiteRegistryService } from '../../../websites/website-registry.service';\nimport { PostEventRepository } from '../post-record-factory';\nimport { BasePostManager } from './base-post-manager.service';\n\n/**\n * PostManager for message submissions.\n * Handles message-only posting.\n * @class MessageSubmissionPostManager\n */\n@Injectable()\nexport class MessageSubmissionPostManager extends BasePostManager {\n  protected readonly logger = Logger(this.constructor.name);\n\n  // eslint-disable-next-line @typescript-eslint/no-useless-constructor\n  constructor(\n    postEventRepository: PostEventRepository,\n    websiteRegistry: WebsiteRegistryService,\n    postParserService: PostParsersService,\n    validationService: ValidationService,\n    notificationService: NotificationsService,\n  ) {\n    super(\n      postEventRepository,\n      websiteRegistry,\n      postParserService,\n      validationService,\n      notificationService,\n    );\n  }\n\n  getSupportedType(): SubmissionType {\n    return SubmissionType.MESSAGE;\n  }\n\n  protected async attemptToPost(\n    entity: PostRecord,\n    accountId: AccountId,\n    instance: UnknownWebsite,\n    data: PostData,\n  ): Promise<void> {\n    this.logger.info(`Posting message to ${instance.id}`);\n\n    await this.waitForPostingWaitInterval(accountId, instance);\n    this.cancelToken.throwIfCancelled();\n\n    const result = await (\n      instance as unknown as MessageWebsite\n    ).onPostMessageSubmission(data, this.cancelToken);\n\n    if (result.exception) {\n      // Emit MESSAGE_FAILED event\n      await this.postEventRepository.insert({\n        postRecordId: entity.id,\n        accountId,\n        eventType: PostEventType.MESSAGE_FAILED,\n        error: {\n          message: result.message || 'Unknown error',\n          stack: result.exception?.stack,\n          stage: result.stage,\n          additionalInfo: result.additionalInfo,\n        },\n        metadata: {\n          accountSnapshot: {\n            name: instance.account.name,\n            website: instance.decoratedProps.metadata.name,\n          },\n          responseMessage: result.message,\n        },\n      });\n\n      // eslint-disable-next-line @typescript-eslint/no-throw-literal\n      throw result;\n    }\n\n    // Emit MESSAGE_POSTED event\n    await this.postEventRepository.insert({\n      postRecordId: entity.id,\n      accountId,\n      eventType: PostEventType.MESSAGE_POSTED,\n      sourceUrl: result.sourceUrl,\n      metadata: {\n        accountSnapshot: {\n          name: instance.account.name,\n          website: instance.decoratedProps.metadata.name,\n        },\n        responseMessage: result.message,\n      },\n    });\n\n    this.logger.withMetadata(result).info(`Message posted to ${instance.id}`);\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/post/services/post-manager-v2/post-manager-registry.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { Logger } from '@postybirb/logger';\nimport { EntityId, SubmissionType } from '@postybirb/types';\nimport { PostRecord } from '../../../drizzle/models';\nimport { PostRecordFactory } from '../post-record-factory';\nimport { BasePostManager } from './base-post-manager.service';\nimport { FileSubmissionPostManager } from './file-submission-post-manager.service';\nimport { MessageSubmissionPostManager } from './message-submission-post-manager.service';\n\n/**\n * Registry for PostManager instances.\n * Maps submission types to appropriate PostManager implementations.\n * Allows multiple submissions to be posted concurrently.\n * @class PostManagerRegistry\n */\n@Injectable()\nexport class PostManagerRegistry {\n  private readonly logger = Logger(this.constructor.name);\n\n  private readonly managers: Map<SubmissionType, BasePostManager>;\n\n  constructor(\n    private readonly fileSubmissionPostManager: FileSubmissionPostManager,\n    private readonly messageSubmissionPostManager: MessageSubmissionPostManager,\n    private readonly postRecordFactory: PostRecordFactory,\n  ) {\n    this.managers = new Map<SubmissionType, BasePostManager>();\n    this.managers.set(SubmissionType.FILE, fileSubmissionPostManager);\n    this.managers.set(SubmissionType.MESSAGE, messageSubmissionPostManager);\n  }\n\n  /**\n   * Get a PostManager for a submission type.\n   * @param {SubmissionType} type - The submission type\n   * @returns {BasePostManager | undefined} The PostManager instance\n   */\n  public getManager(type: SubmissionType): BasePostManager | undefined {\n    return this.managers.get(type);\n  }\n\n  /**\n   * Start posting for a post record.\n   * Determines the appropriate PostManager and starts posting.\n   * @param {PostRecord} postRecord - The post record to start\n   */\n  public async startPost(postRecord: PostRecord): Promise<void> {\n    const submissionType = postRecord.submission.type;\n    const manager = this.getManager(submissionType);\n\n    if (!manager) {\n      this.logger.error(`No PostManager found for type: ${submissionType}`);\n      throw new Error(`No PostManager found for type: ${submissionType}`);\n    }\n\n    if (manager.isPosting()) {\n      this.logger.warn(\n        `PostManager for ${submissionType} is already posting, cannot start new post`,\n      );\n      return;\n    }\n\n    // Build resume context for this post record\n    const resumeContext = await this.postRecordFactory.buildResumeContext(\n      postRecord.submissionId,\n      postRecord.id,\n      postRecord.resumeMode,\n    );\n\n    await manager.startPost(postRecord, resumeContext);\n  }\n\n  /**\n   * Cancel posting for a submission if it's currently running.\n   * Checks all managers to find the one handling this submission.\n   * @param {EntityId} submissionId - The submission ID to cancel\n   * @returns {Promise<boolean>} True if cancelled, false if not found\n   */\n  public async cancelIfRunning(submissionId: EntityId): Promise<boolean> {\n    for (const manager of this.managers.values()) {\n      if (await manager.cancelIfRunning(submissionId)) {\n        return true;\n      }\n    }\n    return false;\n  }\n\n  /**\n   * Check if a specific submission type's manager is posting.\n   * @param {SubmissionType} type - The submission type\n   * @returns {boolean} True if posting\n   */\n  public isPostingType(type: SubmissionType): boolean {\n    const manager = this.getManager(type);\n    return manager?.isPosting() ?? false;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/post/services/post-queue/post-queue.controller.ts",
    "content": "import { Body, Controller, Get, Post } from '@nestjs/common';\nimport { ApiOkResponse, ApiTags } from '@nestjs/swagger';\nimport { PostyBirbController } from '../../../common/controller/postybirb-controller';\nimport { PostQueueActionDto } from '../../dtos/post-queue-action.dto';\nimport { PostQueueService } from './post-queue.service';\n\n/**\n * Queue operations for Post data.\n * @class PostController\n */\n@ApiTags('post-queue')\n@Controller('post-queue')\nexport class PostQueueController extends PostyBirbController<'PostQueueRecordSchema'> {\n  constructor(readonly service: PostQueueService) {\n    super(service);\n  }\n\n  @Post('enqueue')\n  @ApiOkResponse({ description: 'Post(s) queued.' })\n  async enqueue(@Body() request: PostQueueActionDto) {\n    return this.service.enqueue(request.submissionIds, request.resumeMode);\n  }\n\n  @Post('dequeue')\n  @ApiOkResponse({ description: 'Post(s) dequeued.' })\n  async dequeue(@Body() request: PostQueueActionDto) {\n    this.service.dequeue(request.submissionIds);\n  }\n\n  @Get('is-paused')\n  @ApiOkResponse({ description: 'Get if queue is paused.' })\n  async isPaused() {\n    return { paused: await this.service.isPaused() };\n  }\n\n  @Post('pause')\n  @ApiOkResponse({ description: 'Queue paused.' })\n  async pause() {\n    await this.service.pause();\n    return { paused: true };\n  }\n\n  @Post('resume')\n  @ApiOkResponse({ description: 'Queue resumed.' })\n  async resume() {\n    await this.service.resume();\n    return { paused: false };\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/post/services/post-queue/post-queue.service.spec.ts",
    "content": "import { Test, TestingModule } from '@nestjs/testing';\nimport { clearDatabase } from '@postybirb/database';\nimport {\n  AccountId,\n  DefaultDescription,\n  PostRecordState,\n  SubmissionId,\n  SubmissionRating,\n  SubmissionType,\n} from '@postybirb/types';\nimport { AccountModule } from '../../../account/account.module';\nimport { AccountService } from '../../../account/account.service';\nimport { CreateAccountDto } from '../../../account/dtos/create-account.dto';\nimport { PostyBirbDatabase } from '../../../drizzle/postybirb-database/postybirb-database';\nimport { SettingsService } from '../../../settings/settings.service';\nimport { CreateSubmissionDto } from '../../../submission/dtos/create-submission.dto';\nimport { SubmissionService } from '../../../submission/services/submission.service';\nimport { SubmissionModule } from '../../../submission/submission.module';\nimport { CreateWebsiteOptionsDto } from '../../../website-options/dtos/create-website-options.dto';\nimport { WebsiteOptionsModule } from '../../../website-options/website-options.module';\nimport { WebsiteOptionsService } from '../../../website-options/website-options.service';\nimport { WebsiteRegistryService } from '../../../websites/website-registry.service';\nimport { WebsitesModule } from '../../../websites/websites.module';\nimport { PostModule } from '../../post.module';\nimport { PostService } from '../../post.service';\nimport { PostManagerRegistry } from '../post-manager-v2';\nimport { PostQueueService } from './post-queue.service';\n\ndescribe('PostQueueService', () => {\n  let service: PostQueueService;\n  let module: TestingModule;\n  let submissionService: SubmissionService;\n  let accountService: AccountService;\n  let websiteOptionsService: WebsiteOptionsService;\n  let registryService: WebsiteRegistryService;\n  let postService: PostService;\n  let mockPostManagerRegistry: jest.Mocked<PostManagerRegistry>;\n\n  beforeEach(async () => {\n    clearDatabase();\n\n    // Create mock PostManagerRegistry\n    mockPostManagerRegistry = {\n      startPost: jest.fn().mockResolvedValue(undefined),\n      cancelIfRunning: jest.fn().mockResolvedValue(true),\n      isPostingType: jest.fn().mockReturnValue(false),\n      getManager: jest.fn(),\n    } as any;\n\n    try {\n      module = await Test.createTestingModule({\n        imports: [\n          SubmissionModule,\n          AccountModule,\n          WebsiteOptionsModule,\n          WebsitesModule,\n          PostModule,\n        ],\n      })\n        .overrideProvider(PostManagerRegistry)\n        .useValue(mockPostManagerRegistry)\n        .compile();\n\n      service = module.get<PostQueueService>(PostQueueService);\n      submissionService = module.get<SubmissionService>(SubmissionService);\n      accountService = module.get<AccountService>(AccountService);\n      const settingsService = module.get<SettingsService>(SettingsService);\n      websiteOptionsService = module.get<WebsiteOptionsService>(\n        WebsiteOptionsService,\n      );\n      registryService = module.get<WebsiteRegistryService>(\n        WebsiteRegistryService,\n      );\n      postService = module.get<PostService>(PostService);\n      await accountService.onModuleInit();\n      await settingsService.onModuleInit();\n    } catch (err) {\n      console.log(err);\n    }\n  });\n\n  function createSubmissionDto(): CreateSubmissionDto {\n    const dto = new CreateSubmissionDto();\n    dto.name = 'Test';\n    dto.type = SubmissionType.MESSAGE;\n    return dto;\n  }\n\n  function createAccountDto(): CreateAccountDto {\n    const dto = new CreateAccountDto();\n    dto.name = 'Test';\n    dto.website = 'test';\n    return dto;\n  }\n\n  function createWebsiteOptionsDto(\n    submissionId: SubmissionId,\n    accountId: AccountId,\n  ): CreateWebsiteOptionsDto {\n    const dto = new CreateWebsiteOptionsDto();\n    dto.submissionId = submissionId;\n    dto.accountId = accountId;\n    dto.data = {\n      title: 'Test Title',\n      tags: {\n        overrideDefault: true,\n        tags: ['test'],\n      },\n      description: {\n        overrideDefault: true,\n        description: DefaultDescription(),\n      },\n      rating: SubmissionRating.GENERAL,\n    };\n    return dto;\n  }\n\n  afterAll(async () => {\n    await module.close();\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n\n  it('should handle pausing and resuming the queue', async () => {\n    await service.pause();\n    expect(await service.isPaused()).toBe(true);\n    await service.resume();\n    expect(await service.isPaused()).toBe(false);\n  });\n\n  it('should handle enqueue and dequeue of submissions', async () => {\n    await service.pause(); // Just to test the function\n    const submission = await submissionService.create(createSubmissionDto());\n    const account = await accountService.create(createAccountDto());\n    expect(registryService.findInstance(account)).toBeDefined();\n\n    await websiteOptionsService.create(\n      createWebsiteOptionsDto(submission.id, account.id),\n    );\n\n    await service.enqueue([submission.id, submission.id]);\n    expect((await service.findAll()).length).toBe(1);\n    const top = await service.peek();\n    expect(top).toBeDefined();\n    expect(top.submission.id).toBe(submission.id);\n\n    await service.dequeue([submission.id]);\n    expect((await service.findAll()).length).toBe(0);\n    expect(await service.peek()).toBeNull();\n  });\n\n  it('should insert posts into the post manager', async () => {\n    const submission = await submissionService.create(createSubmissionDto());\n    const account = await accountService.create(createAccountDto());\n    expect(registryService.findInstance(account)).toBeDefined();\n\n    await websiteOptionsService.create(\n      createWebsiteOptionsDto(submission.id, account.id),\n    );\n\n    // Enqueue now creates the PostRecord immediately\n    await service.enqueue([submission.id]);\n    expect((await service.findAll()).length).toBe(1);\n\n    // PostRecord should already exist after enqueue\n    let postRecord = (await postService.findAll())[0];\n    expect(postRecord).toBeDefined();\n    expect(postRecord.submissionId).toBe(submission.id);\n    expect(postRecord.state).toBe(PostRecordState.PENDING);\n\n    // Initially, no manager is posting (so execute will start the post)\n    mockPostManagerRegistry.isPostingType.mockReturnValue(false);\n\n    // Execute should start the post manager\n    await service.execute();\n    let queueRecord = await service.peek();\n    expect(mockPostManagerRegistry.startPost).toHaveBeenCalledWith(\n      expect.objectContaining({\n        id: postRecord.id,\n        submissionId: submission.id,\n      }),\n    );\n    expect(queueRecord).toBeDefined();\n    expect(queueRecord.postRecord).toBeDefined();\n\n    // Now simulate that the manager is posting\n    mockPostManagerRegistry.isPostingType.mockReturnValue(true);\n\n    // Simulate cancellation\n    await mockPostManagerRegistry.cancelIfRunning(submission.id);\n    expect(mockPostManagerRegistry.cancelIfRunning).toHaveBeenCalledWith(\n      submission.id,\n    );\n\n    // Simulate the post completing (with failure) - manually update the record\n    const database = new PostyBirbDatabase('PostRecordSchema');\n    await database.update(postRecord.id, {\n      state: PostRecordState.FAILED,\n      completedAt: new Date().toISOString(),\n    });\n\n    // Simulate posting finished\n    mockPostManagerRegistry.isPostingType.mockReturnValue(false);\n\n    queueRecord = await service.peek();\n    expect(queueRecord).toBeDefined();\n    expect(queueRecord.postRecord).toBeDefined();\n\n    // We expect the post to be in a terminal state and cleanup of the record.\n    // The post record should remain after the queue record is deleted.\n    await service.execute();\n    expect((await service.findAll()).length).toBe(0);\n    postRecord = await postService.findById(postRecord.id);\n    expect(postRecord.state).toBe(PostRecordState.FAILED);\n    expect(postRecord.completedAt).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/client-server/src/app/post/services/post-queue/post-queue.service.ts",
    "content": "import {\n  Injectable,\n  InternalServerErrorException,\n  OnModuleInit,\n  Optional,\n} from '@nestjs/common';\nimport { Cron, CronExpression } from '@nestjs/schedule';\nimport {\n  EntityId,\n  PostRecordResumeMode,\n  PostRecordState,\n  ScheduleType,\n  SubmissionId,\n} from '@postybirb/types';\nimport { IsTestEnvironment } from '@postybirb/utils/electron';\nimport { Mutex } from 'async-mutex';\nimport { Cron as CronGenerator } from 'croner';\nimport { PostyBirbService } from '../../../common/service/postybirb-service';\nimport { PostQueueRecord, PostRecord } from '../../../drizzle/models';\nimport { PostyBirbDatabase } from '../../../drizzle/postybirb-database/postybirb-database';\nimport { NotificationsService } from '../../../notifications/notifications.service';\nimport { SettingsService } from '../../../settings/settings.service';\nimport { SubmissionService } from '../../../submission/services/submission.service';\nimport { WSGateway } from '../../../web-socket/web-socket-gateway';\nimport { WebsiteRegistryService } from '../../../websites/website-registry.service';\nimport { PostManagerRegistry } from '../post-manager-v2';\nimport { PostRecordFactory } from '../post-record-factory';\n\n/**\n * Handles the queue of posts to be posted.\n * This service is responsible for managing the queue of posts to be posted.\n * It will create post records and start the post manager when a post is ready to be posted.\n * @class PostQueueService\n */\n@Injectable()\nexport class PostQueueService\n  extends PostyBirbService<'PostQueueRecordSchema'>\n  implements OnModuleInit\n{\n  private readonly queueModificationMutex = new Mutex();\n\n  private readonly queueMutex = new Mutex();\n\n  private initTime = Date.now();\n\n  private readonly postRecordRepository = new PostyBirbDatabase(\n    'PostRecordSchema',\n  );\n\n  private readonly submissionRepository = new PostyBirbDatabase(\n    'SubmissionSchema',\n  );\n\n  /**\n   * Maximum time (in ms) a post can be RUNNING without any activity before being considered stuck.\n   */\n  private readonly maxPostIdleTime = 30 * 60 * 1000; // 30 minutes\n\n  constructor(\n    private readonly postManagerRegistry: PostManagerRegistry,\n    private readonly postRecordFactory: PostRecordFactory,\n    private readonly settingsService: SettingsService,\n    private readonly notificationService: NotificationsService,\n    private readonly submissionService: SubmissionService,\n    private readonly websiteRegistryService: WebsiteRegistryService,\n    @Optional() webSocket?: WSGateway,\n  ) {\n    super('PostQueueRecordSchema', webSocket);\n  }\n\n  /**\n   * Crash recovery: Resume any PostRecords that were left in RUNNING state.\n   * This handles cases where the application was forcefully shut down or crashed\n   * while a post was in progress.\n   *\n   * Also handles orphaned records - PENDING/RUNNING records that have no queue record\n   * (can happen if queue record was deleted but post record state wasn't updated).\n   */\n  async onModuleInit() {\n    if (IsTestEnvironment()) {\n      return;\n    }\n\n    try {\n      // First, handle orphaned post records (PENDING/RUNNING without a queue record)\n      await this.failOrphanedPostRecords();\n\n      // Find all RUNNING post records\n      const runningRecords = await this.postRecordRepository.find({\n        where: (record, { eq }) => eq(record.state, PostRecordState.RUNNING),\n        with: {\n          submission: {\n            with: {\n              files: true,\n              options: {\n                with: {\n                  account: true,\n                },\n              },\n            },\n          },\n        },\n      });\n\n      if (runningRecords.length > 0) {\n        this.logger\n          .withMetadata({ count: runningRecords.length })\n          .info(\n            'Detected interrupted PostRecords from crash/shutdown, scheduling resume',\n          );\n\n        // Resume in the background so onModuleInit does not block server startup\n        this.resumeInterruptedPosts(runningRecords);\n      }\n    } catch (error) {\n      this.logger\n        .withMetadata({ error })\n        .error('Failed to recover interrupted posts on startup');\n      // Don't throw - allow the app to start even if crash recovery fails\n    }\n  }\n\n  /**\n   * Resume interrupted posts in the background.\n   * This is intentionally not awaited so it does not block server startup.\n   */\n  private async resumeInterruptedPosts(\n    runningRecords: PostRecord[],\n  ): Promise<void> {\n    try {\n      this.logger.info(\n        'Waiting for website registry initialization before crash recovery...',\n      );\n      await this.websiteRegistryService.waitForInitialization(60_000);\n      this.logger.info(\n        'Website registry initialized, proceeding with crash recovery',\n      );\n\n      for (const record of runningRecords) {\n        this.logger\n          .withMetadata({\n            recordId: record.id,\n            resumeMode: record.resumeMode,\n          })\n          .info('Resuming interrupted PostRecord');\n\n        await this.postManagerRegistry.startPost(record);\n      }\n    } catch (error) {\n      this.logger\n        .withMetadata({ error })\n        .error('Failed to resume interrupted posts');\n    }\n  }\n\n  /**\n   * Find and fail orphaned post records.\n   * An orphaned record is one in PENDING or RUNNING state that has no corresponding queue record.\n   * This can happen if the queue record was deleted (dequeue/cancel) but the post record\n   * state wasn't properly updated to FAILED.\n   */\n  private async failOrphanedPostRecords(): Promise<void> {\n    // Find all PENDING or RUNNING post records\n    const inProgressRecords = await this.postRecordRepository.find({\n      where: (record, { or, eq }) =>\n        or(\n          eq(record.state, PostRecordState.PENDING),\n          eq(record.state, PostRecordState.RUNNING),\n        ),\n    });\n\n    if (inProgressRecords.length === 0) {\n      return;\n    }\n\n    // Get all queue records to check which post records have a corresponding queue entry\n    const allQueueRecords = await this.repository.findAll();\n    const queuedPostRecordIds = new Set(\n      allQueueRecords\n        .map((qr) => qr.postRecordId)\n        .filter((id): id is string => id != null),\n    );\n\n    // Find orphaned records (in-progress but not in queue)\n    const orphanedRecords = inProgressRecords.filter(\n      (record) => !queuedPostRecordIds.has(record.id),\n    );\n\n    if (orphanedRecords.length > 0) {\n      this.logger\n        .withMetadata({ count: orphanedRecords.length })\n        .warn('Found orphaned PostRecords (PENDING/RUNNING with no queue record), marking as FAILED');\n\n      for (const record of orphanedRecords) {\n        this.logger\n          .withMetadata({\n            recordId: record.id,\n            submissionId: record.submissionId,\n            state: record.state,\n          })\n          .warn('Marking orphaned PostRecord as FAILED');\n\n        await this.postRecordRepository.update(record.id, {\n          state: PostRecordState.FAILED,\n          completedAt: new Date().toISOString(),\n        });\n      }\n    }\n  }\n\n  public async isPaused(): Promise<boolean> {\n    return (await this.settingsService.getDefaultSettings()).settings\n      .queuePaused;\n  }\n\n  public async pause() {\n    this.logger.info('Queue paused');\n    const settings = await this.settingsService.getDefaultSettings();\n    await this.settingsService.update(settings.id, {\n      settings: { ...settings.settings, queuePaused: true },\n    });\n  }\n\n  public async resume() {\n    this.logger.info('Queue resumed');\n    const settings = await this.settingsService.getDefaultSettings();\n    await this.settingsService.update(settings.id, {\n      settings: { ...settings.settings, queuePaused: false },\n    });\n  }\n\n  public override remove(id: EntityId) {\n    return this.dequeue([id]);\n  }\n\n  /**\n   * Get the most recent terminal PostRecord for a submission.\n   * @param {SubmissionId} submissionId - The submission ID\n   * @returns {Promise<PostRecord | null>} The most recent terminal record, or null if none\n   */\n  private async getMostRecentTerminalPostRecord(\n    submissionId: SubmissionId,\n  ): Promise<PostRecord | null> {\n    const records = await this.postRecordRepository.find({\n      where: (record, { eq, and, inArray }) =>\n        and(\n          eq(record.submissionId, submissionId),\n          inArray(record.state, [PostRecordState.DONE, PostRecordState.FAILED]),\n        ),\n      orderBy: (record, { desc }) => desc(record.createdAt),\n      limit: 1,\n    });\n\n    return records.length > 0 ? records[0] : null;\n  }\n\n  /**\n   * Handle terminal state for a completed post record.\n   * Archives the submission if successful and non-recurring.\n   * Emits appropriate notifications.\n   * @param {PostRecord} record - The completed post record\n   */\n  private async handleTerminalState(record: PostRecord): Promise<void> {\n    const { submission } = record;\n    const submissionName = submission.getSubmissionName() ?? 'Submission';\n\n    if (record.state === PostRecordState.DONE) {\n      this.logger\n        .withMetadata({ submissionId: record.submissionId })\n        .info('Post completed successfully');\n\n      // Archive submission if non-recurring schedule\n      if (submission.schedule.scheduleType !== ScheduleType.RECURRING) {\n        await this.submissionService.archive(record.submissionId);\n        this.logger\n          .withMetadata({ submissionId: record.submissionId })\n          .info('Submission archived after successful post');\n      }\n\n      // Emit success notification\n      await this.notificationService.create({\n        type: 'success',\n        title: 'Post Completed',\n        message: `Successfully posted \"${submissionName}\" to all websites`,\n        tags: ['post-success'],\n        data: {\n          submissionId: record.submissionId,\n          type: submission.type,\n        },\n      });\n    } else if (record.state === PostRecordState.FAILED) {\n      this.logger\n        .withMetadata({ submissionId: record.submissionId })\n        .error('Post failed');\n\n      // Clear isScheduled for non-recurring submissions so they don't retry indefinitely\n      if (\n        submission.isScheduled &&\n        submission.schedule.scheduleType !== ScheduleType.RECURRING\n      ) {\n        await this.submissionService.update(record.submissionId, {\n          isScheduled: false,\n        });\n        this.logger\n          .withMetadata({ submissionId: record.submissionId })\n          .info('Cleared isScheduled flag after post failure');\n      }\n\n      // Count failed events for the message\n      const failedEventCount =\n        record.events?.filter((e) => e.eventType === 'POST_ATTEMPT_FAILED')\n          .length ?? 0;\n\n      // Emit failure notification (summary - individual failures already notified)\n      await this.notificationService.create({\n        type: 'warning',\n        title: 'Post Incomplete',\n        message:\n          failedEventCount > 0\n            ? `\"${submissionName}\" failed to post to ${failedEventCount} website(s)`\n            : `\"${submissionName}\" failed to post`,\n        tags: ['post-incomplete'],\n        data: {\n          submissionId: record.submissionId,\n          type: submission.type,\n          failedCount: failedEventCount,\n        },\n      });\n    }\n  }\n\n  /**\n   * Enqueue submissions for posting.\n   * If resumeMode is provided and the submission has a terminal PostRecord,\n   * a new PostRecord will be created using the specified resume mode.\n   *\n   * Smart handling: If the most recent PostRecord is DONE (successful completion),\n   * we always create a fresh record (restart) regardless of the provided resumeMode,\n   * since the user is starting a new posting session.\n   *\n   * @param {SubmissionId[]} submissionIds - The submissions to enqueue\n   * @param {PostRecordResumeMode} resumeMode - Optional resume mode for re-queuing terminal records\n   */\n  public async enqueue(\n    submissionIds: SubmissionId[],\n    resumeMode?: PostRecordResumeMode,\n  ) {\n    if (!submissionIds.length) {\n      return;\n    }\n    const release = await this.queueModificationMutex.acquire();\n    this.logger\n      .withMetadata({ submissionIds, resumeMode })\n      .info('Enqueueing posts');\n\n    try {\n      for (const submissionId of submissionIds) {\n        // Check if submission exists and is not archived before doing anything\n        const submission =\n          await this.submissionRepository.findById(submissionId);\n        if (!submission) {\n          this.logger\n            .withMetadata({ submissionId })\n            .warn('Submission not found, skipping enqueue');\n          continue;\n        }\n        if (submission.isArchived) {\n          this.logger\n            .withMetadata({ submissionId })\n            .info('Submission is archived, skipping enqueue');\n          continue;\n        }\n\n        const existing = await this.repository.findOne({\n          where: (queueRecord, { eq }) =>\n            eq(queueRecord.submissionId, submissionId),\n          with: {\n            postRecord: true,\n          },\n        });\n\n        if (!existing) {\n          // No queue entry exists - determine resume mode based on most recent PostRecord\n          const mostRecentRecord =\n            await this.getMostRecentTerminalPostRecord(submissionId);\n\n          let effectiveResumeMode: PostRecordResumeMode;\n\n          if (!mostRecentRecord) {\n            // No prior post record - fresh start\n            this.logger\n              .withMetadata({ submissionId })\n              .info('No prior PostRecord - creating fresh');\n            effectiveResumeMode = PostRecordResumeMode.NEW;\n          } else if (mostRecentRecord.state === PostRecordState.DONE) {\n            // Prior was successful - always restart fresh regardless of provided mode\n            this.logger\n              .withMetadata({ submissionId })\n              .info('Prior PostRecord was DONE - creating fresh (restart)');\n            effectiveResumeMode = PostRecordResumeMode.NEW;\n          } else {\n            // Prior was FAILED - use provided mode or default to CONTINUE\n            effectiveResumeMode = resumeMode ?? PostRecordResumeMode.CONTINUE;\n            this.logger\n              .withMetadata({\n                submissionId,\n                priorRecordId: mostRecentRecord.id,\n                resumeMode: effectiveResumeMode,\n              })\n              .info('Prior PostRecord was FAILED - using resume mode');\n          }\n\n          const newRecord = await this.postRecordFactory.create(\n            submissionId,\n            effectiveResumeMode,\n          );\n\n          await this.repository.insert({\n            submissionId,\n            postRecordId: newRecord.id,\n          });\n        }\n        // If existing, do nothing (first-in-wins)\n      }\n    } catch (error) {\n      this.logger.withMetadata({ error }).error('Failed to enqueue posts');\n      throw new InternalServerErrorException(error.message);\n    } finally {\n      release();\n      this.initTime -= 61_000; // Ensure queue processing starts after next cycle\n    }\n  }\n\n  public async dequeue(submissionIds: SubmissionId[]) {\n    const release = await this.queueModificationMutex.acquire();\n    this.logger.withMetadata({ submissionIds }).info('Dequeueing posts');\n\n    try {\n      const records = await this.repository.find({\n        where: (queueRecord, { inArray }) =>\n          inArray(queueRecord.submissionId, submissionIds),\n      });\n\n      submissionIds.forEach((id) =>\n        this.postManagerRegistry.cancelIfRunning(id),\n      );\n\n      return await this.repository.deleteById(records.map((r) => r.id));\n    } catch (error) {\n      this.logger.withMetadata({ error }).error('Failed to dequeue posts');\n      throw new InternalServerErrorException(error.message);\n    } finally {\n      release();\n    }\n  }\n\n  /**\n   * CRON based enqueueing of scheduled submissions.\n   */\n  @Cron(CronExpression.EVERY_30_SECONDS)\n  public async checkForScheduledSubmissions() {\n    if (IsTestEnvironment()) {\n      return;\n    }\n\n    const entities = await this.submissionRepository.find({\n      where: (queueRecord, { eq, and }) =>\n        and(\n          eq(queueRecord.isScheduled, true),\n          eq(queueRecord.isArchived, false),\n        ),\n    });\n    const now = Date.now();\n    const sorted = entities\n      .filter((e) => new Date(e.schedule.scheduledFor).getTime() <= now) // Only those that are ready to be posted.\n      .sort(\n        (a, b) =>\n          new Date(a.schedule.scheduledFor).getTime() -\n          new Date(b.schedule.scheduledFor).getTime(),\n      ); // Sort by oldest first.\n    await this.enqueue(sorted.map((s) => s.id));\n    sorted\n      .filter((s) => s.schedule.cron)\n      .forEach((s) => {\n        const next = CronGenerator(s.schedule.cron).nextRun()?.toISOString();\n        if (next) {\n          // eslint-disable-next-line no-param-reassign\n          s.schedule.scheduledFor = next;\n          this.submissionRepository.update(s.id, {\n            schedule: s.schedule,\n          });\n        }\n      });\n  }\n\n  /**\n   * This runs a check every second on the state of queue items.\n   * This aims to have simple logic. Each run will either create a post record and start the post manager,\n   * or remove a submission from the queue if it is in a terminal state.\n   * Nothing happens if the queue is empty.\n   */\n  @Cron(CronExpression.EVERY_SECOND)\n  public async run() {\n    if (!(this.initTime + 60_000 <= Date.now())) {\n      // Only run after 1 minute to allow the application to start up.\n      return;\n    }\n\n    if (IsTestEnvironment()) {\n      return;\n    }\n\n    await this.execute();\n  }\n\n  /**\n   * Check if a RUNNING post record appears to be stuck (no activity for too long).\n   * Uses both record update time and last event time to determine activity.\n   *\n   * @param {PostRecord} record - The post record to check\n   * @returns {boolean} True if the record appears to be stuck\n   */\n  private isStuck(record: PostRecord): boolean {\n    if (record.state !== PostRecordState.RUNNING) {\n      return false;\n    }\n\n    // Find the most recent activity: either record update or last event\n    const recordUpdatedAt = new Date(record.updatedAt).getTime();\n    const lastEventAt = record.events?.length\n      ? Math.max(...record.events.map((e) => new Date(e.createdAt).getTime()))\n      : 0;\n    const lastActivityAt = Math.max(recordUpdatedAt, lastEventAt);\n    const idleTime = Date.now() - lastActivityAt;\n\n    return idleTime > this.maxPostIdleTime;\n  }\n\n  /**\n   * Manages the queue by peeking at the top of the queue and deciding what to do based on the\n   * state of the queue.\n   *\n   * Made public for testing purposes.\n   */\n  public async execute() {\n    if (this.queueMutex.isLocked()) {\n      return;\n    }\n\n    const release = await this.queueMutex.acquire();\n\n    try {\n      const isPaused = await this.isPaused();\n      if (isPaused) {\n        this.logger.info('Queue is paused, skipping execution cycle');\n        return;\n      }\n\n      const top = await this.peek();\n      // Queue Empty\n      if (!top) {\n        return;\n      }\n\n      const { postRecord: record, submissionId, submission } = top;\n      if (submission.isArchived) {\n        // Submission is archived, remove from queue\n        this.logger\n          .withMetadata({ submissionId })\n          .info('Submission is archived, removing from queue');\n        await this.dequeue([submissionId]);\n        return;\n      }\n\n      if (!record) {\n        // PostRecord should always exist since enqueue() creates it\n        // If missing, something is wrong - log error and remove from queue\n        this.logger\n          .withMetadata({ submissionId })\n          .error('Queue entry has no PostRecord - removing invalid entry');\n        await this.dequeue([submissionId]);\n        return;\n      }\n\n      if (\n        record.state === PostRecordState.DONE ||\n        record.state === PostRecordState.FAILED\n      ) {\n        // Post is in a terminal state - handle completion actions and remove from queue\n        await this.handleTerminalState(record);\n        await this.dequeue([submissionId]);\n      } else if (!this.postManagerRegistry.isPostingType(submission.type)) {\n        // Post is not in a terminal state, but the manager for this type is not posting, so restart it.\n        this.logger\n          .withMetadata({ record })\n          .info(\n            'PostManager is not posting, but record is not in terminal state. Resuming record.',\n          );\n\n        // Start the post - the manager will build resume context from the record\n        await this.postManagerRegistry.startPost(record);\n      } else if (this.isStuck(record)) {\n        // Manager is posting but record shows no activity - it's stuck\n        const recordUpdatedAt = new Date(record.updatedAt).getTime();\n        const lastEventAt = record.events?.length\n          ? Math.max(\n              ...record.events.map((e) => new Date(e.createdAt).getTime()),\n            )\n          : 0;\n        const lastActivityAt = Math.max(recordUpdatedAt, lastEventAt);\n\n        this.logger\n          .withMetadata({\n            submissionId,\n            recordId: record.id,\n            idleTime: Date.now() - lastActivityAt,\n            lastActivityAt: new Date(lastActivityAt).toISOString(),\n            eventCount: record.events?.length ?? 0,\n          })\n          .warn(\n            'PostRecord has been RUNNING without activity - marking as failed',\n          );\n\n        await this.postRecordRepository.update(record.id, {\n          state: PostRecordState.FAILED,\n          completedAt: new Date().toISOString(),\n        });\n        // Next cycle will handle terminal state\n      }\n      // else: manager is actively posting and making progress - do nothing\n    } catch (error) {\n      this.logger.withMetadata({ error }).error('Failed to run queue');\n      throw error;\n    } finally {\n      release();\n    }\n  }\n\n  /**\n   * Peeks at the next item in the queue.\n   * Based on the createdAt date.\n   */\n  public async peek(): Promise<PostQueueRecord | undefined> {\n    return this.repository.findOne({\n      orderBy: (queueRecord, { asc }) => asc(queueRecord.createdAt),\n      with: {\n        submission: true,\n        postRecord: {\n          with: {\n            events: true,\n            submission: {\n              with: {\n                files: true,\n                options: {\n                  with: {\n                    account: true,\n                  },\n                },\n              },\n            },\n          },\n        },\n      },\n    });\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/post/services/post-record-factory/index.ts",
    "content": "export * from './post-event.repository';\nexport * from './post-record-factory.service';\n\n"
  },
  {
    "path": "apps/client-server/src/app/post/services/post-record-factory/post-event.repository.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { Insert } from '@postybirb/database';\nimport {\n  AccountId,\n  EntityId,\n  PostEventType,\n} from '@postybirb/types';\nimport { PostEvent } from '../../../drizzle/models';\nimport { PostyBirbDatabase } from '../../../drizzle/postybirb-database/postybirb-database';\n\n/**\n * Repository for querying PostEvent records.\n * Provides specialized query methods for resume mode logic.\n * @class PostEventRepository\n */\n@Injectable()\nexport class PostEventRepository {\n  private readonly repository: PostyBirbDatabase<'PostEventSchema'>;\n\n  constructor() {\n    this.repository = new PostyBirbDatabase('PostEventSchema', {\n      account: true,\n    });\n  }\n\n  /**\n   * Get all source URLs from a post record, excluding a specific account.\n   * Used for cross-website source URL propagation within the current post.\n   * @param {EntityId} postRecordId - The post record ID\n   * @param {AccountId} excludeAccountId - The account ID to exclude (to avoid self-referential URLs)\n   * @returns {Promise<string[]>} Array of source URLs from other accounts\n   */\n  async getSourceUrlsFromPost(\n    postRecordId: EntityId,\n    excludeAccountId: AccountId,\n  ): Promise<string[]> {\n    const events = await this.repository.find({\n      where: (event, { eq, and, inArray }) =>\n        and(\n          eq(event.postRecordId, postRecordId),\n          inArray(event.eventType, [\n            PostEventType.FILE_POSTED,\n            PostEventType.MESSAGE_POSTED,\n          ]),\n        ),\n    });\n\n    return events\n      .filter(\n        (event) =>\n          event.sourceUrl &&\n          event.accountId &&\n          event.accountId !== excludeAccountId,\n      )\n      .map((event) => event.sourceUrl as string);\n  }\n\n  /**\n   * Get all error events for a post record.\n   * @param {EntityId} postRecordId - The post record ID\n   * @returns {Promise<PostEvent[]>} All failed events\n   */\n  async getFailedEvents(postRecordId: EntityId): Promise<PostEvent[]> {\n    return this.repository.find({\n      where: (event, { eq, and, inArray }) =>\n        and(\n          eq(event.postRecordId, postRecordId),\n          inArray(event.eventType, [\n            PostEventType.POST_ATTEMPT_FAILED,\n            PostEventType.FILE_FAILED,\n            PostEventType.MESSAGE_FAILED,\n          ]),\n        ),\n    });\n  }\n\n  /**\n   * Insert a new post event.\n   * @param {Insert<'PostEventSchema'>} event - The event to insert\n   * @returns {Promise<PostEvent>} The inserted event\n   */\n  async insert(event: Insert<'PostEventSchema'>): Promise<PostEvent> {\n    return this.repository.insert(event) as Promise<PostEvent>;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/post/services/post-record-factory/post-record-factory.service.spec.ts",
    "content": "import { Test, TestingModule } from '@nestjs/testing';\nimport { clearDatabase } from '@postybirb/database';\nimport {\n  EntityId,\n  PostEventType,\n  PostRecordResumeMode,\n  PostRecordState,\n  SubmissionType,\n} from '@postybirb/types';\nimport { AccountModule } from '../../../account/account.module';\nimport { AccountService } from '../../../account/account.service';\nimport { PostEvent, PostRecord } from '../../../drizzle/models';\nimport { PostyBirbDatabase } from '../../../drizzle/postybirb-database/postybirb-database';\nimport { PostParsersModule } from '../../../post-parsers/post-parsers.module';\nimport { CreateSubmissionDto } from '../../../submission/dtos/create-submission.dto';\nimport { SubmissionService } from '../../../submission/services/submission.service';\nimport { SubmissionModule } from '../../../submission/submission.module';\nimport { UserSpecifiedWebsiteOptionsModule } from '../../../user-specified-website-options/user-specified-website-options.module';\nimport { WebsitesModule } from '../../../websites/websites.module';\nimport { InvalidPostChainError } from '../../errors';\nimport { PostEventRepository } from './post-event.repository';\nimport { PostRecordFactory, ResumeContext } from './post-record-factory.service';\n\ndescribe('PostRecordFactory', () => {\n  let module: TestingModule;\n  let factory: PostRecordFactory;\n  let submissionService: SubmissionService;\n  let accountService: AccountService;\n  let postRecordRepository: PostyBirbDatabase<'PostRecordSchema'>;\n  let postEventRepository: PostEventRepository;\n\n  beforeEach(async () => {\n    clearDatabase();\n\n    module = await Test.createTestingModule({\n      imports: [\n        SubmissionModule,\n        AccountModule,\n        WebsitesModule,\n        UserSpecifiedWebsiteOptionsModule,\n        PostParsersModule,\n      ],\n      providers: [PostRecordFactory, PostEventRepository],\n    }).compile();\n\n    factory = module.get<PostRecordFactory>(PostRecordFactory);\n    submissionService = module.get<SubmissionService>(SubmissionService);\n    accountService = module.get<AccountService>(AccountService);\n    postEventRepository = module.get<PostEventRepository>(PostEventRepository);\n    postRecordRepository = new PostyBirbDatabase('PostRecordSchema');\n\n    await accountService.onModuleInit();\n  });\n\n  afterAll(async () => {\n    await module.close();\n  });\n\n  async function createSubmission(): Promise<EntityId> {\n    const dto = new CreateSubmissionDto();\n    dto.name = 'Test Submission';\n    dto.type = SubmissionType.MESSAGE;\n    const submission = await submissionService.create(dto);\n    return submission.id;\n  }\n\n  async function createAccount(name: string): Promise<EntityId> {\n    const dto = {\n      name,\n      website: 'test',\n      groups: [],\n    };\n    const account = await accountService.create(dto as any);\n    return account.id;\n  }\n\n  describe('create', () => {\n    it('should create a PostRecord with default NEW mode and null originPostRecordId', async () => {\n      const submissionId = await createSubmission();\n      const record = await factory.create(submissionId);\n\n      expect(record).toBeDefined();\n      expect(record.submissionId).toBe(submissionId);\n      expect(record.state).toBe(PostRecordState.PENDING);\n      expect(record.resumeMode).toBe(PostRecordResumeMode.NEW);\n      expect(record.originPostRecordId).toBeNull();\n      expect(record.id).toBeDefined();\n      expect(record.createdAt).toBeDefined();\n    });\n\n    it('should create multiple records for different submissions', async () => {\n      const submission1 = await createSubmission();\n      const submission2 = await createSubmission();\n\n      const record1 = await factory.create(submission1);\n      const record2 = await factory.create(submission2);\n\n      expect(record1.submissionId).toBe(submission1);\n      expect(record2.submissionId).toBe(submission2);\n      expect(record1.id).not.toBe(record2.id);\n      expect(record1.originPostRecordId).toBeNull();\n      expect(record2.originPostRecordId).toBeNull();\n    });\n\n    it('should set originPostRecordId when creating CONTINUE record', async () => {\n      const submissionId = await createSubmission();\n\n      // Create origin NEW record first\n      const originRecord = await factory.create(submissionId, PostRecordResumeMode.NEW);\n      \n      // Mark it as FAILED so CONTINUE is valid\n      await postRecordRepository.update(originRecord.id, { state: PostRecordState.FAILED });\n\n      const continueRecord = await factory.create(submissionId, PostRecordResumeMode.CONTINUE);\n\n      expect(continueRecord.resumeMode).toBe(PostRecordResumeMode.CONTINUE);\n      expect(continueRecord.originPostRecordId).toBe(originRecord.id);\n    });\n\n    it('should set originPostRecordId when creating CONTINUE_RETRY record', async () => {\n      const submissionId = await createSubmission();\n\n      // Create origin NEW record first\n      const originRecord = await factory.create(submissionId, PostRecordResumeMode.NEW);\n      \n      // Mark it as FAILED so CONTINUE_RETRY is valid\n      await postRecordRepository.update(originRecord.id, { state: PostRecordState.FAILED });\n\n      const retryRecord = await factory.create(submissionId, PostRecordResumeMode.CONTINUE_RETRY);\n\n      expect(retryRecord.resumeMode).toBe(PostRecordResumeMode.CONTINUE_RETRY);\n      expect(retryRecord.originPostRecordId).toBe(originRecord.id);\n    });\n\n    it('should throw InvalidPostChainError for CONTINUE without any origin', async () => {\n      const submissionId = await createSubmission();\n\n      await expect(\n        factory.create(submissionId, PostRecordResumeMode.CONTINUE),\n      ).rejects.toThrow(InvalidPostChainError);\n\n      try {\n        await factory.create(submissionId, PostRecordResumeMode.CONTINUE);\n      } catch (error) {\n        expect(error).toBeInstanceOf(InvalidPostChainError);\n        expect((error as InvalidPostChainError).reason).toBe('no_origin');\n      }\n    });\n\n    it('should throw InvalidPostChainError for CONTINUE_RETRY without any origin', async () => {\n      const submissionId = await createSubmission();\n\n      await expect(\n        factory.create(submissionId, PostRecordResumeMode.CONTINUE_RETRY),\n      ).rejects.toThrow(InvalidPostChainError);\n\n      try {\n        await factory.create(submissionId, PostRecordResumeMode.CONTINUE_RETRY);\n      } catch (error) {\n        expect(error).toBeInstanceOf(InvalidPostChainError);\n        expect((error as InvalidPostChainError).reason).toBe('no_origin');\n      }\n    });\n\n    it('should throw InvalidPostChainError when origin is DONE', async () => {\n      const submissionId = await createSubmission();\n\n      // Create origin NEW record and mark it DONE (closed chain)\n      const originRecord = await factory.create(submissionId, PostRecordResumeMode.NEW);\n      await postRecordRepository.update(originRecord.id, { state: PostRecordState.DONE });\n\n      await expect(\n        factory.create(submissionId, PostRecordResumeMode.CONTINUE),\n      ).rejects.toThrow(InvalidPostChainError);\n\n      try {\n        await factory.create(submissionId, PostRecordResumeMode.CONTINUE);\n      } catch (error) {\n        expect(error).toBeInstanceOf(InvalidPostChainError);\n        expect((error as InvalidPostChainError).reason).toBe('origin_done');\n      }\n    });\n\n    it('should chain to most recent NEW record, not older ones', async () => {\n      const submissionId = await createSubmission();\n\n      // Create first NEW record and mark it DONE\n      const oldOrigin = await factory.create(submissionId, PostRecordResumeMode.NEW);\n      await postRecordRepository.update(oldOrigin.id, { state: PostRecordState.DONE });\n\n      // Create second NEW record (new chain)\n      const newOrigin = await factory.create(submissionId, PostRecordResumeMode.NEW);\n      await postRecordRepository.update(newOrigin.id, { state: PostRecordState.FAILED });\n\n      // CONTINUE should chain to the newer origin\n      const continueRecord = await factory.create(submissionId, PostRecordResumeMode.CONTINUE);\n\n      expect(continueRecord.originPostRecordId).toBe(newOrigin.id);\n      expect(continueRecord.originPostRecordId).not.toBe(oldOrigin.id);\n    });\n\n    it('should throw InvalidPostChainError when PENDING record exists for submission', async () => {\n      const submissionId = await createSubmission();\n\n      // Create a PENDING record (simulating one already in the queue)\n      await postRecordRepository.insert({\n        submissionId,\n        state: PostRecordState.PENDING,\n        resumeMode: PostRecordResumeMode.NEW,\n        originPostRecordId: null,\n      });\n\n      // Attempting to create another should fail\n      await expect(\n        factory.create(submissionId, PostRecordResumeMode.NEW),\n      ).rejects.toThrow(InvalidPostChainError);\n\n      try {\n        await factory.create(submissionId, PostRecordResumeMode.NEW);\n      } catch (error) {\n        expect(error).toBeInstanceOf(InvalidPostChainError);\n        expect((error as InvalidPostChainError).reason).toBe('in_progress');\n      }\n    });\n\n    it('should throw InvalidPostChainError when RUNNING record exists for submission', async () => {\n      const submissionId = await createSubmission();\n\n      // Create a RUNNING record (simulating one currently being processed)\n      await postRecordRepository.insert({\n        submissionId,\n        state: PostRecordState.RUNNING,\n        resumeMode: PostRecordResumeMode.NEW,\n        originPostRecordId: null,\n      });\n\n      // Attempting to create another should fail\n      await expect(\n        factory.create(submissionId, PostRecordResumeMode.NEW),\n      ).rejects.toThrow(InvalidPostChainError);\n\n      try {\n        await factory.create(submissionId, PostRecordResumeMode.NEW);\n      } catch (error) {\n        expect(error).toBeInstanceOf(InvalidPostChainError);\n        expect((error as InvalidPostChainError).reason).toBe('in_progress');\n      }\n    });\n\n    it('should allow creating NEW record when prior records are FAILED or DONE', async () => {\n      const submissionId = await createSubmission();\n\n      // Create a FAILED record\n      await postRecordRepository.insert({\n        submissionId,\n        state: PostRecordState.FAILED,\n        resumeMode: PostRecordResumeMode.NEW,\n        originPostRecordId: null,\n      });\n\n      // Create a DONE record\n      await postRecordRepository.insert({\n        submissionId,\n        state: PostRecordState.DONE,\n        resumeMode: PostRecordResumeMode.NEW,\n        originPostRecordId: null,\n      });\n\n      // Creating a NEW record should succeed (no PENDING/RUNNING blocking)\n      const record = await factory.create(submissionId, PostRecordResumeMode.NEW);\n      expect(record).toBeDefined();\n      expect(record.state).toBe(PostRecordState.PENDING);\n    });\n  });\n\n  describe('buildResumeContext', () => {\n    async function createPostRecordWithState(\n      submissionId: EntityId,\n      state: PostRecordState,\n      resumeMode: PostRecordResumeMode,\n      originPostRecordId?: EntityId,\n    ): Promise<PostRecord> {\n      const record = await postRecordRepository.insert({\n        submissionId,\n        state,\n        resumeMode,\n        originPostRecordId: originPostRecordId ?? null,\n      });\n      return record;\n    }\n\n    async function addEvent(\n      postRecordId: EntityId,\n      eventType: PostEventType,\n      accountId?: string,\n      additionalData?: Partial<PostEvent>,\n    ): Promise<PostEvent> {\n      const eventData: any = {\n        postRecordId,\n        eventType,\n        ...additionalData,\n      };\n      \n      // Only add accountId if provided\n      if (accountId) {\n        eventData.accountId = accountId as EntityId;\n      }\n      \n      return postEventRepository.insert(eventData);\n    }\n\n    it('should return empty context for NEW mode', async () => {\n      const submissionId = await createSubmission();\n      // Create an origin NEW record first\n      const originRecord = await createPostRecordWithState(\n        submissionId,\n        PostRecordState.FAILED,\n        PostRecordResumeMode.NEW,\n      );\n      // Create a CONTINUE record chained to it\n      const priorRecord = await createPostRecordWithState(\n        submissionId,\n        PostRecordState.FAILED,\n        PostRecordResumeMode.CONTINUE,\n        originRecord.id,\n      );\n\n      const context = await factory.buildResumeContext(\n        submissionId,\n        priorRecord.id,\n        PostRecordResumeMode.NEW,\n      );\n\n      expect(context.completedAccountIds.size).toBe(0);\n      expect(context.postedFilesByAccount.size).toBe(0);\n      expect(context.sourceUrlsByAccount.size).toBe(0);\n    });\n\n    it('should return empty context when no prior records exist', async () => {\n      const submissionId = await createSubmission();\n      // Create a NEW record with no chain\n      const priorRecord = await createPostRecordWithState(\n        submissionId,\n        PostRecordState.PENDING,\n        PostRecordResumeMode.NEW,\n      );\n\n      const context = await factory.buildResumeContext(\n        submissionId,\n        priorRecord.id,\n        PostRecordResumeMode.CONTINUE,\n      );\n\n      expect(context.completedAccountIds.size).toBe(0);\n      expect(context.postedFilesByAccount.size).toBe(0);\n    });\n\n    it('should aggregate completed accounts', async () => {\n      const submissionId = await createSubmission();\n      const account1 = await createAccount('account-1');\n      const account2 = await createAccount('account-2');\n      \n      // Create origin NEW record\n      const originRecord = await createPostRecordWithState(\n        submissionId,\n        PostRecordState.FAILED,\n        PostRecordResumeMode.NEW,\n      );\n\n      await addEvent(\n        originRecord.id,\n        PostEventType.POST_ATTEMPT_COMPLETED,\n        account1,\n      );\n      await addEvent(\n        originRecord.id,\n        PostEventType.POST_ATTEMPT_COMPLETED,\n        account2,\n      );\n\n      const context = await factory.buildResumeContext(\n        submissionId,\n        originRecord.id,\n        PostRecordResumeMode.CONTINUE_RETRY,\n      );\n\n      expect(context.completedAccountIds.size).toBe(2);\n      expect(context.completedAccountIds.has(account1)).toBe(true);\n      expect(context.completedAccountIds.has(account2)).toBe(true);\n    });\n\n    it('should aggregate posted files in CONTINUE mode', async () => {\n      const submissionId = await createSubmission();\n      const account1 = await createAccount('account-1');\n      \n      // Create origin NEW record\n      const originRecord = await createPostRecordWithState(\n        submissionId,\n        PostRecordState.FAILED,\n        PostRecordResumeMode.NEW,\n      );\n\n      await addEvent(originRecord.id, PostEventType.FILE_POSTED, account1, {\n        fileId: 'file-1' as EntityId,\n        sourceUrl: 'https://example.com/1',\n      });\n      await addEvent(originRecord.id, PostEventType.FILE_POSTED, account1, {\n        fileId: 'file-2' as EntityId,\n        sourceUrl: 'https://example.com/2',\n      });\n\n      const context = await factory.buildResumeContext(\n        submissionId,\n        originRecord.id,\n        PostRecordResumeMode.CONTINUE,\n      );\n\n      expect(context.postedFilesByAccount.size).toBe(1);\n      const postedFiles = context.postedFilesByAccount.get(account1);\n      expect(postedFiles?.size).toBe(2);\n      expect(postedFiles?.has('file-1' as EntityId)).toBe(true);\n      expect(postedFiles?.has('file-2' as EntityId)).toBe(true);\n    });\n\n    it('should NOT aggregate posted files in CONTINUE_RETRY mode', async () => {\n      const submissionId = await createSubmission();\n      const account1 = await createAccount('account-1');\n      \n      // Create origin NEW record\n      const originRecord = await createPostRecordWithState(\n        submissionId,\n        PostRecordState.FAILED,\n        PostRecordResumeMode.NEW,\n      );\n\n      await addEvent(\n        originRecord.id,\n        PostEventType.FILE_POSTED,\n        account1,\n        {\n          fileId: 'file-1' as EntityId,\n        },\n      );\n\n      const context = await factory.buildResumeContext(\n        submissionId,\n        originRecord.id,\n        PostRecordResumeMode.CONTINUE_RETRY,\n      );\n\n      expect(context.postedFilesByAccount.size).toBe(0);\n    });\n\n    it('should aggregate source URLs from FILE_POSTED events', async () => {\n      const submissionId = await createSubmission();\n      const account1 = await createAccount('account-1');\n      \n      // Create origin NEW record\n      const originRecord = await createPostRecordWithState(\n        submissionId,\n        PostRecordState.FAILED,\n        PostRecordResumeMode.NEW,\n      );\n\n      await addEvent(\n        originRecord.id,\n        PostEventType.FILE_POSTED,\n        account1,\n        {\n          sourceUrl: 'https://example.com/post1',\n        },\n      );\n\n      const context = await factory.buildResumeContext(\n        submissionId,\n        originRecord.id,\n        PostRecordResumeMode.CONTINUE,\n      );\n\n      const sourceUrls = context.sourceUrlsByAccount.get(account1);\n      expect(sourceUrls).toHaveLength(1);\n      expect(sourceUrls?.[0]).toBe('https://example.com/post1');\n    });\n\n    it('should aggregate source URLs from MESSAGE_POSTED events', async () => {\n      const submissionId = await createSubmission();\n      const account1 = await createAccount('account-1');\n      \n      // Create origin NEW record\n      const originRecord = await createPostRecordWithState(\n        submissionId,\n        PostRecordState.FAILED,\n        PostRecordResumeMode.NEW,\n      );\n\n      await addEvent(\n        originRecord.id,\n        PostEventType.MESSAGE_POSTED,\n        account1,\n        {\n          sourceUrl: 'https://example.com/message1',\n        },\n      );\n\n      const context = await factory.buildResumeContext(\n        submissionId,\n        originRecord.id,\n        PostRecordResumeMode.CONTINUE,\n      );\n\n      const sourceUrls = context.sourceUrlsByAccount.get(account1);\n      expect(sourceUrls).toHaveLength(1);\n      expect(sourceUrls?.[0]).toBe('https://example.com/message1');\n    });\n\n    it('should only aggregate events from current chain (not prior DONE chain)', async () => {\n      const submissionId = await createSubmission();\n      const accountOld = await createAccount('account-old');\n      const accountNew = await createAccount('account-new');\n\n      // Create older chain that completed successfully (DONE)\n      const olderOrigin = await createPostRecordWithState(\n        submissionId,\n        PostRecordState.DONE,\n        PostRecordResumeMode.NEW,\n      );\n      await addEvent(\n        olderOrigin.id,\n        PostEventType.POST_ATTEMPT_COMPLETED,\n        accountOld,\n      );\n\n      // Create new chain origin\n      const newOrigin = await createPostRecordWithState(\n        submissionId,\n        PostRecordState.FAILED,\n        PostRecordResumeMode.NEW,\n      );\n      await addEvent(\n        newOrigin.id,\n        PostEventType.POST_ATTEMPT_COMPLETED,\n        accountNew,\n      );\n\n      const context = await factory.buildResumeContext(\n        submissionId,\n        newOrigin.id,\n        PostRecordResumeMode.CONTINUE_RETRY,\n      );\n\n      // Should only have account-new from the current chain\n      expect(context.completedAccountIds.size).toBe(1);\n      expect(context.completedAccountIds.has(accountNew)).toBe(true);\n      expect(context.completedAccountIds.has(accountOld)).toBe(false);\n    });\n\n    it('should aggregate events from origin and all chained records', async () => {\n      const submissionId = await createSubmission();\n      const accountRestart = await createAccount('account-restart');\n      const accountNew = await createAccount('account-new');\n\n      // Create NEW record (origin)\n      const originRecord = await createPostRecordWithState(\n        submissionId,\n        PostRecordState.FAILED,\n        PostRecordResumeMode.NEW,\n      );\n      await addEvent(\n        originRecord.id,\n        PostEventType.POST_ATTEMPT_COMPLETED,\n        accountRestart,\n      );\n\n      // Create CONTINUE record chained to origin\n      const continueRecord = await createPostRecordWithState(\n        submissionId,\n        PostRecordState.FAILED,\n        PostRecordResumeMode.CONTINUE,\n        originRecord.id,\n      );\n      await addEvent(\n        continueRecord.id,\n        PostEventType.POST_ATTEMPT_COMPLETED,\n        accountNew,\n      );\n\n      const context = await factory.buildResumeContext(\n        submissionId,\n        continueRecord.id,\n        PostRecordResumeMode.CONTINUE_RETRY,\n      );\n\n      // Should have both accounts from the chain\n      expect(context.completedAccountIds.size).toBe(2);\n      expect(context.completedAccountIds.has(accountNew)).toBe(true);\n      expect(context.completedAccountIds.has(accountRestart)).toBe(true);\n    });\n\n    it('should aggregate events from multiple chained FAILED records', async () => {\n      const submissionId = await createSubmission();\n      const account1 = await createAccount('account-1');\n      const account2 = await createAccount('account-2');\n      const account3 = await createAccount('account-3');\n\n      // Create origin NEW record\n      const originRecord = await createPostRecordWithState(\n        submissionId,\n        PostRecordState.FAILED,\n        PostRecordResumeMode.NEW,\n      );\n      await addEvent(\n        originRecord.id,\n        PostEventType.POST_ATTEMPT_COMPLETED,\n        account1,\n      );\n\n      // Create first CONTINUE chained to origin\n      const continue1 = await createPostRecordWithState(\n        submissionId,\n        PostRecordState.FAILED,\n        PostRecordResumeMode.CONTINUE,\n        originRecord.id,\n      );\n      await addEvent(\n        continue1.id,\n        PostEventType.POST_ATTEMPT_COMPLETED,\n        account2,\n      );\n\n      // Create second CONTINUE chained to origin\n      const continue2 = await createPostRecordWithState(\n        submissionId,\n        PostRecordState.FAILED,\n        PostRecordResumeMode.CONTINUE,\n        originRecord.id,\n      );\n      await addEvent(\n        continue2.id,\n        PostEventType.POST_ATTEMPT_COMPLETED,\n        account3,\n      );\n\n      const context = await factory.buildResumeContext(\n        submissionId,\n        continue2.id,\n        PostRecordResumeMode.CONTINUE_RETRY,\n      );\n\n      expect(context.completedAccountIds.size).toBe(3);\n      expect(context.completedAccountIds.has(account1)).toBe(true);\n      expect(context.completedAccountIds.has(account2)).toBe(true);\n      expect(context.completedAccountIds.has(account3)).toBe(true);\n    });\n  });\n\n  describe('shouldSkipAccount', () => {\n    it('should return true for completed accounts', () => {\n      const context: ResumeContext = {\n        resumeMode: PostRecordResumeMode.CONTINUE_RETRY,\n        completedAccountIds: new Set(['account-1' as EntityId]),\n        postedFilesByAccount: new Map(),\n        sourceUrlsByAccount: new Map(),\n      };\n\n      expect(factory.shouldSkipAccount(context, 'account-1' as EntityId)).toBe(\n        true,\n      );\n    });\n\n    it('should return false for non-completed accounts', () => {\n      const context: ResumeContext = {\n        resumeMode: PostRecordResumeMode.CONTINUE_RETRY,\n        completedAccountIds: new Set(['account-1' as EntityId]),\n        postedFilesByAccount: new Map(),\n        sourceUrlsByAccount: new Map(),\n      };\n\n      expect(factory.shouldSkipAccount(context, 'account-2' as EntityId)).toBe(\n        false,\n      );\n    });\n  });\n\n  describe('shouldSkipFile', () => {\n    it('should return false in NEW mode', () => {\n      const context: ResumeContext = {\n        resumeMode: PostRecordResumeMode.NEW,\n        completedAccountIds: new Set(),\n        postedFilesByAccount: new Map(),\n        sourceUrlsByAccount: new Map(),\n      };\n\n      expect(\n        factory.shouldSkipFile(\n          context,\n          'account-1' as EntityId,\n          'file-1' as EntityId,\n        ),\n      ).toBe(false);\n    });\n\n    it('should return false in CONTINUE_RETRY mode', () => {\n      const context: ResumeContext = {\n        resumeMode: PostRecordResumeMode.CONTINUE_RETRY,\n        completedAccountIds: new Set(),\n        postedFilesByAccount: new Map([\n          ['account-1' as EntityId, new Set(['file-1' as EntityId])],\n        ]),\n        sourceUrlsByAccount: new Map(),\n      };\n\n      expect(\n        factory.shouldSkipFile(\n          context,\n          'account-1' as EntityId,\n          'file-1' as EntityId,\n        ),\n      ).toBe(false);\n    });\n\n    it('should return true for posted files in CONTINUE mode', () => {\n      const context: ResumeContext = {\n        resumeMode: PostRecordResumeMode.CONTINUE,\n        completedAccountIds: new Set(),\n        postedFilesByAccount: new Map([\n          ['account-1' as EntityId, new Set(['file-1' as EntityId])],\n        ]),\n        sourceUrlsByAccount: new Map(),\n      };\n\n      expect(\n        factory.shouldSkipFile(\n          context,\n          'account-1' as EntityId,\n          'file-1' as EntityId,\n        ),\n      ).toBe(true);\n    });\n\n    it('should return false for non-posted files in CONTINUE mode', () => {\n      const context: ResumeContext = {\n        resumeMode: PostRecordResumeMode.CONTINUE,\n        completedAccountIds: new Set(),\n        postedFilesByAccount: new Map([\n          ['account-1' as EntityId, new Set(['file-1' as EntityId])],\n        ]),\n        sourceUrlsByAccount: new Map(),\n      };\n\n      expect(\n        factory.shouldSkipFile(\n          context,\n          'account-1' as EntityId,\n          'file-2' as EntityId,\n        ),\n      ).toBe(false);\n    });\n  });\n\n  describe('getSourceUrlsForAccount', () => {\n    it('should return source URLs for an account', () => {\n      const context: ResumeContext = {\n        resumeMode: PostRecordResumeMode.CONTINUE,\n        completedAccountIds: new Set(),\n        postedFilesByAccount: new Map(),\n        sourceUrlsByAccount: new Map([\n          [\n            'account-1' as EntityId,\n            ['https://example.com/1', 'https://example.com/2'],\n          ],\n        ]),\n      };\n\n      const urls = factory.getSourceUrlsForAccount(\n        context,\n        'account-1' as EntityId,\n      );\n      expect(urls).toHaveLength(2);\n      expect(urls[0]).toBe('https://example.com/1');\n      expect(urls[1]).toBe('https://example.com/2');\n    });\n\n    it('should return empty array for account with no URLs', () => {\n      const context: ResumeContext = {\n        resumeMode: PostRecordResumeMode.CONTINUE,\n        completedAccountIds: new Set(),\n        postedFilesByAccount: new Map(),\n        sourceUrlsByAccount: new Map(),\n      };\n\n      const urls = factory.getSourceUrlsForAccount(\n        context,\n        'account-1' as EntityId,\n      );\n      expect(urls).toHaveLength(0);\n    });\n  });\n\n  describe('getAllSourceUrls', () => {\n    it('should return all source URLs from all accounts', () => {\n      const context: ResumeContext = {\n        resumeMode: PostRecordResumeMode.CONTINUE,\n        completedAccountIds: new Set(),\n        postedFilesByAccount: new Map(),\n        sourceUrlsByAccount: new Map([\n          ['account-1' as EntityId, ['https://example.com/1']],\n          ['account-2' as EntityId, ['https://example.com/2']],\n        ]),\n      };\n\n      const urls = factory.getAllSourceUrls(context);\n      expect(urls).toHaveLength(2);\n      expect(urls).toContain('https://example.com/1');\n      expect(urls).toContain('https://example.com/2');\n    });\n\n    it('should return empty array when no URLs exist', () => {\n      const context: ResumeContext = {\n        resumeMode: PostRecordResumeMode.CONTINUE,\n        completedAccountIds: new Set(),\n        postedFilesByAccount: new Map(),\n        sourceUrlsByAccount: new Map(),\n      };\n\n      const urls = factory.getAllSourceUrls(context);\n      expect(urls).toHaveLength(0);\n    });\n  });\n\n  describe('crash recovery', () => {\n    /**\n     * These tests verify the crash recovery behavior:\n     * When PostyBirb crashes mid-post, a PostRecord is left in RUNNING state.\n     * On restart, onModuleInit() finds these RUNNING records and resumes them.\n     * The key behavior is that events from the RUNNING record must be preserved,\n     * EVEN if the user originally requested NEW mode (fresh start).\n     */\n\n    async function createPostRecordWithState(\n      submissionId: EntityId,\n      state: PostRecordState,\n      resumeMode: PostRecordResumeMode,\n      originPostRecordId?: EntityId,\n    ): Promise<PostRecord> {\n      const record = await postRecordRepository.insert({\n        submissionId,\n        state,\n        resumeMode,\n        originPostRecordId: originPostRecordId ?? null,\n      });\n      return record;\n    }\n\n    async function addEvent(\n      postRecordId: EntityId,\n      eventType: PostEventType,\n      accountId?: string,\n      additionalData?: Partial<PostEvent>,\n    ): Promise<PostEvent> {\n      const eventData: any = {\n        postRecordId,\n        eventType,\n        ...additionalData,\n      };\n      if (accountId) {\n        eventData.accountId = accountId as EntityId;\n      }\n      return postEventRepository.insert(eventData);\n    }\n\n    it('should aggregate events from RUNNING record even with NEW mode (crash recovery)', async () => {\n      // Scenario: User started a NEW post, posted to 2 accounts, then crashed.\n      // On restart, we must preserve those 2 completed accounts.\n      const submissionId = await createSubmission();\n      const account1 = await createAccount('crash-account-1');\n      const account2 = await createAccount('crash-account-2');\n\n      // Create a RUNNING record with NEW mode (simulates mid-post crash)\n      // NEW mode records are their own origin (originPostRecordId = null)\n      const runningRecord = await createPostRecordWithState(\n        submissionId,\n        PostRecordState.RUNNING,\n        PostRecordResumeMode.NEW,\n      );\n\n      // Add events that occurred before crash\n      await addEvent(runningRecord.id, PostEventType.POST_ATTEMPT_COMPLETED, account1);\n      await addEvent(runningRecord.id, PostEventType.POST_ATTEMPT_COMPLETED, account2);\n      await addEvent(runningRecord.id, PostEventType.FILE_POSTED, account1, {\n        fileId: 'crash-file-1' as EntityId,\n        sourceUrl: 'https://example.com/crash-1',\n      });\n\n      // Simulate crash recovery: buildResumeContext is called with NEW mode\n      // but the record is still RUNNING (not terminal)\n      const context = await factory.buildResumeContext(\n        submissionId,\n        runningRecord.id,\n        PostRecordResumeMode.NEW,\n      );\n\n      // Despite NEW mode, crash recovery should preserve completed accounts\n      expect(context.completedAccountIds.size).toBe(2);\n      expect(context.completedAccountIds.has(account1)).toBe(true);\n      expect(context.completedAccountIds.has(account2)).toBe(true);\n\n      // Crash recovery with NEW mode should include posted files to avoid re-uploading\n      expect(context.postedFilesByAccount.size).toBe(1);\n      const postedFiles = context.postedFilesByAccount.get(account1);\n      expect(postedFiles?.has('crash-file-1' as EntityId)).toBe(true);\n    });\n\n    it('should return empty context for NEW mode with terminal (FAILED/DONE) record', async () => {\n      // Normal NEW behavior: if the prior record is terminal, start fresh\n      const submissionId = await createSubmission();\n      const account1 = await createAccount('terminal-account-1');\n\n      // Create origin NEW record that failed\n      const originRecord = await createPostRecordWithState(\n        submissionId,\n        PostRecordState.FAILED,\n        PostRecordResumeMode.NEW,\n      );\n\n      // Create a CONTINUE record chained to it\n      const failedRecord = await createPostRecordWithState(\n        submissionId,\n        PostRecordState.FAILED,\n        PostRecordResumeMode.CONTINUE,\n        originRecord.id,\n      );\n\n      await addEvent(failedRecord.id, PostEventType.POST_ATTEMPT_COMPLETED, account1);\n\n      // NEW mode with a FAILED record should return empty context\n      const context = await factory.buildResumeContext(\n        submissionId,\n        failedRecord.id,\n        PostRecordResumeMode.NEW,\n      );\n\n      expect(context.completedAccountIds.size).toBe(0);\n      expect(context.postedFilesByAccount.size).toBe(0);\n    });\n\n    it('should aggregate events from RUNNING record with CONTINUE mode (crash recovery)', async () => {\n      // CONTINUE mode crash recovery should also work\n      const submissionId = await createSubmission();\n      const account1 = await createAccount('continue-crash-account-1');\n\n      // Create origin NEW record\n      const originRecord = await createPostRecordWithState(\n        submissionId,\n        PostRecordState.FAILED,\n        PostRecordResumeMode.NEW,\n      );\n\n      // Create RUNNING CONTINUE record chained to origin\n      const runningRecord = await createPostRecordWithState(\n        submissionId,\n        PostRecordState.RUNNING,\n        PostRecordResumeMode.CONTINUE,\n        originRecord.id,\n      );\n\n      await addEvent(runningRecord.id, PostEventType.POST_ATTEMPT_COMPLETED, account1);\n      await addEvent(runningRecord.id, PostEventType.FILE_POSTED, account1, {\n        fileId: 'continue-crash-file-1' as EntityId,\n      });\n\n      const context = await factory.buildResumeContext(\n        submissionId,\n        runningRecord.id,\n        PostRecordResumeMode.CONTINUE,\n      );\n\n      // CONTINUE mode crash recovery should include both completed accounts and posted files\n      expect(context.completedAccountIds.size).toBe(1);\n      expect(context.completedAccountIds.has(account1)).toBe(true);\n      expect(context.postedFilesByAccount.size).toBe(1);\n      const postedFiles = context.postedFilesByAccount.get(account1);\n      expect(postedFiles?.has('continue-crash-file-1' as EntityId)).toBe(true);\n    });\n\n    it('should combine RUNNING record with prior FAILED records in CONTINUE mode', async () => {\n      // Scenario: Previous attempt failed, user chose CONTINUE, then crashed mid-post.\n      // We need to aggregate from both the RUNNING record AND the prior FAILED record.\n      const submissionId = await createSubmission();\n      const account1 = await createAccount('prior-account-1');\n      const account2 = await createAccount('current-account-2');\n\n      // Create origin NEW record\n      const originRecord = await createPostRecordWithState(\n        submissionId,\n        PostRecordState.FAILED,\n        PostRecordResumeMode.NEW,\n      );\n      await addEvent(originRecord.id, PostEventType.POST_ATTEMPT_COMPLETED, account1);\n      await addEvent(originRecord.id, PostEventType.FILE_POSTED, account1, {\n        fileId: 'prior-file-1' as EntityId,\n      });\n\n      // Second attempt: CONTINUE from prior, posted to account2, then crashed\n      const runningRecord = await createPostRecordWithState(\n        submissionId,\n        PostRecordState.RUNNING,\n        PostRecordResumeMode.CONTINUE,\n        originRecord.id,\n      );\n      await addEvent(runningRecord.id, PostEventType.POST_ATTEMPT_COMPLETED, account2);\n      await addEvent(runningRecord.id, PostEventType.FILE_POSTED, account2, {\n        fileId: 'current-file-1' as EntityId,\n      });\n\n      const context = await factory.buildResumeContext(\n        submissionId,\n        runningRecord.id,\n        PostRecordResumeMode.CONTINUE,\n      );\n\n      // Should include completed accounts from both records\n      expect(context.completedAccountIds.size).toBe(2);\n      expect(context.completedAccountIds.has(account1)).toBe(true);\n      expect(context.completedAccountIds.has(account2)).toBe(true);\n\n      // Should include posted files from both records\n      expect(context.postedFilesByAccount.size).toBe(2);\n      expect(context.postedFilesByAccount.get(account1)?.has('prior-file-1' as EntityId)).toBe(true);\n      expect(context.postedFilesByAccount.get(account2)?.has('current-file-1' as EntityId)).toBe(true);\n    });\n\n    it('should preserve source URLs from RUNNING record in crash recovery', async () => {\n      const submissionId = await createSubmission();\n      const account1 = await createAccount('url-crash-account-1');\n\n      // Create RUNNING NEW record (origin with crash)\n      const runningRecord = await createPostRecordWithState(\n        submissionId,\n        PostRecordState.RUNNING,\n        PostRecordResumeMode.NEW,\n      );\n\n      await addEvent(runningRecord.id, PostEventType.FILE_POSTED, account1, {\n        fileId: 'url-file-1' as EntityId,\n        sourceUrl: 'https://example.com/posted-before-crash',\n      });\n\n      const context = await factory.buildResumeContext(\n        submissionId,\n        runningRecord.id,\n        PostRecordResumeMode.NEW,\n      );\n\n      // Source URLs should be preserved for NEW mode crash recovery\n      expect(context.sourceUrlsByAccount.size).toBe(1);\n      const urls = context.sourceUrlsByAccount.get(account1);\n      expect(urls).toContain('https://example.com/posted-before-crash');\n    });\n  });\n});\n"
  },
  {
    "path": "apps/client-server/src/app/post/services/post-record-factory/post-record-factory.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { Logger } from '@postybirb/logger';\nimport {\n  AccountId,\n  EntityId,\n  PostEventType,\n  PostRecordResumeMode,\n  PostRecordState,\n} from '@postybirb/types';\nimport { PostEvent, PostRecord } from '../../../drizzle/models';\nimport { PostyBirbDatabase } from '../../../drizzle/postybirb-database/postybirb-database';\nimport { InvalidPostChainError } from '../../errors';\nimport { PostEventRepository } from './post-event.repository';\n\n/**\n * Resume context containing information from a prior post attempt.\n * Used to determine what to skip or retry when resuming.\n * @interface ResumeContext\n */\nexport interface ResumeContext {\n  /**\n   * The resume mode used to create this context.\n   * @type {PostRecordResumeMode}\n   */\n  resumeMode: PostRecordResumeMode;\n\n  /**\n   * Account IDs that have already completed successfully.\n   * For CONTINUE_RETRY mode, these accounts should be skipped entirely.\n   * @type {Set<AccountId>}\n   */\n  completedAccountIds: Set<AccountId>;\n\n  /**\n   * Map of account ID to file IDs that have been successfully posted.\n   * For CONTINUE mode, these files should be skipped for each account.\n   * @type {Map<AccountId, Set<EntityId>>}\n   */\n  postedFilesByAccount: Map<AccountId, Set<EntityId>>;\n\n  /**\n   * Map of account ID to source URLs from prior successful posts.\n   * Used for cross-website source URL propagation.\n   * @type {Map<AccountId, string[]>}\n   */\n  sourceUrlsByAccount: Map<AccountId, string[]>;\n}\n\n/**\n * Factory service for creating PostRecord entities.\n * Handles resume mode logic by querying the event ledger.\n * @class PostRecordFactory\n */\n@Injectable()\nexport class PostRecordFactory {\n  private readonly logger = Logger(this.constructor.name);\n\n  private readonly postRecordRepository: PostyBirbDatabase<'PostRecordSchema'>;\n\n  constructor(private readonly postEventRepository: PostEventRepository) {\n    this.postRecordRepository = new PostyBirbDatabase('PostRecordSchema');\n  }\n\n  /**\n   * Create a new PostRecord for a submission.\n   *\n   * For NEW mode: Creates a fresh record with originPostRecordId = null (it IS the origin).\n   * For CONTINUE/RETRY: Finds the most recent NEW record for this submission and chains to it.\n   *\n   * @param {EntityId} submissionId - The submission ID\n   * @param {PostRecordResumeMode} resumeMode - The resume mode (defaults to NEW)\n   * @returns {Promise<PostRecord>} The created post record\n   * @throws {InvalidPostChainError} If CONTINUE/RETRY is requested but no valid origin exists,\n   *         or if a PostRecord is already PENDING or RUNNING for this submission\n   */\n  async create(\n    submissionId: EntityId,\n    resumeMode: PostRecordResumeMode = PostRecordResumeMode.NEW,\n  ): Promise<PostRecord> {\n    this.logger\n      .withMetadata({ submissionId, resumeMode })\n      .info('Creating post record');\n\n    // Guard: Prevent creating a new record if one is already in progress\n    const inProgressRecord = await this.findInProgressRecord(submissionId);\n    if (inProgressRecord) {\n      this.logger\n        .withMetadata({ existingRecordId: inProgressRecord.id, existingState: inProgressRecord.state })\n        .warn('Cannot create PostRecord: submission already has an in-progress record');\n      throw new InvalidPostChainError(submissionId, resumeMode, 'in_progress');\n    }\n\n    let originPostRecordId: EntityId | null = null;\n\n    if (resumeMode !== PostRecordResumeMode.NEW) {\n      // Find the most recent NEW record for this submission (no state filter)\n      const originRecord = await this.findMostRecentOrigin(submissionId);\n\n      if (!originRecord) {\n        throw new InvalidPostChainError(submissionId, resumeMode, 'no_origin');\n      }\n\n      if (originRecord.state === PostRecordState.DONE) {\n        throw new InvalidPostChainError(submissionId, resumeMode, 'origin_done');\n      }\n\n      originPostRecordId = originRecord.id;\n      this.logger\n        .withMetadata({ originPostRecordId })\n        .debug('Chaining to origin PostRecord');\n    }\n\n    return this.postRecordRepository.insert({\n      submissionId,\n      state: PostRecordState.PENDING,\n      resumeMode,\n      originPostRecordId,\n    });\n  }\n\n  /**\n   * Find the most recent NEW PostRecord for a submission.\n   * Used to determine the origin for CONTINUE/RETRY records.\n   *\n   * @param {EntityId} submissionId - The submission ID\n   * @returns {Promise<PostRecord | null>} The most recent NEW record, or null if none exists\n   */\n  private async findMostRecentOrigin(\n    submissionId: EntityId,\n  ): Promise<PostRecord | null> {\n    const records = await this.postRecordRepository.find({\n      where: (record, { eq, and }) =>\n        and(\n          eq(record.submissionId, submissionId),\n          eq(record.resumeMode, PostRecordResumeMode.NEW),\n        ),\n      orderBy: (record, { desc }) => desc(record.createdAt),\n      limit: 1,\n    });\n\n    return records.length > 0 ? records[0] : null;\n  }\n\n  /**\n   * Find any PENDING or RUNNING PostRecord for a submission.\n   * Used to prevent creating a new record when one is already in progress.\n   *\n   * @param {EntityId} submissionId - The submission ID\n   * @returns {Promise<PostRecord | null>} An in-progress record, or null if none exists\n   */\n  private async findInProgressRecord(\n    submissionId: EntityId,\n  ): Promise<PostRecord | null> {\n    const records = await this.postRecordRepository.find({\n      where: (record, { eq, and, or }) =>\n        and(\n          eq(record.submissionId, submissionId),\n          or(\n            eq(record.state, PostRecordState.PENDING),\n            eq(record.state, PostRecordState.RUNNING),\n          ),\n        ),\n      limit: 1,\n    });\n\n    return records.length > 0 ? records[0] : null;\n  }\n\n  // ========================================================================\n  // Resume Context Building\n  // ========================================================================\n\n  /**\n   * Build resume context from prior post records for the same submission.\n   *\n   * This method handles the chain of posting attempts correctly:\n   * - DONE records represent complete \"posting sessions\" - they act as stop points\n   * - FAILED records represent incomplete attempts that should be aggregated\n   * - RUNNING records (crash recovery) should aggregate their own events\n   *\n   * Logic:\n   * 1. Always include events from the current record being started (handles crash recovery)\n   * 2. If the most recent terminal is DONE → return empty context (nothing to continue, start fresh)\n   * 3. If the most recent terminal is FAILED → aggregate from FAILED records until we hit a DONE\n   *\n   * @param {EntityId} submissionId - The submission ID\n   * @param {EntityId} currentRecordId - The ID of the record being started\n   * @param {PostRecordResumeMode} resumeMode - The resume mode\n   * @returns {Promise<ResumeContext>} The resume context\n   */\n  async buildResumeContext(\n    submissionId: EntityId,\n    currentRecordId: EntityId,\n    resumeMode: PostRecordResumeMode,\n  ): Promise<ResumeContext> {\n    const context = this.createEmptyContext(resumeMode);\n\n    // First, always fetch the specific record we're starting.\n    // This handles crash recovery where the record is RUNNING (not terminal).\n    const currentRecord = await this.postRecordRepository.findById(\n      currentRecordId,\n      undefined,\n      { events: true },\n    );\n\n    // For NEW mode on a fresh record, return empty context\n    // But for crash recovery (RUNNING state), we still need to aggregate our own events\n    if (resumeMode === PostRecordResumeMode.NEW) {\n      if (currentRecord?.state === PostRecordState.RUNNING) {\n        // Crash recovery: aggregate events from this record regardless of resumeMode\n        this.logger.debug(\n          'NEW mode but RUNNING state (crash recovery) - aggregating own events',\n        );\n        this.aggregateFromRecords([currentRecord], context, true);\n      } else {\n        this.logger.debug('NEW mode - returning empty resume context');\n      }\n      return context;\n    }\n\n    // Get terminal records to aggregate based on the chain logic\n    const terminalRecords = await this.getRecordsToAggregate(submissionId);\n\n    // Combine: current record (if RUNNING) + terminal records (excluding duplicates)\n    const recordsToAggregate = this.combineRecordsForAggregation(\n      currentRecord,\n      terminalRecords,\n    );\n\n    if (recordsToAggregate.length === 0) {\n      this.logger.debug(\n        'No records to aggregate (fresh start or most recent was DONE)',\n      );\n      return context;\n    }\n\n    // Aggregate events based on resume mode\n    const includePostedFiles = resumeMode === PostRecordResumeMode.CONTINUE;\n    this.aggregateFromRecords(recordsToAggregate, context, includePostedFiles);\n\n    this.logger\n      .withMetadata({\n        completedAccountCount: context.completedAccountIds.size,\n        accountsWithPostedFiles: context.postedFilesByAccount.size,\n        aggregatedRecordCount: recordsToAggregate.length,\n        resumeMode,\n      })\n      .debug('Built resume context');\n\n    return context;\n  }\n\n  /**\n   * Combine the current record (if RUNNING) with terminal records, avoiding duplicates.\n   * This ensures crash recovery includes the RUNNING record's events.\n   */\n  private combineRecordsForAggregation(\n    currentRecord: PostRecord | null | undefined,\n    terminalRecords: PostRecord[],\n  ): PostRecord[] {\n    const result: PostRecord[] = [];\n    const seenIds = new Set<EntityId>();\n\n    // Add current record first if it's RUNNING (crash recovery case)\n    if (currentRecord?.state === PostRecordState.RUNNING) {\n      result.push(currentRecord);\n      seenIds.add(currentRecord.id);\n    }\n\n    // Add terminal records that weren't already added\n    for (const record of terminalRecords) {\n      if (!seenIds.has(record.id)) {\n        result.push(record);\n        seenIds.add(record.id);\n      }\n    }\n\n    return result;\n  }\n\n  /**\n   * Aggregate events from a list of records into the context.\n   */\n  private aggregateFromRecords(\n    records: PostRecord[],\n    context: ResumeContext,\n    includePostedFiles: boolean,\n  ): void {\n    this.aggregateSourceUrls(records, context);\n    this.aggregateCompletedAccounts(records, context);\n\n    if (includePostedFiles) {\n      this.aggregatePostedFiles(records, context);\n    }\n  }\n\n  /**\n   * Create an empty resume context with default values.\n   */\n  private createEmptyContext(resumeMode: PostRecordResumeMode): ResumeContext {\n    return {\n      resumeMode,\n      completedAccountIds: new Set<AccountId>(),\n      postedFilesByAccount: new Map<AccountId, Set<EntityId>>(),\n      sourceUrlsByAccount: new Map<AccountId, string[]>(),\n    };\n  }\n\n  /**\n   * Get the list of PostRecords whose events should be aggregated.\n   *\n   * Uses the originPostRecordId field to find all records in the same chain.\n   * A chain consists of:\n   * - The origin NEW record (originPostRecordId = null, resumeMode = NEW)\n   * - All CONTINUE/RETRY records that reference that origin\n   *\n   * @param {EntityId} submissionId - The submission ID\n   * @param {EntityId} [originId] - Optional origin ID to query directly\n   * @returns {Promise<PostRecord[]>} Records to aggregate (may be empty)\n   */\n  private async getRecordsToAggregate(\n    submissionId: EntityId,\n    originId?: EntityId,\n  ): Promise<PostRecord[]> {\n    // If no origin provided, find the most recent origin for this submission\n    let effectiveOriginId = originId;\n    if (!effectiveOriginId) {\n      const origin = await this.findMostRecentOrigin(submissionId);\n      if (!origin) {\n        this.logger.debug('No origin PostRecord found for submission');\n        return [];\n      }\n      effectiveOriginId = origin.id;\n    }\n\n    // Query all records in this chain: the origin + all records referencing it\n    const chainRecords = await this.postRecordRepository.find({\n      where: (record, { eq, or }) =>\n        or(\n          eq(record.id, effectiveOriginId),\n          eq(record.originPostRecordId, effectiveOriginId),\n        ),\n      orderBy: (record, { asc }) => asc(record.createdAt),\n      with: {\n        events: true,\n      },\n    });\n\n    this.logger\n      .withMetadata({\n        submissionId,\n        originId: effectiveOriginId,\n        chainRecordCount: chainRecords.length,\n        chainRecordIds: chainRecords.map((r) => r.id),\n      })\n      .debug('Retrieved chain records for aggregation');\n\n    return chainRecords;\n  }\n\n  /**\n   * Aggregate source URLs from events into the context.\n   * Source URLs are used for cross-website propagation.\n   */\n  private aggregateSourceUrls(\n    records: PostRecord[],\n    context: ResumeContext,\n  ): void {\n    for (const record of records) {\n      if (!record.events) continue;\n\n      for (const event of record.events) {\n        if (\n          this.isSourceUrlEvent(event) &&\n          event.accountId &&\n          event.sourceUrl\n        ) {\n          this.addSourceUrl(context, event.accountId, event.sourceUrl);\n        }\n      }\n    }\n  }\n\n  /**\n   * Aggregate completed account IDs from events into the context.\n   */\n  private aggregateCompletedAccounts(\n    records: PostRecord[],\n    context: ResumeContext,\n  ): void {\n    for (const record of records) {\n      if (!record.events) continue;\n\n      for (const event of record.events) {\n        if (\n          event.eventType === PostEventType.POST_ATTEMPT_COMPLETED &&\n          event.accountId\n        ) {\n          context.completedAccountIds.add(event.accountId);\n        }\n      }\n    }\n  }\n\n  /**\n   * Aggregate posted file IDs (per account) from events into the context.\n   */\n  private aggregatePostedFiles(\n    records: PostRecord[],\n    context: ResumeContext,\n  ): void {\n    for (const record of records) {\n      if (!record.events) continue;\n\n      for (const event of record.events) {\n        if (\n          event.eventType === PostEventType.FILE_POSTED &&\n          event.accountId &&\n          event.fileId\n        ) {\n          this.addPostedFile(context, event.accountId, event.fileId);\n        }\n      }\n    }\n  }\n\n  /**\n   * Check if an event contains a source URL (FILE_POSTED or MESSAGE_POSTED).\n   */\n  private isSourceUrlEvent(event: PostEvent): boolean {\n    return (\n      event.eventType === PostEventType.FILE_POSTED ||\n      event.eventType === PostEventType.MESSAGE_POSTED\n    );\n  }\n\n  /**\n   * Add a source URL to the context for an account.\n   */\n  private addSourceUrl(\n    context: ResumeContext,\n    accountId: AccountId,\n    sourceUrl: string,\n  ): void {\n    const existing = context.sourceUrlsByAccount.get(accountId);\n    if (existing) {\n      existing.push(sourceUrl);\n    } else {\n      context.sourceUrlsByAccount.set(accountId, [sourceUrl]);\n    }\n  }\n\n  /**\n   * Add a posted file to the context for an account.\n   */\n  private addPostedFile(\n    context: ResumeContext,\n    accountId: AccountId,\n    fileId: EntityId,\n  ): void {\n    const existing = context.postedFilesByAccount.get(accountId);\n    if (existing) {\n      existing.add(fileId);\n    } else {\n      context.postedFilesByAccount.set(accountId, new Set([fileId]));\n    }\n  }\n\n  // ========================================================================\n  // Resume Context Helpers\n  // ========================================================================\n\n  /**\n   * Check if an account should be skipped based on resume context.\n   * @param {ResumeContext} context - The resume context\n   * @param {AccountId} accountId - The account to check\n   * @returns {boolean} True if the account should be skipped\n   */\n  shouldSkipAccount(context: ResumeContext, accountId: AccountId): boolean {\n    return context.completedAccountIds.has(accountId);\n  }\n\n  /**\n   * Check if a file should be skipped for a specific account based on resume context.\n   * @param {ResumeContext} context - The resume context\n   * @param {AccountId} accountId - The account ID\n   * @param {EntityId} fileId - The file ID to check\n   * @returns {boolean} True if the file should be skipped\n   */\n  shouldSkipFile(\n    context: ResumeContext,\n    accountId: AccountId,\n    fileId: EntityId,\n  ): boolean {\n    // If account is completed, all files should be skipped (handled by shouldSkipAccount)\n    // This method is for checking individual files within a non-completed account\n\n    if (context.resumeMode === PostRecordResumeMode.NEW) {\n      return false;\n    }\n\n    if (context.resumeMode === PostRecordResumeMode.CONTINUE_RETRY) {\n      // CONTINUE_RETRY retries all files for non-completed accounts\n      return false;\n    }\n\n    // CONTINUE mode: skip files that were already posted\n    const postedFiles = context.postedFilesByAccount.get(accountId);\n    return postedFiles?.has(fileId) ?? false;\n  }\n\n  /**\n   * Get source URLs from prior attempts for cross-website propagation.\n   * @param {ResumeContext} context - The resume context\n   * @param {AccountId} accountId - The account to get URLs for\n   * @returns {string[]} Source URLs from the prior attempt\n   */\n  getSourceUrlsForAccount(\n    context: ResumeContext,\n    accountId: AccountId,\n  ): string[] {\n    return context.sourceUrlsByAccount.get(accountId) ?? [];\n  }\n\n  /**\n   * Get all source URLs from all accounts in the resume context.\n   * @param {ResumeContext} context - The resume context\n   * @returns {string[]} All source URLs\n   */\n  getAllSourceUrls(context: ResumeContext): string[] {\n    const allUrls: string[] = [];\n    for (const urls of context.sourceUrlsByAccount.values()) {\n      allUrls.push(...urls);\n    }\n    return allUrls;\n  }\n\n  /**\n   * Get all source URLs from the resume context, excluding a specific account.\n   * Used for cross-website source URL propagation to avoid self-referential URLs.\n   * @param {ResumeContext} context - The resume context\n   * @param {AccountId} excludeAccountId - The account ID to exclude\n   * @returns {string[]} Source URLs from all accounts except the excluded one\n   */\n  getSourceUrlsExcludingAccount(\n    context: ResumeContext,\n    excludeAccountId: AccountId,\n  ): string[] {\n    const allUrls: string[] = [];\n    for (const [accountId, urls] of context.sourceUrlsByAccount.entries()) {\n      if (accountId !== excludeAccountId) {\n        allUrls.push(...urls);\n      }\n    }\n    return allUrls;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/post-parsers/models/description-node/converters/base-converter.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { UsernameShortcut } from '@postybirb/types';\nimport { ConversionContext } from '../description-node.base';\nimport { InlineTypes, isTextNode, TipTapNode } from '../description-node.types';\n\n/**\n * Base converter for TipTap JSON → output format.\n *\n * Converters process TipTap nodes directly (no wrapper classes).\n * Block-level nodes are dispatched to `convertBlockNode`, inline shortcut\n * atoms to `convertInlineNode`, and text nodes to `convertTextNode`.\n */\nexport abstract class BaseConverter {\n  /** Current depth for nested block rendering */\n  protected currentDepth = 0;\n\n  /** Used to prevent loop when default shortcut is insert into default section */\n  private processingDefaultDescription = false;\n\n  abstract convertBlockNode(\n    node: TipTapNode,\n    context: ConversionContext,\n  ): string;\n\n  abstract convertInlineNode(\n    node: TipTapNode,\n    context: ConversionContext,\n  ): string;\n\n  abstract convertTextNode(\n    node: TipTapNode,\n    context: ConversionContext,\n  ): string;\n\n  /**\n   * Converts an array of top-level TipTap nodes (block nodes).\n   */\n  convertBlocks(nodes: TipTapNode[], context: ConversionContext): string {\n    const results = nodes.map((node) => this.convertBlockNode(node, context));\n    return results.join(this.getBlockSeparator());\n  }\n\n  /**\n   * Converts raw TipTap block data. Handles default description recursion guard.\n   */\n  convertRawBlocks(blocks: TipTapNode[], context: ConversionContext): string {\n    const isDefaultDescription = blocks === context.defaultDescription;\n    if (isDefaultDescription) {\n      if (this.processingDefaultDescription) {\n        return '';\n      }\n      this.processingDefaultDescription = true;\n    }\n\n    try {\n      return this.convertBlocks(blocks, context);\n    } finally {\n      if (isDefaultDescription) {\n        this.processingDefaultDescription = false;\n      }\n    }\n  }\n\n  /**\n   * Returns the separator to use between blocks.\n   */\n  protected abstract getBlockSeparator(): string;\n\n  /**\n   * Converts the `content` array of a block node.\n   * Dispatches each child to the appropriate handler based on type.\n   */\n  protected convertContent(\n    content: TipTapNode[] | undefined,\n    context: ConversionContext,\n  ): string {\n    if (!content || content.length === 0) return '';\n\n    return content\n      .map((child) => {\n        if (isTextNode(child)) {\n          return this.convertTextNode(child, context);\n        }\n        if (InlineTypes.includes(child.type)) {\n          return this.convertInlineNode(child, context);\n        }\n        // Nested block nodes (e.g., listItem content)\n        return this.convertBlockNode(child, context);\n      })\n      .join('');\n  }\n\n  /**\n   * Converts children blocks with increased depth.\n   */\n  protected convertChildren(\n    children: TipTapNode[],\n    context: ConversionContext,\n  ): string {\n    if (!children || children.length === 0) return '';\n\n    this.currentDepth += 1;\n    try {\n      const results = children.map((child) =>\n        this.convertBlockNode(child, context),\n      );\n      return results.join(this.getBlockSeparator());\n    } finally {\n      this.currentDepth -= 1;\n    }\n  }\n\n  /**\n   * Helper to check if a shortcut should be rendered for this website.\n   */\n  protected shouldRenderShortcut(\n    node: TipTapNode,\n    context: ConversionContext,\n  ): boolean {\n    const attrs = node.attrs ?? {};\n    const onlyTo = (attrs.only?.split(',') ?? [])\n      .map((s: string) => s.trim().toLowerCase())\n      .filter((s: string) => s.length > 0);\n\n    if (onlyTo.length === 0) return true;\n\n    return onlyTo.includes(context.website.toLowerCase());\n  }\n\n  /**\n   * Helper to resolve username shortcut link.\n   */\n  protected getUsernameShortcutLink(\n    node: TipTapNode,\n    context: ConversionContext,\n  ):\n    | {\n        url: string;\n        username: string;\n      }\n    | undefined {\n    const attrs = node.attrs ?? {};\n    const username = (attrs.username as string)?.trim() ?? '';\n\n    let convertedUsername = username;\n    let effectiveShortcutId = attrs.shortcut;\n\n    const converted = context.usernameConversions?.get(username);\n    if (converted && converted !== username) {\n      convertedUsername = converted;\n      effectiveShortcutId = context.website;\n    }\n\n    const shortcut: UsernameShortcut | undefined =\n      context.shortcuts[effectiveShortcutId];\n    const url =\n      shortcut?.convert?.call(node, context.website, effectiveShortcutId) ??\n      shortcut?.url;\n\n    return convertedUsername && url\n      ? {\n          url: url.replace('$1', convertedUsername),\n          username: convertedUsername,\n        }\n      : undefined;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/post-parsers/models/description-node/converters/bbcode-converter.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { ConversionContext } from '../description-node.base';\nimport { TipTapNode } from '../description-node.types';\nimport { BaseConverter } from './base-converter';\n\nexport class BBCodeConverter extends BaseConverter {\n  protected getBlockSeparator(): string {\n    return '\\n';\n  }\n\n  convertBlockNode(\n    node: TipTapNode,\n    context: ConversionContext,\n  ): string {\n    const attrs = node.attrs ?? {};\n\n    if (node.type === 'defaultShortcut') {\n      if (!this.shouldRenderShortcut(node, context)) return '';\n      return this.convertRawBlocks(context.defaultDescription, context);\n    }\n\n    // For FA: More than 5 dashes in a line are replaced with a horizontal divider.\n    if (node.type === 'horizontalRule') return '--------';\n\n    if (node.type === 'image') return '';\n    if (node.type === 'hardBreak') return '\\n';\n\n    // List containers\n    if (node.type === 'bulletList') {\n      return (node.content ?? [])\n        .map((child) => this.convertBlockNode(child, context))\n        .join('\\n');\n    }\n\n    if (node.type === 'orderedList') {\n      return (node.content ?? [])\n        .map((child) => this.convertBlockNode(child, context))\n        .join('\\n');\n    }\n\n    if (node.type === 'listItem') {\n      const inner = (node.content ?? [])\n        .map((child) => {\n          if (child.type === 'paragraph') {\n            return this.convertContent(child.content, context);\n          }\n          return this.convertBlockNode(child, context);\n        })\n        .join('');\n      return `• ${inner}`;\n    }\n\n    if (node.type === 'blockquote') {\n      const inner = (node.content ?? [])\n        .map((child) => this.convertBlockNode(child, context))\n        .join('\\n');\n      return `[quote]${inner}[/quote]`;\n    }\n\n    if (node.type === 'paragraph') {\n      let text = this.convertContent(node.content, context);\n\n      // Apply text alignment if not default\n      if (attrs.textAlign && attrs.textAlign !== 'left') {\n        text = `[${attrs.textAlign}]${text}[/${attrs.textAlign}]`;\n      }\n\n      // Apply indentation\n      if (attrs.indent && attrs.indent > 0) {\n        const spaces = '\\u00A0\\u00A0\\u00A0\\u00A0'.repeat(attrs.indent);\n        text = `${spaces}${text}`;\n      }\n\n      return text;\n    }\n\n    if (node.type === 'heading') {\n      const level = attrs.level ?? 1;\n      let text = `[h${level}]${this.convertContent(node.content, context)}[/h${level}]`;\n\n      if (attrs.textAlign && attrs.textAlign !== 'left') {\n        text = `[${attrs.textAlign}]${text}[/${attrs.textAlign}]`;\n      }\n\n      if (attrs.indent && attrs.indent > 0) {\n        const spaces = '\\u00A0\\u00A0\\u00A0\\u00A0'.repeat(attrs.indent);\n        text = `${spaces}${text}`;\n      }\n\n      return text;\n    }\n\n    // Fallback\n    return this.convertContent(node.content, context);\n  }\n\n  convertInlineNode(\n    node: TipTapNode,\n    context: ConversionContext,\n  ): string {\n    const attrs = node.attrs ?? {};\n\n    if (node.type === 'username') {\n      if (!this.shouldRenderShortcut(node, context)) return '';\n      const sc = this.getUsernameShortcutLink(node, context);\n      if (sc?.url.startsWith('http')) {\n        return `[url=${sc.url}]${sc.username}[/url]`;\n      }\n      return sc ? `${sc.url ?? sc.username}` : '';\n    }\n\n    if (node.type === 'customShortcut') {\n      if (!this.shouldRenderShortcut(node, context)) return '';\n      const shortcutBlocks = context.customShortcuts.get(attrs.id);\n      if (shortcutBlocks) {\n        return this.convertRawBlocks(shortcutBlocks, context);\n      }\n      return '';\n    }\n\n    if (node.type === 'titleShortcut') {\n      if (!this.shouldRenderShortcut(node, context)) return '';\n      return context.title ?? '';\n    }\n\n    if (node.type === 'tagsShortcut') {\n      if (!this.shouldRenderShortcut(node, context)) return '';\n      return context.tags?.map((e) => `#${e}`).join(' ') ?? '';\n    }\n\n    if (node.type === 'contentWarningShortcut') {\n      if (!this.shouldRenderShortcut(node, context)) return '';\n      return context.contentWarningText ?? '';\n    }\n\n    if (node.type === 'hardBreak') return '\\n';\n\n    return this.convertContent(node.content, context);\n  }\n\n  convertTextNode(\n    node: TipTapNode,\n    context: ConversionContext,\n  ): string {\n    const textNode = node as any;\n    if (!textNode.text) return '';\n\n    if (textNode.text === '\\n' || textNode.text === '\\r\\n') {\n      return '\\n';\n    }\n\n    const marks = textNode.marks ?? [];\n\n    // Check for link mark\n    const linkMark = marks.find((m: any) => m.type === 'link');\n    if (linkMark) {\n      const href = linkMark.attrs?.href ?? '';\n      const innerText = this.renderTextWithMarks(textNode.text, marks.filter((m: any) => m.type !== 'link'));\n      return `[url=${href}]${innerText}[/url]`;\n    }\n\n    return this.renderTextWithMarks(textNode.text, marks);\n  }\n\n  /**\n   * Renders text with BBCode formatting marks applied.\n   */\n  private renderTextWithMarks(text: string, marks: any[]): string {\n    const segments: string[] = [];\n\n    for (const mark of marks) {\n      switch (mark.type) {\n        case 'bold':\n          segments.push('b');\n          break;\n        case 'italic':\n          segments.push('i');\n          break;\n        case 'underline':\n          segments.push('u');\n          break;\n        case 'strike':\n          segments.push('s');\n          break;\n        default:\n          break;\n      }\n    }\n\n    if (!segments.length) {\n      return text;\n    }\n\n    let segmentedText = `${segments.map((e) => `[${e}]`).join('')}${text}${segments\n      .reverse()\n      .map((e) => `[/${e}]`)\n      .join('')}`;\n\n    // Check for textStyle mark with color\n    const textStyleMark = marks.find((m: any) => m.type === 'textStyle');\n    if (textStyleMark?.attrs?.color) {\n      segmentedText = `[color=${textStyleMark.attrs.color}]${segmentedText}[/color]`;\n    }\n\n    return segmentedText;\n  }\n}\n\n"
  },
  {
    "path": "apps/client-server/src/app/post-parsers/models/description-node/converters/custom-converter.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { ConversionContext } from '../description-node.base';\nimport { TipTapNode } from '../description-node.types';\nimport { BaseConverter } from './base-converter';\n\nexport type CustomNodeHandler = (\n  node: TipTapNode,\n  context: ConversionContext,\n) => string;\n\n/**\n * Converter that uses custom handlers for each node type.\n * This allows websites like Tumblr to inject their own conversion logic.\n */\nexport class CustomConverter extends BaseConverter {\n  constructor(\n    private readonly blockHandler: CustomNodeHandler,\n    private readonly inlineHandler?: CustomNodeHandler,\n    private readonly textHandler?: CustomNodeHandler,\n  ) {\n    super();\n  }\n\n  protected getBlockSeparator(): string {\n    return '\\n';\n  }\n\n  convertBlockNode(\n    node: TipTapNode,\n    context: ConversionContext,\n  ): string {\n    return this.blockHandler(node, context);\n  }\n\n  convertInlineNode(\n    node: TipTapNode,\n    context: ConversionContext,\n  ): string {\n    if (this.inlineHandler) {\n      return this.inlineHandler(node, context);\n    }\n    return this.convertContent(node.content, context);\n  }\n\n  convertTextNode(\n    node: TipTapNode,\n    context: ConversionContext,\n  ): string {\n    if (this.textHandler) {\n      return this.textHandler(node, context);\n    }\n    return (node as any).text ?? '';\n  }\n}\n\n"
  },
  {
    "path": "apps/client-server/src/app/post-parsers/models/description-node/converters/html-converter.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { encode } from 'html-entities';\nimport { ConversionContext } from '../description-node.base';\nimport { TipTapNode } from '../description-node.types';\nimport { BaseConverter } from './base-converter';\n\nexport class HtmlConverter extends BaseConverter {\n  protected getBlockSeparator(): string {\n    return '';\n  }\n\n  convertBlockNode(\n    node: TipTapNode,\n    context: ConversionContext,\n  ): string {\n    const attrs = node.attrs ?? {};\n\n    // Handle special block types\n    if (node.type === 'defaultShortcut') {\n      if (!this.shouldRenderShortcut(node, context)) return '';\n      return this.convertRawBlocks(context.defaultDescription, context);\n    }\n\n    if (node.type === 'horizontalRule') return '<hr>';\n    if (node.type === 'image') return this.convertImage(node);\n    if (node.type === 'hardBreak') return '<br>';\n\n    // List containers: render as <ul>/<ol> wrapping children\n    if (node.type === 'bulletList') {\n      const items = (node.content ?? [])\n        .map((child) => this.convertBlockNode(child, context))\n        .join('');\n      return `<ul>${items}</ul>`;\n    }\n\n    if (node.type === 'orderedList') {\n      const items = (node.content ?? [])\n        .map((child) => this.convertBlockNode(child, context))\n        .join('');\n      return `<ol>${items}</ol>`;\n    }\n\n    if (node.type === 'listItem') {\n      // listItem content is typically [paragraph, ...]. Render inline content of inner paragraphs.\n      const inner = (node.content ?? [])\n        .map((child) => {\n          if (child.type === 'paragraph') {\n            return this.convertContent(child.content, context);\n          }\n          return this.convertBlockNode(child, context);\n        })\n        .join('');\n      return `<li>${inner}</li>`;\n    }\n\n    if (node.type === 'blockquote') {\n      const inner = (node.content ?? [])\n        .map((child) => this.convertBlockNode(child, context))\n        .join('');\n      return `<blockquote>${inner}</blockquote>`;\n    }\n\n    // Regular blocks: paragraph, heading, etc.\n    const tag = this.getBlockTag(node);\n    const styles = this.getBlockStyles(node);\n    const content = this.convertContent(node.content, context);\n\n    return `<${tag}${styles ? ` style=\"${styles}\"` : ''}>${content}</${tag}>`;\n  }\n\n  convertInlineNode(\n    node: TipTapNode,\n    context: ConversionContext,\n  ): string {\n    const attrs = node.attrs ?? {};\n\n    if (node.type === 'username') {\n      if (!this.shouldRenderShortcut(node, context)) return '';\n      const sc = this.getUsernameShortcutLink(node, context);\n      if (!sc) return '';\n      if (!sc.url.startsWith('http')) return `<span>${sc.url}</span>`;\n      return `<a target=\"_blank\" href=\"${sc.url}\">${sc.username}</a>`;\n    }\n\n    if (node.type === 'customShortcut') {\n      if (!this.shouldRenderShortcut(node, context)) return '';\n      const shortcutBlocks = context.customShortcuts.get(attrs.id);\n      if (shortcutBlocks) {\n        return this.convertRawBlocks(shortcutBlocks, context);\n      }\n      return '';\n    }\n\n    if (node.type === 'titleShortcut') {\n      if (!this.shouldRenderShortcut(node, context)) return '';\n      return context.title\n        ? `<span>${encode(context.title, { level: 'html5' })}</span>`\n        : '';\n    }\n\n    if (node.type === 'tagsShortcut') {\n      if (!this.shouldRenderShortcut(node, context)) return '';\n      return context.tags?.length\n        ? `<span>${context.tags.map((t) => encode(`#${t}`, { level: 'html5' })).join(' ')}</span>`\n        : '';\n    }\n\n    if (node.type === 'contentWarningShortcut') {\n      if (!this.shouldRenderShortcut(node, context)) return '';\n      return context.contentWarningText\n        ? `<span>${encode(context.contentWarningText, { level: 'html5' })}</span>`\n        : '';\n    }\n\n    if (node.type === 'hardBreak') return '<br>';\n\n    // Fallback: render content\n    const content = this.convertContent(node.content, context);\n    return content ? `<span>${content}</span>` : '';\n  }\n\n  convertTextNode(\n    node: TipTapNode,\n    context: ConversionContext,\n  ): string {\n    const textNode = node as any;\n    if (!textNode.text) return '';\n\n    // Handle line breaks from merged blocks\n    if (textNode.text === '\\n' || textNode.text === '\\r\\n') {\n      return '<br>';\n    }\n\n    const marks = textNode.marks ?? [];\n    const segments: string[] = [];\n    const styles: string[] = [];\n\n    // Check for link mark — wrap entire text in <a>\n    const linkMark = marks.find((m: any) => m.type === 'link');\n    if (linkMark) {\n      const href = linkMark.attrs?.href ?? '';\n      const innerHtml = this.renderTextWithMarks(textNode.text, marks.filter((m: any) => m.type !== 'link'));\n      return `<a target=\"_blank\" href=\"${href}\">${innerHtml}</a>`;\n    }\n\n    return this.renderTextWithMarks(textNode.text, marks);\n  }\n\n  /**\n   * Renders text with formatting marks (bold, italic, etc.) applied.\n   */\n  private renderTextWithMarks(text: string, marks: any[]): string {\n    const segments: string[] = [];\n    const styles: string[] = [];\n\n    for (const mark of marks) {\n      switch (mark.type) {\n        case 'bold':\n          segments.push('b');\n          break;\n        case 'italic':\n          segments.push('i');\n          break;\n        case 'underline':\n          segments.push('u');\n          break;\n        case 'strike':\n          segments.push('s');\n          break;\n        default:\n          break;\n      }\n    }\n\n    // Check for textStyle mark with color\n    const textStyleMark = marks.find((m: any) => m.type === 'textStyle');\n    if (textStyleMark?.attrs?.color) {\n      styles.push(`color: ${textStyleMark.attrs.color}`);\n    }\n\n    const encodedText = encode(text, { level: 'html5' }).replace(/\\n/g, '<br />');\n\n    if (!segments.length && !styles.length) {\n      return encodedText;\n    }\n\n    const stylesString = styles.join(';');\n    return `<span${\n      stylesString.length ? ` style=\"${stylesString}\"` : ''\n    }>${segments.map((s) => `<${s}>`).join('')}${encodedText}${segments\n      .reverse()\n      .map((s) => `</${s}>`)\n      .join('')}</span>`;\n  }\n\n  private getBlockTag(node: TipTapNode): string {\n    const attrs = node.attrs ?? {};\n    if (node.type === 'paragraph') return 'div';\n    if (node.type === 'heading') return `h${attrs.level ?? 1}`;\n    return 'div';\n  }\n\n  private getBlockStyles(node: TipTapNode): string {\n    const attrs = node.attrs ?? {};\n    const styles: string[] = [];\n\n    if (\n      attrs.textAlign &&\n      attrs.textAlign !== 'left'\n    ) {\n      styles.push(`text-align: ${attrs.textAlign}`);\n    }\n\n    if (attrs.indent && attrs.indent > 0) {\n      styles.push(`margin-left: ${attrs.indent * 2}em`);\n    }\n\n    return styles.join(';');\n  }\n\n  private convertImage(node: TipTapNode): string {\n    const attrs = node.attrs ?? {};\n    const src = attrs.src || '';\n    const alt = attrs.alt || '';\n    const width = attrs.width || '';\n    const height = attrs.height || '';\n\n    let imgTag = `<img src=\"${src}\" alt=\"${alt}\"`;\n    if (width) imgTag += ` width=\"${width}\"`;\n    if (height) imgTag += ` height=\"${height}\"`;\n    imgTag += '>';\n\n    return `<div>${imgTag}</div>`;\n  }\n}\n\n"
  },
  {
    "path": "apps/client-server/src/app/post-parsers/models/description-node/converters/npf-converter.spec.ts",
    "content": "import { NPFTextBlock, TipTapNode } from '@postybirb/types';\nimport { ConversionContext } from '../description-node.base';\nimport { NpfConverter } from './npf-converter';\n\ndescribe('NpfConverter', () => {\n  let converter: NpfConverter;\n  let context: ConversionContext;\n\n  beforeEach(() => {\n    converter = new NpfConverter();\n    context = {\n      website: 'tumblr',\n      shortcuts: {},\n      customShortcuts: new Map(),\n      defaultDescription: [],\n    };\n  });\n\n  describe('convertBlockNode', () => {\n    it('should convert a simple paragraph to NPF text block', () => {\n      const node: TipTapNode = {\n        type: 'paragraph',\n        content: [\n          {\n            type: 'text',\n            text: 'Hello, World!',\n          },\n        ],\n      };\n\n      const resultJson = converter.convertBlockNode(node, context);\n      const result = JSON.parse(resultJson);\n\n      expect(result).toEqual({\n        type: 'text',\n        text: 'Hello, World!',\n      });\n    });\n\n    it('should convert a paragraph with bold text to NPF with formatting', () => {\n      const node: TipTapNode = {\n        type: 'paragraph',\n        content: [\n          {\n            type: 'text',\n            text: 'This is ',\n          },\n          {\n            type: 'text',\n            text: 'bold',\n            marks: [{ type: 'bold' }],\n          },\n          {\n            type: 'text',\n            text: ' text',\n          },\n        ],\n      };\n\n      const resultJson = converter.convertBlockNode(node, context);\n      const result = JSON.parse(resultJson) as NPFTextBlock;\n\n      expect(result.type).toBe('text');\n      expect(result.text).toBe('This is bold text');\n      expect(result.formatting).toEqual([\n        {\n          start: 8,\n          end: 12,\n          type: 'bold',\n        },\n      ]);\n    });\n\n    it('should convert a paragraph with link mark to NPF with link formatting', () => {\n      const node: TipTapNode = {\n        type: 'paragraph',\n        content: [\n          {\n            type: 'text',\n            text: 'Visit ',\n          },\n          {\n            type: 'text',\n            text: 'PostyBirb',\n            marks: [\n              {\n                type: 'link',\n                attrs: { href: 'https://postybirb.com' },\n              },\n            ],\n          },\n        ],\n      };\n\n      const resultJson = converter.convertBlockNode(node, context);\n      const result = JSON.parse(resultJson) as NPFTextBlock;\n\n      expect(result.type).toBe('text');\n      expect(result.text).toBe('Visit PostyBirb');\n      expect(result.formatting).toEqual([\n        {\n          start: 6,\n          end: 15,\n          type: 'link',\n          url: 'https://postybirb.com',\n        },\n      ]);\n    });\n\n    it('should convert heading to NPF text block with subtype', () => {\n      const node: TipTapNode = {\n        type: 'heading',\n        attrs: { level: 1 },\n        content: [\n          {\n            type: 'text',\n            text: 'My Heading',\n          },\n        ],\n      };\n\n      const resultJson = converter.convertBlockNode(node, context);\n      const result = JSON.parse(resultJson);\n\n      expect(result).toEqual({\n        type: 'text',\n        text: 'My Heading',\n        subtype: 'heading1',\n      });\n    });\n\n    it('should convert image to NPF image block', () => {\n      const node: TipTapNode = {\n        type: 'image',\n        attrs: {\n          src: 'https://example.com/image.jpg',\n          alt: 'My Image',\n          width: 800,\n          height: 600,\n        },\n      };\n\n      const resultJson = converter.convertBlockNode(node, context);\n      const result = JSON.parse(resultJson);\n\n      expect(result).toEqual({\n        type: 'image',\n        media: [\n          {\n            url: 'https://example.com/image.jpg',\n            type: 'image/jpeg',\n            width: 800,\n            height: 600,\n          },\n        ],\n        alt_text: 'My Image',\n      });\n    });\n\n    it('should handle multiple formatting types on same text', () => {\n      const node: TipTapNode = {\n        type: 'paragraph',\n        content: [\n          {\n            type: 'text',\n            text: 'Bold and Italic',\n            marks: [{ type: 'bold' }, { type: 'italic' }],\n          },\n        ],\n      };\n\n      const resultJson = converter.convertBlockNode(node, context);\n      const result = JSON.parse(resultJson) as NPFTextBlock;\n\n      expect(result.type).toBe('text');\n      expect(result.text).toBe('Bold and Italic');\n      expect(result.formatting).toEqual([\n        {\n          start: 0,\n          end: 15,\n          type: 'bold',\n        },\n        {\n          start: 0,\n          end: 15,\n          type: 'italic',\n        },\n      ]);\n    });\n\n    it('should handle custom shortcuts', () => {\n      const shortcutContent: TipTapNode[] = [\n        {\n          type: 'paragraph',\n          content: [\n            {\n              type: 'text',\n              text: 'Commission Info',\n              marks: [{ type: 'bold' }],\n            },\n          ],\n        },\n      ];\n\n      context.customShortcuts.set('cs-1', shortcutContent);\n\n      const node: TipTapNode = {\n        type: 'paragraph',\n        content: [\n          {\n            type: 'text',\n            text: 'Check out my ',\n          },\n          {\n            type: 'customShortcut',\n            attrs: { id: 'cs-1' },\n          },\n        ],\n      };\n\n      const resultJson = converter.convertBlockNode(node, context);\n      const result = JSON.parse(resultJson) as NPFTextBlock;\n\n      expect(result.type).toBe('text');\n      expect(result.text).toBe('Check out my Commission Info');\n      expect(result.formatting).toEqual([\n        {\n          start: 13,\n          end: 28,\n          type: 'bold',\n        },\n      ]);\n    });\n\n    it('should handle double spaces', () => {\n      const shortcutContent: TipTapNode[] = [\n        {\n          type: 'paragraph',\n          content: [\n            {\n              type: 'text',\n              text: 'Commission Info',\n              marks: [{ type: 'bold' }],\n            },\n          ],\n        },\n      ];\n\n      context.customShortcuts.set('cs-1', shortcutContent);\n\n      const nodes: TipTapNode[] = [\n        {\n          type: 'paragraph',\n          content: [{ type: 'text', text: 'aaa' }],\n        },\n        {\n          type: 'defaultShortcut',\n        },\n        {\n          type: 'paragraph',\n          content: [],\n        },\n        {\n          type: 'paragraph',\n          content: [{ type: 'text', text: 'aaa' }],\n        },\n      ];\n\n      const resultJson = converter.convertBlocks(nodes, context);\n      const result = JSON.parse(resultJson) as NPFTextBlock;\n\n      expect(result).toMatchInlineSnapshot(`\n        [\n          {\n            \"text\": \"aaa\",\n            \"type\": \"text\",\n          },\n          {\n            \"text\": \"\",\n            \"type\": \"text\",\n          },\n          {\n            \"text\": \"aaa\",\n            \"type\": \"text\",\n          },\n        ]\n      `);\n    });\n  });\n});\n"
  },
  {
    "path": "apps/client-server/src/app/post-parsers/models/description-node/converters/npf-converter.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport {\n  NPFContentBlock,\n  NPFImageBlock,\n  NPFInlineFormatting,\n  NPFMediaObject,\n  NPFTextBlock,\n} from '@postybirb/types';\nimport { ConversionContext } from '../description-node.base';\nimport { isTextNode, TipTapMark, TipTapNode } from '../description-node.types';\nimport { BaseConverter } from './base-converter';\n\n/**\n * Converter that outputs Tumblr NPF (Nuevo Post Format) blocks.\n */\nexport class NpfConverter extends BaseConverter {\n  private currentFormatting: NPFInlineFormatting[] = [];\n\n  private currentPosition = 0;\n\n  private blocks: NPFContentBlock[] = [];\n\n  protected getBlockSeparator(): string {\n    return '';\n  }\n\n  /**\n   * Override convertBlocks to accumulate NPF blocks and return JSON.\n   */\n  convertBlocks(nodes: TipTapNode[], context: ConversionContext): string {\n    this.blocks = [];\n    for (const node of nodes) {\n      this.convertBlockNodeRecursive(node, context, 0);\n    }\n    return JSON.stringify(this.blocks);\n  }\n\n  /**\n   * Recursively converts a block node and its children to NPF blocks.\n   */\n  private convertBlockNodeRecursive(\n    node: TipTapNode,\n    context: ConversionContext,\n    indentLevel: number,\n  ): void {\n    if (node.type === 'defaultShortcut') {\n      if (!this.shouldRenderShortcut(node, context)) return;\n      if (context.defaultDescription && context.defaultDescription.length > 0) {\n        for (const defaultBlock of context.defaultDescription) {\n          this.convertBlockNodeRecursive(defaultBlock, context, indentLevel);\n        }\n      }\n      return;\n    }\n\n    // Handle list containers — recurse into items\n    if (node.type === 'bulletList') {\n      for (const item of node.content ?? []) {\n        this.convertListItemToNpf(\n          item,\n          context,\n          'unordered-list-item',\n          indentLevel,\n        );\n      }\n      return;\n    }\n\n    if (node.type === 'orderedList') {\n      for (const item of node.content ?? []) {\n        this.convertListItemToNpf(\n          item,\n          context,\n          'ordered-list-item',\n          indentLevel,\n        );\n      }\n      return;\n    }\n\n    if (node.type === 'blockquote') {\n      for (const child of node.content ?? []) {\n        const block = this.convertBlockNodeToNpf(child, context);\n        if (block.type === 'text') {\n          block.subtype = 'quote';\n        }\n        this.blocks.push(block);\n      }\n      return;\n    }\n\n    const block = this.convertBlockNodeToNpf(node, context);\n\n    // Apply indent_level for nested blocks (NPF supports 0-7)\n    if (indentLevel > 0 && block.type === 'text' && indentLevel <= 7) {\n      block.indent_level = indentLevel;\n      if (!block.subtype) {\n        block.subtype = 'indented';\n      }\n    }\n\n    this.blocks.push(block);\n  }\n\n  /**\n   * Convert a listItem node to NPF text block with appropriate subtype.\n   */\n  private convertListItemToNpf(\n    node: TipTapNode,\n    context: ConversionContext,\n    subtype: 'ordered-list-item' | 'unordered-list-item',\n    indentLevel: number,\n  ): void {\n    // listItem typically contains [paragraph, ...nested lists]\n    for (const child of node.content ?? []) {\n      if (child.type === 'paragraph') {\n        this.currentFormatting = [];\n        this.currentPosition = 0;\n        const text = this.extractText(child.content ?? [], context);\n        const npfBlock: NPFTextBlock = {\n          type: 'text',\n          text,\n          subtype,\n          formatting:\n            this.currentFormatting.length > 0\n              ? this.currentFormatting\n              : undefined,\n        };\n        if (indentLevel > 0 && indentLevel <= 7) {\n          npfBlock.indent_level = indentLevel;\n        }\n        this.blocks.push(npfBlock);\n      } else if (child.type === 'bulletList') {\n        for (const item of child.content ?? []) {\n          this.convertListItemToNpf(\n            item,\n            context,\n            'unordered-list-item',\n            indentLevel + 1,\n          );\n        }\n      } else if (child.type === 'orderedList') {\n        for (const item of child.content ?? []) {\n          this.convertListItemToNpf(\n            item,\n            context,\n            'ordered-list-item',\n            indentLevel + 1,\n          );\n        }\n      } else {\n        this.convertBlockNodeRecursive(child, context, indentLevel);\n      }\n    }\n  }\n\n  /**\n   * Stub method required by BaseConverter interface.\n   */\n  convertBlockNode(node: TipTapNode, context: ConversionContext): string {\n    const block = this.convertBlockNodeToNpf(node, context);\n    return JSON.stringify(block);\n  }\n\n  /**\n   * Internal method that returns a single NPF block.\n   */\n  private convertBlockNodeToNpf(\n    node: TipTapNode,\n    context: ConversionContext,\n  ): NPFContentBlock {\n    this.currentFormatting = [];\n    this.currentPosition = 0;\n\n    switch (node.type) {\n      case 'paragraph':\n        return this.convertParagraph(node, context);\n      case 'heading':\n        return this.convertHeading(node, context);\n      case 'image':\n        return this.convertImage(node);\n      case 'horizontalRule':\n        return { type: 'text', text: '' };\n      case 'defaultShortcut':\n        return { type: 'text', text: '' };\n      default:\n        return this.convertParagraph(node, context);\n    }\n  }\n\n  private convertParagraph(\n    node: TipTapNode,\n    context: ConversionContext,\n  ): NPFTextBlock {\n    const text = this.extractText(node.content ?? [], context);\n    const formatting =\n      this.currentFormatting.length > 0 ? this.currentFormatting : undefined;\n    return { type: 'text', text, formatting };\n  }\n\n  private convertHeading(\n    node: TipTapNode,\n    context: ConversionContext,\n  ): NPFTextBlock {\n    const text = this.extractText(node.content ?? [], context);\n    const level = parseInt(node.attrs?.level || '1', 10);\n    const subtype = level === 1 ? 'heading1' : 'heading2';\n    return {\n      type: 'text',\n      text,\n      subtype,\n      formatting:\n        this.currentFormatting.length > 0 ? this.currentFormatting : undefined,\n    };\n  }\n\n  private convertImage(node: TipTapNode): NPFImageBlock {\n    const attrs = node.attrs ?? {};\n    const url = attrs.src || '';\n    const alt = attrs.alt || '';\n    const width = attrs.width ? parseInt(String(attrs.width), 10) : undefined;\n    const height = attrs.height\n      ? parseInt(String(attrs.height), 10)\n      : undefined;\n\n    const media: NPFMediaObject[] = [\n      {\n        url,\n        type: this.getMimeType(url),\n        width: width || undefined,\n        height: height || undefined,\n      },\n    ];\n\n    const imageBlock: NPFImageBlock = {\n      type: 'image',\n      media,\n      alt_text: alt || undefined,\n    };\n\n    return imageBlock;\n  }\n\n  convertInlineNode(node: TipTapNode, context: ConversionContext): string {\n    const attrs = node.attrs ?? {};\n    const startPos = this.currentPosition;\n    let text = '';\n\n    if (node.type === 'customShortcut') {\n      if (!this.shouldRenderShortcut(node, context)) return '';\n      const shortcutBlocks = context.customShortcuts.get(attrs.id);\n      if (shortcutBlocks) {\n        for (const block of shortcutBlocks) {\n          text += this.extractText(block.content ?? [], context);\n        }\n      }\n      return text;\n    }\n\n    if (node.type === 'username') {\n      if (!this.shouldRenderShortcut(node, context)) return '';\n      const sc = this.getUsernameShortcutLink(node, context);\n      if (!sc) {\n        text = attrs.username ?? '';\n      } else if (!sc.url.startsWith('http')) {\n        text = sc.url;\n      } else {\n        text = sc.username;\n        if (text.length > 0) {\n          this.currentFormatting.push({\n            start: startPos,\n            end: startPos + text.length,\n            type: 'link',\n            url: sc.url,\n          });\n        }\n      }\n      this.currentPosition += text.length;\n      return text;\n    }\n\n    if (node.type === 'titleShortcut') {\n      if (!this.shouldRenderShortcut(node, context)) return '';\n      text = context.title ?? '';\n      this.currentPosition += text.length;\n      this.addFormattingForMarks(node.marks, startPos, this.currentPosition);\n      return text;\n    }\n\n    if (node.type === 'tagsShortcut') {\n      if (!this.shouldRenderShortcut(node, context)) return '';\n      text = context.tags?.map((e) => `#${e}`).join(' ') ?? '';\n      this.currentPosition += text.length;\n      this.addFormattingForMarks(node.marks, startPos, this.currentPosition);\n      return text;\n    }\n\n    if (node.type === 'contentWarningShortcut') {\n      if (!this.shouldRenderShortcut(node, context)) return '';\n      text = context.contentWarningText ?? '';\n      this.currentPosition += text.length;\n      this.addFormattingForMarks(node.marks, startPos, this.currentPosition);\n      return text;\n    }\n\n    if (node.type === 'hardBreak') {\n      text = '\\n';\n      this.currentPosition += text.length;\n      return text;\n    }\n\n    // Generic inline: extract text from content\n    text = this.extractText(node.content ?? [], context);\n    return text;\n  }\n\n  convertTextNode(node: TipTapNode): string {\n    const text = node.text ?? '';\n    const startPos = this.currentPosition;\n    this.currentPosition += text.length;\n\n    this.addFormattingForMarks(node.marks, startPos, this.currentPosition);\n\n    return text;\n  }\n\n  /**\n   * Extracts plain text from content array and builds formatting.\n   */\n  private extractText(\n    content: TipTapNode[],\n    context: ConversionContext,\n  ): string {\n    let text = '';\n    for (const node of content) {\n      if (isTextNode(node)) {\n        text += this.convertTextNode(node);\n      } else {\n        text += this.convertInlineNode(node, context);\n      }\n    }\n    return text;\n  }\n\n  /**\n   * Adds formatting entries for marks on a text node.\n   */\n  private addFormattingForMarks(\n    marks: TipTapMark[],\n    start: number,\n    end: number,\n  ): void {\n    if (!marks || start === end) return;\n\n    for (const mark of marks) {\n      switch (mark.type) {\n        case 'bold':\n          this.currentFormatting.push({ start, end, type: 'bold' });\n          break;\n        case 'italic':\n          this.currentFormatting.push({ start, end, type: 'italic' });\n          break;\n        case 'strike':\n          this.currentFormatting.push({\n            start,\n            end,\n            type: 'strikethrough',\n          });\n          break;\n        case 'textStyle':\n          if (mark.attrs?.color) {\n            this.currentFormatting.push({\n              start,\n              end,\n              type: 'color',\n              hex: mark.attrs.color,\n            });\n          }\n          break;\n        case 'link':\n          if (typeof mark.attrs?.href === 'string')\n            this.currentFormatting.push({\n              start,\n              end,\n              type: 'link',\n              url: mark.attrs.href,\n            });\n          break;\n        default:\n          break;\n      }\n    }\n  }\n\n  /**\n   * Gets MIME type from file URL.\n   */\n  private getMimeType(url: string): string | undefined {\n    const ext = url.split('.').pop()?.toLowerCase();\n    const mimeMap: Record<string, string> = {\n      jpg: 'image/jpeg',\n      jpeg: 'image/jpeg',\n      png: 'image/png',\n      gif: 'image/gif',\n      webp: 'image/webp',\n      mp4: 'video/mp4',\n      webm: 'video/webm',\n      mp3: 'audio/mp3',\n      ogg: 'audio/ogg',\n      wav: 'audio/wav',\n    };\n    return ext ? mimeMap[ext] : undefined;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/post-parsers/models/description-node/converters/plaintext-converter.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { ConversionContext } from '../description-node.base';\nimport { TipTapNode } from '../description-node.types';\nimport { BaseConverter } from './base-converter';\n\nexport class PlainTextConverter extends BaseConverter {\n  protected getBlockSeparator(): string {\n    return '\\r\\n';\n  }\n\n  convertBlockNode(node: TipTapNode, context: ConversionContext): string {\n    if (node.type === 'defaultShortcut') {\n      if (!this.shouldRenderShortcut(node, context)) return '';\n      return this.convertRawBlocks(context.defaultDescription, context);\n    }\n\n    if (node.type === 'horizontalRule') return '----------';\n    if (node.type === 'image') return '';\n    if (node.type === 'hardBreak') return '\\r\\n';\n\n    // List containers\n    if (node.type === 'bulletList' || node.type === 'orderedList') {\n      return (node.content ?? [])\n        .map((child) => this.convertBlockNode(child, context))\n        .join('\\r\\n');\n    }\n\n    if (node.type === 'listItem') {\n      const inner = (node.content ?? [])\n        .map((child) => {\n          if (child.type === 'paragraph') {\n            return this.convertContent(child.content, context);\n          }\n          return this.convertBlockNode(child, context);\n        })\n        .join('');\n      return `- ${inner}`;\n    }\n\n    if (node.type === 'blockquote') {\n      return (node.content ?? [])\n        .map((child) => {\n          const text = this.convertBlockNode(child, context);\n          return `> ${text}`;\n        })\n        .join('\\r\\n');\n    }\n\n    // Indent paragraph/heading content\n    if (node.type === 'paragraph' || node.type === 'heading') {\n      const attrs = node.attrs ?? {};\n      let text = this.convertContent(node.content, context);\n      if (attrs.indent && attrs.indent > 0) {\n        const spaces = '    '.repeat(attrs.indent);\n        text = `${spaces}${text}`;\n      }\n      return text;\n    }\n\n    return this.convertContent(node.content, context);\n  }\n\n  convertInlineNode(node: TipTapNode, context: ConversionContext): string {\n    const attrs = node.attrs ?? {};\n\n    if (node.type === 'username') {\n      if (!this.shouldRenderShortcut(node, context)) return '';\n      const sc = this.getUsernameShortcutLink(node, context);\n      return sc ? sc.url : '';\n    }\n\n    if (node.type === 'customShortcut') {\n      if (!this.shouldRenderShortcut(node, context)) return '';\n      const shortcutBlocks = context.customShortcuts.get(attrs.id);\n      if (shortcutBlocks) {\n        return this.convertRawBlocks(shortcutBlocks, context);\n      }\n      return '';\n    }\n\n    if (node.type === 'titleShortcut') {\n      if (!this.shouldRenderShortcut(node, context)) return '';\n      return context.title ?? '';\n    }\n\n    if (node.type === 'tagsShortcut') {\n      if (!this.shouldRenderShortcut(node, context)) return '';\n      return context.tags?.map((e) => `#${e}`).join(' ') ?? '';\n    }\n\n    if (node.type === 'contentWarningShortcut') {\n      if (!this.shouldRenderShortcut(node, context)) return '';\n      return context.contentWarningText ?? '';\n    }\n\n    if (node.type === 'hardBreak') return '\\r\\n';\n\n    return this.convertContent(node.content, context);\n  }\n\n  convertTextNode(node: TipTapNode, context: ConversionContext): string {\n    const textNode = node as any;\n\n    // Check for link mark — append URL\n    const marks = textNode.marks ?? [];\n    const linkMark = marks.find((m: any) => m.type === 'link');\n    if (linkMark) {\n      return `${textNode.text}: ${linkMark.attrs?.href ?? ''}`;\n    }\n\n    return textNode.text ?? '';\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/post-parsers/models/description-node/description-node-tree.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport TurndownService from 'turndown';\nimport { BaseConverter } from './converters/base-converter';\nimport { BBCodeConverter } from './converters/bbcode-converter';\nimport {\n  CustomConverter,\n  CustomNodeHandler,\n} from './converters/custom-converter';\nimport { HtmlConverter } from './converters/html-converter';\nimport { PlainTextConverter } from './converters/plaintext-converter';\nimport { ConversionContext } from './description-node.base';\nimport { TipTapNode } from './description-node.types';\n\nexport type InsertionOptions = {\n  insertTitle?: string;\n  insertTags?: string[];\n  insertAd: boolean;\n};\n\nexport class DescriptionNodeTree {\n  private readonly nodes: TipTapNode[];\n\n  private readonly insertionOptions: InsertionOptions;\n\n  private context: ConversionContext;\n\n  /** Empty paragraph used as spacing before the ad */\n  private readonly spacing: TipTapNode = {\n    type: 'paragraph',\n    content: [],\n  };\n\n  /** PostyBirb ad in TipTap JSON format */\n  private readonly ad: TipTapNode = {\n    type: 'paragraph',\n    content: [\n      {\n        type: 'text',\n        text: 'Posted using PostyBirb',\n        marks: [\n          {\n            type: 'link',\n            attrs: { href: 'https://postybirb.com', target: '_blank' },\n          },\n        ],\n      },\n    ],\n  };\n\n  constructor(\n    context: ConversionContext,\n    nodes: TipTapNode[],\n    insertionOptions: InsertionOptions,\n  ) {\n    this.context = context;\n    this.insertionOptions = insertionOptions;\n    this.nodes = nodes ?? [];\n  }\n\n  toBBCode(): string {\n    const converter = new BBCodeConverter();\n    return converter.convertBlocks(this.withInsertions(), this.context);\n  }\n\n  toPlainText(): string {\n    const converter = new PlainTextConverter();\n    return converter.convertBlocks(this.withInsertions(), this.context);\n  }\n\n  toHtml(): string {\n    const converter = new HtmlConverter();\n    return converter.convertBlocks(this.withInsertions(), this.context);\n  }\n\n  toMarkdown(turndownService?: TurndownService): string {\n    const converter = turndownService ?? new TurndownService();\n\n    converter.addRule('nestedIndent', {\n      filter: (node) =>\n        node.nodeName === 'DIV' &&\n        node.getAttribute('style')?.includes('margin-left'),\n      replacement: (content) =>\n        `\\n\\n> ${content.trim().replace(/\\n/g, '\\n> ')}\\n\\n`,\n    });\n\n    const html = this.toHtml();\n    return converter.turndown(html);\n  }\n\n  parseCustom(blockHandler: CustomNodeHandler): string {\n    const converter = new CustomConverter(blockHandler);\n    return converter.convertBlocks(this.withInsertions(), this.context);\n  }\n\n  parseWithConverter(converter: BaseConverter): string {\n    return converter.convertBlocks(this.withInsertions(), this.context);\n  }\n\n  public updateContext(updates: Partial<ConversionContext>): void {\n    this.context = { ...this.context, ...updates };\n  }\n\n  /**\n   * Finds all TipTap nodes of a specific type in the tree (recursively).\n   */\n  public findNodesByType(type: string): TipTapNode[] {\n    const found: TipTapNode[] = [];\n\n    const traverse = (nodes: TipTapNode[]) => {\n      for (const node of nodes) {\n        if (node.type === type) {\n          found.push(node);\n        }\n        if (node.content) {\n          traverse(node.content);\n        }\n      }\n    };\n\n    traverse(this.nodes);\n    return found;\n  }\n\n  /**\n   * Finds all custom shortcut IDs in the tree.\n   */\n  public findCustomShortcutIds(): Set<string> {\n    const ids = new Set<string>();\n    const shortcuts = this.findNodesByType('customShortcut');\n\n    for (const shortcut of shortcuts) {\n      const id = shortcut.attrs?.id;\n      if (id) {\n        ids.add(id);\n      }\n    }\n\n    return ids;\n  }\n\n  /**\n   * Finds all usernames in the tree.\n   */\n  public findUsernames(): Set<string> {\n    const usernames = new Set<string>();\n    const usernameNodes = this.findNodesByType('username');\n\n    for (const node of usernameNodes) {\n      const username = (node.attrs?.username as string)?.trim();\n      if (username) {\n        usernames.add(username);\n      }\n    }\n\n    return usernames;\n  }\n\n  /**\n   * Checks if a TipTap node is structurally empty\n   * (no content, or content is only whitespace text nodes).\n   * Only considers paragraph/heading nodes as trimmable.\n   */\n  private isEmptyNode(node: TipTapNode): boolean {\n    if (node.type !== 'paragraph' && node.type !== 'heading') {\n      return false;\n    }\n\n    if (!node.content || node.content.length === 0) {\n      return true;\n    }\n\n    return node.content.every(\n      (child) =>\n        child.type === 'text' &&\n        (!(child as any).text || (child as any).text.trim() === ''),\n    );\n  }\n\n  /**\n   * Trims structurally empty nodes from the start and end of a block array,\n   * preserving empty nodes in the middle (intentional blank lines).\n   */\n  private trimEmptyEdgeNodes(nodes: TipTapNode[]): TipTapNode[] {\n    let start = 0;\n    let end = nodes.length - 1;\n\n    while (start < nodes.length && this.isEmptyNode(nodes[start])) {\n      start++;\n    }\n\n    while (end >= start && this.isEmptyNode(nodes[end])) {\n      end--;\n    }\n\n    if (start > end) return [];\n\n    return nodes.slice(start, end + 1);\n  }\n\n  private withInsertions(): TipTapNode[] {\n    // Trim empty edge nodes before insertions so converters receive clean input\n    const nodes = this.trimEmptyEdgeNodes([...this.nodes]);\n    const { insertAd, insertTags, insertTitle } = this.insertionOptions;\n\n    if (insertTitle) {\n      nodes.unshift({\n        type: 'heading',\n        attrs: { level: 2 },\n        content: [\n          {\n            type: 'text',\n            text: insertTitle,\n          },\n        ],\n      });\n    }\n\n    if (insertTags) {\n      nodes.push({\n        type: 'paragraph',\n        content: [\n          {\n            type: 'text',\n            text: insertTags.map((e) => `#${e}`).join(' '),\n          },\n        ],\n      });\n    }\n\n    if (insertAd) {\n      const lastNode = nodes[nodes.length - 1];\n      const isLastNodeSpacing =\n        lastNode?.type === 'paragraph' &&\n        (!lastNode.content || lastNode.content.length === 0);\n\n      // Avoid duplicated spacings\n      if (!isLastNodeSpacing) {\n        nodes.push(this.spacing);\n      }\n\n      nodes.push(this.ad);\n    }\n\n    return nodes;\n  }\n}\n\n"
  },
  {
    "path": "apps/client-server/src/app/post-parsers/models/description-node/description-node.base.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { UsernameShortcut } from '@postybirb/types';\nimport { TipTapNode } from './description-node.types';\n\n/**\n * Context provided to all converters during conversion.\n */\nexport interface ConversionContext {\n  website: string;\n  shortcuts: Record<string, UsernameShortcut>;\n  customShortcuts: Map<string, TipTapNode[]>;\n  defaultDescription: TipTapNode[];\n  title?: string;\n  tags?: string[];\n  usernameConversions?: Map<string, string>;\n  contentWarningText?: string;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/post-parsers/models/description-node/description-node.types.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { TipTapMark, TipTapNode } from '@postybirb/types';\n\n/**\n * Re-export TipTap types from the shared types library for use in the server parser.\n */\nexport type { TipTapMark, TipTapNode };\n\n/**\n * A TipTap node used as a block-level element (paragraph, heading, list, etc.).\n * In TipTap, block nodes have `type`, optional `attrs`, and optional `content`.\n */\nexport interface ITipTapBlockNode extends TipTapNode {\n  attrs?: Record<string, any>;\n  content?: TipTapNode[];\n}\n\n/**\n * A TipTap text node. Has `type: 'text'`, `text`, and optional `marks`.\n */\nexport interface ITipTapTextNode extends TipTapNode {\n  type: 'text';\n  text: string;\n  marks?: TipTapMark[];\n}\n\n/**\n * Known block-level node types in TipTap.\n */\nexport const BlockTypes: string[] = [\n  'doc',\n  'paragraph',\n  'heading',\n  'blockquote',\n  'bulletList',\n  'orderedList',\n  'listItem',\n  'horizontalRule',\n  'hardBreak',\n  'image',\n  'defaultShortcut',\n];\n\n/**\n * Known inline/atom node types in TipTap (rendered inline, not block-level).\n */\nexport const InlineTypes: string[] = [\n  'username',\n  'customShortcut',\n  'titleShortcut',\n  'tagsShortcut',\n  'contentWarningShortcut',\n];\n\n/**\n * Helper to check if a TipTap node is a text node.\n */\nexport function isTextNode(node: TipTapNode): node is ITipTapTextNode {\n  return node.type === 'text' && typeof (node as any).text === 'string';\n}\n\n/**\n * Helper to check if a TipTap node is an inline shortcut node.\n */\nexport function isInlineShortcut(node: TipTapNode): boolean {\n  return InlineTypes.includes(node.type);\n}\n\n/**\n * Helper to check if a text node has a specific mark.\n */\nexport function hasMark(node: ITipTapTextNode, markType: string): boolean {\n  return node.marks?.some((m) => m.type === markType) ?? false;\n}\n\n/**\n * Helper to get a mark's attrs from a text node.\n */\nexport function getMarkAttrs(\n  node: ITipTapTextNode,\n  markType: string,\n): Record<string, any> | undefined {\n  return node.marks?.find((m) => m.type === markType)?.attrs;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/post-parsers/models/description-node.spec.ts",
    "content": "import { TipTapNode } from '@postybirb/types';\nimport { DescriptionNodeTree } from './description-node/description-node-tree';\nimport { ConversionContext } from './description-node/description-node.base';\n\ndescribe('DescriptionNode', () => {\n  it('should support username shortcuts', () => {\n    const nodes: TipTapNode[] = [\n      {\n        type: 'paragraph',\n        content: [\n          { type: 'text', text: 'Hello, ', marks: [{ type: 'bold' }] },\n          {\n            type: 'username',\n            attrs: {\n              shortcut: 'test',\n              only: '',\n              username: 'User',\n            },\n          },\n        ],\n      },\n    ];\n\n    const context: ConversionContext = {\n      website: 'test',\n      shortcuts: {\n        test: {\n          id: 'test',\n          url: 'https://test.postybirb.com/$1',\n        },\n      },\n      customShortcuts: new Map(),\n      defaultDescription: [],\n    };\n\n    const tree = new DescriptionNodeTree(context, nodes, {\n      insertAd: false,\n    });\n\n    expect(tree.toPlainText()).toBe('Hello, https://test.postybirb.com/User');\n    expect(tree.toHtml()).toBe(\n      '<div><span><b>Hello, </b></span><a target=\"_blank\" href=\"https://test.postybirb.com/User\">User</a></div>',\n    );\n    expect(tree.toBBCode()).toBe(\n      '[b]Hello, [/b][url=https://test.postybirb.com/User]User[/url]',\n    );\n  });\n\n  it('should support username shortcut conversion', () => {\n    const nodes: TipTapNode[] = [\n      {\n        type: 'paragraph',\n        content: [\n          { type: 'text', text: 'Hello, ', marks: [{ type: 'bold' }] },\n          {\n            type: 'username',\n            attrs: {\n              shortcut: 'test',\n              only: '',\n              username: 'User',\n            },\n          },\n        ],\n      },\n    ];\n\n    const context: ConversionContext = {\n      website: 'test',\n      shortcuts: {\n        test: {\n          id: 'test',\n          url: 'https://test.postybirb.com/$1',\n          convert: (websiteName) => {\n            if (websiteName === 'test') {\n              return '<!~$1>';\n            }\n            return undefined;\n          },\n        },\n      },\n      customShortcuts: new Map(),\n      defaultDescription: [],\n    };\n\n    const tree = new DescriptionNodeTree(context, nodes, {\n      insertAd: false,\n    });\n\n    expect(tree.toPlainText()).toBe('Hello, <!~User>');\n    expect(tree.toHtml()).toBe(\n      '<div><span><b>Hello, </b></span><span><!~User></span></div>',\n    );\n    expect(tree.toBBCode()).toBe('[b]Hello, [/b]<!~User>');\n  });\n\n  it('should handle multiple paragraphs', () => {\n    const nodes: TipTapNode[] = [\n      {\n        type: 'paragraph',\n        content: [{ type: 'text', text: 'First paragraph.' }],\n      },\n      {\n        type: 'paragraph',\n        content: [{ type: 'text', text: 'Second paragraph.' }],\n      },\n    ];\n\n    const context: ConversionContext = {\n      website: 'test',\n      shortcuts: {},\n      customShortcuts: new Map(),\n      defaultDescription: [],\n    };\n\n    const tree = new DescriptionNodeTree(context, nodes, {\n      insertAd: false,\n    });\n\n    expect(tree.toPlainText()).toBe('First paragraph.\\r\\nSecond paragraph.');\n    expect(tree.toHtml()).toBe(\n      '<div>First paragraph.</div><div>Second paragraph.</div>',\n    );\n    expect(tree.toBBCode()).toBe('First paragraph.\\nSecond paragraph.');\n  });\n\n  describe('findUsernames', () => {\n    it('should find all usernames in the tree', () => {\n      const nodes: TipTapNode[] = [\n        {\n          type: 'paragraph',\n          content: [\n            { type: 'text', text: 'Hello ' },\n            {\n              type: 'username',\n              attrs: {\n                shortcut: 'test',\n                only: '',\n                username: 'User1',\n              },\n            },\n            { type: 'text', text: ' and ' },\n            {\n              type: 'username',\n              attrs: {\n                shortcut: 'test',\n                only: '',\n                username: 'User2',\n              },\n            },\n          ],\n        },\n      ];\n\n      const context: ConversionContext = {\n        website: 'test',\n        shortcuts: {},\n        customShortcuts: new Map(),\n        defaultDescription: [],\n      };\n\n      const tree = new DescriptionNodeTree(context, nodes, {\n        insertAd: false,\n      });\n\n      const usernames = tree.findUsernames();\n      expect(usernames.size).toBe(2);\n      expect(usernames.has('User1')).toBe(true);\n      expect(usernames.has('User2')).toBe(true);\n    });\n\n    it('should find usernames across multiple paragraphs', () => {\n      const nodes: TipTapNode[] = [\n        {\n          type: 'paragraph',\n          content: [\n            {\n              type: 'username',\n              attrs: { shortcut: 'test', only: '', username: 'Alice' },\n            },\n          ],\n        },\n        {\n          type: 'paragraph',\n          content: [\n            {\n              type: 'username',\n              attrs: { shortcut: 'test', only: '', username: 'Bob' },\n            },\n          ],\n        },\n      ];\n\n      const context: ConversionContext = {\n        website: 'test',\n        shortcuts: {},\n        customShortcuts: new Map(),\n        defaultDescription: [],\n      };\n\n      const tree = new DescriptionNodeTree(context, nodes, {\n        insertAd: false,\n      });\n\n      const usernames = tree.findUsernames();\n      expect(usernames.size).toBe(2);\n      expect(usernames.has('Alice')).toBe(true);\n      expect(usernames.has('Bob')).toBe(true);\n    });\n\n    it('should return empty set when no usernames exist', () => {\n      const nodes: TipTapNode[] = [\n        {\n          type: 'paragraph',\n          content: [{ type: 'text', text: 'Just plain text' }],\n        },\n      ];\n\n      const context: ConversionContext = {\n        website: 'test',\n        shortcuts: {},\n        customShortcuts: new Map(),\n        defaultDescription: [],\n      };\n\n      const tree = new DescriptionNodeTree(context, nodes, {\n        insertAd: false,\n      });\n\n      const usernames = tree.findUsernames();\n      expect(usernames.size).toBe(0);\n    });\n\n    it('should handle duplicate usernames', () => {\n      const nodes: TipTapNode[] = [\n        {\n          type: 'paragraph',\n          content: [\n            {\n              type: 'username',\n              attrs: {\n                shortcut: 'test',\n                only: '',\n                username: 'SameUser',\n              },\n            },\n            { type: 'text', text: ' and ' },\n            {\n              type: 'username',\n              attrs: {\n                shortcut: 'test',\n                only: '',\n                username: 'SameUser',\n              },\n            },\n          ],\n        },\n      ];\n\n      const context: ConversionContext = {\n        website: 'test',\n        shortcuts: {},\n        customShortcuts: new Map(),\n        defaultDescription: [],\n      };\n\n      const tree = new DescriptionNodeTree(context, nodes, {\n        insertAd: false,\n      });\n\n      const usernames = tree.findUsernames();\n      expect(usernames.size).toBe(1);\n      expect(usernames.has('SameUser')).toBe(true);\n    });\n  });\n\n  describe('findCustomShortcutIds', () => {\n    it('should find all custom shortcut IDs in the tree', () => {\n      const nodes: TipTapNode[] = [\n        {\n          type: 'paragraph',\n          content: [\n            { type: 'text', text: 'Here are some shortcuts: ' },\n            {\n              type: 'customShortcut',\n              attrs: { id: 'shortcut-1' },\n            },\n            { type: 'text', text: ' and ' },\n            {\n              type: 'customShortcut',\n              attrs: { id: 'shortcut-2' },\n            },\n          ],\n        },\n      ];\n\n      const context: ConversionContext = {\n        website: 'test',\n        shortcuts: {},\n        customShortcuts: new Map(),\n        defaultDescription: [],\n      };\n\n      const tree = new DescriptionNodeTree(context, nodes, {\n        insertAd: false,\n      });\n\n      const shortcutIds = tree.findCustomShortcutIds();\n      expect(shortcutIds.size).toBe(2);\n      expect(shortcutIds.has('shortcut-1')).toBe(true);\n      expect(shortcutIds.has('shortcut-2')).toBe(true);\n    });\n\n    it('should find shortcuts across multiple paragraphs', () => {\n      const nodes: TipTapNode[] = [\n        {\n          type: 'paragraph',\n          content: [\n            {\n              type: 'customShortcut',\n              attrs: { id: 'shortcut-a' },\n            },\n          ],\n        },\n        {\n          type: 'paragraph',\n          content: [\n            {\n              type: 'customShortcut',\n              attrs: { id: 'shortcut-b' },\n            },\n          ],\n        },\n      ];\n\n      const context: ConversionContext = {\n        website: 'test',\n        shortcuts: {},\n        customShortcuts: new Map(),\n        defaultDescription: [],\n      };\n\n      const tree = new DescriptionNodeTree(context, nodes, {\n        insertAd: false,\n      });\n\n      const shortcutIds = tree.findCustomShortcutIds();\n      expect(shortcutIds.size).toBe(2);\n      expect(shortcutIds.has('shortcut-a')).toBe(true);\n      expect(shortcutIds.has('shortcut-b')).toBe(true);\n    });\n\n    it('should return empty set when no custom shortcuts exist', () => {\n      const nodes: TipTapNode[] = [\n        {\n          type: 'paragraph',\n          content: [{ type: 'text', text: 'Just plain text' }],\n        },\n      ];\n\n      const context: ConversionContext = {\n        website: 'test',\n        shortcuts: {},\n        customShortcuts: new Map(),\n        defaultDescription: [],\n      };\n\n      const tree = new DescriptionNodeTree(context, nodes, {\n        insertAd: false,\n      });\n\n      const shortcutIds = tree.findCustomShortcutIds();\n      expect(shortcutIds.size).toBe(0);\n    });\n\n    it('should handle duplicate shortcut IDs', () => {\n      const nodes: TipTapNode[] = [\n        {\n          type: 'paragraph',\n          content: [\n            {\n              type: 'customShortcut',\n              attrs: { id: 'same-id' },\n            },\n            { type: 'text', text: ' and ' },\n            {\n              type: 'customShortcut',\n              attrs: { id: 'same-id' },\n            },\n          ],\n        },\n      ];\n\n      const context: ConversionContext = {\n        website: 'test',\n        shortcuts: {},\n        customShortcuts: new Map(),\n        defaultDescription: [],\n      };\n\n      const tree = new DescriptionNodeTree(context, nodes, {\n        insertAd: false,\n      });\n\n      const shortcutIds = tree.findCustomShortcutIds();\n      expect(shortcutIds.size).toBe(1);\n      expect(shortcutIds.has('same-id')).toBe(true);\n    });\n\n    it('should handle shortcuts without IDs gracefully', () => {\n      const nodes: TipTapNode[] = [\n        {\n          type: 'paragraph',\n          content: [\n            {\n              type: 'customShortcut',\n              attrs: { id: '' },\n            },\n          ],\n        },\n      ];\n\n      const context: ConversionContext = {\n        website: 'test',\n        shortcuts: {},\n        customShortcuts: new Map(),\n        defaultDescription: [],\n      };\n\n      const tree = new DescriptionNodeTree(context, nodes, {\n        insertAd: false,\n      });\n\n      const shortcutIds = tree.findCustomShortcutIds();\n      expect(shortcutIds.size).toBe(0);\n    });\n  });\n\n  describe('updateContext', () => {\n    it('should allow updating context after tree creation', () => {\n      const nodes: TipTapNode[] = [\n        {\n          type: 'paragraph',\n          content: [\n            {\n              type: 'username',\n              attrs: {\n                shortcut: 'test',\n                only: '',\n                username: 'TestUser',\n              },\n            },\n          ],\n        },\n      ];\n\n      const context: ConversionContext = {\n        website: 'test',\n        shortcuts: {\n          test: {\n            id: 'test',\n            url: 'https://test.postybirb.com/$1',\n          },\n        },\n        customShortcuts: new Map(),\n        defaultDescription: [],\n        usernameConversions: new Map(),\n      };\n\n      const tree = new DescriptionNodeTree(context, nodes, {\n        insertAd: false,\n      });\n\n      // Before update - no conversion\n      expect(tree.toPlainText()).toBe('https://test.postybirb.com/TestUser');\n\n      // Update context with username conversion\n      tree.updateContext({\n        usernameConversions: new Map([['TestUser', 'ConvertedUser']]),\n      });\n\n      // After update - should use converted username\n      expect(tree.toPlainText()).toBe(\n        'https://test.postybirb.com/ConvertedUser',\n      );\n\n      // Verify the tree still finds the original username\n      const usernames = tree.findUsernames();\n      expect(usernames.has('TestUser')).toBe(true);\n    });\n\n    it('should convert cross-platform username tags to target website', () => {\n      const nodes: TipTapNode[] = [\n        {\n          type: 'paragraph',\n          content: [\n            {\n              type: 'username',\n              attrs: {\n                shortcut: 'twitter',\n                only: '',\n                username: 'abcd',\n              },\n            },\n          ],\n        },\n      ];\n\n      const context: ConversionContext = {\n        website: 'bluesky',\n        shortcuts: {\n          twitter: {\n            id: 'twitter',\n            url: 'https://x.com/$1',\n          },\n          bluesky: {\n            id: 'bluesky',\n            url: 'https://bsky.app/profile/$1',\n          },\n        },\n        customShortcuts: new Map(),\n        defaultDescription: [],\n        usernameConversions: new Map([['abcd', 'abcd.bsky.app']]),\n      };\n\n      const tree = new DescriptionNodeTree(context, nodes, {\n        insertAd: false,\n      });\n\n      expect(tree.toPlainText()).toBe('https://bsky.app/profile/abcd.bsky.app');\n      expect(tree.toHtml()).toBe(\n        '<div><a target=\"_blank\" href=\"https://bsky.app/profile/abcd.bsky.app\">abcd.bsky.app</a></div>',\n      );\n    });\n\n    it('should keep original username when no conversion exists', () => {\n      const nodes: TipTapNode[] = [\n        {\n          type: 'paragraph',\n          content: [\n            {\n              type: 'username',\n              attrs: {\n                shortcut: 'twitter',\n                only: '',\n                username: 'someuser',\n              },\n            },\n          ],\n        },\n      ];\n\n      const context: ConversionContext = {\n        website: 'bluesky',\n        shortcuts: {\n          twitter: {\n            id: 'twitter',\n            url: 'https://x.com/$1',\n          },\n          bluesky: {\n            id: 'bluesky',\n            url: 'https://bsky.app/profile/$1',\n          },\n        },\n        customShortcuts: new Map(),\n        defaultDescription: [],\n        usernameConversions: new Map(),\n      };\n\n      const tree = new DescriptionNodeTree(context, nodes, {\n        insertAd: false,\n      });\n\n      expect(tree.toPlainText()).toBe('https://x.com/someuser');\n      expect(tree.toHtml()).toBe(\n        '<div><a target=\"_blank\" href=\"https://x.com/someuser\">someuser</a></div>',\n      );\n    });\n\n    it('should convert usernames when shortcut ID matches target website', () => {\n      const nodes: TipTapNode[] = [\n        {\n          type: 'paragraph',\n          content: [\n            {\n              type: 'username',\n              attrs: { shortcut: 'bluesky', only: '', username: 'x' },\n            },\n          ],\n        },\n      ];\n\n      const context: ConversionContext = {\n        website: 'bluesky',\n        shortcuts: {\n          twitter: {\n            id: 'twitter',\n            url: 'https://x.com/$1',\n          },\n          bluesky: {\n            id: 'bluesky',\n            url: 'https://bsky.app/profile/$1',\n          },\n        },\n        customShortcuts: new Map(),\n        defaultDescription: [],\n        usernameConversions: new Map([['x', 'bluesky_user']]),\n      };\n\n      const tree = new DescriptionNodeTree(context, nodes, {\n        insertAd: false,\n      });\n\n      expect(tree.toPlainText()).toBe('https://bsky.app/profile/bluesky_user');\n      expect(tree.toHtml()).toBe(\n        '<div><a target=\"_blank\" href=\"https://bsky.app/profile/bluesky_user\">bluesky_user</a></div>',\n      );\n    });\n  });\n\n  describe('blockquote nesting', () => {\n    it('should render blockquotes in HTML', () => {\n      const nodes: TipTapNode[] = [\n        {\n          type: 'paragraph',\n          content: [{ type: 'text', text: 'Para 1' }],\n        },\n        {\n          type: 'blockquote',\n          content: [\n            {\n              type: 'paragraph',\n              content: [{ type: 'text', text: 'Para 1 nested' }],\n            },\n          ],\n        },\n        {\n          type: 'paragraph',\n          content: [{ type: 'text', text: 'Para 2' }],\n        },\n      ];\n\n      const context: ConversionContext = {\n        website: 'test',\n        shortcuts: {},\n        customShortcuts: new Map(),\n        defaultDescription: [],\n      };\n\n      const tree = new DescriptionNodeTree(context, nodes, {\n        insertAd: false,\n      });\n\n      expect(tree.toHtml()).toBe(\n        '<div>Para 1</div><blockquote><div>Para 1 nested</div></blockquote><div>Para 2</div>',\n      );\n    });\n\n    it('should render blockquotes in plain text with > prefix', () => {\n      const nodes: TipTapNode[] = [\n        {\n          type: 'paragraph',\n          content: [{ type: 'text', text: 'Para 1' }],\n        },\n        {\n          type: 'blockquote',\n          content: [\n            {\n              type: 'paragraph',\n              content: [{ type: 'text', text: 'Para 1 nested' }],\n            },\n          ],\n        },\n      ];\n\n      const context: ConversionContext = {\n        website: 'test',\n        shortcuts: {},\n        customShortcuts: new Map(),\n        defaultDescription: [],\n      };\n\n      const tree = new DescriptionNodeTree(context, nodes, {\n        insertAd: false,\n      });\n\n      expect(tree.toPlainText()).toBe('Para 1\\r\\n> Para 1 nested');\n    });\n\n    it('should render blockquotes in BBCode with [quote] tags', () => {\n      const nodes: TipTapNode[] = [\n        {\n          type: 'paragraph',\n          content: [{ type: 'text', text: 'Para 1' }],\n        },\n        {\n          type: 'blockquote',\n          content: [\n            {\n              type: 'paragraph',\n              content: [{ type: 'text', text: 'Para 1 nested' }],\n            },\n          ],\n        },\n      ];\n\n      const context: ConversionContext = {\n        website: 'test',\n        shortcuts: {},\n        customShortcuts: new Map(),\n        defaultDescription: [],\n      };\n\n      const tree = new DescriptionNodeTree(context, nodes, {\n        insertAd: false,\n      });\n\n      expect(tree.toBBCode()).toBe('Para 1\\n[quote]Para 1 nested[/quote]');\n    });\n\n    it('should handle deeply nested blockquotes (multi-level)', () => {\n      const nodes: TipTapNode[] = [\n        {\n          type: 'paragraph',\n          content: [{ type: 'text', text: 'Level 0' }],\n        },\n        {\n          type: 'blockquote',\n          content: [\n            {\n              type: 'paragraph',\n              content: [{ type: 'text', text: 'Level 1' }],\n            },\n            {\n              type: 'blockquote',\n              content: [\n                {\n                  type: 'paragraph',\n                  content: [{ type: 'text', text: 'Level 2' }],\n                },\n              ],\n            },\n          ],\n        },\n      ];\n\n      const context: ConversionContext = {\n        website: 'test',\n        shortcuts: {},\n        customShortcuts: new Map(),\n        defaultDescription: [],\n      };\n\n      const tree = new DescriptionNodeTree(context, nodes, {\n        insertAd: false,\n      });\n\n      expect(tree.toPlainText()).toBe('Level 0\\r\\n> Level 1\\r\\n> > Level 2');\n      expect(tree.toBBCode()).toBe(\n        'Level 0\\n[quote]Level 1\\n[quote]Level 2[/quote][/quote]',\n      );\n    });\n\n    it('should handle multiple paragraphs at same blockquote level', () => {\n      const nodes: TipTapNode[] = [\n        {\n          type: 'paragraph',\n          content: [{ type: 'text', text: 'Para 1' }],\n        },\n        {\n          type: 'blockquote',\n          content: [\n            {\n              type: 'paragraph',\n              content: [{ type: 'text', text: 'Para 1 nested' }],\n            },\n            {\n              type: 'paragraph',\n              content: [{ type: 'text', text: 'Para 1 nested 2' }],\n            },\n          ],\n        },\n      ];\n\n      const context: ConversionContext = {\n        website: 'test',\n        shortcuts: {},\n        customShortcuts: new Map(),\n        defaultDescription: [],\n      };\n\n      const tree = new DescriptionNodeTree(context, nodes, {\n        insertAd: false,\n      });\n\n      expect(tree.toPlainText()).toBe(\n        'Para 1\\r\\n> Para 1 nested\\r\\n> Para 1 nested 2',\n      );\n    });\n\n    it('should find usernames in blockquotes', () => {\n      const nodes: TipTapNode[] = [\n        {\n          type: 'paragraph',\n          content: [{ type: 'text', text: 'Para 1 ' }],\n        },\n        {\n          type: 'blockquote',\n          content: [\n            {\n              type: 'paragraph',\n              content: [\n                { type: 'text', text: 'Nested ' },\n                {\n                  type: 'username',\n                  attrs: {\n                    shortcut: 'test',\n                    only: '',\n                    username: 'NestedUser',\n                  },\n                },\n              ],\n            },\n          ],\n        },\n      ];\n\n      const context: ConversionContext = {\n        website: 'test',\n        shortcuts: {\n          test: {\n            id: 'test',\n            url: 'https://test.postybirb.com/$1',\n          },\n        },\n        customShortcuts: new Map(),\n        defaultDescription: [],\n      };\n\n      const tree = new DescriptionNodeTree(context, nodes, {\n        insertAd: false,\n      });\n\n      const usernames = tree.findUsernames();\n      expect(usernames.has('NestedUser')).toBe(true);\n    });\n\n    it('should render blockquotes in Markdown', () => {\n      const nodes: TipTapNode[] = [\n        {\n          type: 'paragraph',\n          content: [{ type: 'text', text: 'Para 1' }],\n        },\n        {\n          type: 'blockquote',\n          content: [\n            {\n              type: 'paragraph',\n              content: [{ type: 'text', text: 'Para 1 nested' }],\n            },\n          ],\n        },\n      ];\n\n      const context: ConversionContext = {\n        website: 'test',\n        shortcuts: {},\n        customShortcuts: new Map(),\n        defaultDescription: [],\n      };\n\n      const tree = new DescriptionNodeTree(context, nodes, {\n        insertAd: false,\n      });\n\n      expect(tree.toMarkdown()).toBe('Para 1\\n\\n> Para 1 nested');\n    });\n\n    it('should render deeply nested blockquotes in Markdown', () => {\n      const nodes: TipTapNode[] = [\n        {\n          type: 'paragraph',\n          content: [{ type: 'text', text: 'Level 0' }],\n        },\n        {\n          type: 'blockquote',\n          content: [\n            {\n              type: 'paragraph',\n              content: [{ type: 'text', text: 'Level 1' }],\n            },\n            {\n              type: 'blockquote',\n              content: [\n                {\n                  type: 'paragraph',\n                  content: [{ type: 'text', text: 'Level 2' }],\n                },\n              ],\n            },\n          ],\n        },\n      ];\n\n      const context: ConversionContext = {\n        website: 'test',\n        shortcuts: {},\n        customShortcuts: new Map(),\n        defaultDescription: [],\n      };\n\n      const tree = new DescriptionNodeTree(context, nodes, {\n        insertAd: false,\n      });\n\n      expect(tree.toMarkdown()).toBe('Level 0\\n\\n> Level 1\\n> \\n> > Level 2');\n    });\n  });\n\n  describe('system inline shortcuts', () => {\n    it('should render titleShortcut with title from context', () => {\n      const nodes: TipTapNode[] = [\n        {\n          type: 'paragraph',\n          content: [\n            { type: 'text', text: 'Title: ' },\n            { type: 'titleShortcut', attrs: {} },\n          ],\n        },\n      ];\n\n      const context: ConversionContext = {\n        website: 'test',\n        shortcuts: {},\n        customShortcuts: new Map(),\n        defaultDescription: [],\n        title: 'My Amazing Artwork',\n        tags: [],\n      };\n\n      const tree = new DescriptionNodeTree(context, nodes, {\n        insertAd: false,\n      });\n\n      expect(tree.toPlainText()).toBe('Title: My Amazing Artwork');\n      expect(tree.toHtml()).toBe(\n        '<div>Title: <span>My Amazing Artwork</span></div>',\n      );\n      expect(tree.toBBCode()).toBe('Title: My Amazing Artwork');\n    });\n\n    it('should render tagsShortcut with tags from context', () => {\n      const nodes: TipTapNode[] = [\n        {\n          type: 'paragraph',\n          content: [\n            { type: 'text', text: 'Tags: ' },\n            { type: 'tagsShortcut', attrs: {} },\n          ],\n        },\n      ];\n\n      const context: ConversionContext = {\n        website: 'test',\n        shortcuts: {},\n        customShortcuts: new Map(),\n        defaultDescription: [],\n        title: '',\n        tags: ['art', 'digital', 'fantasy'],\n      };\n\n      const tree = new DescriptionNodeTree(context, nodes, {\n        insertAd: false,\n      });\n\n      expect(tree.toPlainText()).toBe('Tags: #art #digital #fantasy');\n      expect(tree.toHtml()).toBe(\n        '<div>Tags: <span>#art #digital #fantasy</span></div>',\n      );\n      expect(tree.toBBCode()).toBe('Tags: #art #digital #fantasy');\n    });\n\n    it('should render contentWarningShortcut with content warning from context', () => {\n      const nodes: TipTapNode[] = [\n        {\n          type: 'paragraph',\n          content: [\n            { type: 'text', text: 'CW: ' },\n            { type: 'contentWarningShortcut', attrs: {} },\n          ],\n        },\n      ];\n\n      const context: ConversionContext = {\n        website: 'test',\n        shortcuts: {},\n        customShortcuts: new Map(),\n        defaultDescription: [],\n        title: '',\n        tags: [],\n        contentWarningText: 'Mild Violence',\n      };\n\n      const tree = new DescriptionNodeTree(context, nodes, {\n        insertAd: false,\n      });\n\n      expect(tree.toPlainText()).toBe('CW: Mild Violence');\n      expect(tree.toHtml()).toBe('<div>CW: <span>Mild Violence</span></div>');\n      expect(tree.toBBCode()).toBe('CW: Mild Violence');\n    });\n\n    it('should render empty string when title is not in context', () => {\n      const nodes: TipTapNode[] = [\n        {\n          type: 'paragraph',\n          content: [\n            { type: 'text', text: 'Title: ' },\n            { type: 'titleShortcut', attrs: {} },\n            { type: 'text', text: ' end' },\n          ],\n        },\n      ];\n\n      const context: ConversionContext = {\n        website: 'test',\n        shortcuts: {},\n        customShortcuts: new Map(),\n        defaultDescription: [],\n      };\n\n      const tree = new DescriptionNodeTree(context, nodes, {\n        insertAd: false,\n      });\n\n      expect(tree.toPlainText()).toBe('Title:  end');\n    });\n\n    it('should render empty string when tags array is empty', () => {\n      const nodes: TipTapNode[] = [\n        {\n          type: 'paragraph',\n          content: [\n            { type: 'text', text: 'Tags: ' },\n            { type: 'tagsShortcut', attrs: {} },\n            { type: 'text', text: ' end' },\n          ],\n        },\n      ];\n\n      const context: ConversionContext = {\n        website: 'test',\n        shortcuts: {},\n        customShortcuts: new Map(),\n        defaultDescription: [],\n        tags: [],\n      };\n\n      const tree = new DescriptionNodeTree(context, nodes, {\n        insertAd: false,\n      });\n\n      expect(tree.toPlainText()).toBe('Tags:  end');\n    });\n\n    it('should render all system shortcuts together', () => {\n      const nodes: TipTapNode[] = [\n        {\n          type: 'paragraph',\n          content: [\n            { type: 'titleShortcut', attrs: {} },\n            { type: 'text', text: ' - ' },\n            { type: 'contentWarningShortcut', attrs: {} },\n          ],\n        },\n        {\n          type: 'paragraph',\n          content: [{ type: 'tagsShortcut', attrs: {} }],\n        },\n      ];\n\n      const context: ConversionContext = {\n        website: 'test',\n        shortcuts: {},\n        customShortcuts: new Map(),\n        defaultDescription: [],\n        title: 'My Art',\n        tags: ['tag1', 'tag2'],\n        contentWarningText: 'NSFW',\n      };\n\n      const tree = new DescriptionNodeTree(context, nodes, {\n        insertAd: false,\n      });\n\n      expect(tree.toPlainText()).toBe('My Art - NSFW\\r\\n#tag1 #tag2');\n      expect(tree.toHtml()).toBe(\n        '<div><span>My Art</span> - <span>NSFW</span></div><div><span>#tag1 #tag2</span></div>',\n      );\n    });\n\n    it('should HTML encode special characters in title', () => {\n      const nodes: TipTapNode[] = [\n        {\n          type: 'paragraph',\n          content: [{ type: 'titleShortcut', attrs: {} }],\n        },\n      ];\n\n      const context: ConversionContext = {\n        website: 'test',\n        shortcuts: {},\n        customShortcuts: new Map(),\n        defaultDescription: [],\n        title: '<script>alert(\"xss\")</script>',\n        tags: [],\n      };\n\n      const tree = new DescriptionNodeTree(context, nodes, {\n        insertAd: false,\n      });\n\n      expect(tree.toHtml()).toBe(\n        '<div><span>&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;</span></div>',\n      );\n    });\n  });\n\n  describe('Username shortcuts', () => {\n    it('should support username attrs format', () => {\n      const nodes: TipTapNode[] = [\n        {\n          type: 'paragraph',\n          content: [\n            { type: 'text', text: 'Hello, ', marks: [{ type: 'bold' }] },\n            {\n              type: 'username',\n              attrs: {\n                shortcut: 'test',\n                only: '',\n                username: 'TestUser',\n              },\n            },\n          ],\n        },\n      ];\n\n      const context: ConversionContext = {\n        website: 'test',\n        shortcuts: {\n          test: {\n            id: 'test',\n            url: 'https://test.postybirb.com/$1',\n          },\n        },\n        customShortcuts: new Map(),\n        defaultDescription: [],\n      };\n\n      const tree = new DescriptionNodeTree(context, nodes, {\n        insertAd: false,\n      });\n\n      expect(tree.toPlainText()).toBe(\n        'Hello, https://test.postybirb.com/TestUser',\n      );\n      expect(tree.toHtml()).toBe(\n        '<div><span><b>Hello, </b></span><a target=\"_blank\" href=\"https://test.postybirb.com/TestUser\">TestUser</a></div>',\n      );\n      expect(tree.toBBCode()).toBe(\n        '[b]Hello, [/b][url=https://test.postybirb.com/TestUser]TestUser[/url]',\n      );\n    });\n\n    it('should find usernames from attrs format', () => {\n      const nodes: TipTapNode[] = [\n        {\n          type: 'paragraph',\n          content: [\n            {\n              type: 'username',\n              attrs: {\n                shortcut: 'twitter',\n                only: '',\n                username: 'alice',\n              },\n            },\n            { type: 'text', text: ' and ' },\n            {\n              type: 'username',\n              attrs: {\n                shortcut: 'twitter',\n                only: '',\n                username: 'bob',\n              },\n            },\n          ],\n        },\n      ];\n\n      const context: ConversionContext = {\n        website: 'test',\n        shortcuts: {},\n        customShortcuts: new Map(),\n        defaultDescription: [],\n      };\n\n      const tree = new DescriptionNodeTree(context, nodes, {\n        insertAd: false,\n      });\n\n      const usernames = tree.findUsernames();\n      expect(usernames.size).toBe(2);\n      expect(usernames.has('alice')).toBe(true);\n      expect(usernames.has('bob')).toBe(true);\n    });\n\n    it('should support username conversion', () => {\n      const nodes: TipTapNode[] = [\n        {\n          type: 'paragraph',\n          content: [\n            { type: 'text', text: 'Follow me: ' },\n            {\n              type: 'username',\n              attrs: {\n                shortcut: 'twitter',\n                only: '',\n                username: 'myusername',\n              },\n            },\n          ],\n        },\n      ];\n\n      const context: ConversionContext = {\n        website: 'test',\n        shortcuts: {\n          twitter: {\n            id: 'twitter',\n            url: 'https://twitter.com/$1',\n          },\n          test: {\n            id: 'test',\n            url: 'https://test.postybirb.com/$1',\n          },\n        },\n        customShortcuts: new Map(),\n        defaultDescription: [],\n        usernameConversions: new Map([['myusername', 'converted_username']]),\n      };\n\n      const tree = new DescriptionNodeTree(context, nodes, {\n        insertAd: false,\n      });\n\n      expect(tree.toPlainText()).toBe(\n        'Follow me: https://test.postybirb.com/converted_username',\n      );\n      expect(tree.toHtml()).toBe(\n        '<div>Follow me: <a target=\"_blank\" href=\"https://test.postybirb.com/converted_username\">converted_username</a></div>',\n      );\n    });\n\n    it('should handle empty username gracefully', () => {\n      const nodes: TipTapNode[] = [\n        {\n          type: 'paragraph',\n          content: [\n            {\n              type: 'username',\n              attrs: {\n                shortcut: 'test',\n                only: '',\n                username: '',\n              },\n            },\n          ],\n        },\n      ];\n\n      const context: ConversionContext = {\n        website: 'test',\n        shortcuts: {\n          test: {\n            id: 'test',\n            url: 'https://test.postybirb.com/$1',\n          },\n        },\n        customShortcuts: new Map(),\n        defaultDescription: [],\n      };\n\n      const tree = new DescriptionNodeTree(context, nodes, {\n        insertAd: false,\n      });\n\n      expect(tree.toPlainText()).toBe('');\n      expect(tree.findUsernames().size).toBe(0);\n    });\n  });\n});\n"
  },
  {
    "path": "apps/client-server/src/app/post-parsers/parsers/content-warning-parser.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { BaseWebsiteOptions } from '../../websites/models/base-website-options';\nimport { DefaultWebsiteOptions } from '../../websites/models/default-website-options';\n\n@Injectable()\nexport class ContentWarningParser {\n  public async parse(\n    defaultOptions: DefaultWebsiteOptions,\n    websiteOptions: BaseWebsiteOptions,\n  ): Promise<string> {\n    const defaultWarningForm = defaultOptions.getFormFieldFor('contentWarning');\n    const websiteWarningForm = websiteOptions.getFormFieldFor('contentWarning');\n    const merged = websiteOptions.mergeDefaults(defaultOptions);\n\n    const warning = merged.contentWarning ?? '';\n    const field = websiteWarningForm ?? defaultWarningForm;\n    const maxLength = field?.maxLength ?? Infinity;\n\n    return warning.trim().slice(0, maxLength);\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/post-parsers/parsers/description-parser.service.spec.ts",
    "content": "/* eslint-disable max-classes-per-file */\nimport { Test, TestingModule } from '@nestjs/testing';\nimport { clearDatabase } from '@postybirb/database';\nimport { DescriptionField } from '@postybirb/form-builder';\nimport {\n    Description,\n    DescriptionType,\n    DescriptionValue,\n    IWebsiteOptions,\n    TipTapNode,\n} from '@postybirb/types';\nimport { WEBSITE_IMPLEMENTATIONS } from '../../constants';\nimport { CustomShortcutsService } from '../../custom-shortcuts/custom-shortcuts.service';\nimport { SettingsService } from '../../settings/settings.service';\nimport { UserConvertersService } from '../../user-converters/user-converters.service';\nimport { BaseWebsiteOptions } from '../../websites/models/base-website-options';\nimport { DefaultWebsiteOptions } from '../../websites/models/default-website-options';\nimport { UnknownWebsite } from '../../websites/website';\nimport { DescriptionParserService } from './description-parser.service';\n\ndescribe('DescriptionParserService', () => {\n  let module: TestingModule;\n  let service: DescriptionParserService;\n  let settingsService: SettingsService;\n  let customShortcutsService: CustomShortcutsService;\n  let userConvertersService: UserConvertersService;\n\n  const testDescription: Description = {\n    type: 'doc',\n    content: [\n      {\n        type: 'paragraph',\n        content: [\n          { type: 'text', text: 'Hello, ', marks: [{ type: 'bold' }] },\n          { type: 'text', text: 'World!' },\n        ],\n      },\n      {\n        type: 'paragraph',\n        content: [\n          {\n            type: 'text',\n            text: 'A link',\n            marks: [\n              {\n                type: 'link',\n                attrs: { href: 'https://postybirb.com' },\n              },\n            ],\n          },\n        ],\n      },\n    ],\n  };\n\n  beforeEach(async () => {\n    clearDatabase();\n    module = await Test.createTestingModule({\n      providers: [\n        DescriptionParserService,\n        {\n          provide: SettingsService,\n          useValue: {\n            getSettings: jest.fn(),\n            getDefaultSettings: jest.fn(),\n          },\n        },\n        {\n          provide: CustomShortcutsService,\n          useValue: {\n            findById: jest.fn(),\n          },\n        },\n        {\n          provide: UserConvertersService,\n          useValue: {\n            convert: jest\n              .fn()\n              .mockImplementation((instance, username) =>\n                Promise.resolve(username),\n              ),\n          },\n        },\n        {\n          provide: WEBSITE_IMPLEMENTATIONS,\n          useValue: [],\n        },\n      ],\n    }).compile();\n    service = module.get(DescriptionParserService);\n    settingsService = module.get(SettingsService);\n    customShortcutsService = module.get(CustomShortcutsService);\n    userConvertersService = module.get(UserConvertersService);\n    settingsService.getDefaultSettings = jest.fn().mockResolvedValue({\n      settings: {\n        hiddenWebsites: [],\n        language: 'en',\n        allowAd: false,\n      },\n    });\n  });\n\n  afterAll(async () => {\n    await module.close();\n  });\n\n  function createWebsiteOptions(\n    description: Description | undefined,\n  ): IWebsiteOptions {\n    return {\n      data: {\n        description: {\n          description,\n        },\n      },\n    } as IWebsiteOptions;\n  }\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n\n  it('should parse plaintext description', async () => {\n    const instance = {\n      decoratedProps: {\n        allowAd: true,\n        metadata: {\n          name: 'Test',\n        },\n      },\n    };\n\n    class PlaintextBaseWebsiteOptions extends BaseWebsiteOptions {\n      @DescriptionField({ descriptionType: DescriptionType.PLAINTEXT })\n      description: DescriptionValue;\n    }\n\n    const defaultOptions = createWebsiteOptions(testDescription);\n    const websiteOptions = createWebsiteOptions(undefined);\n    const description = await service.parse(\n      instance as unknown as UnknownWebsite,\n      new DefaultWebsiteOptions(defaultOptions.data),\n      new PlaintextBaseWebsiteOptions(websiteOptions.data),\n      [],\n      '',\n    );\n    expect(description).toMatchInlineSnapshot(`\n      \"Hello, World!\n      A link: https://postybirb.com\"\n    `);\n  });\n\n  it('should parse html description', async () => {\n    const instance = {\n      decoratedProps: {\n        allowAd: true,\n        metadata: {\n          name: 'Test',\n        },\n      },\n    };\n\n    const defaultOptions = createWebsiteOptions(testDescription);\n    const websiteOptions = createWebsiteOptions(undefined);\n    const description = await service.parse(\n      instance as unknown as UnknownWebsite,\n      new DefaultWebsiteOptions(defaultOptions.data),\n      new BaseWebsiteOptions(websiteOptions.data),\n      [],\n      '',\n    );\n    expect(description).toMatchInlineSnapshot(\n      `\"<div><span><b>Hello, </b></span>World!</div><div><a target=\\\"_blank\\\" href=\\\"https://postybirb.com\\\">A link</a></div>\"`,\n    );\n  });\n\n  it('should parse markdown description', async () => {\n    const instance = {\n      decoratedProps: {\n        allowAd: true,\n        metadata: {\n          name: 'Test',\n        },\n      },\n    };\n\n    class MarkdownBaseWebsiteOptions extends BaseWebsiteOptions {\n      @DescriptionField({ descriptionType: DescriptionType.MARKDOWN })\n      description: DescriptionValue;\n    }\n\n    const defaultOptions = createWebsiteOptions(testDescription);\n    const websiteOptions = createWebsiteOptions(undefined);\n    const description = await service.parse(\n      instance as unknown as UnknownWebsite,\n      new DefaultWebsiteOptions(defaultOptions.data),\n      new MarkdownBaseWebsiteOptions(websiteOptions.data),\n      [],\n      '',\n    );\n    expect(description).toMatchInlineSnapshot(`\n      \"**Hello,** World!\n\n      [A link](https://postybirb.com)\"\n    `);\n  });\n\n  it('should return empty for description type NONE', async () => {\n    const instance = {\n      decoratedProps: {\n        allowAd: true,\n      },\n    };\n\n    class NoneBaseWebsiteOptions extends BaseWebsiteOptions {\n      @DescriptionField({ descriptionType: DescriptionType.NONE })\n      description: DescriptionValue;\n    }\n\n    const defaultOptions = createWebsiteOptions(testDescription);\n    const websiteOptions = createWebsiteOptions(undefined);\n    const description = await service.parse(\n      instance as unknown as UnknownWebsite,\n      new DefaultWebsiteOptions(defaultOptions.data),\n      new NoneBaseWebsiteOptions(websiteOptions.data),\n      [],\n      '',\n    );\n    expect(description).toEqual(undefined);\n  });\n\n  it('should insert ad if allowed in settings and website', async () => {\n    settingsService.getDefaultSettings = jest.fn().mockResolvedValue({\n      settings: {\n        hiddenWebsites: [],\n        language: 'en',\n        allowAd: true,\n      },\n    });\n    const instance = {\n      decoratedProps: {\n        allowAd: true,\n        metadata: {\n          name: 'Test',\n        },\n      },\n    };\n\n    const defaultOptions = createWebsiteOptions(testDescription);\n    const websiteOptions = createWebsiteOptions(undefined);\n    const description = await service.parse(\n      instance as unknown as UnknownWebsite,\n      new DefaultWebsiteOptions(defaultOptions.data),\n      new BaseWebsiteOptions(websiteOptions.data),\n      [],\n      '',\n    );\n\n    expect(description).toMatchInlineSnapshot(\n      `\"<div><span><b>Hello, </b></span>World!</div><div><a target=\\\"_blank\\\" href=\\\"https://postybirb.com\\\">A link</a></div><div></div><div><a target=\\\"_blank\\\" href=\\\"https://postybirb.com\\\">Posted using PostyBirb</a></div>\"`,\n    );\n  });\n\n  it('should not insert ad if allowed in settings and not website', async () => {\n    settingsService.getDefaultSettings = jest.fn().mockResolvedValue({\n      settings: {\n        hiddenWebsites: [],\n        language: 'en',\n        allowAd: true,\n      },\n    });\n    const instance = {\n      decoratedProps: {\n        allowAd: false,\n        metadata: {\n          name: 'Test',\n        },\n      },\n    };\n\n    const defaultOptions = createWebsiteOptions(testDescription);\n    const websiteOptions = createWebsiteOptions(undefined);\n    const description = await service.parse(\n      instance as unknown as UnknownWebsite,\n      new DefaultWebsiteOptions(defaultOptions.data),\n      new BaseWebsiteOptions(websiteOptions.data),\n      [],\n      '',\n    );\n\n    expect(description).toMatchInlineSnapshot(\n      `\"<div><span><b>Hello, </b></span>World!</div><div><a target=\\\"_blank\\\" href=\\\"https://postybirb.com\\\">A link</a></div>\"`,\n    );\n  });\n\n  it('should pass blocks through without merging', () => {\n    const blocks: TipTapNode[] = [\n      {\n        type: 'paragraph',\n        content: [\n          {\n            type: 'text',\n            text: 'Test\\nIn the same block!',\n          },\n        ],\n      },\n      {\n        type: 'paragraph',\n        content: [\n          {\n            type: 'text',\n            text: 'New block',\n          },\n        ],\n      },\n      {\n        type: 'paragraph',\n        attrs: { textAlign: 'center' },\n        content: [\n          {\n            type: 'text',\n            text: 'block',\n          },\n        ],\n      },\n    ];\n\n    const result = service.mergeBlocks(blocks);\n    expect(result).toBe(blocks);\n  });\n\n  it('should insert default when available', async () => {\n    const instance = {\n      decoratedProps: {\n        allowAd: true,\n        metadata: {\n          name: 'Test',\n        },\n      },\n    };\n\n    class PlaintextBaseWebsiteOptions extends BaseWebsiteOptions {\n      @DescriptionField({ descriptionType: DescriptionType.PLAINTEXT })\n      description: DescriptionValue;\n    }\n\n    const defaultOptions = createWebsiteOptions(testDescription);\n    const websiteDesc: Description = {\n      type: 'doc',\n      content: [\n        {\n          type: 'defaultShortcut',\n        },\n        {\n          type: 'paragraph',\n          content: [{ type: 'text', text: 'Hello, Basic' }],\n        },\n      ],\n    };\n    const websiteOptions = createWebsiteOptions(websiteDesc);\n    websiteOptions.data.description.overrideDefault = true;\n    const description = await service.parse(\n      instance as unknown as UnknownWebsite,\n      new DefaultWebsiteOptions(defaultOptions.data),\n      new PlaintextBaseWebsiteOptions(websiteOptions.data),\n      [],\n      '',\n    );\n    expect(description).toMatchInlineSnapshot(`\n      \"Hello, World!\n      A link: https://postybirb.com\n      Hello, Basic\"\n    `);\n  });\n\n  describe('Custom Shortcuts', () => {\n    it('should inject single custom shortcut', async () => {\n      const instance = {\n        decoratedProps: {\n          allowAd: false,\n          metadata: {\n            name: 'Test',\n          },\n        },\n      };\n\n      const shortcutContent: Description = {\n        type: 'doc',\n        content: [\n          {\n            type: 'paragraph',\n            content: [\n              {\n                type: 'text',\n                text: 'Commission Info',\n                marks: [{ type: 'bold' }],\n              },\n            ],\n          },\n        ],\n      };\n\n      customShortcutsService.findById = jest.fn().mockResolvedValue({\n        id: 'cs-1',\n        name: 'commission',\n        shortcut: shortcutContent,\n      });\n\n      const descriptionWithShortcut: Description = {\n        type: 'doc',\n        content: [\n          {\n            type: 'paragraph',\n            content: [\n              { type: 'text', text: 'Check out my ' },\n              { type: 'customShortcut', attrs: { id: 'cs-1' } },\n            ],\n          },\n        ],\n      };\n\n      const defaultOptions = createWebsiteOptions(descriptionWithShortcut);\n      const websiteOptions = createWebsiteOptions(undefined);\n      const description = await service.parse(\n        instance as unknown as UnknownWebsite,\n        new DefaultWebsiteOptions(defaultOptions.data),\n        new BaseWebsiteOptions(websiteOptions.data),\n        [],\n        '',\n      );\n\n      expect(customShortcutsService.findById).toHaveBeenCalledWith('cs-1');\n      expect(description).toMatchInlineSnapshot(\n        `\"<div>Check out my <div><span><b>Commission Info</b></span></div></div>\"`,\n      );\n    });\n\n    it('should inject multiple custom shortcuts', async () => {\n      const instance = {\n        decoratedProps: {\n          allowAd: false,\n          metadata: {\n            name: 'Test',\n          },\n        },\n      };\n\n      const commissionShortcut: Description = {\n        type: 'doc',\n        content: [\n          {\n            type: 'paragraph',\n            content: [{ type: 'text', text: 'Commissions Open!' }],\n          },\n        ],\n      };\n\n      const priceShortcut: Description = {\n        type: 'doc',\n        content: [\n          {\n            type: 'paragraph',\n            content: [{ type: 'text', text: '$50 per hour' }],\n          },\n        ],\n      };\n\n      customShortcutsService.findById = jest\n        .fn()\n        .mockImplementation((id: string) => {\n          if (id === 'cs-1')\n            return Promise.resolve({\n              id: 'cs-1',\n              name: 'commission',\n              shortcut: commissionShortcut,\n            });\n          if (id === 'cs-2')\n            return Promise.resolve({\n              id: 'cs-2',\n              name: 'price',\n              shortcut: priceShortcut,\n            });\n          return Promise.resolve(null);\n        });\n\n      const descriptionWithShortcuts: Description = {\n        type: 'doc',\n        content: [\n          {\n            type: 'paragraph',\n            content: [\n              { type: 'customShortcut', attrs: { id: 'cs-1' } },\n              { type: 'text', text: ' - ' },\n              { type: 'customShortcut', attrs: { id: 'cs-2' } },\n            ],\n          },\n        ],\n      };\n\n      const defaultOptions = createWebsiteOptions(descriptionWithShortcuts);\n      const websiteOptions = createWebsiteOptions(undefined);\n      const description = await service.parse(\n        instance as unknown as UnknownWebsite,\n        new DefaultWebsiteOptions(defaultOptions.data),\n        new BaseWebsiteOptions(websiteOptions.data),\n        [],\n        '',\n      );\n\n      expect(customShortcutsService.findById).toHaveBeenCalledWith('cs-1');\n      expect(customShortcutsService.findById).toHaveBeenCalledWith('cs-2');\n      expect(description).toMatchInlineSnapshot(\n        `\"<div><div>Commissions Open!</div> - <div>$50 per hour</div></div>\"`,\n      );\n    });\n\n    it('should handle missing custom shortcut gracefully', async () => {\n      const instance = {\n        decoratedProps: {\n          allowAd: false,\n          metadata: {\n            name: 'Test',\n          },\n        },\n      };\n\n      customShortcutsService.findById = jest.fn().mockResolvedValue(null);\n\n      const descriptionWithMissing: Description = {\n        type: 'doc',\n        content: [\n          {\n            type: 'paragraph',\n            content: [\n              { type: 'text', text: 'Before ' },\n              {\n                type: 'customShortcut',\n                attrs: { id: 'cs-missing' },\n              },\n              { type: 'text', text: ' After' },\n            ],\n          },\n        ],\n      };\n\n      const defaultOptions = createWebsiteOptions(descriptionWithMissing);\n      const websiteOptions = createWebsiteOptions(undefined);\n      const description = await service.parse(\n        instance as unknown as UnknownWebsite,\n        new DefaultWebsiteOptions(defaultOptions.data),\n        new BaseWebsiteOptions(websiteOptions.data),\n        [],\n        '',\n      );\n\n      expect(customShortcutsService.findById).toHaveBeenCalledWith(\n        'cs-missing',\n      );\n      expect(description).toMatchInlineSnapshot(`\"<div>Before  After</div>\"`);\n    });\n\n    it('should resolve custom shortcuts with different output formats', async () => {\n      const instance = {\n        decoratedProps: {\n          allowAd: false,\n          metadata: {\n            name: 'Test',\n          },\n        },\n      };\n\n      class PlaintextBaseWebsiteOptions extends BaseWebsiteOptions {\n        @DescriptionField({ descriptionType: DescriptionType.PLAINTEXT })\n        description: DescriptionValue;\n      }\n\n      const shortcutContent: Description = {\n        type: 'doc',\n        content: [\n          {\n            type: 'paragraph',\n            content: [\n              {\n                type: 'text',\n                text: 'Bold Text',\n                marks: [{ type: 'bold' }],\n              },\n            ],\n          },\n        ],\n      };\n\n      customShortcutsService.findById = jest.fn().mockResolvedValue({\n        id: 'cs-1',\n        name: 'bold',\n        shortcut: shortcutContent,\n      });\n\n      const descriptionWithShortcut: Description = {\n        type: 'doc',\n        content: [\n          {\n            type: 'paragraph',\n            content: [\n              { type: 'text', text: 'Text: ' },\n              { type: 'customShortcut', attrs: { id: 'cs-1' } },\n            ],\n          },\n        ],\n      };\n\n      const defaultOptions = createWebsiteOptions(descriptionWithShortcut);\n      const websiteOptions = createWebsiteOptions(undefined);\n      const description = await service.parse(\n        instance as unknown as UnknownWebsite,\n        new DefaultWebsiteOptions(defaultOptions.data),\n        new PlaintextBaseWebsiteOptions(websiteOptions.data),\n        [],\n        '',\n      );\n\n      expect(description).toMatchInlineSnapshot(`\"Text: Bold Text\"`);\n    });\n\n    it('should resolve custom shortcuts with links and styling', async () => {\n      const instance = {\n        decoratedProps: {\n          allowAd: false,\n          metadata: {\n            name: 'Test',\n          },\n        },\n      };\n\n      const shortcutWithLink: Description = {\n        type: 'doc',\n        content: [\n          {\n            type: 'paragraph',\n            content: [\n              { type: 'text', text: 'Visit my ' },\n              {\n                type: 'text',\n                text: 'portfolio',\n                marks: [\n                  { type: 'bold' },\n                  {\n                    type: 'link',\n                    attrs: { href: 'https://portfolio.example.com' },\n                  },\n                ],\n              },\n            ],\n          },\n        ],\n      };\n\n      customShortcutsService.findById = jest.fn().mockResolvedValue({\n        id: 'cs-link',\n        name: 'portfolio',\n        shortcut: shortcutWithLink,\n      });\n\n      const descriptionWithShortcut: Description = {\n        type: 'doc',\n        content: [\n          {\n            type: 'paragraph',\n            content: [\n              { type: 'customShortcut', attrs: { id: 'cs-link' } },\n            ],\n          },\n        ],\n      };\n\n      const defaultOptions = createWebsiteOptions(descriptionWithShortcut);\n      const websiteOptions = createWebsiteOptions(undefined);\n      const description = await service.parse(\n        instance as unknown as UnknownWebsite,\n        new DefaultWebsiteOptions(defaultOptions.data),\n        new BaseWebsiteOptions(websiteOptions.data),\n        [],\n        '',\n      );\n\n      expect(description).toMatchInlineSnapshot(\n        `\"<div><div>Visit my <a target=\"_blank\" href=\"https://portfolio.example.com\"><span><b>portfolio</b></span></a></div></div>\"`,\n      );\n    });\n  });\n\n  describe('System Inline Shortcuts', () => {\n    it('should render titleShortcut with submission title', async () => {\n      const instance = {\n        decoratedProps: {\n          allowAd: false,\n          metadata: {\n            name: 'Test',\n          },\n        },\n      };\n\n      const descriptionWithTitle: Description = {\n        type: 'doc',\n        content: [\n          {\n            type: 'paragraph',\n            content: [\n              { type: 'text', text: 'Artwork: ' },\n              { type: 'titleShortcut', attrs: {} },\n            ],\n          },\n        ],\n      };\n\n      const defaultOptions = createWebsiteOptions(descriptionWithTitle);\n      const websiteOptions = createWebsiteOptions(undefined);\n      const description = await service.parse(\n        instance as unknown as UnknownWebsite,\n        new DefaultWebsiteOptions(defaultOptions.data),\n        new BaseWebsiteOptions(websiteOptions.data),\n        [],\n        'My Amazing Art',\n      );\n\n      expect(description).toMatchInlineSnapshot(\n        `\"<div>Artwork: <span>My Amazing Art</span></div>\"`,\n      );\n    });\n\n    it('should render tagsShortcut with submission tags', async () => {\n      const instance = {\n        decoratedProps: {\n          allowAd: false,\n          metadata: {\n            name: 'Test',\n          },\n        },\n      };\n\n      const descriptionWithTags: Description = {\n        type: 'doc',\n        content: [\n          {\n            type: 'paragraph',\n            content: [\n              { type: 'text', text: 'Tags: ' },\n              { type: 'tagsShortcut', attrs: {} },\n            ],\n          },\n        ],\n      };\n\n      const defaultOptions = createWebsiteOptions(descriptionWithTags);\n      const websiteOptions = createWebsiteOptions(undefined);\n      const description = await service.parse(\n        instance as unknown as UnknownWebsite,\n        new DefaultWebsiteOptions(defaultOptions.data),\n        new BaseWebsiteOptions(websiteOptions.data),\n        ['art', 'digital', 'fantasy'],\n        '',\n      );\n\n      expect(description).toMatchInlineSnapshot(\n        `\"<div>Tags: <span>#art #digital #fantasy</span></div>\"`,\n      );\n    });\n\n    it('should render contentWarningShortcut with content warning', async () => {\n      const instance = {\n        decoratedProps: {\n          allowAd: false,\n          metadata: {\n            name: 'Test',\n          },\n        },\n      };\n\n      const descriptionWithCW: Description = {\n        type: 'doc',\n        content: [\n          {\n            type: 'paragraph',\n            content: [\n              { type: 'text', text: 'Content Warning: ' },\n              { type: 'contentWarningShortcut', attrs: {} },\n            ],\n          },\n        ],\n      };\n\n      const defaultOptions = new DefaultWebsiteOptions({\n        description: {\n          description: descriptionWithCW,\n          overrideDefault: false,\n        },\n        contentWarning: 'Mild Violence',\n      });\n      const websiteOptions = new BaseWebsiteOptions({});\n      const description = await service.parse(\n        instance as unknown as UnknownWebsite,\n        defaultOptions,\n        websiteOptions,\n        [],\n        '',\n      );\n\n      expect(description).toMatchInlineSnapshot(\n        `\"<div>Content Warning: <span>Mild Violence</span></div>\"`,\n      );\n    });\n\n    it('should not double-insert title when titleShortcut is present and insertTitle is true', async () => {\n      const instance = {\n        decoratedProps: {\n          allowAd: false,\n          metadata: {\n            name: 'Test',\n          },\n        },\n      };\n\n      const descriptionWithTitle: Description = {\n        type: 'doc',\n        content: [\n          {\n            type: 'paragraph',\n            content: [\n              { type: 'text', text: 'Title: ' },\n              { type: 'titleShortcut', attrs: {} },\n            ],\n          },\n        ],\n      };\n\n      const defaultOptions = createWebsiteOptions(descriptionWithTitle);\n      defaultOptions.data.description.insertTitle = true;\n      const websiteOptions = createWebsiteOptions(undefined);\n      const description = await service.parse(\n        instance as unknown as UnknownWebsite,\n        new DefaultWebsiteOptions(defaultOptions.data),\n        new BaseWebsiteOptions(websiteOptions.data),\n        [],\n        'My Title',\n      );\n\n      expect(description).toMatchInlineSnapshot(\n        `\"<div>Title: <span>My Title</span></div>\"`,\n      );\n      expect((description.match(/My Title/g) || []).length).toBe(1);\n    });\n\n    it('should not double-insert tags when tagsShortcut is present and insertTags is true', async () => {\n      const instance = {\n        decoratedProps: {\n          allowAd: false,\n          metadata: {\n            name: 'Test',\n          },\n        },\n      };\n\n      const descriptionWithTags: Description = {\n        type: 'doc',\n        content: [\n          {\n            type: 'paragraph',\n            content: [\n              { type: 'text', text: 'Tags: ' },\n              { type: 'tagsShortcut', attrs: {} },\n            ],\n          },\n        ],\n      };\n\n      const defaultOptions = createWebsiteOptions(descriptionWithTags);\n      defaultOptions.data.description.insertTags = true;\n      const websiteOptions = createWebsiteOptions(undefined);\n      const description = await service.parse(\n        instance as unknown as UnknownWebsite,\n        new DefaultWebsiteOptions(defaultOptions.data),\n        new BaseWebsiteOptions(websiteOptions.data),\n        ['tag1', 'tag2'],\n        '',\n      );\n\n      expect(description).toMatchInlineSnapshot(\n        `\"<div>Tags: <span>#tag1 #tag2</span></div>\"`,\n      );\n      expect((description.match(/#tag1 #tag2/g) || []).length).toBe(1);\n    });\n\n    it('should render all system shortcuts together in plaintext', async () => {\n      const instance = {\n        decoratedProps: {\n          allowAd: false,\n          metadata: {\n            name: 'Test',\n          },\n        },\n      };\n\n      class PlaintextBaseWebsiteOptions extends BaseWebsiteOptions {\n        @DescriptionField({ descriptionType: DescriptionType.PLAINTEXT })\n        description: DescriptionValue;\n      }\n\n      const descriptionWithAll: Description = {\n        type: 'doc',\n        content: [\n          {\n            type: 'paragraph',\n            content: [\n              { type: 'titleShortcut', attrs: {} },\n              { type: 'text', text: ' (' },\n              { type: 'contentWarningShortcut', attrs: {} },\n              { type: 'text', text: ')' },\n            ],\n          },\n          {\n            type: 'paragraph',\n            content: [{ type: 'tagsShortcut', attrs: {} }],\n          },\n        ],\n      };\n\n      const defaultOptions = new DefaultWebsiteOptions({\n        description: {\n          description: descriptionWithAll,\n          overrideDefault: false,\n        },\n        contentWarning: 'NSFW',\n      });\n      const websiteOptions = new PlaintextBaseWebsiteOptions({});\n      const description = await service.parse(\n        instance as unknown as UnknownWebsite,\n        defaultOptions,\n        websiteOptions,\n        ['art', 'digital'],\n        'My Art',\n      );\n\n      expect(description).toMatchInlineSnapshot(`\n        \"My Art (NSFW)\n        #art #digital\"\n      `);\n    });\n  });\n\n  // TODO: Add test for description type CUSTOM\n});\n"
  },
  {
    "path": "apps/client-server/src/app/post-parsers/parsers/description-parser.service.ts",
    "content": "import { Inject, Injectable } from '@nestjs/common';\nimport {\n  DescriptionType,\n  TipTapNode,\n  UsernameShortcut,\n} from '@postybirb/types';\nimport { Class } from 'type-fest';\nimport { WEBSITE_IMPLEMENTATIONS } from '../../constants';\nimport { CustomShortcutsService } from '../../custom-shortcuts/custom-shortcuts.service';\nimport { SettingsService } from '../../settings/settings.service';\nimport { UserConvertersService } from '../../user-converters/user-converters.service';\nimport { BaseWebsiteOptions } from '../../websites/models/base-website-options';\nimport { DefaultWebsiteOptions } from '../../websites/models/default-website-options';\nimport { isWithCustomDescriptionParser } from '../../websites/models/website-modifiers/with-custom-description-parser';\nimport { isWithRuntimeDescriptionParser } from '../../websites/models/website-modifiers/with-runtime-description-parser';\nimport { UnknownWebsite, Website } from '../../websites/website';\nimport {\n  DescriptionNodeTree,\n  InsertionOptions,\n} from '../models/description-node/description-node-tree';\nimport { ConversionContext } from '../models/description-node/description-node.base';\n\n@Injectable()\nexport class DescriptionParserService {\n  private readonly websiteShortcuts: Record<string, UsernameShortcut> = {};\n\n  constructor(\n    private readonly settingsService: SettingsService,\n    @Inject(WEBSITE_IMPLEMENTATIONS)\n    private readonly websiteImplementations: Class<UnknownWebsite>[],\n    private readonly customShortcutsService?: CustomShortcutsService,\n    private readonly userConvertersService?: UserConvertersService,\n  ) {\n    this.websiteImplementations.forEach((website) => {\n      const shortcut: UsernameShortcut | undefined =\n        website.prototype.decoratedProps.usernameShortcut;\n      if (shortcut) {\n        this.websiteShortcuts[shortcut.id] = shortcut;\n      }\n    });\n  }\n\n  public async parse(\n    instance: Website<unknown>,\n    defaultOptions: DefaultWebsiteOptions,\n    websiteOptions: BaseWebsiteOptions,\n    tags: string[],\n    title: string,\n  ): Promise<string> {\n    const mergedOptions = websiteOptions.mergeDefaults(defaultOptions);\n    const { descriptionType, hidden } =\n      mergedOptions.getFormFieldFor('description');\n\n    if (descriptionType === DescriptionType.NONE || hidden) {\n      return undefined;\n    }\n\n    const settings = await this.settingsService.getDefaultSettings();\n    let { allowAd } = settings.settings;\n\n    if (!instance.decoratedProps.allowAd) {\n      allowAd = false;\n    }\n\n    const descriptionValue = mergedOptions.description;\n    const descriptionBlocks: TipTapNode[] =\n      descriptionValue.description?.content ?? [];\n\n    const { contentWarning } = mergedOptions;\n\n    // Detect presence of shortcut tags to prevent double insertion\n    const hasTitleShortcut = this.hasInlineContentType(\n      descriptionBlocks,\n      'titleShortcut',\n    );\n    const hasTagsShortcut = this.hasInlineContentType(\n      descriptionBlocks,\n      'tagsShortcut',\n    );\n\n    const insertionOptions: InsertionOptions = {\n      insertTitle:\n        descriptionValue.insertTitle && !hasTitleShortcut ? title : undefined,\n      insertTags:\n        descriptionValue.insertTags && !hasTagsShortcut ? tags : undefined,\n      insertAd: allowAd,\n    };\n\n    /*\n     * We choose to merge blocks here to avoid confusing user expectations.\n     * Most editors want you to use Shift + Enter to insert a new line. But in\n     * most cases this is not something the user cares about. They just want to\n     * see the description on a line-by-line basis. So we choose to merge similar\n     * blocks together to avoid confusion.\n     */\n    const mergedDescriptionBlocks = this.mergeBlocks(descriptionBlocks);\n\n    // Pre-resolve default description\n    const defaultDescription = this.mergeBlocks(\n      defaultOptions.description.description?.content ?? [],\n    );\n\n    for (let i = defaultDescription.length - 1; i >= 0; i--) {\n      const element = defaultDescription[i];\n      const isSpacing =\n        element?.type === 'paragraph' &&\n        (!element.content || element.content.length === 0);\n      if (isSpacing) {\n        defaultDescription.splice(i);\n      } else break;\n    }\n\n    // Build tree once with minimal context\n    const context: ConversionContext = {\n      website: instance.decoratedProps.metadata.name,\n      shortcuts: this.websiteShortcuts,\n      customShortcuts: new Map(),\n      defaultDescription,\n      title,\n      tags,\n      usernameConversions: new Map(),\n      contentWarningText: contentWarning,\n    };\n\n    const tree = new DescriptionNodeTree(\n      context,\n      mergedDescriptionBlocks,\n      insertionOptions,\n    );\n\n    // Resolve and inject into the same tree\n    const customShortcuts = await this.resolveCustomShortcutsFromTree(tree);\n    const usernameConversions = await this.resolveUsernamesFromTree(\n      tree,\n      instance,\n    );\n\n    tree.updateContext({\n      customShortcuts,\n      usernameConversions,\n    });\n\n    return this.createDescription(instance, descriptionType, tree);\n  }\n\n  private createDescription(\n    instance: Website<unknown>,\n    descriptionType: DescriptionType,\n    tree: DescriptionNodeTree,\n  ): string {\n    switch (descriptionType) {\n      case DescriptionType.MARKDOWN:\n        return tree.toMarkdown();\n      case DescriptionType.HTML:\n        return tree.toHtml();\n      case DescriptionType.PLAINTEXT:\n        return tree.toPlainText();\n      case DescriptionType.BBCODE:\n        return tree.toBBCode();\n      case DescriptionType.CUSTOM:\n        if (isWithCustomDescriptionParser(instance)) {\n          const converter = instance.getDescriptionConverter();\n          return tree.parseWithConverter(converter);\n        }\n        throw new Error(\n          `Website does not implement custom description parser: ${instance.constructor.name}`,\n        );\n      case DescriptionType.RUNTIME:\n        if (isWithRuntimeDescriptionParser(instance)) {\n          return this.createDescription(\n            instance,\n            instance.getRuntimeParser(),\n            tree,\n          );\n        }\n        throw new Error(\n          `Website does not implement runtime description mapping: ${instance.constructor.name}`,\n        );\n      default:\n        throw new Error(`Unsupported description type: ${descriptionType}`);\n    }\n  }\n\n  /**\n   * Pre-resolves all custom shortcuts found in the description tree.\n   * Note: Does not handle nested shortcuts - users should not create circular references.\n   */\n  private async resolveCustomShortcutsFromTree(\n    tree: DescriptionNodeTree,\n  ): Promise<Map<string, TipTapNode[]>> {\n    const customShortcuts = new Map<string, TipTapNode[]>();\n    const shortcutIds = tree.findCustomShortcutIds();\n\n    for (const id of shortcutIds) {\n      const shortcut = await this.customShortcutsService?.findById(id);\n      if (shortcut) {\n        const shortcutBlocks = this.mergeBlocks(\n          shortcut.shortcut?.content ?? [],\n        );\n        customShortcuts.set(id, shortcutBlocks);\n      }\n    }\n\n    return customShortcuts;\n  }\n\n  /**\n   * Pre-resolves all usernames found in the description tree.\n   */\n  private async resolveUsernamesFromTree(\n    tree: DescriptionNodeTree,\n    instance: Website<unknown>,\n  ): Promise<Map<string, string>> {\n    const usernameConversions = new Map<string, string>();\n    const usernames = tree.findUsernames();\n\n    for (const username of usernames) {\n      const converted =\n        (await this.userConvertersService?.convert(instance, username)) ??\n        username;\n      usernameConversions.set(username, converted);\n    }\n\n    return usernameConversions;\n  }\n\n  public mergeBlocks(blocks: TipTapNode[]): TipTapNode[] {\n    return blocks;\n  }\n\n  /**\n   * Recursively checks if TipTap nodes contain a specific inline content type.\n   */\n  private hasInlineContentType(blocks: TipTapNode[], type: string): boolean {\n    for (const block of blocks) {\n      if (block?.type === type) return true;\n      if (Array.isArray(block?.content)) {\n        if (this.hasInlineContentType(block.content, type)) {\n          return true;\n        }\n      }\n    }\n    return false;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/post-parsers/parsers/rating-parser.spec.ts",
    "content": "import { IWebsiteOptions, SubmissionRating } from '@postybirb/types';\nimport { BaseWebsiteOptions } from '../../websites/models/base-website-options';\nimport { DefaultWebsiteOptions } from '../../websites/models/default-website-options';\nimport { RatingParser } from './rating-parser';\n\ndescribe('RatingParser', () => {\n  let parser: RatingParser;\n\n  beforeEach(() => {\n    parser = new RatingParser();\n  });\n\n  it('should parse rating', () => {\n    const options: IWebsiteOptions = {\n      data: {\n        rating: SubmissionRating.ADULT,\n      },\n    } as IWebsiteOptions;\n    const defaultOptions: IWebsiteOptions = {\n      data: {\n        rating: SubmissionRating.GENERAL,\n      },\n    } as IWebsiteOptions;\n    expect(\n      parser.parse(\n        new DefaultWebsiteOptions(defaultOptions.data),\n        new BaseWebsiteOptions(options.data),\n      ),\n    ).toEqual(SubmissionRating.ADULT);\n  });\n\n  it('should parse default rating', () => {\n    const options: IWebsiteOptions = {\n      data: {},\n    } as IWebsiteOptions;\n    const defaultOptions: IWebsiteOptions = {\n      data: {\n        rating: SubmissionRating.GENERAL,\n      },\n    } as IWebsiteOptions;\n    expect(\n      parser.parse(\n        new DefaultWebsiteOptions(defaultOptions.data),\n        new BaseWebsiteOptions(options.data),\n      ),\n    ).toEqual(SubmissionRating.GENERAL);\n  });\n\n  it('should throw on parsing no rating', () => {\n    expect(() =>\n      parser.parse(\n        { rating: null } as unknown as DefaultWebsiteOptions,\n        { rating: null } as unknown as BaseWebsiteOptions,\n      ),\n    ).toThrow(Error);\n  });\n});\n"
  },
  {
    "path": "apps/client-server/src/app/post-parsers/parsers/rating-parser.ts",
    "content": "import { SubmissionRating } from '@postybirb/types';\nimport { BaseWebsiteOptions } from '../../websites/models/base-website-options';\nimport { DefaultWebsiteOptions } from '../../websites/models/default-website-options';\n\nexport class RatingParser {\n  public parse(\n    defaultOptions: DefaultWebsiteOptions,\n    websiteOptions: BaseWebsiteOptions,\n  ): SubmissionRating {\n    if (websiteOptions.rating) {\n      return websiteOptions.rating;\n    }\n\n    if (defaultOptions.rating) {\n      return defaultOptions.rating;\n    }\n\n    throw new Error('No rating found');\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/post-parsers/parsers/tag-parser.service.spec.ts",
    "content": "/* eslint-disable max-classes-per-file */\nimport { Test, TestingModule } from '@nestjs/testing';\nimport { clearDatabase } from '@postybirb/database';\nimport { TagField } from '@postybirb/form-builder';\nimport { IWebsiteOptions, TagValue } from '@postybirb/types';\nimport { TagConvertersService } from '../../tag-converters/tag-converters.service';\nimport { BaseWebsiteOptions } from '../../websites/models/base-website-options';\nimport { DefaultWebsiteOptions } from '../../websites/models/default-website-options';\nimport { UnknownWebsite } from '../../websites/website';\nimport { TagParserService } from './tag-parser.service';\n\ndescribe('TagParserService', () => {\n  let module: TestingModule;\n  let service: TagParserService;\n\n  beforeEach(async () => {\n    clearDatabase();\n    module = await Test.createTestingModule({\n      providers: [\n        TagParserService,\n        {\n          provide: TagConvertersService,\n          useValue: {\n            convert: (_, tags: string[]) => tags,\n          },\n        },\n      ],\n    }).compile();\n    service = module.get(TagParserService);\n  });\n\n  afterAll(async () => {\n    await module.close();\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n\n  it('should parse tags', async () => {\n    const instance = {};\n    const defaultOptions: IWebsiteOptions = {\n      data: {\n        tags: {\n          tags: ['default'],\n        },\n      },\n    } as IWebsiteOptions;\n    const websiteOptions: IWebsiteOptions = {\n      data: {\n        tags: {\n          tags: ['website'],\n        },\n      },\n    } as IWebsiteOptions;\n    const tags = [\n      ...websiteOptions.data.tags.tags,\n      ...defaultOptions.data.tags.tags,\n    ];\n    const result = await service.parse(\n      instance as unknown as UnknownWebsite,\n      new DefaultWebsiteOptions(defaultOptions.data),\n      new BaseWebsiteOptions(websiteOptions.data),\n    );\n\n    expect(result).toEqual(tags);\n  });\n\n  it('should parse tags with default tags override', async () => {\n    const instance = {};\n    const defaultOptions: IWebsiteOptions = {\n      data: {\n        tags: {\n          tags: ['default'],\n        },\n      },\n    } as IWebsiteOptions;\n    const websiteOptions: IWebsiteOptions = {\n      data: {\n        tags: {\n          tags: ['website'],\n          overrideDefault: true,\n        },\n      },\n    } as IWebsiteOptions;\n    const { tags } = websiteOptions.data.tags;\n\n    const result = await service.parse(\n      instance as unknown as UnknownWebsite,\n      new DefaultWebsiteOptions(defaultOptions.data),\n      new BaseWebsiteOptions(websiteOptions.data),\n    );\n\n    expect(result).toEqual(tags);\n  });\n\n  it('should parse tags with no website options', async () => {\n    const instance = {};\n    const defaultOptions: IWebsiteOptions = {\n      data: {\n        tags: {\n          tags: ['default'],\n        },\n      },\n    } as IWebsiteOptions;\n    const websiteOptions: IWebsiteOptions = {\n      data: {},\n    } as IWebsiteOptions;\n    const { tags } = defaultOptions.data.tags;\n\n    const result = await service.parse(\n      instance as unknown as UnknownWebsite,\n      new DefaultWebsiteOptions(defaultOptions.data),\n      new BaseWebsiteOptions(websiteOptions.data),\n    );\n\n    expect(result).toEqual(tags);\n  });\n\n  it('should parse tags with no website options and no default tags', async () => {\n    const instance = {};\n    const defaultOptions: IWebsiteOptions = {\n      data: {},\n    } as IWebsiteOptions;\n    const websiteOptions: IWebsiteOptions = {\n      data: {},\n    } as IWebsiteOptions;\n    const tags = [];\n\n    const result = await service.parse(\n      instance as unknown as UnknownWebsite,\n      new DefaultWebsiteOptions(defaultOptions.data),\n      new BaseWebsiteOptions(websiteOptions.data),\n    );\n\n    expect(result).toEqual(tags);\n  });\n\n  it('should parse tags with no tag support and return empty', async () => {\n    const instance = {};\n    const defaultOptions: IWebsiteOptions = {\n      data: {\n        tags: {\n          tags: ['default'],\n        },\n      },\n    } as IWebsiteOptions;\n    const websiteOptions: IWebsiteOptions = {\n      data: {\n        tags: {\n          tags: ['website'],\n        },\n      },\n    } as IWebsiteOptions;\n    const tags = [];\n    class TagOptions extends BaseWebsiteOptions {\n      @TagField({ hidden: true })\n      tags: TagValue;\n    }\n    const result = await service.parse(\n      instance as unknown as UnknownWebsite,\n      new DefaultWebsiteOptions(defaultOptions.data),\n      new TagOptions(websiteOptions.data),\n    );\n\n    expect(result).toEqual(tags);\n  });\n\n  it('should parse tags with custom instance tag parser', async () => {\n    const instance = {};\n    class TagOptions extends BaseWebsiteOptions {\n      @TagField({})\n      tags: TagValue;\n\n      protected processTag(tag: string): string {\n        return tag.toUpperCase();\n      }\n    }\n    const defaultOptions: IWebsiteOptions = {\n      data: {\n        tags: {\n          tags: ['default'],\n        },\n      },\n    } as IWebsiteOptions;\n    const websiteOptions: IWebsiteOptions = {\n      data: {\n        tags: {\n          tags: ['website'],\n        },\n      },\n    } as IWebsiteOptions;\n    const tags = [\n      ...websiteOptions.data.tags.tags,\n      ...defaultOptions.data.tags.tags,\n    ].map((tag) => tag.toUpperCase());\n\n    const result = await service.parse(\n      instance as unknown as UnknownWebsite,\n      new DefaultWebsiteOptions(defaultOptions.data),\n      new TagOptions(websiteOptions.data),\n    );\n\n    expect(result).toEqual(tags);\n  });\n\n  it('should truncate tags to maxTags', async () => {\n    const instance = {};\n    const defaultOptions: IWebsiteOptions = {\n      data: {\n        tags: {\n          tags: ['default', 'default2'],\n        },\n      },\n    } as IWebsiteOptions;\n    const websiteOptions: IWebsiteOptions = {\n      data: {\n        tags: {\n          tags: ['website', 'website2'],\n        },\n      },\n    } as IWebsiteOptions;\n    const tags = [websiteOptions.data.tags.tags[0]];\n    class TagOptions extends BaseWebsiteOptions {\n      @TagField({ maxTags: 1 })\n      tags: TagValue;\n    }\n    const result = await service.parse(\n      instance as unknown as UnknownWebsite,\n      new DefaultWebsiteOptions(defaultOptions.data),\n      new TagOptions(websiteOptions.data),\n    );\n\n    expect(result).toEqual(tags);\n  });\n});\n"
  },
  {
    "path": "apps/client-server/src/app/post-parsers/parsers/tag-parser.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { TagConvertersService } from '../../tag-converters/tag-converters.service';\nimport { BaseWebsiteOptions } from '../../websites/models/base-website-options';\nimport { DefaultWebsiteOptions } from '../../websites/models/default-website-options';\nimport { Website } from '../../websites/website';\n\n@Injectable()\nexport class TagParserService {\n  constructor(private readonly tagConvertersService: TagConvertersService) {}\n\n  public async parse(\n    instance: Website<unknown>,\n    defaultOptions: DefaultWebsiteOptions,\n    websiteOptions: BaseWebsiteOptions,\n  ): Promise<string[]> {\n    const mergedOptions = websiteOptions.mergeDefaults(defaultOptions);\n    return mergedOptions.getProcessedTags((tag) =>\n      this.tagConvertersService.convert(instance, tag),\n    );\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/post-parsers/parsers/title-parser.spec.ts",
    "content": "/* eslint-disable max-classes-per-file */\nimport { Test, TestingModule } from '@nestjs/testing';\nimport { clearDatabase } from '@postybirb/database';\nimport { TitleField } from '@postybirb/form-builder';\nimport { IWebsiteOptions } from '@postybirb/types';\nimport { FormGeneratorService } from '../../form-generator/form-generator.service';\nimport { BaseWebsiteOptions } from '../../websites/models/base-website-options';\nimport { DefaultWebsiteOptions } from '../../websites/models/default-website-options';\nimport { TitleParser } from './title-parser';\n\ndescribe('TitleParserService', () => {\n  let module: TestingModule;\n  let service: TitleParser;\n\n  beforeEach(async () => {\n    clearDatabase();\n    module = await Test.createTestingModule({\n      providers: [\n        TitleParser,\n        {\n          provide: FormGeneratorService,\n          useValue: {\n            getDefaultForm: jest.fn(),\n            generateForm: jest.fn(),\n          },\n        },\n      ],\n    }).compile();\n    service = module.get(TitleParser);\n  });\n\n  afterAll(async () => {\n    await module.close();\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n\n  it('should parse title', async () => {\n    const defaultOptions: IWebsiteOptions = {\n      id: 'default',\n      data: {\n        title: 'default',\n      },\n    } as IWebsiteOptions;\n    const websiteOptions: IWebsiteOptions = {\n      id: 'website',\n      data: {\n        title: 'website',\n      },\n    } as IWebsiteOptions;\n\n    const title = await service.parse(\n      new DefaultWebsiteOptions(defaultOptions.data),\n      new BaseWebsiteOptions(websiteOptions.data),\n    );\n\n    expect(title).toBe('website');\n  });\n\n  it('should parse title with no website options', async () => {\n    class TestWebsiteOptions extends BaseWebsiteOptions {\n      @TitleField({ maxLength: 5 })\n      public title: string;\n    }\n\n    class TestDefaultWebsiteOptions extends DefaultWebsiteOptions {\n      @TitleField({ maxLength: 10 })\n      public title: string;\n    }\n\n    const defaultOptions: IWebsiteOptions = {\n      id: 'default',\n      data: {\n        title: 'default',\n      },\n    } as IWebsiteOptions;\n    const websiteOptions: IWebsiteOptions = {\n      id: 'website',\n      data: {},\n    } as IWebsiteOptions;\n\n    const title = await service.parse(\n      new TestDefaultWebsiteOptions(defaultOptions.data),\n      new TestWebsiteOptions(websiteOptions.data),\n    );\n\n    // Title should be truncated\n    expect(title).toBe('defau');\n  });\n\n  it('should parse title and use default form if website form is not available', async () => {\n    const defaultOptions: IWebsiteOptions = {\n      id: 'default',\n      data: {\n        title: 'default',\n      },\n    } as IWebsiteOptions;\n    const websiteOptions: IWebsiteOptions = {\n      id: 'website',\n      data: {},\n    } as IWebsiteOptions;\n\n    const title = await service.parse(\n      new DefaultWebsiteOptions(defaultOptions.data),\n      new BaseWebsiteOptions(websiteOptions.data),\n    );\n\n    expect(title).toBe('default');\n  });\n});\n"
  },
  {
    "path": "apps/client-server/src/app/post-parsers/parsers/title-parser.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { BaseWebsiteOptions } from '../../websites/models/base-website-options';\nimport { DefaultWebsiteOptions } from '../../websites/models/default-website-options';\n\n@Injectable()\nexport class TitleParser {\n  public async parse(\n    defaultOptions: DefaultWebsiteOptions,\n    websiteOptions: BaseWebsiteOptions,\n  ): Promise<string> {\n    const defaultTitleForm = defaultOptions.getFormFieldFor('title');\n    const websiteTitleForm = websiteOptions.getFormFieldFor('title');\n    const merged = websiteOptions.mergeDefaults(defaultOptions);\n\n    const title = merged.title ?? '';\n    const field = websiteTitleForm ?? defaultTitleForm;\n    const maxLength = field?.maxLength ?? Infinity;\n\n    return title.trim().slice(0, maxLength);\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/post-parsers/post-parsers.module.ts",
    "content": "import { Module, forwardRef } from '@nestjs/common';\nimport { CustomShortcutsModule } from '../custom-shortcuts/custom-shortcuts.module';\nimport { FormGeneratorModule } from '../form-generator/form-generator.module';\nimport { SettingsModule } from '../settings/settings.module';\nimport { TagConvertersModule } from '../tag-converters/tag-converters.module';\nimport { UserConvertersModule } from '../user-converters/user-converters.module';\nimport { WebsiteImplProvider } from '../websites/implementations/provider';\nimport { DescriptionParserService } from './parsers/description-parser.service';\nimport { TagParserService } from './parsers/tag-parser.service';\nimport { PostParsersService } from './post-parsers.service';\n\n@Module({\n  imports: [\n    TagConvertersModule,\n    UserConvertersModule,\n    FormGeneratorModule,\n    SettingsModule,\n    forwardRef(() => CustomShortcutsModule),\n  ],\n  providers: [\n    PostParsersService,\n    TagParserService,\n    WebsiteImplProvider,\n    DescriptionParserService,\n  ],\n  exports: [PostParsersService, TagParserService, DescriptionParserService],\n})\nexport class PostParsersModule {}\n"
  },
  {
    "path": "apps/client-server/src/app/post-parsers/post-parsers.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport {\n  ISubmission,\n  IWebsiteFormFields,\n  IWebsiteOptions,\n  PostData,\n} from '@postybirb/types';\nimport { DefaultWebsiteOptions } from '../websites/models/default-website-options';\nimport { UnknownWebsite } from '../websites/website';\nimport { ContentWarningParser } from './parsers/content-warning-parser';\nimport { DescriptionParserService } from './parsers/description-parser.service';\nimport { RatingParser } from './parsers/rating-parser';\nimport { TagParserService } from './parsers/tag-parser.service';\nimport { TitleParser } from './parsers/title-parser';\n\n@Injectable()\nexport class PostParsersService {\n  private readonly ratingParser: RatingParser = new RatingParser();\n\n  private readonly titleParser: TitleParser = new TitleParser();\n\n  private readonly contentWarningParser: ContentWarningParser =\n    new ContentWarningParser();\n\n  constructor(\n    private readonly tagParser: TagParserService,\n    private readonly descriptionParser: DescriptionParserService,\n  ) {}\n\n  public async parse(\n    submission: ISubmission,\n    instance: UnknownWebsite,\n    websiteOptions: IWebsiteOptions,\n  ): Promise<PostData<IWebsiteFormFields>> {\n    const defaultOptions: IWebsiteOptions = submission.options.find(\n      (o) => o.isDefault,\n    );\n    const defaultOpts = Object.assign(new DefaultWebsiteOptions(), {\n      ...defaultOptions.data,\n    });\n    const websiteOpts = Object.assign(instance.getModelFor(submission.type), {\n      ...websiteOptions.data,\n    });\n    const tags = await this.tagParser.parse(instance, defaultOpts, websiteOpts);\n    const title = await this.titleParser.parse(defaultOpts, websiteOpts);\n    const contentWarning = await this.contentWarningParser.parse(\n      defaultOpts,\n      websiteOpts,\n    );\n\n    return {\n      submission,\n      options: {\n        ...defaultOptions.data,\n        ...websiteOptions.data,\n        tags,\n        description: await this.descriptionParser.parse(\n          instance,\n          defaultOpts,\n          websiteOpts,\n          tags,\n          title,\n        ),\n        title,\n        contentWarning,\n        rating: this.ratingParser.parse(defaultOpts, websiteOpts),\n      },\n    };\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/remote/models/update-cookies-remote.dto.ts",
    "content": "import { UpdateCookiesRemote } from '@postybirb/types';\nimport { IsString } from 'class-validator';\n\nexport class UpdateCookiesRemoteDto implements UpdateCookiesRemote {\n  @IsString()\n  accountId: string;\n\n  @IsString()\n  cookies: string;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/remote/remote.controller.ts",
    "content": "import { Body, Controller, Get, Param, Post } from '@nestjs/common';\nimport { UpdateCookiesRemoteDto } from './models/update-cookies-remote.dto';\nimport { RemoteService } from './remote.service';\n\n@Controller('remote')\nexport class RemoteController {\n  constructor(private readonly remoteService: RemoteService) {}\n\n  @Get('ping/:password')\n  ping(@Param('password') password: string) {\n    return this.remoteService.validate(password);\n  }\n\n  @Post('set-cookies')\n  setCookies(@Body() updateCookies: UpdateCookiesRemoteDto) {\n    return this.remoteService.setCookies(updateCookies);\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/remote/remote.middleware.ts",
    "content": "import {\n  Injectable,\n  NestMiddleware,\n  UnauthorizedException,\n} from '@nestjs/common';\nimport { NextFunction, Request, Response } from 'express';\nimport { RemoteService } from './remote.service';\n\n@Injectable()\nexport class RemotePasswordMiddleware implements NestMiddleware {\n  constructor(private readonly remoteService: RemoteService) {}\n\n  async use(req: Request, res: Response, next: NextFunction) {\n    try {\n      if (req.baseUrl.startsWith('/api/file/')) {\n        // Skip authentication for file API routes\n        // This is mostly just to avoid nuisance password injection into query params\n        next();\n        return;\n      }\n\n      if (req.baseUrl.startsWith('/api/remote/ping')) {\n        // Skip authentication for ping API routes to let user check the password\n        next();\n        return;\n      }\n\n      const remotePassword = req.headers['x-remote-password'] as string;\n\n      if (!remotePassword) {\n        throw new UnauthorizedException('No remote password provided');\n      }\n\n      const isValid = await this.remoteService.validate(remotePassword);\n      if (!isValid) {\n        throw new UnauthorizedException('Invalid remote password');\n      }\n\n      next(); // Proceed to the next middleware or route handler\n    } catch (error) {\n      next(error); // Pass the error to the global error handler\n    }\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/remote/remote.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { SettingsModule } from '../settings/settings.module';\nimport { RemoteController } from './remote.controller';\nimport { RemoteService } from './remote.service';\n\n@Module({\n  imports: [SettingsModule],\n  controllers: [RemoteController],\n  providers: [RemoteService],\n  exports: [RemoteService],\n})\nexport class RemoteModule {}\n"
  },
  {
    "path": "apps/client-server/src/app/remote/remote.service.ts",
    "content": "import { Injectable, UnauthorizedException } from '@nestjs/common';\nimport { Logger } from '@postybirb/logger';\nimport { getRemoteConfig } from '@postybirb/utils/electron';\nimport { session } from 'electron';\nimport { UpdateCookiesRemoteDto } from './models/update-cookies-remote.dto';\n\n@Injectable()\nexport class RemoteService {\n  protected readonly logger = Logger(this.constructor.name);\n\n  async validate(password: string): Promise<boolean> {\n    const remoteConfig = await getRemoteConfig();\n    // if (!remoteConfig.enabled) {\n    //   this.logger.error('Remote access is not enabled');\n    //   throw new UnauthorizedException('Remote access is not enabled');\n    // }\n\n    if (remoteConfig.password !== password) {\n      this.logger.error('Invalid remote access password');\n      throw new UnauthorizedException('Invalid remote access password');\n    }\n\n    return true;\n  }\n\n  /**\n   * Set cookies for the specified account ID.\n   * To share cookies with the remote host, this method should be called.\n   *\n   * @param {UpdateCookiesRemoteDto} updateCookies\n   */\n  async setCookies(updateCookies: UpdateCookiesRemoteDto) {\n    this.logger\n      .withMetadata({ accountId: updateCookies.accountId })\n      .info('Updating cookies from remote client');\n\n    const clientCookies = Buffer.from(updateCookies.cookies, 'base64').toString(\n      'utf-8',\n    );\n    const cookies = JSON.parse(clientCookies);\n\n    if (!Array.isArray(cookies)) {\n      this.logger.error('Invalid cookies format received from remote client');\n      throw new Error('Invalid cookies format received from remote client');\n    }\n    if (cookies.length === 0) {\n      this.logger.warn('No cookies provided for account, skipping update');\n      return;\n    }\n    const accountSession = session.fromPartition(\n      `persist:${updateCookies.accountId}`,\n    );\n    await accountSession.clearStorageData();\n    await Promise.all(\n      cookies.map((cookie) =>\n        accountSession.cookies.set(this.convertCookie(cookie)),\n      ),\n    );\n  }\n\n  private convertCookie(cookie: Electron.Cookie): Electron.CookiesSetDetails {\n    const url = `${cookie.secure ? 'https' : 'http'}://${cookie.domain}${cookie.path || ''}`;\n    const details: Electron.CookiesSetDetails = {\n      domain: `.${cookie.domain}`.replace('..', '.'),\n      httpOnly: cookie.httpOnly || false,\n      name: cookie.name,\n      secure: cookie.secure || false,\n      url: url.replace('://.', '://'),\n      value: cookie.value,\n    };\n\n    if (cookie.expirationDate) {\n      details.expirationDate = cookie.expirationDate;\n    }\n\n    return details;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/security-and-authentication/ssl.ts",
    "content": "import { Logger } from '@postybirb/logger';\nimport { app } from 'electron';\nimport { mkdir, readFile, stat, writeFile } from 'fs/promises';\nimport forge from 'node-forge';\nimport { join } from 'path';\n\nexport class SSL {\n  private static cachedCerts?: { key: string; cert: string };\n\n  static async getOrCreateSSL(): Promise<{ key: string; cert: string }> {\n    // Return cached certs if available\n    if (this.cachedCerts) {\n      return this.cachedCerts;\n    }\n\n    const logger = Logger().withContext({ name: 'SSL' });\n    const path = join(app.getPath('userData'), 'auth');\n    const keyPath = join(path, 'key.pem');\n    const certPath = join(path, 'cert.pem');\n\n    // Check if certificates already exist\n    let exists = false;\n    try {\n      await stat(certPath);\n      exists = true;\n    } catch {\n      try {\n        await mkdir(path, { recursive: true });\n      } catch (err) {\n        if (err.code !== 'EEXIST') {\n          logger.error(err);\n        }\n      }\n    }\n\n    if (exists) {\n      const certs = {\n        key: (await readFile(keyPath)).toString(),\n        cert: (await readFile(certPath)).toString(),\n      };\n      this.cachedCerts = certs;\n      return certs;\n    }\n\n    logger.trace('Creating SSL certs...');\n    const { pki } = forge;\n\n    // Generate RSA key pair - will use native crypto if available\n    const keys = pki.rsa.generateKeyPair(2048);\n    const cert = pki.createCertificate();\n\n    cert.publicKey = keys.publicKey;\n    cert.serialNumber = '01';\n    cert.validity.notBefore = new Date();\n    cert.validity.notAfter = new Date();\n    cert.validity.notAfter.setFullYear(\n      cert.validity.notBefore.getFullYear() + 99,\n    );\n\n    const attrs = [\n      { name: 'commonName', value: 'postybirb.com' },\n      { name: 'countryName', value: 'US' },\n      { shortName: 'ST', value: 'Virginia' },\n      { name: 'localityName', value: 'Arlington' },\n      { name: 'organizationName', value: 'PostyBirb' },\n      { shortName: 'OU', value: 'PostyBirb' },\n    ];\n    cert.setSubject(attrs);\n    cert.setIssuer(attrs);\n    cert.sign(keys.privateKey);\n\n    const pkey = pki.privateKeyToPem(keys.privateKey);\n    const pcert = pki.certificateToPem(cert);\n\n    await Promise.all([writeFile(keyPath, pkey), writeFile(certPath, pcert)]);\n\n    logger.info('SSL Certs created');\n\n    const certs = { cert: pcert, key: pkey };\n    this.cachedCerts = certs;\n    return certs;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/settings/dtos/update-settings.dto.ts",
    "content": "import { ApiProperty } from '@nestjs/swagger';\nimport { ISettingsOptions, IUpdateSettingsDto } from '@postybirb/types';\nimport { IsObject } from 'class-validator';\n\n/**\n * Settings update request object.\n */\nexport class UpdateSettingsDto implements IUpdateSettingsDto {\n  @ApiProperty()\n  @IsObject()\n  settings: ISettingsOptions;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/settings/dtos/update-startup-settings.dto.ts",
    "content": "import { ApiProperty } from '@nestjs/swagger';\nimport { StartupOptions } from '@postybirb/utils/electron';\nimport { IsBoolean, IsOptional, IsString } from 'class-validator';\n\nexport class UpdateStartupSettingsDto implements StartupOptions {\n  @ApiProperty()\n  @IsOptional()\n  @IsString()\n  appDataPath: string;\n\n  @ApiProperty()\n  @IsOptional()\n  @IsString()\n  port: string;\n\n  @ApiProperty()\n  @IsOptional()\n  @IsBoolean()\n  startAppOnSystemStartup: boolean;\n\n  @ApiProperty()\n  @IsOptional()\n  @IsBoolean()\n  spellchecker: boolean;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/settings/settings.controller.ts",
    "content": "import { Body, Controller, Get, Param, Patch } from '@nestjs/common';\nimport { ApiNotFoundResponse, ApiOkResponse, ApiTags } from '@nestjs/swagger';\nimport { EntityId } from '@postybirb/types';\nimport { PostyBirbController } from '../common/controller/postybirb-controller';\nimport { UpdateSettingsDto } from './dtos/update-settings.dto';\nimport { UpdateStartupSettingsDto } from './dtos/update-startup-settings.dto';\nimport { SettingsService } from './settings.service';\n\n/**\n * CRUD operations for settings.\n * @class SettingsController\n */\n@ApiTags('settings')\n@Controller('settings')\nexport class SettingsController extends PostyBirbController<'SettingsSchema'> {\n  constructor(readonly service: SettingsService) {\n    super(service);\n  }\n\n  @Patch(':id')\n  @ApiOkResponse({ description: 'Update successful.' })\n  @ApiNotFoundResponse({ description: 'Settings profile not found.' })\n  update(\n    @Body() updateSettingsDto: UpdateSettingsDto,\n    @Param('id') id: EntityId,\n  ) {\n    return this.service\n      .update(id, updateSettingsDto)\n      .then((entity) => entity.toDTO());\n  }\n\n  @Get('startup')\n  getStartupSettings() {\n    return this.service.getStartupSettings();\n  }\n\n  @Patch('startup/system-startup')\n  updateStartupSettings(@Body() startupOptions: UpdateStartupSettingsDto) {\n    return this.service.updateStartupSettings(startupOptions);\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/settings/settings.events.ts",
    "content": "import { SETTINGS_UPDATES } from '@postybirb/socket-events';\nimport { SettingsDto } from '@postybirb/types';\nimport { WebsocketEvent } from '../web-socket/models/web-socket-event';\n\nexport type SettingsEventTypes = SettingsUpdateEvent;\n\nclass SettingsUpdateEvent implements WebsocketEvent<SettingsDto[]> {\n  event: string = SETTINGS_UPDATES;\n\n  data: SettingsDto[];\n}\n"
  },
  {
    "path": "apps/client-server/src/app/settings/settings.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { SettingsController } from './settings.controller';\nimport { SettingsService } from './settings.service';\n\n@Module({\n  providers: [SettingsService],\n  controllers: [SettingsController],\n  exports: [SettingsService],\n})\nexport class SettingsModule {}\n"
  },
  {
    "path": "apps/client-server/src/app/settings/settings.service.spec.ts",
    "content": "import { Test, TestingModule } from '@nestjs/testing';\nimport { clearDatabase } from '@postybirb/database';\nimport { UpdateSettingsDto } from './dtos/update-settings.dto';\nimport { SettingsService } from './settings.service';\n\ndescribe('SettingsService', () => {\n  let service: SettingsService;\n  let module: TestingModule;\n\n  beforeEach(async () => {\n    clearDatabase();\n    module = await Test.createTestingModule({\n      providers: [SettingsService],\n    }).compile();\n\n    service = module.get<SettingsService>(SettingsService);\n    await service.onModuleInit();\n  });\n\n  afterAll(async () => {\n    await module.close();\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n\n  it('should update entities', async () => {\n    const groups = await service.findAll();\n    expect(groups).toHaveLength(1);\n\n    const record = groups[0];\n\n    const updateDto = new UpdateSettingsDto();\n    updateDto.settings = {\n      desktopNotifications: {\n        enabled: true,\n        showOnDirectoryWatcherError: true,\n        showOnDirectoryWatcherSuccess: true,\n        showOnPostError: true,\n        showOnPostSuccess: true,\n      },\n      tagSearchProvider: {\n        id: undefined,\n        showWikiInHelpOnHover: false,\n      },\n      hiddenWebsites: ['test'],\n      language: 'en',\n      allowAd: true,\n      queuePaused: false,\n    };\n    await service.update(record.id, updateDto);\n    const updatedRec = await service.findById(record.id);\n    expect(updatedRec.settings).toEqual(updateDto.settings);\n  });\n});\n"
  },
  {
    "path": "apps/client-server/src/app/settings/settings.service.ts",
    "content": "import {\n    BadRequestException,\n    Injectable,\n    OnModuleInit,\n    Optional,\n} from '@nestjs/common';\nimport { SETTINGS_UPDATES } from '@postybirb/socket-events';\nimport { EntityId, SettingsConstants } from '@postybirb/types';\nimport {\n    StartupOptions,\n    getStartupOptions,\n    setStartupOptions,\n} from '@postybirb/utils/electron';\nimport { eq } from 'drizzle-orm';\nimport { PostyBirbService } from '../common/service/postybirb-service';\nimport { Settings } from '../drizzle/models';\nimport { WSGateway } from '../web-socket/web-socket-gateway';\nimport { UpdateSettingsDto } from './dtos/update-settings.dto';\n\n@Injectable()\nexport class SettingsService\n  extends PostyBirbService<'SettingsSchema'>\n  implements OnModuleInit\n{\n  constructor(@Optional() webSocket: WSGateway) {\n    super('SettingsSchema', webSocket);\n    this.repository.subscribe('SettingsSchema', () => this.emit());\n  }\n\n  /**\n   * Initializes default settings if required.\n   * Also updates existing settings with any new default fields that might be missing.\n   * Heavy merge operations are deferred to avoid blocking application startup.\n   */\n  async onModuleInit() {\n    const defaultSettingsCount = await this.repository.count(\n      eq(this.schema.profile, SettingsConstants.DEFAULT_PROFILE_NAME),\n    );\n\n    if (!defaultSettingsCount) {\n      this.createDefaultSettings();\n    } else {\n      // Defer the settings merge check to avoid blocking startup\n      setImmediate(async () => {\n        // Get existing default settings\n        const existingSettings = await this.getDefaultSettings();\n        if (existingSettings) {\n          // Check if there are any missing fields compared to the current default settings\n          const currentDefaults = SettingsConstants.DEFAULT_SETTINGS;\n          let hasChanges = false;\n          const updatedSettings = { ...existingSettings.settings };\n\n          // Recursively merge missing fields\n          // eslint-disable-next-line @typescript-eslint/no-explicit-any\n          const mergeObjects = (target: any, source: any, path = ''): boolean => {\n            let changed = false;\n\n            Object.keys(source).forEach((key) => {\n              const fullPath = path ? `${path}.${key}` : key;\n\n              // If key doesn't exist in target, add it\n              if (!(key in target)) {\n                // eslint-disable-next-line no-param-reassign\n                target[key] = source[key];\n                this.logger.debug(`Added missing setting: ${fullPath}`);\n                changed = true;\n              }\n              // If both are objects, recursively merge\n              else if (\n                typeof source[key] === 'object' &&\n                source[key] !== null &&\n                typeof target[key] === 'object' &&\n                target[key] !== null &&\n                !Array.isArray(source[key]) &&\n                !Array.isArray(target[key])\n              ) {\n                const nestedChanged = mergeObjects(\n                  target[key],\n                  source[key],\n                  fullPath,\n                );\n                if (nestedChanged) changed = true;\n              }\n            });\n\n            return changed;\n          };\n\n          hasChanges = mergeObjects(updatedSettings, currentDefaults);\n\n          // Update database if there were changes\n          if (hasChanges) {\n            this.logger.debug('Updating default settings with missing fields');\n            await this.repository.update(existingSettings.id, {\n              settings: updatedSettings,\n            });\n          }\n        }\n      });\n    }\n  }\n\n  // Not sure if we'll ever need this\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  create(createDto: unknown): Promise<Settings> {\n    throw new Error('Method not implemented.');\n  }\n\n  /**\n   * Creates the default settings record.\n   */\n  private createDefaultSettings() {\n    this.repository\n      .insert({\n        profile: SettingsConstants.DEFAULT_PROFILE_NAME,\n        settings: SettingsConstants.DEFAULT_SETTINGS,\n      })\n      .then((entity) => {\n        this.logger.withMetadata(entity).debug('Default settings created');\n      })\n      .catch((err: Error) => {\n        this.logger.withError(err).error('Unable to create default settings');\n      });\n  }\n\n  /**\n   * Emits settings.\n   */\n  async emit() {\n    super.emit({\n      event: SETTINGS_UPDATES,\n      data: (await this.findAll()).map((entity) => entity.toDTO()),\n    });\n  }\n\n  /**\n   * Gets the startup settings.\n   */\n  public getStartupSettings() {\n    return getStartupOptions();\n  }\n\n  /**\n   * Gets the default settings.\n   */\n  public getDefaultSettings() {\n    return this.repository.findOne({\n      where: (setting, { eq: equals }) =>\n        equals(setting.profile, SettingsConstants.DEFAULT_PROFILE_NAME),\n    });\n  }\n\n  /**\n   * Updates app startup settings.\n   */\n  public updateStartupSettings(startUpOptions: Partial<StartupOptions>) {\n    if (startUpOptions.appDataPath) {\n      // eslint-disable-next-line no-param-reassign\n      startUpOptions.appDataPath = startUpOptions.appDataPath.trim();\n    }\n\n    if (startUpOptions.port) {\n      // eslint-disable-next-line no-param-reassign\n      startUpOptions.port = startUpOptions.port.trim();\n      const port = parseInt(startUpOptions.port, 10);\n      if (Number.isNaN(port) || port < 1024 || port > 65535) {\n        throw new BadRequestException('Invalid port');\n      }\n    }\n\n    setStartupOptions({ ...startUpOptions });\n  }\n\n  /**\n   * Updates settings.\n   *\n   * @param {string} id\n   * @param {UpdateSettingsDto} updateSettingsDto\n   * @return {*}\n   */\n  async update(id: EntityId, updateSettingsDto: UpdateSettingsDto) {\n    this.logger\n      .withMetadata(updateSettingsDto)\n      .info(`Updating Settings '${id}'`);\n\n    return this.repository.update(id, updateSettingsDto);\n  }\n\n  /**\n   * Tests remote connection to a PostyBirb host.\n   *\n   * @param {string} hostUrl\n   * @param {string} password\n   * @return {Promise<{ success: boolean; message: string }>\n   */\n  async testRemoteConnection(\n    hostUrl: string,\n    password: string,\n  ): Promise<{ success: boolean; message: string }> {\n    try {\n      if (!hostUrl || !password) {\n        return {\n          success: false,\n          message: 'Host URL and password are required',\n        };\n      }\n\n      // Clean up the URL\n      const cleanUrl = hostUrl.trim().replace(/\\/$/, '');\n      const testUrl = `${cleanUrl}/api/remote/ping/${encodeURIComponent(password)}`;\n\n      this.logger.debug(`Testing remote connection to: ${cleanUrl}`);\n\n      const response = await fetch(testUrl, {\n        method: 'GET',\n        headers: {\n          'Content-Type': 'application/json',\n        },\n        // Set a reasonable timeout\n        signal: AbortSignal.timeout(10000), // 10 seconds\n      });\n\n      if (response.ok) {\n        const result = await response.json();\n        if (result === true) {\n          return {\n            success: true,\n            message: 'Connection successful! Host is reachable and password is correct.',\n          };\n        }\n      }\n\n      // Handle different HTTP status codes\n      switch (response.status) {\n        case 401:\n          return {\n            success: false,\n            message: 'Authentication failed. Please check your password.',\n          };\n        case 404:\n          return {\n            success: false,\n            message: 'Host not found. Please check the URL.',\n          };\n        case 500:\n          return {\n            success: false,\n            message: 'Host server error. The remote host may not be configured properly.',\n          };\n        default:\n          return {\n            success: false,\n            message: `Connection failed with status ${response.status}`,\n          };\n      }\n    } catch (error) {\n      this.logger.withError(error).error('Remote connection test failed');\n\n      if (error instanceof TypeError && error.message.includes('fetch')) {\n        return {\n          success: false,\n          message: 'Network error. Please check the host URL and ensure the host is running.',\n        };\n      }\n\n      if (error.name === 'AbortError') {\n        return {\n          success: false,\n          message: 'Connection timeout. The host may be unreachable.',\n        };\n      }\n\n      return {\n        success: false,\n        message: `Connection test failed: ${error.message}`,\n      };\n    }\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/submission/dtos/apply-multi-submission.dto.ts",
    "content": "import { ApiProperty } from '@nestjs/swagger';\nimport { IApplyMultiSubmissionDto, SubmissionId } from '@postybirb/types';\nimport { IsArray, IsBoolean, IsNotEmpty, IsString } from 'class-validator';\n\nexport class ApplyMultiSubmissionDto implements IApplyMultiSubmissionDto {\n  @ApiProperty()\n  @IsString()\n  @IsNotEmpty()\n  submissionToApply: SubmissionId;\n\n  @ApiProperty()\n  @IsArray()\n  @IsString({ each: true })\n  @IsNotEmpty()\n  submissionIds: SubmissionId[];\n\n  @ApiProperty()\n  @IsBoolean()\n  merge: boolean;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/submission/dtos/apply-template-options.dto.ts",
    "content": "import { ApiProperty } from '@nestjs/swagger';\nimport { SubmissionId } from '@postybirb/types';\nimport { Type } from 'class-transformer';\nimport {\n    IsArray,\n    IsBoolean,\n    IsNotEmpty,\n    IsString,\n    ValidateNested,\n} from 'class-validator';\nimport { TemplateOptionDto } from './template-option.dto';\n\n/**\n * DTO for applying selected template options to multiple submissions.\n */\nexport class ApplyTemplateOptionsDto {\n  @ApiProperty({ description: 'Submission IDs to apply template options to' })\n  @IsArray()\n  @IsString({ each: true })\n  @IsNotEmpty()\n  targetSubmissionIds: SubmissionId[];\n\n  @ApiProperty({\n    description: 'Template options to apply',\n    type: [TemplateOptionDto],\n  })\n  @IsArray()\n  @ValidateNested({ each: true })\n  @Type(() => TemplateOptionDto)\n  options: TemplateOptionDto[];\n\n  @ApiProperty({ description: 'Whether to replace title with template title' })\n  @IsBoolean()\n  overrideTitle: boolean;\n\n  @ApiProperty({\n    description: 'Whether to replace description with template description',\n  })\n  @IsBoolean()\n  overrideDescription: boolean;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/submission/dtos/create-submission.dto.ts",
    "content": "import { ApiProperty } from '@nestjs/swagger';\nimport {\n    ICreateSubmissionDefaultOptions,\n    ICreateSubmissionDto,\n    IFileMetadata,\n    SubmissionType,\n} from '@postybirb/types';\nimport { Transform } from 'class-transformer';\nimport {\n    IsArray,\n    IsBoolean,\n    IsEnum,\n    IsObject,\n    IsOptional,\n    IsString,\n} from 'class-validator';\n\n/**\n * Helper to parse JSON strings from FormData.\n * Returns the parsed object or the original value if already an object.\n */\nfunction parseJsonField<T>(value: unknown): T | undefined {\n  if (value === undefined || value === null || value === '') {\n    return undefined;\n  }\n  if (typeof value === 'string') {\n    try {\n      return JSON.parse(value) as T;\n    } catch {\n      return undefined;\n    }\n  }\n  return value as T;\n}\n\nexport class CreateSubmissionDto implements ICreateSubmissionDto {\n  @ApiProperty()\n  @IsOptional()\n  @IsString()\n  name: string;\n\n  @ApiProperty({ enum: SubmissionType })\n  @IsOptional()\n  @IsEnum(SubmissionType)\n  type: SubmissionType;\n\n  @ApiProperty()\n  @IsOptional()\n  @IsBoolean()\n  @Transform(({ value }) => value === 'true' || value === true)\n  isTemplate?: boolean;\n\n  @ApiProperty()\n  @IsOptional()\n  @IsBoolean()\n  @Transform(({ value }) => value === 'true' || value === true)\n  isMultiSubmission?: boolean;\n\n  @ApiProperty({ description: 'Default options to apply to all created submissions' })\n  @IsOptional()\n  @IsObject()\n  @Transform(({ value }) => parseJsonField<ICreateSubmissionDefaultOptions>(value))\n  defaultOptions?: ICreateSubmissionDefaultOptions;\n\n  @ApiProperty({ description: 'Per-file metadata for batch uploads' })\n  @IsOptional()\n  @IsArray()\n  @Transform(({ value }) => parseJsonField<IFileMetadata[]>(value))\n  fileMetadata?: IFileMetadata[];\n}\n"
  },
  {
    "path": "apps/client-server/src/app/submission/dtos/reorder-submission-files.dto.ts",
    "content": "import { IReorderSubmissionFilesDto } from '@postybirb/types';\nimport { IsObject } from 'class-validator';\n\nexport class ReorderSubmissionFilesDto implements IReorderSubmissionFilesDto {\n  @IsObject()\n  order: Record<string, number>;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/submission/dtos/reorder-submission.dto.ts",
    "content": "import { ApiProperty } from '@nestjs/swagger';\nimport { SubmissionId } from '@postybirb/types';\nimport { IsIn, IsString } from 'class-validator';\n\nexport class ReorderSubmissionDto {\n  @ApiProperty({ description: 'The ID of the submission to move' })\n  @IsString()\n  id: SubmissionId;\n\n  @ApiProperty({\n    description: 'The ID of the target submission to position relative to',\n  })\n  @IsString()\n  targetId: SubmissionId;\n\n  @ApiProperty({\n    description: 'Whether to place before or after the target',\n    enum: ['before', 'after'],\n  })\n  @IsIn(['before', 'after'])\n  position: 'before' | 'after';\n}\n"
  },
  {
    "path": "apps/client-server/src/app/submission/dtos/template-option.dto.ts",
    "content": "import { ApiProperty } from '@nestjs/swagger';\nimport { AccountId, IWebsiteFormFields } from '@postybirb/types';\nimport { IsNotEmpty, IsObject, IsString } from 'class-validator';\n\n/**\n * Single template option to apply.\n */\nexport class TemplateOptionDto {\n  @ApiProperty()\n  @IsString()\n  @IsNotEmpty()\n  accountId: AccountId;\n\n  @ApiProperty({ type: Object })\n  @IsObject()\n  data: IWebsiteFormFields;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/submission/dtos/update-alt-file.dto.ts",
    "content": "import { IUpdateAltFileDto } from '@postybirb/types';\nimport { IsString } from 'class-validator';\n\nexport class UpdateAltFileDto implements IUpdateAltFileDto {\n  @IsString()\n  text: string;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/submission/dtos/update-submission-template-name.dto.ts",
    "content": "import { ApiProperty } from '@nestjs/swagger';\nimport { IUpdateSubmissionTemplateNameDto } from '@postybirb/types';\nimport { IsString } from 'class-validator';\n\nexport class UpdateSubmissionTemplateNameDto\n  implements IUpdateSubmissionTemplateNameDto\n{\n  @ApiProperty()\n  @IsString()\n  name: string;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/submission/dtos/update-submission.dto.ts",
    "content": "import { ApiProperty } from '@nestjs/swagger';\nimport {\n  ISubmissionMetadata,\n  IUpdateSubmissionDto,\n  IWebsiteFormFields,\n  ScheduleType,\n  WebsiteOptionsDto\n} from '@postybirb/types';\nimport {\n  IsArray,\n  IsBoolean,\n  IsEnum,\n  IsISO8601,\n  IsObject,\n  IsOptional,\n  IsString,\n} from 'class-validator';\n\nexport class UpdateSubmissionDto implements IUpdateSubmissionDto {\n  @ApiProperty()\n  @IsOptional()\n  @IsBoolean()\n  isArchived?: boolean;\n\n  @ApiProperty()\n  @IsOptional()\n  @IsBoolean()\n  isScheduled?: boolean;\n\n  @ApiProperty()\n  @IsOptional()\n  @IsString()\n  @IsISO8601()\n  scheduledFor?: string | null | undefined;\n\n  @ApiProperty({ enum: ScheduleType })\n  @IsOptional()\n  @IsEnum(ScheduleType)\n  scheduleType?: ScheduleType;\n\n  @ApiProperty()\n  @IsOptional()\n  @IsString()\n  cron?: string | null | undefined;\n\n  @ApiProperty()\n  @IsOptional()\n  @IsArray()\n  deletedWebsiteOptions?: string[];\n\n  @ApiProperty()\n  @IsOptional()\n  @IsArray()\n  newOrUpdatedOptions?: WebsiteOptionsDto<IWebsiteFormFields>[];\n\n  @ApiProperty()\n  @IsOptional()\n  @IsObject()\n  metadata?: ISubmissionMetadata;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/submission/file-submission.controller.ts",
    "content": "import {\n  BadRequestException,\n  Body,\n  Controller,\n  Delete,\n  Get,\n  Param,\n  Patch,\n  Post,\n  UploadedFile,\n  UploadedFiles,\n  UseInterceptors,\n} from '@nestjs/common';\nimport { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express';\nimport {\n  ApiBadRequestResponse,\n  ApiConsumes,\n  ApiOkResponse,\n  ApiTags,\n} from '@nestjs/swagger';\nimport {\n  EntityId,\n  SubmissionFileMetadata,\n  SubmissionId,\n} from '@postybirb/types';\nimport { MulterFileInfo } from '../file/models/multer-file-info';\nimport { ReorderSubmissionFilesDto } from './dtos/reorder-submission-files.dto';\nimport { UpdateAltFileDto } from './dtos/update-alt-file.dto';\nimport { FileSubmissionService } from './services/file-submission.service';\nimport { SubmissionService } from './services/submission.service';\n\ntype Target = 'file' | 'thumbnail';\n\n/**\n * Specific REST operations for File Submissions.\n * i.e. as thumbnail changes.\n * @class FileSubmissionController\n */\n@ApiTags('file-submission')\n@Controller('file-submission')\nexport class FileSubmissionController {\n  constructor(\n    private service: FileSubmissionService,\n    private submissionService: SubmissionService,\n  ) {}\n\n  private findOne(id: SubmissionId) {\n    return this.submissionService.findById(id).then((record) => record.toDTO());\n  }\n\n  @Post('add/:target/:id')\n  @ApiConsumes('multipart/form-data')\n  @ApiOkResponse({ description: 'File appended.' })\n  @ApiBadRequestResponse({ description: 'Bad request made.' })\n  @UseInterceptors(FilesInterceptor('files', undefined, { preservePath: true }))\n  async appendFile(\n    @Param('target') target: Target,\n    @Param('id') id: SubmissionId,\n    @UploadedFiles() files: MulterFileInfo[],\n  ) {\n    switch (target) {\n      case 'file':\n        await Promise.all(\n          files.map((file) => this.service.appendFile(id, file)),\n        );\n        break;\n      case 'thumbnail':\n      default:\n        throw new BadRequestException(`Unsupported add target '${target}'`);\n    }\n\n    return this.findOne(id);\n  }\n\n  @Post('replace/:target/:id/:fileId')\n  @ApiConsumes('multipart/form-data')\n  @ApiOkResponse({ description: 'File replaced.' })\n  @ApiBadRequestResponse({ description: 'Bad request made.' })\n  @UseInterceptors(FileInterceptor('file', { preservePath: true }))\n  async replaceFile(\n    @Param('target') target: Target,\n    @Param('id') id: SubmissionId,\n    @Param('fileId') fileId: EntityId,\n    @UploadedFile() file: MulterFileInfo,\n  ) {\n    switch (target) {\n      case 'file':\n        await this.service.replaceFile(id, fileId, file);\n        break;\n      case 'thumbnail':\n        await this.service.replaceThumbnail(id, fileId, file);\n        break;\n      default:\n        throw new BadRequestException(`Unsupported replace target '${target}'`);\n    }\n\n    return this.findOne(id);\n  }\n\n  @Delete('remove/:target/:id/:fileId')\n  @ApiOkResponse({ description: 'File removed.' })\n  @ApiBadRequestResponse({ description: 'Bad request made.' })\n  async removeFile(\n    @Param('target') target: Target,\n    @Param('id') id: SubmissionId,\n    @Param('fileId') fileId: EntityId,\n  ) {\n    switch (target) {\n      case 'file':\n        await this.service.removeFile(id, fileId);\n        break;\n      case 'thumbnail':\n      default:\n        throw new BadRequestException(`Unsupported remove target '${target}'`);\n    }\n\n    return this.findOne(id);\n  }\n\n  @Get('alt/:id')\n  @ApiOkResponse({ description: 'Alt File Text.' })\n  async getAltFileText(@Param('id') id: EntityId) {\n    return this.service.getAltFileText(id);\n  }\n\n  @Patch('alt/:id')\n  @ApiOkResponse({ description: 'Updated Alt File Text.' })\n  async updateAltFileText(\n    @Param('id') id: EntityId,\n    @Body() update: UpdateAltFileDto,\n  ) {\n    return this.service.updateAltFileText(id, update);\n  }\n\n  @Patch('metadata/:id')\n  @ApiOkResponse({ description: 'Updated Metadata.' })\n  async updateMetadata(\n    @Param('id') id: EntityId,\n    @Body() update: SubmissionFileMetadata,\n  ) {\n    return this.service.updateMetadata(id, update);\n  }\n\n  @Patch('reorder')\n  @ApiOkResponse({ description: 'Files reordered.' })\n  async reorderFiles(@Body() update: ReorderSubmissionFilesDto) {\n    return this.service.reorderFiles(update);\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/submission/services/file-submission.service.ts",
    "content": "import {\n  BadRequestException,\n  forwardRef,\n  Inject,\n  Injectable,\n} from '@nestjs/common';\nimport {\n  EntityId,\n  FileSubmission,\n  FileType,\n  isFileSubmission,\n  ISubmission,\n  SubmissionFileMetadata,\n  SubmissionId,\n  SubmissionType,\n} from '@postybirb/types';\nimport { getFileType } from '@postybirb/utils/file-type';\nimport { PostyBirbService } from '../../common/service/postybirb-service';\nimport { PostyBirbDatabase } from '../../drizzle/postybirb-database/postybirb-database';\nimport { FileService } from '../../file/file.service';\nimport { MulterFileInfo } from '../../file/models/multer-file-info';\nimport { CreateSubmissionDto } from '../dtos/create-submission.dto';\nimport { ReorderSubmissionFilesDto } from '../dtos/reorder-submission-files.dto';\nimport { UpdateAltFileDto } from '../dtos/update-alt-file.dto';\nimport { ISubmissionService } from './submission-service.interface';\nimport { SubmissionService } from './submission.service';\n\n/**\n * Service that implements logic for manipulating a FileSubmission.\n * All actions perform mutations on the original object.\n *\n * @class FileSubmissionService\n * @implements {ISubmissionService<FileSubmission>}\n */\n@Injectable()\nexport class FileSubmissionService\n  extends PostyBirbService<'SubmissionSchema'>\n  implements ISubmissionService<FileSubmission>\n{\n  constructor(\n    private readonly fileService: FileService,\n    @Inject(forwardRef(() => SubmissionService))\n    private readonly submissionService: SubmissionService,\n  ) {\n    super(\n      new PostyBirbDatabase('SubmissionSchema', {\n        files: true,\n      }),\n    );\n  }\n\n  async populate(\n    submission: FileSubmission,\n    createSubmissionDto: CreateSubmissionDto,\n    file: MulterFileInfo,\n  ): Promise<void> {\n    // eslint-disable-next-line no-param-reassign\n    submission.metadata = {\n      ...submission.metadata,\n    };\n\n    await this.appendFile(submission, file);\n  }\n\n  private guardIsFileSubmission(submission: ISubmission) {\n    if (!isFileSubmission(submission)) {\n      throw new BadRequestException(\n        `Submission '${(submission as ISubmission).id}' is not a ${SubmissionType.FILE} submission.`,\n      );\n    }\n\n    if (submission.metadata.template) {\n      throw new BadRequestException(\n        `Submission '${submission.id}' is a template and cannot have files.`,\n      );\n    }\n  }\n\n  /**\n   * Guards against mixing different file types in the same submission.\n   * For example, prevents adding an IMAGE file to a submission that already contains a TEXT (PDF) file.\n   *\n   * @param {FileSubmission} submission - The submission to check\n   * @param {MulterFileInfo} file - The new file being added\n   * @throws {BadRequestException} if file types are incompatible\n   */\n  private guardFileTypeCompatibility(\n    submission: FileSubmission,\n    file: MulterFileInfo,\n  ) {\n    if (!submission.files || submission.files.length === 0) {\n      return; // No existing files, any type is allowed\n    }\n\n    const newFileType = getFileType(file.originalname);\n    const existingFileType = getFileType(submission.files[0].fileName);\n\n    if (newFileType !== existingFileType) {\n      const fileTypeLabels: Record<FileType, string> = {\n        [FileType.IMAGE]: 'IMAGE',\n        [FileType.VIDEO]: 'VIDEO',\n        [FileType.AUDIO]: 'AUDIO',\n        [FileType.TEXT]: 'TEXT',\n        [FileType.UNKNOWN]: 'UNKNOWN',\n      };\n      throw new BadRequestException(\n        `Cannot add ${fileTypeLabels[newFileType]} file to a submission containing ${fileTypeLabels[existingFileType]} files. All files in a submission must be of the same type.`,\n      );\n    }\n  }\n\n  /**\n   * Adds a file to a submission.\n   *\n   * @param {string} id\n   * @param {MulterFileInfo} file\n   */\n  async appendFile(id: EntityId | FileSubmission, file: MulterFileInfo) {\n    const submission = (\n      typeof id === 'string'\n        ? await this.repository.findById(id, {\n            failOnMissing: true,\n          })\n        : id\n    ) as FileSubmission;\n\n    this.guardIsFileSubmission(submission);\n    this.guardFileTypeCompatibility(submission, file);\n\n    const createdFile = await this.fileService.create(file, submission);\n    this.logger\n      .withMetadata(submission)\n      .info(`Created file ${createdFile.id} = ${submission.id}`);\n\n    await this.repository.update(submission.id, {\n      metadata: submission.metadata,\n    });\n\n    return submission;\n  }\n\n  async replaceFile(id: EntityId, fileId: EntityId, file: MulterFileInfo) {\n    const submission = (await this.repository.findById(\n      id,\n    )) as unknown as FileSubmission;\n    this.guardIsFileSubmission(submission);\n\n    await this.fileService.update(file, fileId, false);\n  }\n\n  /**\n   * Replaces a thumbnail file.\n   *\n   * @param {SubmissionId} id\n   * @param {EntityId} fileId\n   * @param {MulterFileInfo} file\n   */\n  async replaceThumbnail(\n    id: SubmissionId,\n    fileId: EntityId,\n    file: MulterFileInfo,\n  ) {\n    const submission = (await this.repository.findById(\n      id,\n    )) as unknown as FileSubmission;\n    this.guardIsFileSubmission(submission);\n\n    await this.fileService.update(file, fileId, true);\n  }\n\n  /**\n   * Removes a file of thumbnail that matches file id.\n   *\n   * @param {SubmissionId} id\n   * @param {EntityId} fileId\n   */\n  async removeFile(id: SubmissionId, fileId: EntityId) {\n    const submission = (await this.repository.findById(\n      id,\n    )) as unknown as FileSubmission;\n    this.guardIsFileSubmission(submission);\n\n    await this.fileService.remove(fileId);\n    await this.repository.update(submission.id, {\n      metadata: submission.metadata,\n    });\n  }\n\n  getAltFileText(id: EntityId) {\n    return this.fileService.getAltText(id);\n  }\n\n  updateAltFileText(id: EntityId, update: UpdateAltFileDto) {\n    return this.fileService.updateAltText(id, update);\n  }\n\n  updateMetadata(id: EntityId, update: SubmissionFileMetadata) {\n    return this.fileService.updateMetadata(id, update);\n  }\n\n  reorderFiles(update: ReorderSubmissionFilesDto) {\n    return this.fileService.reorderFiles(update);\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/submission/services/message-submission.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { MessageSubmission } from '@postybirb/types';\nimport { CreateSubmissionDto } from '../dtos/create-submission.dto';\nimport { ISubmissionService } from './submission-service.interface';\n\n@Injectable()\nexport class MessageSubmissionService\n  implements ISubmissionService<MessageSubmission>\n{\n  async populate(\n    submission: MessageSubmission,\n    createSubmissionDto: CreateSubmissionDto,\n  ): Promise<void> {\n    // Do nothing for now\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/submission/services/submission-service.interface.ts",
    "content": "import { ISubmission, SubmissionMetadataType } from '@postybirb/types';\nimport { MulterFileInfo } from '../../file/models/multer-file-info';\nimport { CreateSubmissionDto } from '../dtos/create-submission.dto';\n\nexport interface ISubmissionService<\n  T extends ISubmission<SubmissionMetadataType>,\n> {\n  populate(\n    submission: T,\n    createSubmissionDto: CreateSubmissionDto,\n    file?: MulterFileInfo,\n  ): Promise<void>;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/submission/services/submission.service.spec.ts",
    "content": "import { BadRequestException, NotFoundException } from '@nestjs/common';\nimport { Test, TestingModule } from '@nestjs/testing';\nimport { clearDatabase } from '@postybirb/database';\nimport { PostyBirbDirectories, writeSync } from '@postybirb/fs';\nimport {\n  FileSubmissionMetadata,\n  IWebsiteFormFields,\n  ScheduleType,\n  SubmissionRating,\n  SubmissionType,\n  WebsiteOptionsDto,\n} from '@postybirb/types';\nimport { readFileSync } from 'fs';\nimport { join } from 'path';\nimport { AccountModule } from '../../account/account.module';\nimport { AccountService } from '../../account/account.service';\nimport { CreateAccountDto } from '../../account/dtos/create-account.dto';\nimport { FileConverterService } from '../../file-converter/file-converter.service';\nimport { FileService } from '../../file/file.service';\nimport { MulterFileInfo } from '../../file/models/multer-file-info';\nimport { CreateFileService } from '../../file/services/create-file.service';\nimport { UpdateFileService } from '../../file/services/update-file.service';\nimport { FormGeneratorModule } from '../../form-generator/form-generator.module';\nimport { SharpInstanceManager } from '../../image-processing/sharp-instance-manager';\nimport { PostParsersModule } from '../../post-parsers/post-parsers.module';\nimport { UserSpecifiedWebsiteOptionsModule } from '../../user-specified-website-options/user-specified-website-options.module';\nimport { UserSpecifiedWebsiteOptionsService } from '../../user-specified-website-options/user-specified-website-options.service';\nimport { waitUntilPromised } from '../../utils/wait.util';\nimport { ValidationService } from '../../validation/validation.service';\nimport { WebsiteOptionsService } from '../../website-options/website-options.service';\nimport { WebsiteImplProvider } from '../../websites/implementations/provider';\nimport { WebsiteRegistryService } from '../../websites/website-registry.service';\nimport { WebsitesModule } from '../../websites/websites.module';\nimport { CreateSubmissionDto } from '../dtos/create-submission.dto';\nimport { UpdateSubmissionDto } from '../dtos/update-submission.dto';\nimport { FileSubmissionService } from './file-submission.service';\nimport { MessageSubmissionService } from './message-submission.service';\nimport { SubmissionService } from './submission.service';\n\ndescribe('SubmissionService', () => {\n  let testFile: Buffer | null = null;\n  let service: SubmissionService;\n  let websiteOptionsService: WebsiteOptionsService;\n  let accountService: AccountService;\n  let module: TestingModule;\n\n  beforeAll(() => {\n    testFile = readFileSync(\n      join(__dirname, '../../../test-files/small_image.jpg'),\n    );\n  });\n\n  beforeEach(async () => {\n    clearDatabase();\n    try {\n      module = await Test.createTestingModule({\n        imports: [\n          AccountModule,\n          WebsitesModule,\n          UserSpecifiedWebsiteOptionsModule,\n          PostParsersModule,\n          FormGeneratorModule,\n        ],\n        providers: [\n          SubmissionService,\n          CreateFileService,\n          UpdateFileService,\n          SharpInstanceManager,\n          FileService,\n          FileSubmissionService,\n          MessageSubmissionService,\n          AccountService,\n          WebsiteRegistryService,\n          UserSpecifiedWebsiteOptionsService,\n          ValidationService,\n          WebsiteOptionsService,\n          WebsiteImplProvider,\n          FileConverterService,\n        ],\n      }).compile();\n\n      service = module.get<SubmissionService>(SubmissionService);\n      websiteOptionsService = module.get<WebsiteOptionsService>(\n        WebsiteOptionsService,\n      );\n      accountService = module.get<AccountService>(AccountService);\n      await accountService.onModuleInit();\n    } catch (e) {\n      console.error(e);\n    }\n  });\n\n  afterAll(async () => {\n    await module.close();\n  });\n\n  function setup(): string {\n    const path = `${PostyBirbDirectories.DATA_DIRECTORY}/${Date.now()}.jpg`;\n    writeSync(path, testFile);\n    return path;\n  }\n\n  async function createAccount() {\n    const dto = new CreateAccountDto();\n    dto.groups = ['test'];\n    dto.name = 'test';\n    dto.website = 'test';\n\n    const record = await accountService.create(dto);\n    return record;\n  }\n\n  function createSubmissionDto(): CreateSubmissionDto {\n    const dto = new CreateSubmissionDto();\n    dto.name = 'Test';\n    dto.type = SubmissionType.MESSAGE;\n    return dto;\n  }\n\n  function createMulterData(path: string): MulterFileInfo {\n    return {\n      fieldname: 'file',\n      originalname: 'small_image.jpg',\n      encoding: '',\n      mimetype: 'image/jpeg',\n      size: testFile.length,\n      destination: '',\n      filename: 'small_image.jpg',\n      path,\n      origin: undefined,\n    };\n  }\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n\n  it('should create message entities', async () => {\n    const createDto = createSubmissionDto();\n    const record = await service.create(createDto);\n\n    const records = await service.findAll();\n    expect(records).toHaveLength(1);\n    expect(records[0].type).toEqual(createDto.type);\n    expect(records[0].options).toHaveLength(1);\n    expect(record.toDTO()).toEqual({\n      id: record.id,\n      createdAt: record.createdAt,\n      updatedAt: record.updatedAt,\n      type: record.type,\n      isScheduled: false,\n      isTemplate: false,\n      isArchived: false,\n      isInitialized: true,\n      isMultiSubmission: false,\n      schedule: {\n        scheduleType: ScheduleType.NONE,\n      },\n      metadata: {},\n      files: [],\n      order: 1,\n      posts: [],\n      postQueueRecord: undefined,\n      options: [record.options[0].toDTO()],\n      validations: [],\n    });\n  });\n\n  it('should delete entity', async () => {\n    const createDto = createSubmissionDto();\n    const record = await service.create(createDto);\n\n    const records = await service.findAll();\n    expect(records).toHaveLength(1);\n    expect(records[0].type).toEqual(createDto.type);\n\n    await service.remove(record.id);\n    expect(await service.findAll()).toHaveLength(0);\n  });\n\n  it('should throw exception on message submission with provided file', async () => {\n    const createDto = createSubmissionDto();\n    createDto.type = SubmissionType.MESSAGE;\n    await expect(\n      service.create(createDto, {} as MulterFileInfo),\n    ).rejects.toThrow(BadRequestException);\n  });\n\n  it('should create file entities', async () => {\n    const createDto = createSubmissionDto();\n    delete createDto.name; // To ensure file name check\n    createDto.type = SubmissionType.FILE;\n    const path = setup();\n    const fileInfo = createMulterData(path);\n\n    const record = await service.create(createDto, fileInfo);\n    const defaultOptions = record.options[0];\n    const file = record.files[0];\n\n    const records = await service.findAll();\n    expect(records).toHaveLength(1);\n    expect(records[0].type).toEqual(createDto.type);\n    expect(records[0].options).toHaveLength(1);\n    expect(records[0].files).toHaveLength(1);\n    expect(record.toDTO()).toEqual({\n      id: record.id,\n      createdAt: record.createdAt,\n      updatedAt: record.updatedAt,\n      type: record.type,\n      isScheduled: false,\n      postQueueRecord: undefined,\n      isTemplate: false,\n      isMultiSubmission: false,\n      isArchived: false,\n      isInitialized: true,\n      schedule: {\n        scheduleType: ScheduleType.NONE,\n      },\n      metadata: {},\n      files: [\n        {\n          createdAt: file.createdAt,\n          primaryFileId: file.primaryFileId,\n          fileName: fileInfo.originalname,\n          hasThumbnail: true,\n          hasCustomThumbnail: false,\n          hasAltFile: false,\n          hash: file.hash,\n          height: 202,\n          width: 138,\n          id: file.id,\n          mimeType: fileInfo.mimetype,\n          size: testFile.length,\n          submissionId: record.id,\n          altFileId: null,\n          thumbnailId: file.thumbnailId,\n          updatedAt: file.updatedAt,\n          metadata: {\n            altText: '',\n            dimensions: {\n              default: {\n                height: 202,\n                width: 138,\n              },\n            },\n            ignoredWebsites: [],\n            sourceUrls: [],\n            spoilerText: '',\n          },\n          order: file.order,\n        },\n      ],\n      posts: [],\n      order: 1,\n      options: [defaultOptions.toObject()],\n      validations: [],\n    });\n  });\n\n  it('should throw on missing file on file submission', async () => {\n    const createDto = createSubmissionDto();\n    createDto.type = SubmissionType.FILE;\n    await expect(service.create(createDto)).rejects.toThrow(\n      BadRequestException,\n    );\n  });\n\n  it('should remove entities', async () => {\n    const fileService = module.get<FileService>(FileService);\n    const optionsService = module.get<WebsiteOptionsService>(\n      WebsiteOptionsService,\n    );\n\n    const createDto = createSubmissionDto();\n    createDto.type = SubmissionType.FILE;\n    const path = setup();\n    const fileInfo = createMulterData(path);\n\n    const record = await service.create(createDto, fileInfo);\n    const fileId = record.files[0].id;\n\n    expect(await service.findAll()).toHaveLength(1);\n    expect(await optionsService.findAll()).toHaveLength(1);\n    expect(await fileService.findFile(fileId)).toBeDefined();\n\n    await service.remove(record.id);\n    expect(await service.findAll()).toHaveLength(0);\n    expect(await optionsService.findAll()).toHaveLength(0);\n    await expect(fileService.findFile(fileId)).rejects.toThrow(\n      NotFoundException,\n    );\n  });\n\n  it('should update entity props', async () => {\n    const createDto = createSubmissionDto();\n    const record = await service.create(createDto);\n\n    const updateDto = new UpdateSubmissionDto();\n    updateDto.isScheduled = true;\n    updateDto.scheduleType = ScheduleType.RECURRING;\n    updateDto.scheduledFor = '*';\n    updateDto.metadata = {\n      test: 'test',\n    } as unknown;\n\n    const updatedRecord = await service.update(record.id, updateDto);\n    expect(updatedRecord.isScheduled).toEqual(updateDto.isScheduled);\n    expect(updatedRecord.schedule.scheduleType).toEqual(updateDto.scheduleType);\n    expect(updatedRecord.schedule.scheduledFor).toEqual(updateDto.scheduledFor);\n    expect(updatedRecord.metadata).toEqual(updateDto.metadata);\n  });\n\n  it('should remove entity options', async () => {\n    const createDto = createSubmissionDto();\n    const record = await service.create(createDto);\n\n    const updateDto = new UpdateSubmissionDto();\n    updateDto.deletedWebsiteOptions = [record.options[0].id];\n\n    const updatedRecord = await service.update(record.id, updateDto);\n    expect(updatedRecord.options).toHaveLength(0);\n  });\n\n  it('should update entity options', async () => {\n    const createDto = createSubmissionDto();\n    const record = await service.create(createDto);\n\n    const updateDto = new UpdateSubmissionDto();\n    updateDto.newOrUpdatedOptions = [\n      {\n        ...record.options[0],\n        data: {\n          rating: SubmissionRating.GENERAL,\n          title: 'Updated',\n        },\n      } as unknown as WebsiteOptionsDto<IWebsiteFormFields>,\n    ];\n\n    const updatedRecord = await service.update(record.id, updateDto);\n    expect(updatedRecord.options[0].data.title).toEqual('Updated');\n  });\n\n  it('should serialize entity', async () => {\n    const createDto = createSubmissionDto();\n    const record = await service.create(createDto);\n\n    const serialized = JSON.stringify(record.toDTO());\n    expect(serialized).toBeDefined();\n  });\n\n  it('should reorder entities', async () => {\n    const createDto = createSubmissionDto();\n    const record1 = await service.create(createDto);\n    const record2 = await service.create(createDto);\n    const record3 = await service.create(createDto);\n\n    // Move record1 after record2 (from position 0 to after position 1)\n    await service.reorder(record1.id, record2.id, 'after');\n    const records = (await service.findAll()).sort((a, b) => a.order - b.order);\n    expect(records[0].id).toEqual(record2.id);\n    expect(records[1].id).toEqual(record1.id);\n    expect(records[2].id).toEqual(record3.id);\n  });\n\n  it('should create multi submissions onModuleInit', async () => {\n    service.onModuleInit();\n    await waitUntilPromised(async () => {\n      const records = await service.findAll();\n      return records.length === 2;\n    }, 50);\n    const records = await service.findAll();\n    expect(records).toHaveLength(2);\n    expect(records[0].isMultiSubmission).toBeTruthy();\n    expect(records[1].isMultiSubmission).toBeTruthy();\n  });\n\n  it('should apply template', async () => {\n    const account = await createAccount();\n    const createDto = createSubmissionDto();\n    const record = await service.create(createDto);\n\n    createDto.isTemplate = true;\n    createDto.name = 'Template';\n    const template = await service.create(createDto);\n    await websiteOptionsService.create({\n      submissionId: template.id,\n      accountId: account.id,\n      data: {\n        title: 'Template Test',\n        rating: SubmissionRating.MATURE,\n      },\n    });\n\n    const updatedTemplate = await service.updateTemplateName(template.id, {\n      name: 'Updated',\n    });\n\n    expect(updatedTemplate.metadata.template.name).toEqual('Updated');\n\n    const updatedRecord = await service.applyOverridingTemplate(\n      record.id,\n      template.id,\n    );\n    const defaultOptions = updatedRecord.options[0];\n    // The default title should not be updated\n    expect(defaultOptions.data.title).not.toEqual('Template Test');\n    const nonDefault = updatedRecord.options.find((o) => !o.isDefault);\n    expect(nonDefault).toBeDefined();\n    expect(nonDefault?.data.title).toEqual('Template Test');\n    expect(nonDefault?.data.rating).toEqual(SubmissionRating.MATURE);\n  });\n\n  it('should apply multi submission merge', async () => {\n    const account = await createAccount();\n    const createDto = createSubmissionDto();\n    const record = await service.create(createDto);\n\n    createDto.isMultiSubmission = true;\n    createDto.name = 'Multi';\n    const multi = await service.create(createDto);\n    await websiteOptionsService.create({\n      submissionId: multi.id,\n      accountId: account.id,\n      data: {\n        title: 'Multi Test',\n        rating: SubmissionRating.MATURE,\n      },\n    });\n\n    await service.applyMultiSubmission({\n      submissionToApply: multi.id,\n      submissionIds: [record.id],\n      merge: true,\n    });\n\n    const updatedRecord = await service.findById(record.id);\n    const defaultOptions = updatedRecord.options[0];\n    const multiDefaultOptions = multi.options.find((o) => o.isDefault);\n    // The default title should not be updated\n    expect(defaultOptions.data.title).not.toEqual(\n      multiDefaultOptions?.data.title,\n    );\n    const nonDefault = updatedRecord.options.find((o) => !o.isDefault);\n    expect(nonDefault).toBeDefined();\n    expect(nonDefault?.data.title).toEqual('Multi Test');\n    expect(nonDefault?.data.rating).toEqual(SubmissionRating.MATURE);\n  });\n\n  it('should apply multi submission without merge', async () => {\n    const account = await createAccount();\n    const createDto = createSubmissionDto();\n    const record = await service.create(createDto);\n\n    createDto.isMultiSubmission = true;\n    createDto.name = 'Multi';\n    const multi = await service.create(createDto);\n    await websiteOptionsService.create({\n      submissionId: multi.id,\n      accountId: account.id,\n      data: {\n        title: 'Multi Test',\n        rating: SubmissionRating.MATURE,\n      },\n    });\n\n    await service.applyMultiSubmission({\n      submissionToApply: multi.id,\n      submissionIds: [record.id],\n      merge: false,\n    });\n\n    const updatedRecord = await service.findById(record.id);\n    const multiSubmission = await service.findById(multi.id);\n    expect(updatedRecord.options).toHaveLength(multiSubmission.options.length);\n    const defaultOptions = updatedRecord.options.find((o) => o.isDefault);\n    const nonDefault = updatedRecord.options.find((o) => !o.isDefault);\n    expect(nonDefault).toBeDefined();\n    expect(nonDefault.data).toEqual(multiSubmission.options[1].data);\n    expect(defaultOptions).toBeDefined();\n    expect(defaultOptions.data).toEqual({\n      ...multiSubmission.options[0].data,\n      title: defaultOptions.data.title,\n    });\n  });\n\n  it('should duplicate submission', async () => {\n    const account = await createAccount();\n    const createDto = createSubmissionDto();\n    createDto.type = SubmissionType.FILE;\n    const path = setup();\n    const fileInfo = createMulterData(path);\n\n    const record = await service.create(createDto, fileInfo);\n    await websiteOptionsService.create({\n      submissionId: record.id,\n      accountId: account.id,\n      data: {\n        title: 'Duplicate Test',\n        rating: SubmissionRating.MATURE,\n      },\n    });\n\n    await service.duplicate(record.id);\n\n    const records = await service.findAll();\n    const duplicated = records.find((r) => r.id !== record.id);\n    expect(duplicated).toBeDefined();\n    expect(duplicated?.type).toEqual(record.type);\n    expect(duplicated?.options).toHaveLength(2);\n    expect(duplicated?.files).toHaveLength(1);\n    expect(duplicated.order).toEqual(record.order);\n\n    // Check that the metadata references the new file IDs\n    const duplicatedFileId = duplicated?.files[0].id;\n    const duplicatedMetadata = duplicated?.metadata as FileSubmissionMetadata;\n    expect(duplicatedFileId).toBeDefined();\n\n    for (const file of duplicated.files) {\n      expect(record.files.find((f) => f.id === file.id)).toBeUndefined();\n    }\n\n    // Check that the original metadata is preserved\n    const originalMetadata = record?.metadata as FileSubmissionMetadata;\n    for (const file of record.files) {\n      expect(duplicated.files.find((f) => f.id === file.id)).toBeUndefined();\n    }\n  });\n});\n"
  },
  {
    "path": "apps/client-server/src/app/submission/services/submission.service.ts",
    "content": "/* eslint-disable no-param-reassign */\nimport {\n  BadRequestException,\n  forwardRef,\n  Inject,\n  Injectable,\n  NotFoundException,\n  OnModuleInit,\n  Optional,\n} from '@nestjs/common';\nimport {\n  FileBufferSchema,\n  Insert,\n  SubmissionFileSchema,\n  SubmissionSchema,\n  WebsiteOptionsSchema,\n} from '@postybirb/database';\nimport { SUBMISSION_UPDATES } from '@postybirb/socket-events';\nimport {\n  FileSubmission,\n  FileSubmissionMetadata,\n  ISubmissionDto,\n  ISubmissionMetadata,\n  MessageSubmission,\n  NULL_ACCOUNT_ID,\n  ScheduleType,\n  SubmissionId,\n  SubmissionMetadataType,\n  SubmissionType,\n} from '@postybirb/types';\nimport { IsTestEnvironment } from '@postybirb/utils/electron';\nimport { eq } from 'drizzle-orm';\nimport * as path from 'path';\nimport { PostyBirbService } from '../../common/service/postybirb-service';\nimport { FileBuffer, Submission, WebsiteOptions } from '../../drizzle/models';\nimport { PostyBirbDatabase } from '../../drizzle/postybirb-database/postybirb-database';\nimport { withTransactionContext } from '../../drizzle/transaction-context';\nimport { MulterFileInfo } from '../../file/models/multer-file-info';\nimport { WSGateway } from '../../web-socket/web-socket-gateway';\nimport { WebsiteOptionsService } from '../../website-options/website-options.service';\nimport { ApplyMultiSubmissionDto } from '../dtos/apply-multi-submission.dto';\nimport { ApplyTemplateOptionsDto } from '../dtos/apply-template-options.dto';\nimport { CreateSubmissionDto } from '../dtos/create-submission.dto';\nimport { UpdateSubmissionTemplateNameDto } from '../dtos/update-submission-template-name.dto';\nimport { UpdateSubmissionDto } from '../dtos/update-submission.dto';\nimport { FileSubmissionService } from './file-submission.service';\nimport { MessageSubmissionService } from './message-submission.service';\n\ntype SubmissionEntity = Submission<SubmissionMetadataType>;\n\n/**\n * Service that handles the vast majority of submission management logic.\n * @class SubmissionService\n */\n@Injectable()\nexport class SubmissionService\n  extends PostyBirbService<'SubmissionSchema'>\n  implements OnModuleInit\n{\n  private emitDebounceTimer: ReturnType<typeof setTimeout> | null = null;\n\n  constructor(\n    @Inject(forwardRef(() => WebsiteOptionsService))\n    private readonly websiteOptionsService: WebsiteOptionsService,\n    @Inject(forwardRef(() => FileSubmissionService))\n    private readonly fileSubmissionService: FileSubmissionService,\n    private readonly messageSubmissionService: MessageSubmissionService,\n    @Optional() webSocket: WSGateway,\n  ) {\n    super(\n      new PostyBirbDatabase('SubmissionSchema', {\n        options: {\n          with: {\n            account: true,\n          },\n        },\n        posts: {\n          with: {\n            events: {\n              account: true,\n            },\n          },\n        },\n        postQueueRecord: true,\n        files: true,\n      }),\n      webSocket,\n    );\n    this.repository.subscribe(\n      [\n        'PostRecordSchema',\n        'PostQueueRecordSchema',\n        'SubmissionFileSchema',\n        'FileBufferSchema',\n      ],\n      () => {\n        this.emit();\n      },\n    );\n\n    this.repository.subscribe(['WebsiteOptionsSchema'], (_, action) => {\n      if (action === 'delete') {\n        this.emit();\n      }\n    });\n  }\n\n  async onModuleInit() {\n    await this.cleanupUninitializedSubmissions();\n    await this.normalizeOrders();\n    for (const type of Object.values(SubmissionType)) {\n      // eslint-disable-next-line no-await-in-loop\n      await this.populateMultiSubmission(type);\n    }\n  }\n\n  /**\n   * Normalizes order values to sequential integers on startup.\n   * This cleans up fractional values that accumulate from reordering operations.\n   */\n  private async normalizeOrders() {\n    this.logger.info('Normalizing submission orders');\n\n    for (const type of [SubmissionType.FILE, SubmissionType.MESSAGE]) {\n      const submissions = (await this.repository.findAll())\n        .filter((s) => s.type === type && !s.isTemplate && !s.isMultiSubmission)\n        .sort((a, b) => a.order - b.order);\n\n      for (let i = 0; i < submissions.length; i++) {\n        if (submissions[i].order !== i) {\n          // eslint-disable-next-line no-await-in-loop\n          await this.repository.update(submissions[i].id, { order: i });\n        }\n      }\n    }\n\n    this.logger.info('Order normalization complete');\n  }\n\n  /**\n   * Cleans up any submissions that were left in an uninitialized state\n   * (e.g., from a crash during creation).\n   */\n  private async cleanupUninitializedSubmissions() {\n    const all = await super.findAll();\n    const uninitialized = all.filter((s) => !s.isInitialized);\n    if (uninitialized.length > 0) {\n      const ids = uninitialized.map((s) => s.id);\n      this.logger\n        .withMetadata({ submissionIds: ids })\n        .info(\n          `Cleaning up ${uninitialized.length} uninitialized submission(s) from previous session`,\n        );\n      await this.repository.deleteById(ids);\n    }\n  }\n\n  /**\n   * Emits submissions onto websocket.\n   * Debounced by 50ms to avoid rapid consecutive emits.\n   * Overrides base class emit to provide submission-specific behavior.\n   */\n  public async emit() {\n    if (IsTestEnvironment()) {\n      return;\n    }\n\n    if (this.emitDebounceTimer) {\n      clearTimeout(this.emitDebounceTimer);\n    }\n\n    this.emitDebounceTimer = setTimeout(() => {\n      this.emitDebounceTimer = null;\n      this.performEmit();\n    }, 50);\n  }\n\n  private async performEmit() {\n    const now = Date.now();\n    super.emit({\n      event: SUBMISSION_UPDATES,\n      data: await this.findAllAsDto(),\n    });\n    this.logger.info(`Emitted submission updates in ${Date.now() - now}ms`);\n  }\n\n  public async findAllAsDto(): Promise<ISubmissionDto<ISubmissionMetadata>[]> {\n    const all = (await super.findAll()).filter((s) => s.isInitialized);\n\n    // Separate archived from non-archived for efficient processing\n    const archived = all.filter((s) => s.isArchived);\n    const nonArchived = all.filter((s) => !s.isArchived);\n\n    // Validate non-archived submissions in parallel batches to avoid overwhelming the system\n    const BATCH_SIZE = 10;\n    const validatedNonArchived: ISubmissionDto<ISubmissionMetadata>[] = [];\n\n    for (let i = 0; i < nonArchived.length; i += BATCH_SIZE) {\n      const batch = nonArchived.slice(i, i + BATCH_SIZE);\n      const batchResults = await Promise.all(\n        batch.map(\n          async (s) =>\n            ({\n              ...s.toDTO(),\n              validations:\n                await this.websiteOptionsService.validateSubmission(s),\n            }) as ISubmissionDto<ISubmissionMetadata>,\n        ),\n      );\n      validatedNonArchived.push(...batchResults);\n    }\n\n    // Archived submissions don't need validation\n    const archivedDtos = archived.map(\n      (s) =>\n        ({\n          ...s.toDTO(),\n          validations: [],\n        }) as ISubmissionDto<ISubmissionMetadata>,\n    );\n\n    return [...validatedNonArchived, ...archivedDtos];\n  }\n\n  /**\n   * Returns all initialized submissions.\n   * Overrides base class to filter out submissions still being created.\n   */\n  public async findAll() {\n    const all = await super.findAll();\n    return all.filter((s) => s.isInitialized);\n  }\n\n  private async populateMultiSubmission(type: SubmissionType) {\n    const existing = await this.repository.findOne({\n      where: (submission, { eq: equals, and }) =>\n        and(\n          eq(submission.type, type),\n          equals(submission.isMultiSubmission, true),\n        ),\n    });\n    if (existing) {\n      return;\n    }\n\n    await this.create({ name: type, type, isMultiSubmission: true });\n  }\n\n  /**\n   * Creates a submission.\n   *\n   * @param {CreateSubmissionDto} createSubmissionDto\n   * @param {MulterFileInfo} [file]\n   * @return {*}  {Promise<Submission<SubmissionMetadataType>>}\n   */\n  async create(\n    createSubmissionDto: CreateSubmissionDto,\n    file?: MulterFileInfo,\n  ): Promise<SubmissionEntity> {\n    this.logger.withMetadata(createSubmissionDto).info('Creating Submission');\n\n    // Templates and multi-submissions are immediately initialized since they don't need file population\n    const isImmediatelyInitialized =\n      !!createSubmissionDto.isMultiSubmission ||\n      !!createSubmissionDto.isTemplate;\n\n    let submission = new Submission<ISubmissionMetadata>({\n      isScheduled: false,\n      isMultiSubmission: !!createSubmissionDto.isMultiSubmission,\n      isTemplate: !!createSubmissionDto.isTemplate,\n      isInitialized: isImmediatelyInitialized,\n      ...createSubmissionDto,\n      schedule: {\n        scheduledFor: undefined,\n        scheduleType: ScheduleType.NONE,\n        cron: undefined,\n      },\n      metadata: {\n        template: createSubmissionDto.isTemplate\n          ? { name: createSubmissionDto.name.trim() }\n          : undefined,\n      },\n      order: (await this.repository.count()) + 1,\n    });\n\n    submission = await this.repository.insert(submission);\n\n    // Determine the submission name/title\n    let name = 'New submission';\n    if (createSubmissionDto.name) {\n      name = createSubmissionDto.name;\n    } else if (file) {\n      // Check for per-file title override from fileMetadata\n      const fileMetadata = createSubmissionDto.fileMetadata?.find(\n        (meta) => meta.filename === file.originalname,\n      );\n      if (fileMetadata?.title) {\n        name = fileMetadata.title;\n      } else {\n        name = path.parse(file.filename).name;\n      }\n    }\n\n    // Convert defaultOptions from DTO format to IWebsiteFormFields format\n    const defaultOptions = createSubmissionDto.defaultOptions\n      ? {\n          tags: createSubmissionDto.defaultOptions.tags\n            ? {\n                overrideDefault: false,\n                tags: createSubmissionDto.defaultOptions.tags,\n              }\n            : undefined,\n          description: createSubmissionDto.defaultOptions.description,\n          rating: createSubmissionDto.defaultOptions.rating,\n        }\n      : undefined;\n\n    try {\n      await this.websiteOptionsService.createDefaultSubmissionOptions(\n        submission,\n        name,\n        defaultOptions,\n      );\n\n      switch (createSubmissionDto.type) {\n        case SubmissionType.MESSAGE: {\n          if (file) {\n            throw new BadRequestException(\n              'A file was provided for SubmissionType Message.',\n            );\n          }\n\n          await this.messageSubmissionService.populate(\n            submission as unknown as MessageSubmission,\n            createSubmissionDto,\n          );\n          break;\n        }\n\n        case SubmissionType.FILE: {\n          if (\n            createSubmissionDto.isTemplate ||\n            createSubmissionDto.isMultiSubmission\n          ) {\n            // Don't need to populate on a template\n            break;\n          }\n\n          if (!file) {\n            throw new BadRequestException(\n              'No file provided for SubmissionType FILE.',\n            );\n          }\n\n          // This currently mutates the submission object metadata\n          await this.fileSubmissionService.populate(\n            submission as unknown as FileSubmission,\n            createSubmissionDto,\n            file,\n          );\n          break;\n        }\n\n        default: {\n          throw new BadRequestException(\n            `Unknown SubmissionType: ${createSubmissionDto.type}.`,\n          );\n        }\n      }\n\n      // Re-save to capture any mutations during population and mark as initialized\n      await this.repository.update(submission.id, {\n        ...submission.toObject(),\n        isInitialized: true,\n      });\n      this.emit();\n      return await this.findById(submission.id);\n    } catch (err) {\n      // Clean up on error, tx is too much work\n      this.logger.error(err, 'Error creating submission');\n      await this.repository.deleteById([submission.id]);\n      throw err;\n    }\n  }\n\n  /**\n   * Applies a template to a submission.\n   * Primarily used when a submission is created from a template.\n   * Or when applying overriding multi-submission options.\n   *\n   * @param {SubmissionId} id\n   * @param {SubmissionId} templateId\n   */\n  async applyOverridingTemplate(id: SubmissionId, templateId: SubmissionId) {\n    this.logger\n      .withMetadata({ id, templateId })\n      .info('Applying template to submission');\n    const submission = await this.findById(id, { failOnMissing: true });\n    const template: Submission = await this.findById(templateId, {\n      failOnMissing: true,\n    });\n\n    if (!template.metadata.template) {\n      throw new BadRequestException('Template Id provided is not a template.');\n    }\n\n    const defaultOption: WebsiteOptions = submission.options.find(\n      (option: WebsiteOptions) => option.accountId === NULL_ACCOUNT_ID,\n    );\n    const defaultTitle = defaultOption?.data?.title;\n\n    // Prepare all option insertions before the transaction\n    const newOptionInsertions: Insert<'WebsiteOptionsSchema'>[] =\n      await Promise.all(\n        template.options.map((option) =>\n          this.websiteOptionsService.createOptionInsertObject(\n            submission,\n            option.accountId,\n            option.data,\n            (option.isDefault ? defaultTitle : option?.data?.title) ?? '',\n          ),\n        ),\n      );\n\n    await withTransactionContext(this.repository.db, async (ctx) => {\n      // clear all existing options\n      await ctx\n        .getDb()\n        .delete(WebsiteOptionsSchema)\n        .where(eq(WebsiteOptionsSchema.submissionId, id));\n\n      await ctx\n        .getDb()\n        .insert(WebsiteOptionsSchema)\n        .values(newOptionInsertions);\n\n      // Track all created options for cleanup if needed\n      newOptionInsertions.forEach((option) => {\n        if (option.id) {\n          ctx.track('WebsiteOptionsSchema', option.id);\n        }\n      });\n    });\n\n    try {\n      return await this.findById(id);\n    } catch (err) {\n      throw new BadRequestException(err);\n    }\n  }\n\n  /**\n   * Updates a submission.\n   *\n   * @param {SubmissionId} id\n   * @param {UpdateSubmissionDto} update\n   */\n  async update(id: SubmissionId, update: UpdateSubmissionDto) {\n    this.logger.withMetadata(update).info(`Updating Submission '${id}'`);\n    const submission = await this.findById(id, { failOnMissing: true });\n\n    const scheduleType =\n      update.scheduleType ?? submission.schedule.scheduleType;\n    const updates: Pick<\n      SubmissionEntity,\n      'metadata' | 'isArchived' | 'isScheduled' | 'schedule'\n    > = {\n      metadata: {\n        ...submission.metadata,\n        ...(update.metadata ?? {}),\n      },\n      isArchived: update.isArchived ?? submission.isArchived,\n      isScheduled:\n        scheduleType === ScheduleType.NONE\n          ? false\n          : (update.isScheduled ?? submission.isScheduled),\n      schedule:\n        scheduleType === ScheduleType.NONE\n          ? {\n              scheduleType: ScheduleType.NONE,\n              scheduledFor: undefined,\n              cron: undefined,\n            }\n          : {\n              scheduledFor:\n                scheduleType === ScheduleType.SINGLE && update.scheduledFor\n                  ? new Date(update.scheduledFor).toISOString()\n                  : (update.scheduledFor ?? submission.schedule.scheduledFor),\n              scheduleType:\n                update.scheduleType ?? submission.schedule.scheduleType,\n              cron: update.cron ?? submission.schedule.cron,\n            },\n    };\n\n    const optionChanges: Promise<unknown>[] = [];\n\n    // Removes unused website options\n    if (update.deletedWebsiteOptions?.length) {\n      update.deletedWebsiteOptions.forEach((deletedOptionId) => {\n        optionChanges.push(this.websiteOptionsService.remove(deletedOptionId));\n      });\n    }\n\n    // Creates or updates new website options\n    if (update.newOrUpdatedOptions?.length) {\n      update.newOrUpdatedOptions.forEach((option) => {\n        if (option.createdAt) {\n          optionChanges.push(\n            this.websiteOptionsService.update(option.id, {\n              data: option.data,\n            }),\n          );\n        } else {\n          optionChanges.push(\n            this.websiteOptionsService.create({\n              accountId: option.accountId,\n              data: option.data,\n              submissionId: submission.id,\n            }),\n          );\n        }\n      });\n    }\n\n    await Promise.allSettled(optionChanges);\n\n    try {\n      // Update Here\n      await this.repository.update(id, updates);\n      this.emit();\n      return await this.findById(id);\n    } catch (err) {\n      throw new BadRequestException(err);\n    }\n  }\n\n  public async remove(id: SubmissionId) {\n    const result = await super.remove(id);\n    this.emit();\n    return result;\n  }\n\n  async applyMultiSubmission(applyMultiSubmissionDto: ApplyMultiSubmissionDto) {\n    const { submissionToApply, submissionIds, merge } = applyMultiSubmissionDto;\n    const origin = await this.repository.findById(submissionToApply, {\n      failOnMissing: true,\n    });\n    const submissions = await this.repository.find({\n      where: (submission, { inArray }) => inArray(submission.id, submissionIds),\n    });\n    if (merge) {\n      // Keeps unique options, overwrites overlapping options\n      for (const submission of submissions) {\n        for (const option of origin.options) {\n          const existingOption = submission.options.find(\n            (o) => o.accountId === option.accountId,\n          );\n          if (existingOption) {\n            // Don't overwrite set title\n            const opts = { ...option.data, title: existingOption.data.title };\n            await this.websiteOptionsService.update(existingOption.id, {\n              data: opts,\n            });\n          } else {\n            await this.websiteOptionsService.createOption(\n              submission,\n              option.accountId,\n              option.data,\n              option.isDefault ? undefined : option.data.title,\n            );\n          }\n        }\n      }\n    } else {\n      // Removes all options not included in the origin submission\n      for (const submission of submissions) {\n        const { options } = submission;\n        const defaultOptions = options.find((option) => option.isDefault);\n        const defaultTitle = defaultOptions?.data.title;\n        await Promise.all(\n          options.map((option) => this.websiteOptionsService.remove(option.id)),\n        );\n        // eslint-disable-next-line no-restricted-syntax\n        for (const option of origin.options) {\n          const opts = { ...option.data };\n          if (option.isDefault) {\n            opts.title = defaultTitle;\n          }\n          await this.websiteOptionsService.createOption(\n            submission,\n            option.accountId,\n            opts,\n            option.isDefault ? defaultTitle : option.data.title,\n          );\n        }\n      }\n    }\n\n    this.emit();\n  }\n\n  /**\n   * Applies selected template options to multiple submissions.\n   * Upserts options (update if exists, create if new) with merge behavior.\n   *\n   * @param dto - The apply template options DTO\n   * @returns Object with success/failure counts\n   */\n  async applyTemplateOptions(dto: ApplyTemplateOptionsDto): Promise<{\n    success: number;\n    failed: number;\n    errors: Array<{ submissionId: SubmissionId; error: string }>;\n  }> {\n    const { targetSubmissionIds, options, overrideTitle, overrideDescription } =\n      dto;\n\n    this.logger\n      .withMetadata({\n        targetCount: targetSubmissionIds.length,\n        optionCount: options.length,\n      })\n      .info('Applying template options to submissions');\n\n    const results = {\n      success: 0,\n      failed: 0,\n      errors: [] as Array<{ submissionId: SubmissionId; error: string }>,\n    };\n\n    for (const submissionId of targetSubmissionIds) {\n      try {\n        const submission = await this.findById(submissionId, {\n          failOnMissing: true,\n        });\n\n        for (const templateOption of options) {\n          // Find existing option for this account\n          const existingOption = submission.options.find(\n            (o) => o.accountId === templateOption.accountId,\n          );\n\n          // Prepare the data to apply\n          const dataToApply = { ...templateOption.data };\n\n          // Handle title override: only replace if overrideTitle is true AND template has non-empty title\n          if (!overrideTitle || !dataToApply.title?.trim()) {\n            delete dataToApply.title;\n          }\n\n          // Handle description override: only replace if overrideDescription is true AND template has non-empty description\n          if (\n            !overrideDescription ||\n            !dataToApply.description?.description?.content?.length\n          ) {\n            delete dataToApply.description;\n          }\n\n          if (existingOption) {\n            // Upsert: merge existing data with template data\n            const mergedData = {\n              ...existingOption.data,\n              ...dataToApply,\n            };\n            await this.websiteOptionsService.update(existingOption.id, {\n              data: mergedData,\n            });\n          } else {\n            // Create new option - only pass title if it exists in dataToApply\n            await this.websiteOptionsService.createOption(\n              submission,\n              templateOption.accountId,\n              dataToApply,\n              'title' in dataToApply ? dataToApply.title : undefined,\n            );\n          }\n        }\n\n        results.success++;\n      } catch (error) {\n        results.failed++;\n        results.errors.push({\n          submissionId,\n          error: error instanceof Error ? error.message : String(error),\n        });\n        this.logger\n          .withMetadata({ submissionId, error })\n          .error('Failed to apply template options to submission');\n      }\n    }\n\n    this.emit();\n    return results;\n  }\n\n  /**\n   * Duplicates a submission.\n   * @param {string} id\n   */\n  public async duplicate(id: SubmissionId) {\n    this.logger.info(`Duplicating Submission '${id}'`);\n    const entityToDuplicate = await this.repository.findOne({\n      where: (submission, { eq: equals }) => equals(submission.id, id),\n      with: {\n        options: {\n          with: {\n            account: true,\n          },\n        },\n        files: true,\n      },\n    });\n    await withTransactionContext(this.repository.db, async (ctx) => {\n      const newSubmission = (\n        await ctx\n          .getDb()\n          .insert(SubmissionSchema)\n          .values({\n            metadata: entityToDuplicate.metadata,\n            type: entityToDuplicate.type,\n            isScheduled: entityToDuplicate.isScheduled,\n            schedule: entityToDuplicate.schedule,\n            isMultiSubmission: entityToDuplicate.isMultiSubmission,\n            isTemplate: entityToDuplicate.isTemplate,\n            isInitialized: false, // Will be set to true at the end of the transaction\n            order: entityToDuplicate.order,\n          })\n          .returning()\n      )[0];\n      ctx.track('SubmissionSchema', newSubmission.id);\n\n      const optionValues = entityToDuplicate.options.map((option) => ({\n        ...option,\n        id: undefined,\n        submissionId: newSubmission.id,\n      }));\n      await ctx.getDb().insert(WebsiteOptionsSchema).values(optionValues);\n\n      for (const file of entityToDuplicate.files) {\n        await file.load();\n        const newFile = (\n          await ctx\n            .getDb()\n            .insert(SubmissionFileSchema)\n            .values({\n              submissionId: newSubmission.id,\n              fileName: file.fileName,\n              mimeType: file.mimeType,\n              hash: file.hash,\n              size: file.size,\n              height: file.height,\n              width: file.width,\n              hasThumbnail: file.hasThumbnail,\n              hasCustomThumbnail: file.hasCustomThumbnail,\n              hasAltFile: file.hasAltFile,\n              metadata: file.metadata,\n              order: file.order,\n            })\n            .returning()\n        )[0];\n        ctx.track('SubmissionFileSchema', newFile.id);\n\n        const primaryFile = (\n          await ctx\n            .getDb()\n            .insert(FileBufferSchema)\n            .values({\n              ...file.file,\n              id: undefined,\n              submissionFileId: newFile.id,\n            })\n            .returning()\n        )[0];\n        ctx.track('FileBufferSchema', primaryFile.id);\n\n        const thumbnail: FileBuffer | undefined = file.thumbnail\n          ? (\n              await ctx\n                .getDb()\n                .insert(FileBufferSchema)\n                .values({\n                  ...file.thumbnail,\n                  id: undefined,\n                  submissionFileId: newFile.id,\n                })\n                .returning()\n            )[0]\n          : undefined;\n        if (thumbnail) {\n          ctx.track('FileBufferSchema', thumbnail.id);\n        }\n\n        const altFile: FileBuffer | undefined = file.altFile\n          ? (\n              await ctx\n                .getDb()\n                .insert(FileBufferSchema)\n                .values({\n                  ...file.altFile,\n                  id: undefined,\n                  submissionFileId: newFile.id,\n                })\n                .returning()\n            )[0]\n          : undefined;\n        if (altFile) {\n          ctx.track('FileBufferSchema', altFile.id);\n        }\n\n        await ctx\n          .getDb()\n          .update(SubmissionFileSchema)\n          .set({\n            primaryFileId: primaryFile.id,\n            thumbnailId: thumbnail?.id,\n            altFileId: altFile?.id,\n          })\n          .where(eq(SubmissionFileSchema.id, newFile.id));\n\n        const oldId = file.id;\n        // eslint-disable-next-line prefer-destructuring\n        const metadata: FileSubmissionMetadata =\n          newSubmission.metadata as FileSubmissionMetadata;\n      }\n\n      // Save updated metadata and mark as initialized\n      await ctx\n        .getDb()\n        .update(SubmissionSchema)\n        .set({ metadata: newSubmission.metadata, isInitialized: true })\n        .where(eq(SubmissionSchema.id, newSubmission.id));\n    });\n    this.emit();\n  }\n\n  async updateTemplateName(\n    id: SubmissionId,\n    updateSubmissionDto: UpdateSubmissionTemplateNameDto,\n  ) {\n    const entity = await this.findById(id, { failOnMissing: true });\n\n    if (!entity.isTemplate) {\n      throw new BadRequestException(`Submission '${id}' is not a template`);\n    }\n\n    const name = updateSubmissionDto.name.trim();\n    if (!updateSubmissionDto.name) {\n      throw new BadRequestException(\n        'Template name cannot be empty or whitespace',\n      );\n    }\n\n    if (entity.metadata.template) {\n      entity.metadata.template.name = name;\n    }\n    const result = await this.repository.update(id, {\n      metadata: entity.metadata,\n    });\n    this.emit();\n    return result;\n  }\n\n  async reorder(\n    id: SubmissionId,\n    targetId: SubmissionId,\n    position: 'before' | 'after',\n  ) {\n    const moving = await this.findById(id, { failOnMissing: true });\n    const target = await this.findById(targetId, { failOnMissing: true });\n\n    // Ensure same type (FILE or MESSAGE)\n    if (moving.type !== target.type) {\n      throw new BadRequestException(\n        'Cannot reorder across different submission types',\n      );\n    }\n\n    // Get all submissions of the same type, sorted by order\n    // Exclude templates and multi-submissions from ordering\n    const allOfType = (await this.repository.findAll())\n      .filter(\n        (s) =>\n          s.type === moving.type && !s.isTemplate && !s.isMultiSubmission,\n      )\n      .sort((a, b) => a.order - b.order);\n\n    const targetIndex = allOfType.findIndex((s) => s.id === targetId);\n    if (targetIndex === -1) {\n      throw new NotFoundException(`Target submission '${targetId}' not found`);\n    }\n\n    let newOrder: number;\n\n    if (position === 'before') {\n      if (targetIndex === 0) {\n        // Insert at the very beginning\n        newOrder = target.order - 1;\n      } else {\n        // Insert between previous and target\n        const prevOrder = allOfType[targetIndex - 1].order;\n        newOrder = (prevOrder + target.order) / 2;\n      }\n    } else if (targetIndex === allOfType.length - 1) {\n      // position === 'after', Insert at the very end\n      newOrder = target.order + 1;\n    } else {\n      // position === 'after', Insert between target and next\n      const nextOrder = allOfType[targetIndex + 1].order;\n      newOrder = (target.order + nextOrder) / 2;\n    }\n\n    await this.repository.update(id, { order: newOrder });\n    this.emit();\n  }\n\n  async unarchive(id: SubmissionId) {\n    const submission = await this.findById(id, { failOnMissing: true });\n    if (!submission.isArchived) {\n      throw new BadRequestException(`Submission '${id}' is not archived`);\n    }\n    await this.repository.update(id, {\n      isArchived: false,\n    });\n    this.emit();\n  }\n\n  async archive(id: SubmissionId) {\n    const submission = await this.findById(id, { failOnMissing: true });\n    if (submission.isArchived) {\n      throw new BadRequestException(`Submission '${id}' is already archived`);\n    }\n    await this.repository.update(id, {\n      isArchived: true,\n      isScheduled: false,\n      schedule: {\n        scheduledFor: undefined,\n        scheduleType: ScheduleType.NONE,\n        cron: undefined,\n      },\n    });\n    this.emit();\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/submission/submission.controller.ts",
    "content": "import {\n  Body,\n  Controller,\n  Get,\n  Param,\n  Patch,\n  Post,\n  UploadedFiles,\n  UseInterceptors,\n} from '@nestjs/common';\nimport { FilesInterceptor } from '@nestjs/platform-express';\nimport {\n  ApiBadRequestResponse,\n  ApiConsumes,\n  ApiNotFoundResponse,\n  ApiOkResponse,\n  ApiTags,\n} from '@nestjs/swagger';\nimport { ISubmissionDto, SubmissionId, SubmissionType } from '@postybirb/types';\nimport { parse } from 'path';\nimport { PostyBirbController } from '../common/controller/postybirb-controller';\nimport { MulterFileInfo } from '../file/models/multer-file-info';\nimport { ApplyMultiSubmissionDto } from './dtos/apply-multi-submission.dto';\nimport { ApplyTemplateOptionsDto } from './dtos/apply-template-options.dto';\nimport { CreateSubmissionDto } from './dtos/create-submission.dto';\nimport { ReorderSubmissionDto } from './dtos/reorder-submission.dto';\nimport { UpdateSubmissionTemplateNameDto } from './dtos/update-submission-template-name.dto';\nimport { UpdateSubmissionDto } from './dtos/update-submission.dto';\nimport { SubmissionService } from './services/submission.service';\n\n/**\n * CRUD operations on Submission data.\n *\n * @class SubmissionController\n */\n@ApiTags('submissions')\n@Controller('submissions')\nexport class SubmissionController extends PostyBirbController<'SubmissionSchema'> {\n  constructor(readonly service: SubmissionService) {\n    super(service);\n  }\n\n  @Get()\n  async findAll(): Promise<ISubmissionDto[]> {\n    return this.service.findAllAsDto();\n  }\n\n  @Post()\n  @ApiConsumes('multipart/form-data')\n  @ApiOkResponse({ description: 'Submission created.' })\n  @ApiBadRequestResponse({ description: 'Bad request made.' })\n  @UseInterceptors(FilesInterceptor('files', undefined, { preservePath: true }))\n  async create(\n    @Body() createSubmissionDto: CreateSubmissionDto,\n    @UploadedFiles() files: MulterFileInfo[],\n  ) {\n    const mapper = (res) => res.toDTO();\n    if ((files || []).length) {\n      const results = [];\n      // !NOTE: Currently this shouldn't be able to happen with the current UI, but may need to be addressed in the future.\n      // Efforts have been made to prevent this from happening, with the removal of using entity.create({}) but it may still be possible.\n      // There appears to be an issue where if trying to create many submissions in parallel\n      // the database will attempt to create them all at once and fail on a race condition.\n      // not sure if this is a database issue or a typeorm issue.\n      for (const file of files) {\n        const createFileSubmission = new CreateSubmissionDto();\n        Object.assign(createFileSubmission, createSubmissionDto);\n        if (!createSubmissionDto.name) {\n          createFileSubmission.name = parse(file.originalname).name;\n        }\n\n        createFileSubmission.type = SubmissionType.FILE;\n        results.push(await this.service.create(createFileSubmission, file));\n      }\n\n      return results.map(mapper);\n    }\n    return (\n      await Promise.all([await this.service.create(createSubmissionDto)])\n    ).map(mapper);\n  }\n\n  @Post('duplicate/:id')\n  @ApiOkResponse({ description: 'Submission duplicated.' })\n  @ApiNotFoundResponse({ description: 'Submission Id not found.' })\n  async duplicate(@Param('id') id: SubmissionId) {\n    this.service.duplicate(id);\n  }\n\n  @Post('unarchive/:id')\n  @ApiOkResponse({ description: 'Submission unarchived.' })\n  @ApiNotFoundResponse({ description: 'Submission Id not found.' })\n  async unarchive(@Param('id') id: SubmissionId) {\n    return this.service.unarchive(id);\n  }\n\n  @Post('archive/:id')\n  @ApiOkResponse({ description: 'Submission archived.' })\n  @ApiNotFoundResponse({ description: 'Submission Id not found.' })\n  async archive(@Param('id') id: SubmissionId) {\n    return this.service.archive(id);\n  }\n\n  // IMPORTANT: This route MUST be defined BEFORE @Patch(':id') to prevent\n  // Express from matching 'reorder' as an :id parameter\n  @Patch('reorder')\n  @ApiOkResponse({ description: 'Submission reordered.' })\n  @ApiNotFoundResponse({ description: 'Submission Id not found.' })\n  async reorder(@Body() reorderDto: ReorderSubmissionDto) {\n    return this.service.reorder(\n      reorderDto.id,\n      reorderDto.targetId,\n      reorderDto.position,\n    );\n  }\n\n  @Patch(':id')\n  @ApiOkResponse({ description: 'Submission updated.' })\n  @ApiNotFoundResponse({ description: 'Submission Id not found.' })\n  async update(\n    @Param('id') id: SubmissionId,\n    @Body() updateSubmissionDto: UpdateSubmissionDto,\n  ) {\n    return this.service\n      .update(id, updateSubmissionDto)\n      .then((entity) => entity.toDTO());\n  }\n\n  @Patch('template/:id')\n  @ApiOkResponse({ description: 'Submission updated.' })\n  @ApiNotFoundResponse({ description: 'Submission Id not found.' })\n  async updateTemplateName(\n    @Param('id') id: SubmissionId,\n    @Body() updateSubmissionDto: UpdateSubmissionTemplateNameDto,\n  ) {\n    return this.service\n      .updateTemplateName(id, updateSubmissionDto)\n      .then((entity) => entity.toDTO());\n  }\n\n  @Patch('apply/multi')\n  @ApiOkResponse({ description: 'Submission applied to multiple submissions.' })\n  @ApiNotFoundResponse({ description: 'Submission Id not found.' })\n  async applyMulti(@Body() applyMultiSubmissionDto: ApplyMultiSubmissionDto) {\n    return this.service.applyMultiSubmission(applyMultiSubmissionDto);\n  }\n\n  @Patch('apply/template/options')\n  @ApiOkResponse({ description: 'Template options applied to submissions.' })\n  @ApiBadRequestResponse({ description: 'Invalid request.' })\n  async applyTemplateOptions(\n    @Body() applyTemplateOptionsDto: ApplyTemplateOptionsDto,\n  ) {\n    return this.service.applyTemplateOptions(applyTemplateOptionsDto);\n  }\n\n  @Patch('apply/template/:id/:templateId')\n  @ApiOkResponse({ description: 'Template applied to submission.' })\n  @ApiNotFoundResponse({ description: 'Submission Id or Template Id not found.' })\n  async applyTemplate(\n    @Param('id') id: SubmissionId,\n    @Param('templateId') templateId: SubmissionId,\n  ) {\n    return this.service\n      .applyOverridingTemplate(id, templateId)\n      .then((entity) => entity.toDTO());\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/submission/submission.events.ts",
    "content": "import { SUBMISSION_UPDATES } from '@postybirb/socket-events';\nimport { ISubmissionDto, ISubmissionMetadata } from '@postybirb/types';\nimport { WebsocketEvent } from '../web-socket/models/web-socket-event';\n\nexport type SubmissionEventTypes = SubmissionUpdateEvent;\n\nclass SubmissionUpdateEvent\n  implements WebsocketEvent<ISubmissionDto<ISubmissionMetadata>[]>\n{\n  event: string = SUBMISSION_UPDATES;\n\n  data: ISubmissionDto<ISubmissionMetadata>[];\n}\n"
  },
  {
    "path": "apps/client-server/src/app/submission/submission.module.ts",
    "content": "import { forwardRef, Module } from '@nestjs/common';\nimport { MulterModule } from '@nestjs/platform-express';\nimport { PostyBirbDirectories } from '@postybirb/fs';\nimport { diskStorage } from 'multer';\nimport { extname } from 'path';\nimport { v4 } from 'uuid';\nimport { AccountModule } from '../account/account.module';\nimport { FileModule } from '../file/file.module';\nimport { WebsiteOptionsModule } from '../website-options/website-options.module';\nimport { WebsitesModule } from '../websites/websites.module';\nimport { FileSubmissionController } from './file-submission.controller';\nimport { FileSubmissionService } from './services/file-submission.service';\nimport { MessageSubmissionService } from './services/message-submission.service';\nimport { SubmissionService } from './services/submission.service';\nimport { SubmissionController } from './submission.controller';\n\n@Module({\n  imports: [\n    WebsitesModule,\n    AccountModule,\n    FileModule,\n    forwardRef(() => WebsiteOptionsModule),\n    MulterModule.register({\n      limits: {\n        fileSize: 3e8, // Max 300MB\n      },\n      storage: diskStorage({\n        destination(req, file, cb) {\n          cb(null, PostyBirbDirectories.TEMP_DIRECTORY);\n        },\n        filename(req, file, cb) {\n          cb(null, v4() + extname(file.originalname)); // Appending extension\n        },\n      }),\n    }),\n  ],\n  providers: [\n    SubmissionService,\n    MessageSubmissionService,\n    FileSubmissionService,\n  ],\n  controllers: [SubmissionController, FileSubmissionController],\n  exports: [SubmissionService, FileSubmissionService],\n})\nexport class SubmissionModule {}\n"
  },
  {
    "path": "apps/client-server/src/app/tag-converters/dtos/create-tag-converter.dto.ts",
    "content": "import { ApiProperty } from '@nestjs/swagger';\nimport { ICreateTagConverterDto } from '@postybirb/types';\nimport { IsObject, IsString } from 'class-validator';\n\nexport class CreateTagConverterDto implements ICreateTagConverterDto {\n  @ApiProperty()\n  @IsString()\n  tag: string;\n\n  @ApiProperty()\n  @IsObject()\n  convertTo: Record<string, string>;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/tag-converters/dtos/update-tag-converter.dto.ts",
    "content": "import { ApiProperty } from '@nestjs/swagger';\nimport { IUpdateTagConverterDto } from '@postybirb/types';\nimport { IsObject, IsString } from 'class-validator';\n\nexport class UpdateTagConverterDto implements IUpdateTagConverterDto {\n  @ApiProperty()\n  @IsString()\n  tag: string;\n\n  @ApiProperty()\n  @IsObject()\n  convertTo: Record<string, string>;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/tag-converters/tag-converter.events.ts",
    "content": "import { TAG_CONVERTER_UPDATES } from '@postybirb/socket-events';\nimport { TagConverterDto } from '@postybirb/types';\nimport { WebsocketEvent } from '../web-socket/models/web-socket-event';\n\nexport type TagConverterEventTypes = TagConverterUpdateEvent;\n\nclass TagConverterUpdateEvent implements WebsocketEvent<TagConverterDto[]> {\n  event: string = TAG_CONVERTER_UPDATES;\n\n  data: TagConverterDto[];\n}\n"
  },
  {
    "path": "apps/client-server/src/app/tag-converters/tag-converters.controller.ts",
    "content": "import { Body, Controller, Param, Patch, Post } from '@nestjs/common';\nimport {\n  ApiBadRequestResponse,\n  ApiNotFoundResponse,\n  ApiOkResponse,\n  ApiTags,\n} from '@nestjs/swagger';\nimport { EntityId } from '@postybirb/types';\nimport { PostyBirbController } from '../common/controller/postybirb-controller';\nimport { CreateTagConverterDto } from './dtos/create-tag-converter.dto';\nimport { UpdateTagConverterDto } from './dtos/update-tag-converter.dto';\nimport { TagConvertersService } from './tag-converters.service';\n\n/**\n * CRUD operations on TagConverters\n * @class TagConvertersController\n */\n@ApiTags('tag-converters')\n@Controller('tag-converters')\nexport class TagConvertersController extends PostyBirbController<'TagConverterSchema'> {\n  constructor(readonly service: TagConvertersService) {\n    super(service);\n  }\n\n  @Post()\n  @ApiOkResponse({ description: 'Tag converter created.' })\n  @ApiBadRequestResponse({ description: 'Bad request made.' })\n  create(@Body() createTagConverterDto: CreateTagConverterDto) {\n    return this.service\n      .create(createTagConverterDto)\n      .then((entity) => entity.toDTO());\n  }\n\n  @Patch(':id')\n  @ApiOkResponse({ description: 'Tag converter updated.' })\n  @ApiNotFoundResponse({ description: 'Tag converter not found.' })\n  update(@Body() updateDto: UpdateTagConverterDto, @Param('id') id: EntityId) {\n    return this.service.update(id, updateDto).then((entity) => entity.toDTO());\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/tag-converters/tag-converters.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { TagConvertersController } from './tag-converters.controller';\nimport { TagConvertersService } from './tag-converters.service';\n\n@Module({\n  controllers: [TagConvertersController],\n  providers: [TagConvertersService],\n  exports: [TagConvertersService],\n})\nexport class TagConvertersModule {}\n"
  },
  {
    "path": "apps/client-server/src/app/tag-converters/tag-converters.service.spec.ts",
    "content": "import { BadRequestException } from '@nestjs/common';\nimport { Test, TestingModule } from '@nestjs/testing';\nimport { clearDatabase } from '@postybirb/database';\nimport { CreateTagConverterDto } from './dtos/create-tag-converter.dto';\nimport { UpdateTagConverterDto } from './dtos/update-tag-converter.dto';\nimport { TagConvertersService } from './tag-converters.service';\n\ndescribe('TagConvertersService', () => {\n  let service: TagConvertersService;\n  let module: TestingModule;\n\n  function createTagConverterDto(\n    tag: string,\n    convertTo: Record<string, string>,\n  ) {\n    const dto = new CreateTagConverterDto();\n    dto.tag = tag;\n    dto.convertTo = convertTo;\n    return dto;\n  }\n\n  beforeEach(async () => {\n    clearDatabase();\n    try {\n      module = await Test.createTestingModule({\n        imports: [],\n        providers: [TagConvertersService],\n      }).compile();\n\n      service = module.get<TagConvertersService>(TagConvertersService);\n    } catch (e) {\n      console.log(e);\n    }\n  });\n\n  afterAll(async () => {\n    await module.close();\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n\n  it('should create entities', async () => {\n    const dto = createTagConverterDto('test', { default: 'converted' });\n\n    const record = await service.create(dto);\n    const groups = await service.findAll();\n    expect(groups).toHaveLength(1);\n    expect(groups[0].tag).toEqual(dto.tag);\n    expect(groups[0].convertTo).toEqual(dto.convertTo);\n    expect(record.toObject()).toEqual({\n      tag: dto.tag,\n      convertTo: dto.convertTo,\n      id: record.id,\n      createdAt: record.createdAt,\n      updatedAt: record.updatedAt,\n    });\n  });\n\n  it('should fail to create duplicate tag converters', async () => {\n    const dto = createTagConverterDto('test', { default: 'converted' });\n    const dto2 = createTagConverterDto('test', { default: 'converted' });\n\n    await service.create(dto);\n\n    let expectedException = null;\n    try {\n      await service.create(dto2);\n    } catch (err) {\n      expectedException = err;\n    }\n    expect(expectedException).toBeInstanceOf(BadRequestException);\n  });\n\n  it('should update entities', async () => {\n    const dto = createTagConverterDto('test', { default: 'converted' });\n\n    const record = await service.create(dto);\n    const groups = await service.findAll();\n    expect(groups).toHaveLength(1);\n\n    const updateDto = new UpdateTagConverterDto();\n    updateDto.tag = 'test';\n    updateDto.convertTo = { default: 'converted', test: 'converted2' };\n    await service.update(record.id, updateDto);\n    const updatedRec = await service.findById(record.id);\n    expect(updatedRec.tag).toBe(updateDto.tag);\n    expect(updatedRec.convertTo).toEqual(updateDto.convertTo);\n  });\n\n  it('should delete entities', async () => {\n    const dto = createTagConverterDto('test', { default: 'converted' });\n\n    const record = await service.create(dto);\n    expect(await service.findAll()).toHaveLength(1);\n\n    await service.remove(record.id);\n    expect(await service.findAll()).toHaveLength(0);\n  });\n\n  it('should convert tags', async () => {\n    const dto = createTagConverterDto('test', { default: 'converted' });\n\n    await service.create(dto);\n    const result = await service.convert(\n      { decoratedProps: { metadata: { name: 'default' } } } as any,\n      ['test', 'test2'],\n    );\n    expect(result).toEqual(['converted', 'test2']);\n  });\n});\n"
  },
  {
    "path": "apps/client-server/src/app/tag-converters/tag-converters.service.ts",
    "content": "import { Injectable, Optional } from '@nestjs/common';\nimport { TAG_CONVERTER_UPDATES } from '@postybirb/socket-events';\nimport { EntityId } from '@postybirb/types';\nimport { eq } from 'drizzle-orm';\nimport { PostyBirbService } from '../common/service/postybirb-service';\nimport { TagConverter } from '../drizzle/models';\nimport { WSGateway } from '../web-socket/web-socket-gateway';\nimport { Website } from '../websites/website';\nimport { CreateTagConverterDto } from './dtos/create-tag-converter.dto';\nimport { UpdateTagConverterDto } from './dtos/update-tag-converter.dto';\n\n@Injectable()\nexport class TagConvertersService extends PostyBirbService<'TagConverterSchema'> {\n  constructor(@Optional() webSocket?: WSGateway) {\n    super('TagConverterSchema', webSocket);\n    this.repository.subscribe('TagConverterSchema', () => {\n      this.emit();\n    });\n  }\n\n  async create(createDto: CreateTagConverterDto): Promise<TagConverter> {\n    this.logger\n      .withMetadata(createDto)\n      .info(`Creating TagConverter '${createDto.tag}'`);\n    await this.throwIfExists(eq(this.schema.tag, createDto.tag));\n    return this.repository.insert(createDto);\n  }\n\n  update(id: EntityId, update: UpdateTagConverterDto) {\n    this.logger.withMetadata(update).info(`Updating TagConverter '${id}'`);\n    return this.repository.update(id, update);\n  }\n\n  /**\n   * Converts a list of tags using user defined conversion table.\n   *\n   * @param {Website<unknown>} instance\n   * @param {string[]} tags\n   * @return {*}  {Promise<string[]>}\n   */\n  async convert(instance: Website<unknown>, tags: string): Promise<string>;\n  async convert(instance: Website<unknown>, tags: string[]): Promise<string[]>;\n  async convert(\n    instance: Website<unknown>,\n    tags: string[] | string,\n  ): Promise<string[] | string> {\n    if (typeof tags === 'string') {\n      return (await this.convert(instance, [tags]))[0];\n    }\n\n    // { tag: { $in: tags } }\n    const converters = await this.repository.find({\n      where: (converter, { inArray }) => inArray(converter.tag, tags),\n    });\n    return tags.map((tag) => {\n      const converter = converters.find((c) => c.tag === tag);\n      if (!converter) {\n        return tag;\n      }\n      return (\n        converter.convertTo[instance.decoratedProps.metadata.name] ??\n        converter.convertTo.default ?? // NOTE: This is not currently used, but it's here for future proofing\n        tag\n      );\n    });\n  }\n\n  protected async emit() {\n    super.emit({\n      event: TAG_CONVERTER_UPDATES,\n      data: (await this.repository.findAll()).map((entity) => entity.toDTO()),\n    });\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/tag-groups/dtos/create-tag-group.dto.ts",
    "content": "import { ApiProperty } from '@nestjs/swagger';\nimport { ICreateTagGroupDto } from '@postybirb/types';\nimport { IsArray, IsString } from 'class-validator';\n\nexport class CreateTagGroupDto implements ICreateTagGroupDto {\n  @ApiProperty()\n  @IsString()\n  name: string;\n\n  @ApiProperty()\n  @IsArray()\n  tags: string[];\n}\n"
  },
  {
    "path": "apps/client-server/src/app/tag-groups/dtos/update-tag-group.dto.ts",
    "content": "import { ApiProperty } from '@nestjs/swagger';\nimport { IUpdateTagGroupDto } from '@postybirb/types';\nimport { IsArray, IsString } from 'class-validator';\n\nexport class UpdateTagGroupDto implements IUpdateTagGroupDto {\n  @ApiProperty()\n  @IsString()\n  name: string;\n\n  @ApiProperty()\n  @IsArray()\n  tags: string[];\n}\n"
  },
  {
    "path": "apps/client-server/src/app/tag-groups/tag-group.events.ts",
    "content": "import { TAG_GROUP_UPDATES } from '@postybirb/socket-events';\nimport { TagGroupDto } from '@postybirb/types';\nimport { WebsocketEvent } from '../web-socket/models/web-socket-event';\n\nexport type TagGroupEventTypes = TagGroupUpdateEvent;\n\nclass TagGroupUpdateEvent implements WebsocketEvent<TagGroupDto[]> {\n  event: string = TAG_GROUP_UPDATES;\n\n  data: TagGroupDto[];\n}\n"
  },
  {
    "path": "apps/client-server/src/app/tag-groups/tag-groups.controller.ts",
    "content": "import { Body, Controller, Param, Patch, Post } from '@nestjs/common';\nimport {\n  ApiBadRequestResponse,\n  ApiNotFoundResponse,\n  ApiOkResponse,\n  ApiTags,\n} from '@nestjs/swagger';\nimport { EntityId } from '@postybirb/types';\nimport { PostyBirbController } from '../common/controller/postybirb-controller';\nimport { CreateTagGroupDto } from './dtos/create-tag-group.dto';\nimport { UpdateTagGroupDto } from './dtos/update-tag-group.dto';\nimport { TagGroupsService } from './tag-groups.service';\n\n/**\n * CRUD operations for TagGroups.\n * @class TagGroupsController\n */\n@ApiTags('tag-groups')\n@Controller('tag-groups')\nexport class TagGroupsController extends PostyBirbController<'TagGroupSchema'> {\n  constructor(readonly service: TagGroupsService) {\n    super(service);\n  }\n\n  @Post()\n  @ApiOkResponse({ description: 'Tag group created.' })\n  @ApiBadRequestResponse({ description: 'Bad request made.' })\n  create(@Body() createDto: CreateTagGroupDto) {\n    return this.service.create(createDto).then((entity) => entity.toDTO());\n  }\n\n  @Patch(':id')\n  @ApiOkResponse({ description: 'Tag group updated.', type: Boolean })\n  @ApiNotFoundResponse({ description: 'Tag group not found.' })\n  update(@Param('id') id: EntityId, @Body() updateDto: UpdateTagGroupDto) {\n    return this.service.update(id, updateDto).then((entity) => entity.toDTO());\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/tag-groups/tag-groups.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { TagGroupsController } from './tag-groups.controller';\nimport { TagGroupsService } from './tag-groups.service';\n\n@Module({\n  providers: [TagGroupsService],\n  controllers: [TagGroupsController],\n})\nexport class TagGroupsModule {}\n"
  },
  {
    "path": "apps/client-server/src/app/tag-groups/tag-groups.service.spec.ts",
    "content": "import { BadRequestException } from '@nestjs/common';\nimport { Test, TestingModule } from '@nestjs/testing';\nimport { clearDatabase } from '@postybirb/database';\nimport { CreateTagGroupDto } from './dtos/create-tag-group.dto';\nimport { TagGroupsService } from './tag-groups.service';\n\ndescribe('TagGroupsService', () => {\n  let service: TagGroupsService;\n  let module: TestingModule;\n\n  function createTagGroupDto(name: string, tags: string[]) {\n    const dto = new CreateTagGroupDto();\n    dto.name = name;\n    dto.tags = tags;\n    return dto;\n  }\n\n  beforeEach(async () => {\n    clearDatabase();\n    module = await Test.createTestingModule({\n      providers: [TagGroupsService],\n    }).compile();\n\n    service = module.get<TagGroupsService>(TagGroupsService);\n  });\n\n  afterAll(async () => {\n    await module.close();\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n\n  it('should create entities', async () => {\n    const dto = createTagGroupDto('test', ['test', 'tag group']);\n\n    const record = await service.create(dto);\n    const groups = await service.findAll();\n    expect(groups).toHaveLength(1);\n    expect(groups[0].name).toEqual(dto.name);\n    expect(groups[0].tags).toEqual(dto.tags);\n    expect(record.toDTO()).toEqual({\n      name: dto.name,\n      tags: dto.tags,\n      id: record.id,\n      createdAt: record.createdAt,\n      updatedAt: record.updatedAt,\n    });\n  });\n\n  it('should fail to create duplicate named groups', async () => {\n    const dto = createTagGroupDto('test', ['test']);\n    const dto2 = createTagGroupDto('test', ['test', 'test-dupe']);\n\n    await service.create(dto);\n    const groups = await service.findAll();\n    expect(groups).toHaveLength(1);\n    expect(groups[0].name).toEqual(dto.name);\n    expect(groups[0].tags).toEqual(dto.tags);\n\n    let expectedException = null;\n    try {\n      await service.create(dto2);\n    } catch (err) {\n      expectedException = err;\n    }\n    expect(expectedException).toBeInstanceOf(BadRequestException);\n  });\n\n  it('should update entities', async () => {\n    const dto = createTagGroupDto('test', ['test', 'tag group']);\n\n    const record = await service.create(dto);\n    const groups = await service.findAll();\n    expect(groups).toHaveLength(1);\n\n    const updateDto = new CreateTagGroupDto();\n    updateDto.name = 'test';\n    updateDto.tags = ['test', 'updated'];\n    await service.update(record.id, updateDto);\n    const updatedRec = await service.findById(record.id);\n    expect(updatedRec.name).toBe(updateDto.name);\n    expect(updatedRec.tags).toEqual(updateDto.tags);\n  });\n\n  it('should delete entities', async () => {\n    const dto = createTagGroupDto('test', ['test', 'tag group']);\n\n    const record = await service.create(dto);\n    expect(await service.findAll()).toHaveLength(1);\n\n    await service.remove(record.id);\n    expect(await service.findAll()).toHaveLength(0);\n  });\n});\n"
  },
  {
    "path": "apps/client-server/src/app/tag-groups/tag-groups.service.ts",
    "content": "import { Injectable, Optional } from '@nestjs/common';\nimport { TAG_GROUP_UPDATES } from '@postybirb/socket-events';\nimport { EntityId } from '@postybirb/types';\nimport { eq } from 'drizzle-orm';\nimport { PostyBirbService } from '../common/service/postybirb-service';\nimport { TagGroup } from '../drizzle/models';\nimport { WSGateway } from '../web-socket/web-socket-gateway';\nimport { CreateTagGroupDto } from './dtos/create-tag-group.dto';\nimport { UpdateTagGroupDto } from './dtos/update-tag-group.dto';\n\n@Injectable()\nexport class TagGroupsService extends PostyBirbService<'TagGroupSchema'> {\n  constructor(@Optional() webSocket?: WSGateway) {\n    super('TagGroupSchema', webSocket);\n    this.repository.subscribe('TagGroupSchema', () => this.emit());\n  }\n\n  async create(createDto: CreateTagGroupDto): Promise<TagGroup> {\n    this.logger\n      .withMetadata(createDto)\n      .info(`Creating TagGroup '${createDto.name}'`);\n    await this.throwIfExists(eq(this.schema.name, createDto.name));\n    return this.repository.insert(createDto);\n  }\n\n  update(id: EntityId, update: UpdateTagGroupDto) {\n    this.logger.withMetadata(update).info(`Updating TagGroup '${id}'`);\n    return this.repository.update(id, update);\n  }\n\n  protected async emit() {\n    super.emit({\n      event: TAG_GROUP_UPDATES,\n      data: (await this.repository.findAll()).map((entity) => entity.toDTO()),\n    });\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/update/update.controller.ts",
    "content": "import { Controller, Get, Post } from '@nestjs/common';\nimport { UpdateState } from '@postybirb/types';\nimport { UpdateService } from './update.service';\n\n@Controller('update')\nexport class UpdateController {\n  constructor(private readonly service: UpdateService) {}\n\n  /**\n   * Checks for updates.\n   * @returns For some reason, to fix error while\n   * build we need to directly import and specify type\n   */\n  @Get()\n  checkForUpdates(): UpdateState {\n    return this.service.getUpdateState();\n  }\n\n  @Post('start')\n  update() {\n    return this.service.update();\n  }\n\n  /**\n   * Quit and install the downloaded update.\n   */\n  @Post('install')\n  install() {\n    return this.service.install();\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/update/update.events.ts",
    "content": "import { UPDATE_UPDATES } from '@postybirb/socket-events';\nimport { UpdateState } from '@postybirb/types';\nimport { WebsocketEvent } from '../web-socket/models/web-socket-event';\n\nexport type UpdateEventTypes = UpdateUpdateEvent;\n\nclass UpdateUpdateEvent implements WebsocketEvent<UpdateState> {\n  event: string = UPDATE_UPDATES;\n\n  data: UpdateState;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/update/update.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { WebSocketModule } from '../web-socket/web-socket.module';\nimport { UpdateController } from './update.controller';\nimport { UpdateService } from './update.service';\n\n@Module({\n  imports: [WebSocketModule],\n  providers: [UpdateService],\n  controllers: [UpdateController],\n})\nexport class UpdateModule {}\n"
  },
  {
    "path": "apps/client-server/src/app/update/update.service.ts",
    "content": "import { Injectable, Optional } from '@nestjs/common';\nimport { Logger } from '@postybirb/logger';\nimport { UPDATE_UPDATES } from '@postybirb/socket-events';\nimport { ReleaseNoteInfo, UpdateState } from '@postybirb/types';\nimport { ProgressInfo, UpdateInfo, autoUpdater } from 'electron-updater';\nimport isDocker from 'is-docker';\nimport { WSGateway } from '../web-socket/web-socket-gateway';\n\n/**\n * Handles updates for the application.\n *\n * @class UpdateService\n */\n@Injectable()\nexport class UpdateService {\n  private readonly logger = Logger('Updates');\n\n  private updateState: UpdateState = {\n    updateAvailable: false,\n    updateDownloaded: false,\n    updateDownloading: false,\n    updateError: undefined,\n    updateProgress: undefined,\n    updateNotes: undefined,\n  };\n\n  constructor(@Optional() private readonly webSocket?: WSGateway) {\n    autoUpdater.logger = this.logger;\n    autoUpdater.autoDownload = false;\n    autoUpdater.fullChangelog = true;\n    autoUpdater.allowPrerelease = true;\n\n    this.registerListeners();\n    if (!isDocker()) setTimeout(() => this.checkForUpdates(), 5_000);\n  }\n\n  /**\n   * Emit update state changes via WebSocket.\n   */\n  private emit() {\n    if (this.webSocket) {\n      this.webSocket.emit({\n        event: UPDATE_UPDATES,\n        data: this.getUpdateState(),\n      });\n    }\n  }\n\n  private registerListeners() {\n    autoUpdater.on('update-available', (update) => {\n      this.onUpdateAvailable(update);\n    });\n    autoUpdater.on('download-progress', (progress) => {\n      this.onDownloadProgress(progress);\n    });\n    autoUpdater.on('error', (error) => {\n      this.onUpdateError(error);\n    });\n    autoUpdater.on('update-downloaded', () => {\n      this.onUpdateDownloaded();\n    });\n  }\n\n  private onUpdateDownloaded() {\n    this.updateState = {\n      ...this.updateState,\n      updateDownloaded: true,\n      updateDownloading: false,\n      updateProgress: 100,\n    };\n    this.emit();\n  }\n\n  private onUpdateAvailable(update: UpdateInfo) {\n    this.updateState = {\n      ...this.updateState,\n      updateAvailable: true,\n      updateNotes: (update.releaseNotes as ReleaseNoteInfo[]) ?? [],\n    };\n    this.emit();\n  }\n\n  private onUpdateError(error: Error) {\n    this.logger.withError(error).error();\n    this.updateState = {\n      ...this.updateState,\n      updateError: error.message,\n      updateDownloading: false,\n    };\n    this.emit();\n  }\n\n  private onDownloadProgress(progress: ProgressInfo) {\n    this.updateState = {\n      ...this.updateState,\n      updateProgress: progress.percent,\n    };\n    this.emit();\n  }\n\n  public checkForUpdates() {\n    if (\n      this.updateState.updateDownloading ||\n      this.updateState.updateDownloaded\n    ) {\n      return;\n    }\n\n    autoUpdater.checkForUpdates();\n  }\n\n  public getUpdateState() {\n    return { ...this.updateState };\n  }\n\n  public update() {\n    if (\n      !this.updateState.updateAvailable ||\n      this.updateState.updateDownloaded ||\n      this.updateState.updateDownloading\n    ) {\n      return;\n    }\n\n    this.updateState = {\n      ...this.updateState,\n      updateDownloading: true,\n    };\n    this.emit();\n\n    autoUpdater.downloadUpdate();\n  }\n\n  /**\n   * Quit the application and install the downloaded update.\n   * Only works if an update has been downloaded.\n   */\n  public install() {\n    if (!this.updateState.updateDownloaded) {\n      return;\n    }\n\n    autoUpdater.quitAndInstall(false, true);\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/user-converters/dtos/create-user-converter.dto.ts",
    "content": "import { ApiProperty } from '@nestjs/swagger';\nimport { ICreateUserConverterDto } from '@postybirb/types';\nimport { IsObject, IsString } from 'class-validator';\n\nexport class CreateUserConverterDto implements ICreateUserConverterDto {\n  @ApiProperty()\n  @IsString()\n  username: string;\n\n  @ApiProperty()\n  @IsObject()\n  convertTo: Record<string, string>;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/user-converters/dtos/update-user-converter.dto.ts",
    "content": "import { ApiProperty, PartialType } from '@nestjs/swagger';\nimport { IUpdateUserConverterDto } from '@postybirb/types';\nimport { CreateUserConverterDto } from './create-user-converter.dto';\n\nexport class UpdateUserConverterDto\n  extends PartialType(CreateUserConverterDto)\n  implements IUpdateUserConverterDto\n{\n  @ApiProperty()\n  username?: string;\n\n  @ApiProperty()\n  convertTo?: Record<string, string>;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/user-converters/user-converter.events.ts",
    "content": "import { USER_CONVERTER_UPDATES } from '@postybirb/socket-events';\nimport { UserConverterDto } from '@postybirb/types';\nimport { WebsocketEvent } from '../web-socket/models/web-socket-event';\n\nexport type UserConverterEventTypes = UserConverterUpdateEvent;\n\nclass UserConverterUpdateEvent implements WebsocketEvent<UserConverterDto[]> {\n  event: string = USER_CONVERTER_UPDATES;\n\n  data: UserConverterDto[];\n}\n"
  },
  {
    "path": "apps/client-server/src/app/user-converters/user-converters.controller.ts",
    "content": "import { Body, Controller, Param, Patch, Post } from '@nestjs/common';\nimport {\n  ApiBadRequestResponse,\n  ApiNotFoundResponse,\n  ApiOkResponse,\n  ApiTags,\n} from '@nestjs/swagger';\nimport { EntityId } from '@postybirb/types';\nimport { PostyBirbController } from '../common/controller/postybirb-controller';\nimport { CreateUserConverterDto } from './dtos/create-user-converter.dto';\nimport { UpdateUserConverterDto } from './dtos/update-user-converter.dto';\nimport { UserConvertersService } from './user-converters.service';\n\n/**\n * CRUD operations on UserConverters\n * @class UserConvertersController\n */\n@ApiTags('user-converters')\n@Controller('user-converters')\nexport class UserConvertersController extends PostyBirbController<'UserConverterSchema'> {\n  constructor(readonly service: UserConvertersService) {\n    super(service);\n  }\n\n  @Post()\n  @ApiOkResponse({ description: 'User converter created.' })\n  @ApiBadRequestResponse({ description: 'Bad request made.' })\n  create(@Body() createUserConverterDto: CreateUserConverterDto) {\n    return this.service\n      .create(createUserConverterDto)\n      .then((entity) => entity.toDTO());\n  }\n\n  @Patch(':id')\n  @ApiOkResponse({ description: 'User converter updated.' })\n  @ApiNotFoundResponse({ description: 'User converter not found.' })\n  update(@Body() updateDto: UpdateUserConverterDto, @Param('id') id: EntityId) {\n    return this.service.update(id, updateDto).then((entity) => entity.toDTO());\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/user-converters/user-converters.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { UserConvertersController } from './user-converters.controller';\nimport { UserConvertersService } from './user-converters.service';\n\n@Module({\n  controllers: [UserConvertersController],\n  providers: [UserConvertersService],\n  exports: [UserConvertersService],\n})\nexport class UserConvertersModule {}\n"
  },
  {
    "path": "apps/client-server/src/app/user-converters/user-converters.service.spec.ts",
    "content": "import { BadRequestException } from '@nestjs/common';\nimport { Test, TestingModule } from '@nestjs/testing';\nimport { clearDatabase } from '@postybirb/database';\nimport { CreateUserConverterDto } from './dtos/create-user-converter.dto';\nimport { UpdateUserConverterDto } from './dtos/update-user-converter.dto';\nimport { UserConvertersService } from './user-converters.service';\n\ndescribe('UserConvertersService', () => {\n  let service: UserConvertersService;\n  let module: TestingModule;\n\n  function createUserConverterDto(\n    username: string,\n    convertTo: Record<string, string>,\n  ) {\n    const dto = new CreateUserConverterDto();\n    dto.username = username;\n    dto.convertTo = convertTo;\n    return dto;\n  }\n\n  beforeEach(async () => {\n    clearDatabase();\n    try {\n      module = await Test.createTestingModule({\n        imports: [],\n        providers: [UserConvertersService],\n      }).compile();\n\n      service = module.get<UserConvertersService>(UserConvertersService);\n    } catch (e) {\n      console.log(e);\n    }\n  });\n\n  afterAll(async () => {\n    await module.close();\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n\n  it('should create entities', async () => {\n    const dto = createUserConverterDto('my_friend', {\n      default: 'converted_friend',\n    });\n\n    const record = await service.create(dto);\n    const converters = await service.findAll();\n    expect(converters).toHaveLength(1);\n    expect(converters[0].username).toEqual(dto.username);\n    expect(converters[0].convertTo).toEqual(dto.convertTo);\n    expect(record.toObject()).toEqual({\n      username: dto.username,\n      convertTo: dto.convertTo,\n      id: record.id,\n      createdAt: record.createdAt,\n      updatedAt: record.updatedAt,\n    });\n  });\n\n  it('should fail to create duplicate user converters', async () => {\n    const dto = createUserConverterDto('my_friend', {\n      default: 'converted_friend',\n    });\n    const dto2 = createUserConverterDto('my_friend', {\n      default: 'converted_friend2',\n    });\n\n    await service.create(dto);\n\n    let expectedException = null;\n    try {\n      await service.create(dto2);\n    } catch (err) {\n      expectedException = err;\n    }\n    expect(expectedException).toBeInstanceOf(BadRequestException);\n  });\n\n  it('should update entities', async () => {\n    const dto = createUserConverterDto('my_friend', {\n      default: 'converted_friend',\n    });\n\n    const record = await service.create(dto);\n    const converters = await service.findAll();\n    expect(converters).toHaveLength(1);\n\n    const updateDto = new UpdateUserConverterDto();\n    updateDto.username = 'my_friend';\n    updateDto.convertTo = {\n      default: 'converted_friend',\n      bluesky: 'converted_friend2',\n    };\n    await service.update(record.id, updateDto);\n    const updatedRec = await service.findById(record.id);\n    expect(updatedRec.username).toBe(updateDto.username);\n    expect(updatedRec.convertTo).toEqual(updateDto.convertTo);\n  });\n\n  it('should delete entities', async () => {\n    const dto = createUserConverterDto('my_friend', {\n      default: 'converted_friend',\n    });\n\n    const record = await service.create(dto);\n    expect(await service.findAll()).toHaveLength(1);\n\n    await service.remove(record.id);\n    expect(await service.findAll()).toHaveLength(0);\n  });\n\n  it('should convert usernames', async () => {\n    const dto = createUserConverterDto('my_friend', {\n      default: 'default_friend',\n      bluesky: 'friend.bsky.social',\n    });\n\n    await service.create(dto);\n\n    // Test conversion for bluesky\n    const resultBluesky = await service.convert(\n      { decoratedProps: { metadata: { name: 'bluesky' } } } as any,\n      'my_friend',\n    );\n    expect(resultBluesky).toEqual('friend.bsky.social');\n\n    // Test conversion for unknown website (should use default)\n    const resultDefault = await service.convert(\n      { decoratedProps: { metadata: { name: 'unknown' } } } as any,\n      'my_friend',\n    );\n    expect(resultDefault).toEqual('default_friend');\n\n    // Test conversion for username not in converter (should return original)\n    const resultNotFound = await service.convert(\n      { decoratedProps: { metadata: { name: 'bluesky' } } } as any,\n      'unknown_user',\n    );\n    expect(resultNotFound).toEqual('unknown_user');\n  });\n});\n"
  },
  {
    "path": "apps/client-server/src/app/user-converters/user-converters.service.ts",
    "content": "import { Injectable, Optional } from '@nestjs/common';\nimport { USER_CONVERTER_UPDATES } from '@postybirb/socket-events';\nimport { EntityId } from '@postybirb/types';\nimport { eq } from 'drizzle-orm';\nimport { PostyBirbService } from '../common/service/postybirb-service';\nimport { UserConverter } from '../drizzle/models';\nimport { WSGateway } from '../web-socket/web-socket-gateway';\nimport { Website } from '../websites/website';\nimport { CreateUserConverterDto } from './dtos/create-user-converter.dto';\nimport { UpdateUserConverterDto } from './dtos/update-user-converter.dto';\n\n@Injectable()\nexport class UserConvertersService extends PostyBirbService<'UserConverterSchema'> {\n  constructor(@Optional() webSocket?: WSGateway) {\n    super('UserConverterSchema', webSocket);\n    this.repository.subscribe('UserConverterSchema', () => {\n      this.emit();\n    });\n  }\n\n  async create(createDto: CreateUserConverterDto): Promise<UserConverter> {\n    this.logger\n      .withMetadata(createDto)\n      .info(`Creating UserConverter '${createDto.username}'`);\n    await this.throwIfExists(eq(this.schema.username, createDto.username));\n    return this.repository.insert(createDto);\n  }\n\n  update(id: EntityId, update: UpdateUserConverterDto) {\n    this.logger.withMetadata(update).info(`Updating UserConverter '${id}'`);\n    return this.repository.update(id, update);\n  }\n\n  /**\n   * Converts a username using user defined conversion table.\n   *\n   * @param {Website<unknown>} instance\n   * @param {string} username\n   * @return {*}  {Promise<string>}\n   */\n  async convert(instance: Website<unknown>, username: string): Promise<string> {\n    const converter = await this.repository.findOne({\n      where: (c, { eq: eqFn }) => eqFn(c.username, username),\n    });\n\n    if (!converter) {\n      return username;\n    }\n\n    return (\n      converter.convertTo[instance.decoratedProps.metadata.name] ??\n      converter.convertTo.default ??\n      username\n    );\n  }\n\n  protected async emit() {\n    super.emit({\n      event: USER_CONVERTER_UPDATES,\n      data: (await this.repository.findAll()).map((entity) => entity.toDTO()),\n    });\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/user-specified-website-options/dtos/create-user-specified-website-options.dto.ts",
    "content": "import { ApiProperty } from '@nestjs/swagger';\nimport {\n  DynamicObject,\n  EntityId,\n  ICreateUserSpecifiedWebsiteOptionsDto,\n  SubmissionType,\n} from '@postybirb/types';\nimport { IsEnum, IsObject, IsString } from 'class-validator';\n\nexport class CreateUserSpecifiedWebsiteOptionsDto\n  implements ICreateUserSpecifiedWebsiteOptionsDto\n{\n  @ApiProperty()\n  @IsObject()\n  options: DynamicObject;\n\n  @ApiProperty({ enum: SubmissionType })\n  @IsEnum(SubmissionType)\n  type: SubmissionType;\n\n  @IsString()\n  accountId: EntityId;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/user-specified-website-options/dtos/update-user-specified-website-options.dto.ts",
    "content": "import { ApiProperty } from '@nestjs/swagger';\nimport {\n  DynamicObject,\n  IUpdateUserSpecifiedWebsiteOptionsDto,\n  SubmissionType,\n} from '@postybirb/types';\nimport { IsEnum, IsObject } from 'class-validator';\n\nexport class UpdateUserSpecifiedWebsiteOptionsDto\n  implements IUpdateUserSpecifiedWebsiteOptionsDto\n{\n  @ApiProperty({ enum: SubmissionType })\n  @IsEnum(SubmissionType)\n  type: SubmissionType;\n\n  @ApiProperty()\n  @IsObject()\n  options: DynamicObject;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/user-specified-website-options/user-specified-website-options.controller.ts",
    "content": "import { Body, Controller, Param, Patch, Post } from '@nestjs/common';\nimport {\n  ApiBadRequestResponse,\n  ApiNotFoundResponse,\n  ApiOkResponse,\n  ApiTags,\n} from '@nestjs/swagger';\nimport { EntityId } from '@postybirb/types';\nimport { PostyBirbController } from '../common/controller/postybirb-controller';\nimport { CreateUserSpecifiedWebsiteOptionsDto } from './dtos/create-user-specified-website-options.dto';\nimport { UpdateUserSpecifiedWebsiteOptionsDto } from './dtos/update-user-specified-website-options.dto';\nimport { UserSpecifiedWebsiteOptionsService } from './user-specified-website-options.service';\n\n/**\n * CRUD operations for UserSpecifiedWebsiteOptions\n * @class UserSpecifiedWebsiteOptionsController\n */\n@ApiTags('user-specified-website-options')\n@Controller('user-specified-website-options')\nexport class UserSpecifiedWebsiteOptionsController extends PostyBirbController<'UserSpecifiedWebsiteOptionsSchema'> {\n  constructor(readonly service: UserSpecifiedWebsiteOptionsService) {\n    super(service);\n  }\n\n  @Post()\n  @ApiOkResponse({ description: 'Entity created or updated.' })\n  @ApiBadRequestResponse({ description: 'Bad request made.' })\n  async create(@Body() createDto: CreateUserSpecifiedWebsiteOptionsDto) {\n    // Use upsert to handle both create and update based on accountId+type\n    return this.service.upsert(createDto).then((entity) => entity.toDTO());\n  }\n\n  @Patch(':id')\n  @ApiOkResponse({ description: 'Entity updated.', type: Boolean })\n  @ApiNotFoundResponse()\n  update(\n    @Param('id') id: EntityId,\n    @Body() updateDto: UpdateUserSpecifiedWebsiteOptionsDto,\n  ) {\n    return this.service.update(id, updateDto).then((entity) => entity.toDTO());\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/user-specified-website-options/user-specified-website-options.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { UserSpecifiedWebsiteOptionsController } from './user-specified-website-options.controller';\nimport { UserSpecifiedWebsiteOptionsService } from './user-specified-website-options.service';\n\n@Module({\n  controllers: [UserSpecifiedWebsiteOptionsController],\n  providers: [UserSpecifiedWebsiteOptionsService],\n  exports: [UserSpecifiedWebsiteOptionsService],\n})\nexport class UserSpecifiedWebsiteOptionsModule {}\n"
  },
  {
    "path": "apps/client-server/src/app/user-specified-website-options/user-specified-website-options.service.spec.ts",
    "content": "import { BadRequestException } from '@nestjs/common';\nimport { Test, TestingModule } from '@nestjs/testing';\nimport { clearDatabase } from '@postybirb/database';\nimport { NULL_ACCOUNT_ID, SubmissionType } from '@postybirb/types';\nimport { AccountModule } from '../account/account.module';\nimport { AccountService } from '../account/account.service';\nimport { CreateUserSpecifiedWebsiteOptionsDto } from './dtos/create-user-specified-website-options.dto';\nimport { UpdateUserSpecifiedWebsiteOptionsDto } from './dtos/update-user-specified-website-options.dto';\nimport { UserSpecifiedWebsiteOptionsService } from './user-specified-website-options.service';\n\ndescribe('UserSpecifiedWebsiteOptionsService', () => {\n  let service: UserSpecifiedWebsiteOptionsService;\n  let module: TestingModule;\n\n  beforeEach(async () => {\n    clearDatabase();\n    module = await Test.createTestingModule({\n      imports: [AccountModule],\n      providers: [UserSpecifiedWebsiteOptionsService],\n    }).compile();\n\n    service = module.get<UserSpecifiedWebsiteOptionsService>(\n      UserSpecifiedWebsiteOptionsService,\n    );\n\n    const accountService = module.get<AccountService>(AccountService);\n    await accountService.onModuleInit();\n  });\n\n  afterAll(async () => {\n    await module.close();\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n\n  it('should create entities', async () => {\n    const dto = new CreateUserSpecifiedWebsiteOptionsDto();\n    dto.accountId = NULL_ACCOUNT_ID;\n    dto.options = { test: 'test' };\n    dto.type = SubmissionType.MESSAGE;\n\n    const record = await service.create(dto);\n    expect(await service.findAll()).toHaveLength(1);\n    expect(record.options).toEqual(dto.options);\n    expect(record.type).toEqual(dto.type);\n    expect(record.toDTO()).toEqual({\n      accountId: NULL_ACCOUNT_ID,\n      createdAt: record.createdAt,\n      id: record.id,\n      options: dto.options,\n      type: dto.type,\n      updatedAt: record.updatedAt,\n    });\n  });\n\n  it('should fail to create a duplicate entity', async () => {\n    const dto = new CreateUserSpecifiedWebsiteOptionsDto();\n    dto.accountId = NULL_ACCOUNT_ID;\n    dto.options = { test: 'test' };\n    dto.type = SubmissionType.MESSAGE;\n\n    await service.create(dto);\n    await expect(service.create(dto)).rejects.toThrow(BadRequestException);\n  });\n\n  it('should upsert - create when not exists', async () => {\n    const dto = new CreateUserSpecifiedWebsiteOptionsDto();\n    dto.accountId = NULL_ACCOUNT_ID;\n    dto.options = { test: 'test' };\n    dto.type = SubmissionType.MESSAGE;\n\n    const record = await service.upsert(dto);\n    expect(await service.findAll()).toHaveLength(1);\n    expect(record.options).toEqual(dto.options);\n  });\n\n  it('should upsert - update when exists', async () => {\n    const dto = new CreateUserSpecifiedWebsiteOptionsDto();\n    dto.accountId = NULL_ACCOUNT_ID;\n    dto.options = { test: 'original' };\n    dto.type = SubmissionType.MESSAGE;\n\n    // First create\n    const created = await service.upsert(dto);\n    expect(created.options).toEqual({ test: 'original' });\n\n    // Second upsert should update, not throw\n    dto.options = { test: 'updated' };\n    const updated = await service.upsert(dto);\n\n    expect(await service.findAll()).toHaveLength(1); // Still only one record\n    expect(updated.id).toEqual(created.id); // Same record\n    expect(updated.options).toEqual({ test: 'updated' }); // Updated options\n  });\n\n  it('should upsert different account+type combinations independently', async () => {\n    const dto1 = new CreateUserSpecifiedWebsiteOptionsDto();\n    dto1.accountId = NULL_ACCOUNT_ID;\n    dto1.options = { test: 'message' };\n    dto1.type = SubmissionType.MESSAGE;\n\n    const dto2 = new CreateUserSpecifiedWebsiteOptionsDto();\n    dto2.accountId = NULL_ACCOUNT_ID;\n    dto2.options = { test: 'file' };\n    dto2.type = SubmissionType.FILE;\n\n    await service.upsert(dto1);\n    await service.upsert(dto2);\n\n    expect(await service.findAll()).toHaveLength(2);\n  });\n\n  it('should update entities', async () => {\n    const dto = new CreateUserSpecifiedWebsiteOptionsDto();\n    dto.accountId = NULL_ACCOUNT_ID;\n    dto.options = { test: 'test' };\n    dto.type = SubmissionType.MESSAGE;\n\n    const record = await service.create(dto);\n    const options = { ...record.options };\n\n    const updateDto = new UpdateUserSpecifiedWebsiteOptionsDto();\n    updateDto.type = SubmissionType.MESSAGE;\n    updateDto.options = { test: 'updated' };\n    const updateRecord = await service.update(record.id, updateDto);\n    expect(record.id).toEqual(updateRecord.id);\n    expect(options).not.toEqual(updateRecord.options);\n  });\n});\n"
  },
  {
    "path": "apps/client-server/src/app/user-specified-website-options/user-specified-website-options.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { AccountId, EntityId, SubmissionType } from '@postybirb/types';\nimport { and, eq } from 'drizzle-orm';\nimport { PostyBirbService } from '../common/service/postybirb-service';\nimport { UserSpecifiedWebsiteOptions } from '../drizzle/models';\nimport { CreateUserSpecifiedWebsiteOptionsDto } from './dtos/create-user-specified-website-options.dto';\nimport { UpdateUserSpecifiedWebsiteOptionsDto } from './dtos/update-user-specified-website-options.dto';\n\n@Injectable()\nexport class UserSpecifiedWebsiteOptionsService extends PostyBirbService<'UserSpecifiedWebsiteOptionsSchema'> {\n  constructor() {\n    super('UserSpecifiedWebsiteOptionsSchema');\n  }\n\n  async create(\n    createDto: CreateUserSpecifiedWebsiteOptionsDto,\n  ): Promise<UserSpecifiedWebsiteOptions> {\n    this.logger\n      .withMetadata(createDto)\n      .info(`Creating UserSpecifiedWebsiteOptions '${createDto.accountId}'`);\n    await this.throwIfExists(\n      and(\n        eq(this.schema.accountId, createDto.accountId),\n        eq(this.schema.type, createDto.type),\n      ),\n    );\n    return this.repository.insert({\n      accountId: createDto.accountId,\n      ...createDto,\n    });\n  }\n\n  update(id: EntityId, update: UpdateUserSpecifiedWebsiteOptionsDto) {\n    this.logger\n      .withMetadata(update)\n      .info(`Updating UserSpecifiedWebsiteOptions '${id}'`);\n    return this.repository.update(id, { options: update.options });\n  }\n\n  /**\n   * Creates or updates user-specified website options.\n   * If options already exist for this account+type combination, updates them.\n   * Otherwise, creates new options.\n   */\n  async upsert(\n    dto: CreateUserSpecifiedWebsiteOptionsDto,\n  ): Promise<UserSpecifiedWebsiteOptions> {\n    const existing = await this.findByAccountAndSubmissionType(\n      dto.accountId,\n      dto.type,\n    );\n\n    if (existing) {\n      this.logger\n        .withMetadata(dto)\n        .info(\n          `Updating existing UserSpecifiedWebsiteOptions for '${dto.accountId}'`,\n        );\n      return this.repository.update(existing.id, { options: dto.options });\n    }\n\n    return this.create(dto);\n  }\n\n  public findByAccountAndSubmissionType(\n    accountId: AccountId,\n    type: SubmissionType,\n  ) {\n    return this.repository.findOne({\n      // eslint-disable-next-line @typescript-eslint/no-shadow\n      where: (options, { and, eq }) =>\n        and(eq(options.accountId, accountId), eq(options.type, type)),\n    });\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/utils/blocknote-to-tiptap.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { Logger } from '@postybirb/logger';\nimport { Description, TipTapMark, TipTapNode } from '@postybirb/types';\n\nconst logger = Logger();\n\n/**\n * BlockNote block shape (old format):\n * {\n *   id: string,\n *   type: string,\n *   props: Record<string, any>,\n *   content: Array<{ type: 'text', text: string, styles: Record<string, any> }\n *                 | { type: 'link', href: string, content: [...textNodes] }\n *                 | { type: string, props: Record<string, any> }>,\n *   children: Block[]\n * }\n */\n\ninterface BNTextNode {\n  type: 'text';\n  text: string;\n  styles?: Record<string, any>;\n}\n\ninterface BNLinkNode {\n  type: 'link';\n  href: string;\n  content: BNTextNode[];\n}\n\ninterface BNInlineNode {\n  type: string;\n  props?: Record<string, any>;\n}\n\ntype BNInlineContent = BNTextNode | BNLinkNode | BNInlineNode;\n\ninterface BNBlock {\n  id?: string;\n  type: string;\n  props?: Record<string, any>;\n  content?: BNInlineContent[] | 'none' | string;\n  children?: BNBlock[];\n}\n\n// Default props that BlockNote includes but are meaningless — strip them\nconst DEFAULT_PROP_VALUES: Record<string, any> = {\n  textColor: 'default',\n  backgroundColor: 'default',\n  textAlignment: 'left',\n  level: 1,\n};\n\n/**\n * Returns true if the value is a BlockNote-format description (old format).\n * BlockNote descriptions are stored as an array of blocks.\n * TipTap descriptions are stored as { type: 'doc', content: [...] }.\n */\nexport function isBlockNoteFormat(desc: unknown): desc is BNBlock[] {\n  return Array.isArray(desc);\n}\n\n/**\n * Convert BlockNote styles object to TipTap marks array.\n * e.g. { bold: true, italic: true, textColor: '#ff0000' }\n * → [{ type: 'bold' }, { type: 'italic' }, { type: 'textColor', attrs: { color: '#ff0000' } }]\n */\nfunction convertStyles(styles: Record<string, any>): TipTapMark[] {\n  const marks: TipTapMark[] = [];\n  for (const [key, value] of Object.entries(styles)) {\n    if (value === false || value === undefined || value === null) {\n      continue;\n    }\n    switch (key) {\n      case 'bold':\n      case 'italic':\n      case 'strike':\n      case 'underline':\n      case 'code':\n        if (value === true) {\n          marks.push({ type: key });\n        }\n        break;\n      case 'textColor':\n        if (value && value !== 'default') {\n          marks.push({ type: 'textStyle', attrs: { color: value } });\n        }\n        break;\n      case 'backgroundColor':\n        if (value && value !== 'default') {\n          marks.push({ type: 'highlight', attrs: { color: value } });\n        }\n        break;\n      default:\n        // Unknown style — preserve as-is\n        if (value === true) {\n          marks.push({ type: key });\n        } else {\n          marks.push({ type: key, attrs: { value } });\n        }\n        break;\n    }\n  }\n  return marks;\n}\n\n/**\n * Convert a BlockNote inline content node to TipTap inline nodes.\n * - text → { type: 'text', text, marks }\n * - link → text nodes with link mark added\n * - custom inline shortcuts → { type, attrs }\n */\nfunction convertInlineContent(\n  inlineNode: BNInlineContent,\n): TipTapNode[] {\n  if (inlineNode.type === 'text') {\n    const bn = inlineNode as BNTextNode;\n    const node: TipTapNode = { type: 'text', text: bn.text };\n    if (bn.styles && Object.keys(bn.styles).length > 0) {\n      const marks = convertStyles(bn.styles);\n      if (marks.length > 0) {\n        node.marks = marks;\n      }\n    }\n    return [node];\n  }\n\n  if (inlineNode.type === 'link') {\n    const bn = inlineNode as BNLinkNode;\n    const linkMark: TipTapMark = {\n      type: 'link',\n      attrs: { href: bn.href, target: '_blank', rel: 'noopener noreferrer nofollow' },\n    };\n    // Each text node inside the link gets the link mark added\n    return (bn.content || []).flatMap((textNode) => {\n      const converted = convertInlineContent(textNode);\n      return converted.map((n) => {\n        if (n.type === 'text') {\n          const existing = n.marks || [];\n          return { ...n, marks: [...existing, linkMark] };\n        }\n        return n;\n      });\n    });\n  }\n\n  // Custom inline nodes: customShortcut, titleShortcut, tagsShortcut,\n  // contentWarningShortcut, username\n  const bn = inlineNode as BNInlineNode;\n  const attrs: Record<string, any> = {};\n  if (bn.props) {\n    for (const [key, value] of Object.entries(bn.props)) {\n      if (value !== undefined && value !== null && value !== '') {\n        attrs[key] = value;\n      }\n    }\n  }\n  const node: TipTapNode = { type: bn.type };\n  if (Object.keys(attrs).length > 0) {\n    node.attrs = attrs;\n  }\n  return [node];\n}\n\n/**\n * Convert a single BlockNote block to a TipTap node.\n */\nfunction convertBlock(block: BNBlock): TipTapNode[] {\n  const {type} = block;\n  const props = block.props || {};\n\n  // Map BlockNote block types to TipTap node types\n  switch (type) {\n    case 'paragraph': {\n      const node: TipTapNode = { type: 'paragraph' };\n      const attrs = extractBlockAttrs(props);\n      if (Object.keys(attrs).length > 0) {\n        node.attrs = attrs;\n      }\n      if (\n        block.content &&\n        Array.isArray(block.content) &&\n        block.content.length > 0\n      ) {\n        node.content = block.content.flatMap(convertInlineContent);\n      }\n      const result: TipTapNode[] = [node];\n      // BlockNote children are nested blocks (e.g. indented content)\n      if (block.children && block.children.length > 0) {\n        result.push(...block.children.flatMap(convertBlock));\n      }\n      return result;\n    }\n\n    case 'heading': {\n      const level = props.level || 1;\n      const node: TipTapNode = {\n        type: 'heading',\n        attrs: { level, ...extractBlockAttrs(props, ['level']) },\n      };\n      if (\n        block.content &&\n        Array.isArray(block.content) &&\n        block.content.length > 0\n      ) {\n        node.content = block.content.flatMap(convertInlineContent);\n      }\n      const result: TipTapNode[] = [node];\n      if (block.children && block.children.length > 0) {\n        result.push(...block.children.flatMap(convertBlock));\n      }\n      return result;\n    }\n\n    case 'bulletListItem': {\n      // BlockNote uses flat blocks with type 'bulletListItem'\n      // TipTap uses bulletList > listItem > paragraph\n      const paragraph: TipTapNode = { type: 'paragraph' };\n      if (\n        block.content &&\n        Array.isArray(block.content) &&\n        block.content.length > 0\n      ) {\n        paragraph.content = block.content.flatMap(convertInlineContent);\n      }\n      const listItem: TipTapNode = {\n        type: 'listItem',\n        content: [paragraph],\n      };\n      // Children of a list item become nested list items inside a sub-list\n      if (block.children && block.children.length > 0) {\n        const childItems = block.children.flatMap((child) =>\n          convertBlock(child),\n        );\n        const subList: TipTapNode = {\n          type: 'bulletList',\n          content: childItems.filter((n) => n.type === 'listItem'),\n        };\n        if (subList.content && subList.content.length > 0) {\n          listItem.content = [paragraph, subList];\n        }\n      }\n      // Wrap in bulletList — caller will merge adjacent ones\n      return [{ type: 'bulletList', content: [listItem] }];\n    }\n\n    case 'numberedListItem': {\n      const paragraph: TipTapNode = { type: 'paragraph' };\n      if (\n        block.content &&\n        Array.isArray(block.content) &&\n        block.content.length > 0\n      ) {\n        paragraph.content = block.content.flatMap(convertInlineContent);\n      }\n      const listItem: TipTapNode = {\n        type: 'listItem',\n        content: [paragraph],\n      };\n      if (block.children && block.children.length > 0) {\n        const childItems = block.children.flatMap((child) =>\n          convertBlock(child),\n        );\n        const subList: TipTapNode = {\n          type: 'orderedList',\n          content: childItems.filter((n) => n.type === 'listItem'),\n        };\n        if (subList.content && subList.content.length > 0) {\n          listItem.content = [paragraph, subList];\n        }\n      }\n      return [{ type: 'orderedList', content: [listItem] }];\n    }\n\n    case 'blockquote': {\n      // BlockNote stores blockquote content as inline content in a single block\n      const paragraph: TipTapNode = { type: 'paragraph' };\n      if (\n        block.content &&\n        Array.isArray(block.content) &&\n        block.content.length > 0\n      ) {\n        paragraph.content = block.content.flatMap(convertInlineContent);\n      }\n      const result: TipTapNode[] = [\n        { type: 'blockquote', content: [paragraph] },\n      ];\n      if (block.children && block.children.length > 0) {\n        result.push(...block.children.flatMap(convertBlock));\n      }\n      return result;\n    }\n\n    case 'codeBlock': {\n      const node: TipTapNode = { type: 'codeBlock' };\n      if (props.language) {\n        node.attrs = { language: props.language };\n      }\n      // Code blocks in BlockNote have text content\n      if (\n        block.content &&\n        Array.isArray(block.content) &&\n        block.content.length > 0\n      ) {\n        node.content = block.content\n          .filter(\n            (c): c is BNTextNode => c.type === 'text' && 'text' in c,\n          )\n          .map((c) => ({ type: 'text', text: c.text }));\n      }\n      return [node];\n    }\n\n    // Custom block nodes — map directly\n    case 'defaultShortcut': {\n      const node: TipTapNode = { type: 'defaultShortcut' };\n      const attrs: Record<string, any> = {};\n      if (props.only) attrs.only = props.only;\n      if (Object.keys(attrs).length > 0) {\n        node.attrs = attrs;\n      }\n      return [node];\n    }\n\n    // Fallback for any other block type (e.g. horizontal rule, images, etc.)\n    default: {\n      // Map known aliases\n      if (type === 'horizontalRule' || type === 'rule') {\n        return [{ type: 'horizontalRule' }];\n      }\n\n      // Generic passthrough — preserve type and convert content\n      const node: TipTapNode = { type };\n      const attrs = extractBlockAttrs(props);\n      if (Object.keys(attrs).length > 0) {\n        node.attrs = attrs;\n      }\n      if (\n        block.content &&\n        Array.isArray(block.content) &&\n        block.content.length > 0\n      ) {\n        node.content = block.content.flatMap(convertInlineContent);\n      }\n      return [node];\n    }\n  }\n}\n\n/**\n * Extract meaningful attrs from BlockNote props, stripping defaults.\n */\nfunction extractBlockAttrs(\n  props: Record<string, any>,\n  skipKeys: string[] = [],\n): Record<string, any> {\n  const attrs: Record<string, any> = {};\n  for (const [key, value] of Object.entries(props)) {\n    if (skipKeys.includes(key)) continue;\n    if (value === undefined || value === null) continue;\n    if (DEFAULT_PROP_VALUES[key] === value) continue;\n    if (key === 'textAlignment' && value !== 'left') {\n      attrs.textAlign = value;\n    } else if (\n      key !== 'textColor' &&\n      key !== 'backgroundColor' &&\n      key !== 'textAlignment'\n    ) {\n      attrs[key] = value;\n    }\n  }\n  return attrs;\n}\n\n/**\n * Merge adjacent list nodes of the same type.\n * BlockNote creates one bulletList/orderedList per list item,\n * but TipTap expects all items in a single list node.\n */\nfunction mergeAdjacentLists(nodes: TipTapNode[]): TipTapNode[] {\n  const result: TipTapNode[] = [];\n  for (const node of nodes) {\n    const prev = result[result.length - 1];\n    if (\n      prev &&\n      prev.type === node.type &&\n      (node.type === 'bulletList' || node.type === 'orderedList') &&\n      Array.isArray(prev.content) &&\n      Array.isArray(node.content)\n    ) {\n      prev.content = [...prev.content, ...node.content];\n    } else {\n      result.push(node);\n    }\n  }\n  return result;\n}\n\n/**\n * Convert an entire BlockNote description (array of blocks) to TipTap JSON.\n */\nexport function convertBlockNoteToTipTap(blocks: BNBlock[]): Description {\n  if (!Array.isArray(blocks) || blocks.length === 0) {\n    return { type: 'doc', content: [] };\n  }\n\n  const converted = blocks.flatMap(convertBlock);\n  const merged = mergeAdjacentLists(converted);\n\n  return {\n    type: 'doc',\n    content: merged,\n  };\n}\n\n/**\n * Migrate a Description field if it is in BlockNote format.\n * Returns the migrated TipTap doc, or the original if already in TipTap format.\n */\nexport function migrateDescription(desc: unknown): Description {\n  if (!desc) {\n    return { type: 'doc', content: [] };\n  }\n\n  // Already TipTap format\n  if (\n    typeof desc === 'object' &&\n    !Array.isArray(desc) &&\n    (desc as any).type === 'doc'\n  ) {\n    return desc as Description;\n  }\n\n  // BlockNote format (array of blocks)\n  if (Array.isArray(desc)) {\n    try {\n      return convertBlockNoteToTipTap(desc as BNBlock[]);\n    } catch (err) {\n      logger.error(\n        `Failed to migrate BlockNote description: ${(err as Error).message}`,\n        (err as Error).stack,\n      );\n      return { type: 'doc', content: [] };\n    }\n  }\n\n  // Unknown format\n  logger.warn(`Unknown description format, resetting to empty`);\n  return { type: 'doc', content: [] };\n}\n"
  },
  {
    "path": "apps/client-server/src/app/utils/coerce.util.ts",
    "content": "export class Coerce {\n  static boolean(value: string | boolean): boolean {\n    return !!value.toString().match(/^(true|[1-9][0-9]*|[0-9]*[1-9]+|yes)$/i);\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/utils/filesize.util.ts",
    "content": "/**\n * Defines simple file size conversion methods\n * @class FileSize\n */\nexport default class FileSize {\n  static megabytes(size: number): number {\n    return size * 1000000;\n  }\n\n  static bytesToMB(size: number): number {\n    return size / 1000000;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/utils/html-parser.util.ts",
    "content": "import { NotFoundException } from '@nestjs/common';\n\nexport default class HtmlParserUtil {\n  public static getInputValue(html: string, name: string, index = 0): string {\n    // eslint-disable-next-line no-param-reassign\n    index = index || 0;\n    const inputs = (html.match(/<input.*?(\\/)*>/gim) || [])\n      .filter((input?: string) => input?.includes(`name=\"${name}\"`))\n      .map((input) => input.match(/value=\"(.*?)\"/)[1]);\n\n    const picked = inputs[index];\n    if (!picked) {\n      throw new NotFoundException(`Could not find form key: ${name}[${index}]`);\n    }\n\n    return picked;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/utils/select-option.util.ts",
    "content": "import { SelectOption } from '@postybirb/form-builder';\n\nexport class SelectOptionUtil {\n  static findOptionById(\n    options: SelectOption[],\n    id: string,\n  ): SelectOption | undefined {\n    for (const option of options) {\n      if (option.value === id) {\n        return option;\n      }\n      if ('items' in option && option.items) {\n        const found = this.findOptionById(option.items, id);\n        if (found) {\n          return found;\n        }\n      }\n    }\n    return undefined;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/utils/wait.util.ts",
    "content": "import { setInterval } from 'timers/promises';\n\nexport function wait(milliseconds: number): Promise<void> {\n  return new Promise((resolve) => {\n    setTimeout(resolve, milliseconds);\n  });\n}\n\nexport async function waitUntil(\n  fn: () => boolean,\n  milliseconds: number,\n): Promise<void> {\n  if (fn()) {\n    return;\n  }\n\n  const interval = setInterval(milliseconds);\n\n  // eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unused-vars\n  for await (const i of interval) {\n    if (fn()) {\n      break;\n    }\n  }\n}\n\nexport async function waitUntilPromised(\n  fn: () => Promise<boolean>,\n  milliseconds: number,\n): Promise<void> {\n  if (await fn()) {\n    return;\n  }\n\n  const interval = setInterval(milliseconds);\n\n  // eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unused-vars\n  for await (const i of interval) {\n    if (await fn()) {\n      break;\n    }\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/validation/validation.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { FileConverterModule } from '../file-converter/file-converter.module';\nimport { FileModule } from '../file/file.module';\nimport { PostParsersModule } from '../post-parsers/post-parsers.module';\nimport { WebsitesModule } from '../websites/websites.module';\nimport { ValidationService } from './validation.service';\n\n@Module({\n  imports: [WebsitesModule, PostParsersModule, FileConverterModule, FileModule],\n  providers: [ValidationService],\n  exports: [ValidationService],\n})\nexport class ValidationModule {}\n"
  },
  {
    "path": "apps/client-server/src/app/validation/validation.service.spec.ts",
    "content": "import { Test, TestingModule } from '@nestjs/testing';\nimport { clearDatabase } from '@postybirb/database';\nimport { FileConverterService } from '../file-converter/file-converter.service';\nimport { FileModule } from '../file/file.module';\nimport { FileService } from '../file/file.service';\nimport { CreateFileService } from '../file/services/create-file.service';\nimport { UpdateFileService } from '../file/services/update-file.service';\nimport { SharpInstanceManager } from '../image-processing/sharp-instance-manager';\nimport { PostParsersModule } from '../post-parsers/post-parsers.module';\nimport { PostParsersService } from '../post-parsers/post-parsers.service';\nimport { WebsiteImplProvider } from '../websites/implementations/provider';\nimport { WebsiteRegistryService } from '../websites/website-registry.service';\nimport { WebsitesModule } from '../websites/websites.module';\nimport { ValidationService } from './validation.service';\n\ndescribe('ValidationService', () => {\n  let service: ValidationService;\n\n  beforeEach(async () => {\n    clearDatabase();\n    const module: TestingModule = await Test.createTestingModule({\n      imports: [WebsitesModule, PostParsersModule, FileModule],\n      providers: [\n        WebsiteImplProvider,\n        ValidationService,\n        WebsiteRegistryService,\n        PostParsersService,\n        FileConverterService,\n        FileService,\n        CreateFileService,\n        UpdateFileService,\n        SharpInstanceManager,\n      ],\n    }).compile();\n\n    service = module.get<ValidationService>(ValidationService);\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "apps/client-server/src/app/validation/validation.service.ts",
    "content": "import { Injectable } from '@nestjs/common';\nimport { Logger } from '@postybirb/logger';\nimport {\n  EntityId,\n  ISubmission,\n  IWebsiteOptions,\n  PostData,\n  SimpleValidationResult,\n  SubmissionId,\n  SubmissionType,\n  ValidationResult,\n} from '@postybirb/types';\nimport { Account, Submission, WebsiteOptions } from '../drizzle/models';\nimport { FileConverterService } from '../file-converter/file-converter.service';\nimport { FileService } from '../file/file.service';\nimport { PostParsersService } from '../post-parsers/post-parsers.service';\nimport DefaultWebsite from '../websites/implementations/default/default.website';\nimport { DefaultWebsiteOptions } from '../websites/models/default-website-options';\nimport { isFileWebsite } from '../websites/models/website-modifiers/file-website';\nimport { isMessageWebsite } from '../websites/models/website-modifiers/message-website';\nimport { UnknownWebsite } from '../websites/website';\nimport { WebsiteRegistryService } from '../websites/website-registry.service';\nimport { validators } from './validators';\nimport {\n  FieldValidator,\n  Validator,\n  ValidatorParams,\n} from './validators/validator.type';\n\ntype ValidationCacheRecord = {\n  submissionLastUpdatedTimestamp: string;\n  results: Record<\n    EntityId, // WebsiteOptionId\n    {\n      validationResult: ValidationResult;\n      websiteOptionLastUpdatedTimestamp: string;\n    }\n  >;\n};\n\n@Injectable()\nexport class ValidationService {\n  private readonly logger = Logger(this.constructor.name);\n\n  private readonly validations: Validator[] = validators;\n\n  private readonly validationCache = new Map<\n    SubmissionId,\n    ValidationCacheRecord\n  >();\n\n  constructor(\n    private readonly websiteRegistry: WebsiteRegistryService,\n    private readonly postParserService: PostParsersService,\n    private readonly fileConverterService: FileConverterService,\n    private readonly fileService: FileService,\n  ) {}\n\n  private getCachedValidation(\n    submissionId: SubmissionId,\n  ): ValidationCacheRecord | undefined {\n    return this.validationCache.get(submissionId);\n  }\n\n  private clearCachedValidation(submissionId: SubmissionId) {\n    this.validationCache.delete(submissionId);\n  }\n\n  private setCachedValidation(\n    submission: Submission,\n    websiteOption: WebsiteOptions,\n    validationResult: ValidationResult,\n  ) {\n    const cachedValidation = this.getCachedValidation(submission.id);\n    if (!cachedValidation) {\n      this.validationCache.set(submission.id, {\n        submissionLastUpdatedTimestamp: submission.updatedAt,\n        results: {\n          [websiteOption.id]: {\n            validationResult,\n            websiteOptionLastUpdatedTimestamp: websiteOption.updatedAt,\n          },\n        },\n      });\n    } else {\n      cachedValidation.results[websiteOption.id] = {\n        validationResult,\n        websiteOptionLastUpdatedTimestamp: websiteOption.updatedAt,\n      };\n    }\n  }\n\n  /**\n   * Validate a submission for all website options.\n   *\n   * @param {ISubmission} submission\n   * @return {*}  {Promise<ValidationResult[]>}\n   */\n  public async validateSubmission(\n    submission: Submission,\n  ): Promise<ValidationResult[]> {\n    if (this.isStale(submission)) {\n      this.clearCachedValidation(submission.id);\n    }\n    return Promise.all(\n      submission.options.map((website) => this.validate(submission, website)),\n    );\n  }\n\n  /**\n   * Check if a submission is stale by comparing the last updated timestamps of\n   * the submission and the website options.\n   *\n   * @param {Submission} submission\n   * @return {*}  {boolean}\n   */\n  private isStale(submission: Submission): boolean {\n    const cachedValidation = this.getCachedValidation(submission.id);\n    if (!cachedValidation) {\n      return false;\n    }\n    if (\n      cachedValidation.submissionLastUpdatedTimestamp !== submission.updatedAt\n    ) {\n      return true;\n    }\n\n    return submission.options.some(\n      (website) =>\n        cachedValidation.results[website.id] &&\n        cachedValidation.results[website.id]\n          .websiteOptionLastUpdatedTimestamp !== website.updatedAt,\n    );\n  }\n\n  /**\n   * Validate an individual website option.\n   *\n   * @param {ISubmission} submission\n   * @param {IWebsiteOptions} websiteOption\n   * @return {*}  {Promise<ValidationResult>}\n   */\n  public async validate(\n    submission: Submission,\n    websiteOption: WebsiteOptions,\n  ): Promise<ValidationResult> {\n    try {\n      const cachedValidation = this.getCachedValidation(submission.id);\n      if (\n        cachedValidation &&\n        cachedValidation.results[websiteOption.id] &&\n        cachedValidation.results[websiteOption.id]\n          .websiteOptionLastUpdatedTimestamp === websiteOption.updatedAt\n      ) {\n        return cachedValidation.results[websiteOption.id].validationResult;\n      }\n\n      const website = websiteOption.isDefault\n        ? new DefaultWebsite(new Account(websiteOption.account))\n        : this.websiteRegistry.findInstance(websiteOption.account);\n\n      if (!website) {\n        this.logger.error(\n          `Failed to find website instance for account ${websiteOption.accountId}`,\n        );\n        throw new Error(\n          `Failed to find website instance for account ${websiteOption.accountId}`,\n        );\n      }\n      // All sub-validations mutate the result object\n      const result: ValidationResult = {\n        id: websiteOption.id,\n        account: website.account.toDTO(),\n        warnings: [],\n        errors: [],\n      };\n\n      const data = await this.postParserService.parse(\n        submission,\n        website,\n        websiteOption,\n      );\n\n      const defaultOptions: IWebsiteOptions = submission.options.find(\n        (o) => o.isDefault,\n      );\n      const defaultOpts = Object.assign(new DefaultWebsiteOptions(), {\n        ...defaultOptions.data,\n      });\n      const mergedWebsiteOptions = Object.assign(\n        website.getModelFor(submission.type),\n        websiteOption.data,\n      ).mergeDefaults(defaultOpts);\n\n      const params: ValidatorParams = {\n        result,\n        validator: new FieldValidator(result.errors, result.warnings),\n        websiteInstance: website,\n        data,\n        submission,\n        fileConverterService: this.fileConverterService,\n        fileService: this.fileService,\n        mergedWebsiteOptions,\n      };\n\n      // eslint-disable-next-line no-restricted-syntax\n      for (const validation of this.validations) {\n        await validation(params);\n      }\n\n      const instanceResult = await this.validateWebsiteInstance(\n        websiteOption.id,\n        submission,\n        website,\n        data,\n      );\n\n      result.warnings.push(...(instanceResult.warnings ?? []));\n      result.errors.push(...(instanceResult.errors ?? []));\n\n      this.setCachedValidation(submission, websiteOption, result);\n      return result;\n    } catch (error) {\n      this.logger.warn(\n        `Failed to validate website options ${websiteOption.id}`,\n        error,\n      );\n      return {\n        id: websiteOption.id,\n        account: new Account(websiteOption.account).toDTO(),\n        warnings: [\n          {\n            id: 'validation.failed',\n            values: {\n              message: error.message,\n            },\n          },\n        ],\n      };\n    }\n  }\n\n  private async validateWebsiteInstance(\n    websiteId: EntityId,\n    submission: ISubmission,\n    website: UnknownWebsite,\n    postData: PostData,\n  ): Promise<ValidationResult> {\n    let result: SimpleValidationResult;\n    try {\n      if (submission.type === SubmissionType.FILE && isFileWebsite(website)) {\n        result = await website.onValidateFileSubmission(postData);\n      }\n\n      if (\n        submission.type === SubmissionType.MESSAGE &&\n        isMessageWebsite(website)\n      ) {\n        result = await website.onValidateMessageSubmission(postData);\n      }\n\n      return {\n        id: websiteId,\n        account: website.account.toDTO(),\n        warnings: result?.warnings,\n        errors: result?.errors,\n      };\n    } catch (error) {\n      this.logger.warn(\n        `Failed to validate website instance for submission ${submission.id}, website ${websiteId}, type ${submission.type}, instance ${website.constructor.name}`,\n        error,\n      );\n      return {\n        id: websiteId,\n        account: website.account.toDTO(),\n        warnings: [\n          {\n            id: 'validation.failed',\n            values: {\n              message: error.message,\n            },\n          },\n        ],\n      };\n    }\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/validation/validators/common-field-validators.ts",
    "content": "import { ValidatorParams } from './validator.type';\n\n/**\n * Validates that a required text field (input/textarea) is not empty.\n */\nexport async function validateRequiredTextField({\n  result,\n  data,\n  mergedWebsiteOptions,\n}: ValidatorParams) {\n  const fields = mergedWebsiteOptions.getFormFields();\n\n  for (const [fieldName, field] of Object.entries(fields)) {\n    // Skip if field is not required or hidden\n    if (!field.required || field.hidden) continue;\n\n    // Only check text field types (includes title, content warning etc.)\n    if (field.formField !== 'input' && field.formField !== 'textarea') continue;\n\n    // Check if the field is a title field\n    // Check if the field is a content warning field\n    if (\n      (field.formField === 'input' && field.label === 'title') ||\n      (field.formField === 'input' && field.label === 'contentWarning')\n    ) {\n      continue;\n    }\n\n    const value = data.options[fieldName] as string;\n\n    // Check if the value is empty\n    if (!value || value.trim() === '') {\n      result.errors.push({\n        id: 'validation.field.required',\n        field: fieldName,\n        values: {},\n      });\n    }\n  }\n}\n\n/**\n * Validates that a required select field has a value selected.\n */\nexport async function validateRequiredSelectField({\n  result,\n  data,\n  mergedWebsiteOptions,\n}: ValidatorParams) {\n  const fields = mergedWebsiteOptions.getFormFields();\n\n  for (const [fieldName, field] of Object.entries(fields)) {\n    // Skip if field is not required or hidden\n    if (!field.required || field.hidden) continue;\n\n    // Only check select fields\n    if (field.formField !== 'select') continue;\n\n    // Skip fields with min selected, they gets handled by selectFieldValidator\n    if ('minSelected' in field && typeof field.minSelected === 'number')\n      continue;\n\n    const value = data.options[fieldName];\n    const isEmpty = Array.isArray(value) ? value.length === 0 : !value;\n\n    if (isEmpty) {\n      result.errors.push({\n        id: 'validation.field.required',\n        field: fieldName,\n        values: {},\n      });\n    }\n  }\n}\n\n/**\n * Validates that a required radio field has a value selected.\n */\nexport async function validateRequiredRadioField({\n  result,\n  data,\n  mergedWebsiteOptions,\n}: ValidatorParams) {\n  const fields = mergedWebsiteOptions.getFormFields();\n\n  for (const [fieldName, field] of Object.entries(fields)) {\n    // Skip if field is not required or hidden\n    if (!field.required || field.hidden) continue;\n\n    // Only check radio fields (includes rating fields)\n    if (field.formField !== 'radio' && field.formField !== 'rating') continue;\n\n    const value = data.options[fieldName];\n\n    if (!value) {\n      result.errors.push({\n        id: 'validation.field.required',\n        field: fieldName,\n        values: {},\n      });\n    }\n  }\n}\n\n/**\n * Validates that a required boolean field (checkbox) is checked.\n */\nexport async function validateRequiredBooleanField({\n  result,\n  data,\n  mergedWebsiteOptions,\n}: ValidatorParams) {\n  const fields = mergedWebsiteOptions.getFormFields();\n\n  for (const [fieldName, field] of Object.entries(fields)) {\n    // Skip if field is not required or hidden\n    if (!field.required || field.hidden) continue;\n\n    // Only check checkbox fields\n    if (field.formField !== 'checkbox') continue;\n\n    const value = data.options[fieldName] as boolean;\n\n    if (typeof value !== 'boolean') {\n      result.errors.push({\n        id: 'validation.field.required',\n        field: fieldName,\n        values: {},\n      });\n    }\n  }\n}\n\n/**\n * Validates that a required description field has content.\n */\nexport async function validateRequiredDescriptionField({\n  result,\n  data,\n  mergedWebsiteOptions,\n}: ValidatorParams) {\n  const fields = mergedWebsiteOptions.getFormFields();\n\n  for (const [fieldName, field] of Object.entries(fields)) {\n    // Skip if field is not required or hidden\n    if (!field.required || field.hidden) continue;\n\n    // Only check description fields\n    if (field.formField !== 'description') continue;\n\n    // Description field value structure\n    let value: string = data.options[fieldName] || '';\n    value = value.replaceAll('<div></div>', '').trim();\n\n    if (!value || value.length === 0) {\n      result.errors.push({\n        id: 'validation.field.required',\n        field: fieldName,\n        values: {},\n      });\n    }\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/validation/validators/datetime-field-validators.ts",
    "content": "import { ValidatorParams } from './validator.type';\n\n/**\n * Validates that a datetime field value is a valid ISO date string.\n */\nexport async function validateDateTimeFormat({\n  result,\n  data,\n  mergedWebsiteOptions,\n}: ValidatorParams) {\n  const fields = mergedWebsiteOptions.getFormFields();\n\n  for (const [fieldName, field] of Object.entries(fields)) {\n    // Skip if field is hidden\n    if (field.hidden) continue;\n\n    // Only check datetime fields\n    if (field.formField !== 'datetime') continue;\n\n    const value = data.options[fieldName] as string;\n\n    // Skip if no value (empty is handled by required validator)\n    if (!value || value.trim() === '') continue;\n\n    // Try to parse as ISO date string\n    const date = new Date(value);\n    if (Number.isNaN(date.getTime())) {\n      result.errors.push({\n        id: 'validation.datetime.invalid-format',\n        field: fieldName,\n        values: {\n          value,\n        },\n      });\n    }\n  }\n}\n\n/**\n * Validates that a datetime field value is not before the minimum allowed date.\n */\nexport async function validateDateTimeMinimum({\n  result,\n  data,\n  mergedWebsiteOptions,\n}: ValidatorParams) {\n  const fields = mergedWebsiteOptions.getFormFields();\n\n  for (const [fieldName, field] of Object.entries(fields)) {\n    // Skip if field is hidden\n    if (field.hidden) continue;\n\n    // Only check datetime fields\n    if (field.formField !== 'datetime') continue;\n\n    // Skip if no min constraint\n    if (!('min' in field) || !field.min) continue;\n\n    const value = data.options[fieldName] as string;\n\n    // Skip if no value\n    if (!value || value.trim() === '') continue;\n\n    const date = new Date(value);\n    const minDate = new Date(field.min);\n\n    // Skip if date is invalid (handled by format validator)\n    if (Number.isNaN(date.getTime())) continue;\n\n    if (date < minDate) {\n      result.errors.push({\n        id: 'validation.datetime.min',\n        field: fieldName,\n        values: {\n          currentDate: value,\n          minDate: field.min,\n        },\n      });\n    }\n  }\n}\n\n/**\n * Validates that a datetime field value is not after the maximum allowed date.\n */\nexport async function validateDateTimeMaximum({\n  result,\n  data,\n  mergedWebsiteOptions,\n}: ValidatorParams) {\n  const fields = mergedWebsiteOptions.getFormFields();\n\n  for (const [fieldName, field] of Object.entries(fields)) {\n    // Skip if field is hidden\n    if (field.hidden) continue;\n\n    // Only check datetime fields\n    if (field.formField !== 'datetime') continue;\n\n    // Skip if no max constraint\n    if (!('max' in field) || !field.max) continue;\n\n    const value = data.options[fieldName] as string;\n\n    // Skip if no value\n    if (!value || value.trim() === '') continue;\n\n    const date = new Date(value);\n    const maxDate = new Date(field.max);\n\n    // Skip if date is invalid (handled by format validator)\n    if (Number.isNaN(date.getTime())) continue;\n\n    if (date > maxDate) {\n      result.errors.push({\n        id: 'validation.datetime.max',\n        field: fieldName,\n        values: {\n          currentDate: value,\n          maxDate: field.max,\n        },\n      });\n    }\n  }\n}\n\n/**\n * Validates that a datetime field value is within the allowed range (min and max).\n * This is a convenience validator that checks both min and max constraints.\n */\nexport async function validateDateTimeRange({\n  result,\n  data,\n  mergedWebsiteOptions,\n}: ValidatorParams) {\n  const fields = mergedWebsiteOptions.getFormFields();\n\n  for (const [fieldName, field] of Object.entries(fields)) {\n    // Skip if field is hidden\n    if (field.hidden) continue;\n\n    // Only check datetime fields\n    if (field.formField !== 'datetime') continue;\n\n    // Skip if no range constraints\n    const hasMin = 'min' in field && field.min;\n    const hasMax = 'max' in field && field.max;\n    if (!hasMin || !hasMax) continue;\n\n    const value = data.options[fieldName] as string;\n\n    // Skip if no value\n    if (!value || value.trim() === '') continue;\n\n    const date = new Date(value);\n    const minDate = new Date(field.min);\n    const maxDate = new Date(field.max);\n\n    // Skip if date is invalid (handled by format validator)\n    if (Number.isNaN(date.getTime())) continue;\n\n    if (date < minDate || date > maxDate) {\n      result.errors.push({\n        id: 'validation.datetime.range',\n        field: fieldName,\n        values: {\n          currentDate: value,\n          minDate: field.min,\n          maxDate: field.max,\n        },\n      });\n    }\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/validation/validators/description-validators.ts",
    "content": "import { DescriptionType } from '@postybirb/types';\nimport DefaultWebsite from '../../websites/implementations/default/default.website';\nimport { ValidatorParams } from './validator.type';\n\nexport async function validateDescriptionMaxLength({\n  data,\n  mergedWebsiteOptions,\n  validator,\n}: ValidatorParams) {\n  const { hidden, descriptionType, maxDescriptionLength } =\n    mergedWebsiteOptions.getFormFieldFor('description');\n\n  if (\n    descriptionType === undefined ||\n    descriptionType === DescriptionType.NONE ||\n    hidden\n  ) {\n    return;\n  }\n\n  const { description } = data.options;\n  const maxLength = maxDescriptionLength ?? Number.MAX_SAFE_INTEGER;\n  if (description.length > maxLength) {\n    validator.warning(\n      'validation.description.max-length',\n      { currentLength: description.length, maxLength },\n      'description',\n    );\n  }\n}\n\nexport async function validateDescriptionMinLength({\n  data,\n  mergedWebsiteOptions,\n  validator,\n}: ValidatorParams) {\n  const { hidden, descriptionType, minDescriptionLength } =\n    mergedWebsiteOptions.getFormFieldFor('description');\n\n  if (\n    descriptionType === undefined ||\n    descriptionType === DescriptionType.NONE ||\n    hidden\n  ) {\n    return;\n  }\n\n  const { description } = data.options;\n  const minLength = minDescriptionLength ?? -1;\n  if (description.length < minLength) {\n    validator.error(\n      'validation.description.min-length',\n      { minLength, currentLength: description.length },\n      'description',\n    );\n  }\n}\n\nexport async function validateTagsPresence({\n  data,\n  mergedWebsiteOptions,\n  validator,\n  websiteInstance,\n}: ValidatorParams) {\n  if (websiteInstance instanceof DefaultWebsite) return;\n\n  const tagsField = mergedWebsiteOptions.getFormFieldFor('tags');\n  const descriptionField = mergedWebsiteOptions.getFormFieldFor('description');\n  const { tags, description } = data.options;\n\n  if (tagsField.hidden || descriptionField.hidden) return;\n  if (!description || !tags.length) return;\n\n  const presentTags = tags.filter((e) => description.includes(`#${e}`));\n\n  if (descriptionField.expectsInlineTags) {\n    if (presentTags.length === 0) {\n      // Tags are missing in the description\n      validator.warning(\n        'validation.description.missing-tags',\n        {},\n        'description',\n      );\n    }\n  } else if (presentTags.length === tags.length) {\n    // All tags are in description\n    validator.warning(\n      'validation.description.unexpected-tags',\n      {},\n      'description',\n    );\n  }\n}\n\nexport async function validateTitlePresence({\n  data,\n  mergedWebsiteOptions,\n  validator,\n  websiteInstance,\n}: ValidatorParams) {\n  if (websiteInstance instanceof DefaultWebsite) return;\n\n  const titleField = mergedWebsiteOptions.getFormFieldFor('tags');\n  const descriptionField = mergedWebsiteOptions.getFormFieldFor('description');\n  const { title, description } = data.options;\n  if (titleField.hidden || descriptionField.hidden) return;\n  if (!description || !title) return;\n\n  const hasTitleText = description.includes(title);\n\n  if (descriptionField.expectsInlineTitle) {\n    if (!hasTitleText) {\n      // Title is missing in the description\n      validator.warning(\n        'validation.description.missing-title',\n        {},\n        'description',\n      );\n    }\n  } else if (hasTitleText) {\n    // Title is in the description\n    validator.warning(\n      'validation.description.unexpected-title',\n      {},\n      'description',\n    );\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/validation/validators/file-submission-validators.ts",
    "content": "import {\n  FileSubmission,\n  FileType,\n  ISubmission,\n  ISubmissionFile,\n  SubmissionType,\n  ValidationMessage,\n} from '@postybirb/types';\nimport { getFileType } from '@postybirb/utils/file-type';\nimport { parse } from 'path';\nimport { getSupportedFileSize } from '../../websites/decorators/supports-files.decorator';\nimport DefaultWebsite from '../../websites/implementations/default/default.website';\nimport {\n  ImplementedFileWebsite,\n  isFileWebsite,\n} from '../../websites/models/website-modifiers/file-website';\nimport { UnknownWebsite } from '../../websites/website';\nimport { ValidatorParams } from './validator.type';\n\nfunction isFileHandlingWebsite(\n  websiteInstance: UnknownWebsite,\n): websiteInstance is ImplementedFileWebsite {\n  return isFileWebsite(websiteInstance);\n}\n\nfunction isFileSubmission(\n  submission: ISubmission,\n): submission is FileSubmission {\n  return submission.type === SubmissionType.FILE;\n}\n\nfunction isFileFiltered(\n  file: ISubmissionFile,\n  submission: FileSubmission,\n  websiteInstance: UnknownWebsite,\n): boolean {\n  if (file.metadata?.ignoredWebsites?.includes(websiteInstance.accountId)) {\n    return true;\n  }\n  return false;\n}\n\nasync function validateTextFileRequiresFallback({\n  result,\n  websiteInstance,\n  submission,\n  fileService,\n}: ValidatorParams & { file: ISubmissionFile }) {\n  if (\n    !isFileHandlingWebsite(websiteInstance) ||\n    !isFileSubmission(submission) ||\n    websiteInstance instanceof DefaultWebsite\n  ) {\n    return;\n  }\n\n  for (const file of submission.files) {\n    if (isFileFiltered(file, submission, websiteInstance)) {\n      continue;\n    }\n    if (getFileType(file.fileName) === FileType.TEXT) {\n      const supportedMimeTypes =\n        websiteInstance.decoratedProps.fileOptions?.acceptedMimeTypes ?? [];\n      if (supportedMimeTypes.length === 0) {\n        // Assume empty to accept all file types if no accepted mime types are specified\n        continue;\n      }\n      // Check if the alt file has content by querying its size\n      let altFileHasContent = false;\n      if (file.altFileId) {\n        const altFileSize = await fileService.getAltFileSize(file.altFileId);\n        altFileHasContent = altFileSize > 0;\n      }\n      // Fail validation if the file is not supported and alt file is empty or missing\n      if (!supportedMimeTypes.includes(file.mimeType) && !altFileHasContent) {\n        result.errors.push({\n          id: 'validation.file.text-file-no-fallback',\n          field: 'files',\n          values: {\n            fileName: file.fileName,\n            fileExtension: parse(file.fileName).ext,\n            fileId: file.id,\n          },\n        });\n      }\n    }\n  }\n}\n\nexport async function validateNotAllFilesIgnored({\n  result,\n  websiteInstance,\n  submission,\n}: ValidatorParams) {\n  if (\n    !isFileHandlingWebsite(websiteInstance) ||\n    !isFileSubmission(submission) ||\n    websiteInstance instanceof DefaultWebsite\n  ) {\n    return;\n  }\n\n  const numFiles = submission.files.filter(\n    (file) => !isFileFiltered(file, submission, websiteInstance),\n  ).length;\n  if (numFiles === 0) {\n    result.warnings.push({\n      id: 'validation.file.all-ignored',\n      field: 'files',\n      values: {},\n    });\n  }\n}\n\nexport async function validateAcceptedFiles({\n  result,\n  websiteInstance,\n  submission,\n  data,\n  fileConverterService,\n  ...rest\n}: ValidatorParams) {\n  if (\n    !isFileHandlingWebsite(websiteInstance) ||\n    !isFileSubmission(submission) ||\n    websiteInstance instanceof DefaultWebsite\n  ) {\n    return;\n  }\n\n  const acceptedMimeTypes =\n    websiteInstance.decoratedProps.fileOptions?.acceptedMimeTypes ?? [];\n  const supportedFileTypes =\n    websiteInstance.decoratedProps.fileOptions?.supportedFileTypes ?? [];\n\n  if (!acceptedMimeTypes.length && !supportedFileTypes.length) {\n    return;\n  }\n\n  submission.files.forEach((file) => {\n    if (isFileFiltered(file, submission, websiteInstance)) {\n      return;\n    }\n    if (!acceptedMimeTypes.includes(file.mimeType)) {\n      const fileType = getFileType(file.fileName);\n      if (!supportedFileTypes.includes(fileType)) {\n        result.errors.push({\n          id: 'validation.file.unsupported-file-type',\n          field: 'files',\n          values: {\n            fileName: file.fileName,\n            fileType: getFileType(file.fileName),\n            fileId: file.id,\n          },\n        });\n      }\n\n      if (fileType === FileType.TEXT) {\n        validateTextFileRequiresFallback({\n          result,\n          websiteInstance,\n          submission,\n          file,\n          data,\n          fileConverterService,\n          ...rest,\n        });\n        return;\n      }\n\n      if (!fileConverterService.canConvert(file.mimeType, acceptedMimeTypes)) {\n        result.errors.push({\n          id: 'validation.file.invalid-mime-type',\n          field: 'files',\n          values: {\n            mimeType: file.mimeType,\n            acceptedMimeTypes,\n            fileId: file.id,\n          },\n        });\n      }\n    }\n  });\n}\n\nexport async function validateFileBatchSize({\n  result,\n  websiteInstance,\n  submission,\n}: ValidatorParams) {\n  if (\n    !isFileHandlingWebsite(websiteInstance) ||\n    !isFileSubmission(submission) ||\n    websiteInstance instanceof DefaultWebsite\n  ) {\n    return;\n  }\n\n  const maxBatchSize =\n    websiteInstance.decoratedProps.fileOptions?.fileBatchSize ?? 0;\n  const numFiles = submission.files.filter(\n    (file) => !isFileFiltered(file, submission, websiteInstance),\n  ).length;\n  if (numFiles > maxBatchSize) {\n    const expectedBatchesToCreate = Math.ceil(numFiles / maxBatchSize);\n    result.warnings.push({\n      id: 'validation.file.file-batch-size',\n      field: 'files',\n      values: {\n        maxBatchSize,\n        expectedBatchesToCreate,\n      },\n    });\n  }\n}\n\nexport async function validateFileSize({\n  result,\n  websiteInstance,\n  submission,\n}: ValidatorParams) {\n  if (\n    !isFileHandlingWebsite(websiteInstance) ||\n    !isFileSubmission(submission) ||\n    websiteInstance instanceof DefaultWebsite\n  ) {\n    return;\n  }\n\n  submission.files.forEach((file) => {\n    if (isFileFiltered(file, submission, websiteInstance)) {\n      return;\n    }\n\n    const maxFileSize = getSupportedFileSize(websiteInstance, file);\n    if (maxFileSize && file.size > maxFileSize) {\n      const issue: ValidationMessage = {\n        id: 'validation.file.file-size',\n        field: 'files',\n        values: {\n          maxFileSize,\n          fileSize: file.size,\n          fileName: file.fileName,\n          fileId: file.id,\n        },\n      };\n      if (getFileType(file.fileName) === FileType.IMAGE) {\n        result.warnings.push(issue);\n      } else {\n        result.errors.push(issue);\n      }\n    }\n  });\n}\n\nexport async function validateImageFileDimensions({\n  result,\n  websiteInstance,\n  submission,\n}: ValidatorParams) {\n  if (\n    !isFileHandlingWebsite(websiteInstance) ||\n    !isFileSubmission(submission) ||\n    websiteInstance instanceof DefaultWebsite\n  ) {\n    return;\n  }\n\n  submission.files.forEach((file) => {\n    if (isFileFiltered(file, submission, websiteInstance)) {\n      return;\n    }\n    if (getFileType(file.fileName) === FileType.IMAGE) {\n      const resizeProps = websiteInstance.calculateImageResize(file);\n      if (resizeProps) {\n        result.warnings.push({\n          id: 'validation.file.image-resize',\n          field: 'files',\n          values: {\n            fileName: file.fileName,\n            resizeProps,\n            fileId: file.id,\n          },\n        });\n      }\n    }\n  });\n}\n"
  },
  {
    "path": "apps/client-server/src/app/validation/validators/index.ts",
    "content": "import * as commonFieldValidators from './common-field-validators';\nimport * as dateTimeValidators from './datetime-field-validators';\nimport * as descriptionValidators from './description-validators';\nimport * as fileValidators from './file-submission-validators';\nimport * as selectFieldValidators from './select-field-validators';\nimport * as tagValidators from './tag-validators';\nimport * as titleValidators from './title-validators';\nimport { Validator } from './validator.type';\n\nexport const validators: Validator[] = [\n  ...Object.values(titleValidators),\n  ...Object.values(descriptionValidators),\n  ...Object.values(tagValidators),\n  ...Object.values(fileValidators),\n  ...Object.values(commonFieldValidators),\n  ...Object.values(selectFieldValidators),\n  ...Object.values(dateTimeValidators),\n];\n"
  },
  {
    "path": "apps/client-server/src/app/validation/validators/select-field-validators.ts",
    "content": "import {\n  FieldAggregateType,\n  SelectFieldType,\n  SelectOption,\n} from '@postybirb/form-builder';\nimport { ValidatorParams } from './validator.type';\n\nfunction isSelectField(field: FieldAggregateType): field is SelectFieldType {\n  return field.formField === 'select';\n}\n\nexport async function validateSelectFieldMinSelected({\n  result,\n  data,\n  mergedWebsiteOptions,\n}: ValidatorParams) {\n  const fields = mergedWebsiteOptions.getFormFields();\n  for (const [fieldName, field] of Object.entries(fields)) {\n    if (!isSelectField(field)) continue;\n\n    const options = data.options[fieldName];\n    const { minSelected } = field;\n    if (!minSelected) continue;\n\n    const selected = options?.length ?? 0;\n    if (selected < minSelected) {\n      result.errors.push({\n        id: 'validation.select-field.min-selected',\n        field: fieldName,\n        values: {\n          currentSelected: selected,\n          minSelected,\n        },\n      });\n    }\n  }\n}\n\n/**\n * Validates that the selected value(s) for a select field are among the valid options.\n */\nexport async function validateSelectFieldValidOptions({\n  result,\n  data,\n  mergedWebsiteOptions,\n}: ValidatorParams) {\n  const fields = mergedWebsiteOptions.getFormFields();\n\n  for (const [fieldName, field] of Object.entries(fields)) {\n    if (!isSelectField(field)) continue;\n    if (!field.options) continue;\n\n    // Skip if field has no value\n    if (\n      data.options[fieldName] === undefined ||\n      data.options[fieldName] === null\n    ) {\n      continue;\n    }\n\n    // Get the current value(s)\n    const currentValue = data.options[fieldName];\n\n    // Skip if no value\n    if (\n      currentValue === undefined ||\n      currentValue === null ||\n      currentValue === ''\n    )\n      continue;\n\n    // Handle discriminator-based options differently (like overallFileType)\n    if ('discriminator' in field.options) {\n      // Skip validation for discriminator-based options as they're dynamically determined\n      continue;\n    }\n\n    // Flatten all available options to a simple array of values\n    const availableOptions = flattenSelectOptions(field.options);\n\n    if (field.allowMultiple) {\n      // For multi-select, validate each selected value\n      if (Array.isArray(currentValue)) {\n        const invalidOptions = currentValue.filter(\n          (value) => !availableOptions.includes(value),\n        );\n\n        if (invalidOptions.length > 0) {\n          result.errors.push({\n            id: 'validation.select-field.invalid-option',\n            field: fieldName,\n            values: {\n              invalidOptions,\n              fieldName,\n              fieldLabel: field.label,\n            },\n          });\n        }\n      }\n    } else if (currentValue && !availableOptions.includes(currentValue)) {\n      // For single-select, validate the selected value\n      result.errors.push({\n        id: 'validation.select-field.invalid-option',\n        field: fieldName,\n        values: {\n          invalidOptions: [currentValue],\n        },\n      });\n    }\n  }\n}\n\n/**\n * Helper function to flatten nested select options into a single array of values\n */\nfunction flattenSelectOptions(options: SelectOption[]): string[] {\n  const result: string[] = [];\n\n  for (const option of options) {\n    if ('items' in option && Array.isArray(option.items)) {\n      // This is a group of options\n      for (const item of option.items) {\n        if ('value' in item) {\n          result.push(String(item.value));\n        }\n      }\n    } else if ('value' in option) {\n      // This is a single option\n      result.push(String(option.value));\n    }\n  }\n\n  return result;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/validation/validators/tag-validators.ts",
    "content": "import { ValidatorParams } from './validator.type';\n\nexport async function validateMaxTags({\n  data,\n  mergedWebsiteOptions,\n  validator,\n}: ValidatorParams) {\n  const { hidden, maxTags } = mergedWebsiteOptions.getFormFieldFor('tags');\n  if (hidden) return;\n\n  const { tags } = data.options;\n  const maxLength = maxTags ?? Number.MAX_SAFE_INTEGER;\n\n  if (tags.length > maxLength) {\n    validator.warning(\n      'validation.tags.max-tags',\n      { maxLength, currentLength: tags.length },\n      'tags',\n    );\n  }\n}\n\nexport async function validateMinTags({\n  data,\n  validator,\n  mergedWebsiteOptions,\n}: ValidatorParams) {\n  const tagField = mergedWebsiteOptions.getFormFieldFor('tags');\n  if (tagField.hidden) return;\n\n  const { tags } = data.options;\n  const minLength = tagField.minTags ?? -1;\n\n  if (tags.length < minLength) {\n    validator.error(\n      'validation.tags.min-tags',\n      { currentLength: tags.length, minLength },\n      'tags',\n    );\n  }\n}\n\nexport async function validateMaxTagLength({\n  data,\n  mergedWebsiteOptions,\n  validator,\n}: ValidatorParams) {\n  const { hidden, maxTagLength } = mergedWebsiteOptions.getFormFieldFor('tags');\n  if (hidden) return;\n\n  const { tags } = data.options;\n  const maxLength = maxTagLength ?? Number.MAX_SAFE_INTEGER;\n  const invalidTags = tags.filter((tag) => tag.length > maxLength);\n\n  if (invalidTags.length > 0) {\n    validator.warning(\n      'validation.tags.max-tag-length',\n      { tags: invalidTags, maxLength },\n      'tags',\n    );\n  }\n}\n\nexport async function validateTagHashtag({\n  data,\n  mergedWebsiteOptions,\n  validator,\n}: ValidatorParams) {\n  const { hidden } = mergedWebsiteOptions.getFormFieldFor('tags');\n  if (hidden) return;\n\n  const { tags } = data.options;\n  const invalidTags = tags.filter((tag) => tag.startsWith('#'));\n\n  if (invalidTags.length > 0) {\n    validator.error(\n      'validation.tags.double-hashtag',\n      { tags: invalidTags },\n      'tags',\n    );\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/validation/validators/title-validators.ts",
    "content": "import { ValidatorParams } from './validator.type';\n\nexport async function validateTitleMaxLength({\n  data,\n  mergedWebsiteOptions,\n  validator,\n}: ValidatorParams) {\n  const { hidden, maxLength } = mergedWebsiteOptions.getFormFieldFor('title');\n  if (hidden) return;\n\n  const { title } = data.options;\n  const maxTitleLength = maxLength ?? Number.MAX_SAFE_INTEGER;\n\n  if (title.length > maxLength) {\n    validator.warning(\n      'validation.title.max-length',\n      { currentLength: title.length, maxLength: maxTitleLength },\n      'title',\n    );\n  }\n}\n\nexport async function validateTitleMinLength({\n  data,\n  mergedWebsiteOptions,\n  validator,\n}: ValidatorParams) {\n  const { hidden, minLength } = mergedWebsiteOptions.getFormFieldFor('title');\n  if (hidden) return;\n\n  const { title } = data.options;\n  const minTitleLength = minLength ?? -1;\n\n  if (title.length < minTitleLength) {\n    validator.error(\n      'validation.title.min-length',\n      { currentLength: title.length, minLength: minTitleLength },\n      'title',\n    );\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/validation/validators/validator.type.ts",
    "content": "import {\n  ISubmission,\n  PostData,\n  ValidationMessage,\n  ValidationResult,\n} from '@postybirb/types';\nimport { FileConverterService } from '../../file-converter/file-converter.service';\nimport { FileService } from '../../file/file.service';\nimport { SubmissionValidator } from '../../websites/commons/validator';\nimport { BaseWebsiteOptions } from '../../websites/models/base-website-options';\nimport { UnknownWebsite } from '../../websites/website';\n\nexport type ValidatorParams = {\n  result: ValidationResult;\n  validator: FieldValidator;\n  websiteInstance: UnknownWebsite;\n  data: PostData;\n  submission: ISubmission;\n  fileConverterService: FileConverterService;\n  fileService: FileService;\n  mergedWebsiteOptions: BaseWebsiteOptions;\n};\n\nexport type Validator = (props: ValidatorParams) => Promise<void>;\n\nexport class FieldValidator extends SubmissionValidator {\n  constructor(\n    override errors: ValidationMessage[],\n    override warnings: ValidationMessage[],\n  ) {\n    super();\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/web-socket/models/web-socket-event.ts",
    "content": "export abstract class WebsocketEvent<D> {\n  event: string;\n\n  data: D;\n\n  constructor(data: D) {\n    this.data = data;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/web-socket/web-socket-adapter.ts",
    "content": "import { IoAdapter } from '@nestjs/platform-socket.io';\nimport { ServerOptions } from 'socket.io';\n\nexport class WebSocketAdapter extends IoAdapter {\n  createIOServer(\n    port: number,\n    options?: ServerOptions & {\n      namespace?: string;\n      server?: unknown;\n    },\n  ) {\n    const server = super.createIOServer(port, { ...options, cors: true });\n    return server;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/web-socket/web-socket-gateway.ts",
    "content": "import {\n  OnGatewayInit,\n  WebSocketGateway,\n  WebSocketServer,\n} from '@nestjs/websockets';\nimport { getRemoteConfig } from '@postybirb/utils/electron';\nimport { Server } from 'socket.io';\nimport { WebSocketEvents } from './web-socket.events';\n\n@WebSocketGateway({ cors: true })\nexport class WSGateway implements OnGatewayInit {\n  @WebSocketServer()\n  private server: Server;\n\n  afterInit(server: Server) {\n    server.use(async (socket, next) => {\n      const remoteConfig = await getRemoteConfig();\n      if (socket.handshake.headers.authorization === remoteConfig.password) {\n        return next();\n      }\n      return next(new Error('Authentication Error'));\n    });\n  }\n\n  public emit(socketEvent: WebSocketEvents) {\n    const { event, data } = socketEvent;\n    this.server.emit(event, data);\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/web-socket/web-socket.events.ts",
    "content": "import { AccountEventTypes } from '../account/account.events';\nimport { CustomShortcutEventTypes } from '../custom-shortcuts/custom-shortcut.events';\nimport { DirectoryWatcherEventTypes } from '../directory-watchers/directory-watcher.events';\nimport { NotificationEventTypes } from '../notifications/notification.events';\nimport { SettingsEventTypes } from '../settings/settings.events';\nimport { SubmissionEventTypes } from '../submission/submission.events';\nimport { TagConverterEventTypes } from '../tag-converters/tag-converter.events';\nimport { TagGroupEventTypes } from '../tag-groups/tag-group.events';\nimport { UpdateEventTypes } from '../update/update.events';\nimport { UserConverterEventTypes } from '../user-converters/user-converter.events';\nimport { WebsiteEventTypes } from '../websites/website.events';\n\nexport type WebSocketEvents =\n  | AccountEventTypes\n  | DirectoryWatcherEventTypes\n  | SettingsEventTypes\n  | SubmissionEventTypes\n  | TagGroupEventTypes\n  | TagConverterEventTypes\n  | UpdateEventTypes\n  | UserConverterEventTypes\n  | WebsiteEventTypes\n  | NotificationEventTypes\n  | CustomShortcutEventTypes;\n"
  },
  {
    "path": "apps/client-server/src/app/web-socket/web-socket.module.ts",
    "content": "import { Global, Module } from '@nestjs/common';\nimport { WSGateway } from './web-socket-gateway';\n\n@Global()\n@Module({\n  providers: [WSGateway],\n  exports: [WSGateway],\n})\nexport class WebSocketModule {}\n"
  },
  {
    "path": "apps/client-server/src/app/website-options/dtos/create-website-options.dto.ts",
    "content": "import { ApiProperty } from '@nestjs/swagger';\nimport {\n  AccountId,\n  ICreateWebsiteOptionsDto,\n  IWebsiteFormFields,\n  SubmissionId,\n} from '@postybirb/types';\nimport { IsObject, IsOptional, IsString } from 'class-validator';\n\nexport class CreateWebsiteOptionsDto implements ICreateWebsiteOptionsDto {\n  @ApiProperty()\n  @IsString()\n  accountId: AccountId;\n\n  @ApiProperty({ type: Object })\n  @IsOptional()\n  @IsObject()\n  data: IWebsiteFormFields;\n\n  @ApiProperty()\n  @IsString()\n  submissionId: SubmissionId;\n\n  isDefault?: boolean = false;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/website-options/dtos/preview-description.dto.ts",
    "content": "import { ApiProperty } from '@nestjs/swagger';\nimport {\n  EntityId,\n  IPreviewDescriptionDto,\n  SubmissionId,\n} from '@postybirb/types';\nimport { IsString } from 'class-validator';\n\nexport class PreviewDescriptionDto implements IPreviewDescriptionDto {\n  @ApiProperty()\n  @IsString()\n  submissionId: SubmissionId;\n\n  @ApiProperty()\n  @IsString()\n  websiteOptionId: EntityId;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/website-options/dtos/update-submission-website-options.dto.ts",
    "content": "import { ApiProperty } from '@nestjs/swagger';\nimport {\n  EntityId,\n  ICreateWebsiteOptionsDto,\n  IUpdateSubmissionWebsiteOptionsDto,\n} from '@postybirb/types';\nimport { IsArray, IsOptional } from 'class-validator';\n\nexport class UpdateSubmissionWebsiteOptionsDto\n  implements IUpdateSubmissionWebsiteOptionsDto\n{\n  @ApiProperty()\n  @IsOptional()\n  @IsArray()\n  remove?: EntityId[];\n\n  @ApiProperty()\n  @IsOptional()\n  @IsArray()\n  add?: ICreateWebsiteOptionsDto[];\n}\n"
  },
  {
    "path": "apps/client-server/src/app/website-options/dtos/update-website-options.dto.ts",
    "content": "import { ApiProperty } from '@nestjs/swagger';\nimport { IUpdateWebsiteOptionsDto, IWebsiteFormFields } from '@postybirb/types';\nimport { IsObject } from 'class-validator';\n\nexport class UpdateWebsiteOptionsDto implements IUpdateWebsiteOptionsDto {\n  @ApiProperty({ type: Object })\n  @IsObject()\n  data: IWebsiteFormFields;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/website-options/dtos/validate-website-options.dto.ts",
    "content": "import { ApiProperty } from '@nestjs/swagger';\nimport {\n  EntityId,\n  IValidateWebsiteOptionsDto,\n  SubmissionId,\n} from '@postybirb/types';\nimport { IsString } from 'class-validator';\n\nexport class ValidateWebsiteOptionsDto implements IValidateWebsiteOptionsDto {\n  @ApiProperty()\n  @IsString()\n  submissionId: SubmissionId;\n\n  @ApiProperty()\n  @IsString()\n  websiteOptionId: EntityId;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/website-options/website-options.controller.ts",
    "content": "import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';\nimport {\n    ApiBadRequestResponse,\n    ApiNotFoundResponse,\n    ApiOkResponse,\n    ApiTags,\n} from '@nestjs/swagger';\nimport { EntityId, SubmissionId } from '@postybirb/types';\nimport { PostyBirbController } from '../common/controller/postybirb-controller';\nimport { CreateWebsiteOptionsDto } from './dtos/create-website-options.dto';\nimport { PreviewDescriptionDto } from './dtos/preview-description.dto';\nimport { UpdateSubmissionWebsiteOptionsDto } from './dtos/update-submission-website-options.dto';\nimport { UpdateWebsiteOptionsDto } from './dtos/update-website-options.dto';\nimport { ValidateWebsiteOptionsDto } from './dtos/validate-website-options.dto';\nimport { WebsiteOptionsService } from './website-options.service';\n\n/**\n * CRUD operation on WebsiteOptions.\n *\n * @class WebsiteOptionsController\n */\n@ApiTags('website-option')\n@Controller('website-option')\nexport class WebsiteOptionsController extends PostyBirbController<'WebsiteOptionsSchema'> {\n  constructor(readonly service: WebsiteOptionsService) {\n    super(service);\n  }\n\n  @Post()\n  @ApiOkResponse({ description: 'Website option created.' })\n  @ApiBadRequestResponse({\n    description: 'Bad request.',\n  })\n  @ApiNotFoundResponse({\n    description: 'Account or website instance not found.',\n  })\n  create(\n    @Body()\n    createDto: CreateWebsiteOptionsDto,\n  ) {\n    return this.service.create(createDto).then((entity) => entity.toDTO());\n  }\n\n  @Patch(':id')\n  @ApiOkResponse({ description: 'Submission option updated.', type: Boolean })\n  @ApiNotFoundResponse({ description: 'Submission option Id not found.' })\n  update(\n    @Body()\n    updateDto: UpdateWebsiteOptionsDto,\n    @Param('id') id: EntityId,\n  ) {\n    return this.service.update(id, updateDto).then((entity) => entity.toDTO());\n  }\n\n  @Patch('submission/:id')\n  @ApiOkResponse({ description: 'Submission updated.', type: Boolean })\n  @ApiNotFoundResponse({ description: 'Submission Id not found.' })\n  updateSubmission(\n    @Body()\n    updateDto: UpdateSubmissionWebsiteOptionsDto,\n    @Param('id') submissionId: SubmissionId,\n  ) {\n    return this.service\n      .updateSubmissionOptions(submissionId, updateDto)\n      .then((entity) => entity.toDTO());\n  }\n\n  @Post('validate')\n  @ApiOkResponse({ description: 'Submission validation completed.' })\n  @ApiBadRequestResponse()\n  @ApiNotFoundResponse({ description: 'Submission not found.' })\n  validate(@Body() validateOptionsDto: ValidateWebsiteOptionsDto) {\n    return this.service.validateWebsiteOption(validateOptionsDto);\n  }\n\n  @Get('validate/:submissionId')\n  @ApiOkResponse({ description: 'Submission validation completed.' })\n  @ApiBadRequestResponse()\n  @ApiNotFoundResponse({ description: 'Submission not found.' })\n  validateSubmission(@Param('submissionId') submissionId: SubmissionId) {\n    return this.service.validateSubmission(submissionId);\n  }\n\n  @Post('preview-description')\n  @ApiOkResponse({ description: 'Description preview generated.' })\n  @ApiBadRequestResponse()\n  @ApiNotFoundResponse({ description: 'Submission or option not found.' })\n  previewDescription(@Body() dto: PreviewDescriptionDto) {\n    return this.service.previewDescription(dto);\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/website-options/website-options.module.ts",
    "content": "import { forwardRef, Module } from '@nestjs/common';\nimport { AccountModule } from '../account/account.module';\nimport { FormGeneratorModule } from '../form-generator/form-generator.module';\nimport { PostParsersModule } from '../post-parsers/post-parsers.module';\nimport { SubmissionModule } from '../submission/submission.module';\nimport { UserSpecifiedWebsiteOptionsModule } from '../user-specified-website-options/user-specified-website-options.module';\nimport { ValidationModule } from '../validation/validation.module';\nimport { WebsitesModule } from '../websites/websites.module';\nimport { WebsiteOptionsController } from './website-options.controller';\nimport { WebsiteOptionsService } from './website-options.service';\n\n@Module({\n  imports: [\n    forwardRef(() => SubmissionModule),\n    WebsitesModule,\n    AccountModule,\n    UserSpecifiedWebsiteOptionsModule,\n    FormGeneratorModule,\n    ValidationModule,\n    PostParsersModule,\n  ],\n  providers: [WebsiteOptionsService],\n  controllers: [WebsiteOptionsController],\n  exports: [WebsiteOptionsService],\n})\nexport class WebsiteOptionsModule {}\n"
  },
  {
    "path": "apps/client-server/src/app/website-options/website-options.service.spec.ts",
    "content": "import { Test, TestingModule } from '@nestjs/testing';\nimport { clearDatabase } from '@postybirb/database';\nimport {\n    DefaultDescriptionValue,\n    DefaultTagValue,\n    SubmissionRating,\n    SubmissionType,\n    TipTapNode,\n} from '@postybirb/types';\nimport { AccountModule } from '../account/account.module';\nimport { AccountService } from '../account/account.service';\nimport { CreateAccountDto } from '../account/dtos/create-account.dto';\nimport { FileConverterService } from '../file-converter/file-converter.service';\nimport { FileService } from '../file/file.service';\nimport { CreateFileService } from '../file/services/create-file.service';\nimport { UpdateFileService } from '../file/services/update-file.service';\nimport { SharpInstanceManager } from '../image-processing/sharp-instance-manager';\nimport { FormGeneratorModule } from '../form-generator/form-generator.module';\nimport { PostParsersModule } from '../post-parsers/post-parsers.module';\nimport { CreateSubmissionDto } from '../submission/dtos/create-submission.dto';\nimport { FileSubmissionService } from '../submission/services/file-submission.service';\nimport { MessageSubmissionService } from '../submission/services/message-submission.service';\nimport { SubmissionService } from '../submission/services/submission.service';\nimport { UserSpecifiedWebsiteOptionsModule } from '../user-specified-website-options/user-specified-website-options.module';\nimport { UserSpecifiedWebsiteOptionsService } from '../user-specified-website-options/user-specified-website-options.service';\nimport { ValidationService } from '../validation/validation.service';\nimport { WebsiteImplProvider } from '../websites/implementations/provider';\nimport { WebsiteRegistryService } from '../websites/website-registry.service';\nimport { WebsitesModule } from '../websites/websites.module';\nimport { CreateWebsiteOptionsDto } from './dtos/create-website-options.dto';\nimport { WebsiteOptionsService } from './website-options.service';\n\ndescribe('WebsiteOptionsService', () => {\n  let service: WebsiteOptionsService;\n  let submissionService: SubmissionService;\n  let accountService: AccountService;\n  let module: TestingModule;\n\n  async function createAccount() {\n    const dto = new CreateAccountDto();\n    dto.groups = ['test'];\n    dto.name = 'test';\n    dto.website = 'test';\n\n    const record = await accountService.create(dto);\n    return record;\n  }\n\n  async function createSubmission() {\n    const dto = new CreateSubmissionDto();\n    dto.name = 'test';\n    dto.type = SubmissionType.MESSAGE;\n\n    const record = await submissionService.create(dto);\n    return record;\n  }\n\n  beforeEach(async () => {\n    clearDatabase();\n    try {\n      module = await Test.createTestingModule({\n        imports: [\n          WebsitesModule,\n          AccountModule,\n          UserSpecifiedWebsiteOptionsModule,\n          PostParsersModule,\n          FormGeneratorModule,\n        ],\n        providers: [\n          SubmissionService,\n          CreateFileService,\n          UpdateFileService,\n          SharpInstanceManager,\n          FileService,\n          SubmissionService,\n          FileSubmissionService,\n          MessageSubmissionService,\n          AccountService,\n          WebsiteRegistryService,\n          ValidationService,\n          WebsiteOptionsService,\n          WebsiteImplProvider,\n          UserSpecifiedWebsiteOptionsService,\n          FileConverterService,\n        ],\n      }).compile();\n\n      service = module.get<WebsiteOptionsService>(WebsiteOptionsService);\n      submissionService = module.get<SubmissionService>(SubmissionService);\n      accountService = module.get<AccountService>(AccountService);\n      await accountService.onModuleInit();\n    } catch (e) {\n      console.error(e);\n    }\n  });\n\n  afterAll(async () => {\n    await module.close();\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n\n  it('should create entity', async () => {\n    const account = await createAccount();\n    const submission = await createSubmission();\n\n    const dto = new CreateWebsiteOptionsDto();\n    dto.data = {\n      contentWarning: '',\n      title: 'title',\n      tags: DefaultTagValue(),\n      description: DefaultDescriptionValue(),\n      rating: SubmissionRating.GENERAL,\n    };\n    dto.accountId = account.id;\n    dto.submissionId = submission.id;\n\n    const record = await service.create(dto);\n    const groups = await service.findAll();\n    expect(groups).toHaveLength(2); // 2 because default\n\n    expect(groups[1].accountId).toEqual(dto.accountId);\n    expect(groups[1].isDefault).toEqual(false);\n    expect(groups[1].data).toEqual(dto.data);\n    expect(groups[1].submission.id).toEqual(dto.submissionId);\n\n    expect(record.toDTO()).toEqual({\n      data: record.data,\n      isDefault: false,\n      id: record.id,\n      accountId: account.id,\n      createdAt: record.createdAt,\n      updatedAt: record.updatedAt,\n      account: record.account.toObject(),\n      submissionId: submission.id,\n      submission: record.submission.toDTO(),\n    });\n  });\n\n  it('should remove entity', async () => {\n    const account = await createAccount();\n    const submission = await createSubmission();\n\n    const dto = new CreateWebsiteOptionsDto();\n    dto.data = {\n      title: 'title',\n      tags: DefaultTagValue(),\n      description: DefaultDescriptionValue(),\n      rating: SubmissionRating.GENERAL,\n    };\n    dto.accountId = account.id;\n    dto.submissionId = submission.id;\n\n    const record = await service.create(dto);\n    expect(await service.findAll()).toHaveLength(2); // 2 because default\n\n    await service.remove(record.id);\n    expect(await service.findAll()).toHaveLength(1);\n  });\n\n  it('should remove entity when parent is removed', async () => {\n    const account = await createAccount();\n    const submission = await createSubmission();\n\n    const dto = new CreateWebsiteOptionsDto();\n    dto.data = {\n      title: 'title',\n      tags: DefaultTagValue(),\n      description: DefaultDescriptionValue(),\n      rating: SubmissionRating.GENERAL,\n    };\n    dto.accountId = account.id;\n    dto.submissionId = submission.id;\n\n    await service.create(dto);\n    expect(await service.findAll()).toHaveLength(2); // 2 because default\n\n    await submissionService.remove(submission.id);\n    expect(await service.findAll()).toHaveLength(0);\n  });\n\n  it('should update entity', async () => {\n    const account = await createAccount();\n    const submission = await createSubmission();\n\n    const dto = new CreateWebsiteOptionsDto();\n    dto.data = {\n      contentWarning: '',\n      title: 'title',\n      tags: DefaultTagValue(),\n      description: DefaultDescriptionValue(),\n      rating: SubmissionRating.GENERAL,\n    };\n    dto.accountId = account.id;\n    dto.submissionId = submission.id;\n\n    const record = await service.create(dto);\n    expect(record.accountId).toEqual(dto.accountId);\n    expect(record.isDefault).toEqual(false);\n    expect(record.data).toEqual(dto.data);\n    expect(record.submission.id).toEqual(dto.submissionId);\n\n    const update = await service.update(record.id, {\n      data: {\n        title: 'title updated',\n        tags: DefaultTagValue(),\n        description: DefaultDescriptionValue(),\n        rating: SubmissionRating.GENERAL,\n      },\n    });\n\n    expect(update.data.title).toEqual('title updated');\n  });\n\n  it('filters nested inline content', async () => {\n    const blocks: TipTapNode[] = [\n      {\n        type: 'paragraph',\n        content: [\n          { type: 'text', text: 'Hello, ', marks: [{ type: 'bold' }] },\n          {\n            type: 'customShortcut',\n            attrs: {\n              id: 'to-delete',\n            },\n            content: [\n              {\n                type: 'text',\n                text: 'User',\n              },\n            ],\n          },\n        ],\n      },\n    ];\n\n    const { changed, filtered } = service.filterCustomShortcut(\n      blocks,\n      'to-delete',\n    );\n    expect(changed).toBeTruthy();\n    expect(filtered).toEqual([\n      {\n        type: 'paragraph',\n        content: [{ type: 'text', text: 'Hello, ', marks: [{ type: 'bold' }] }],\n      },\n    ]);\n  });\n});\n"
  },
  {
    "path": "apps/client-server/src/app/website-options/website-options.service.ts",
    "content": "import {\n  forwardRef,\n  Inject,\n  Injectable,\n  NotFoundException,\n  OnModuleInit,\n} from '@nestjs/common';\nimport { Insert } from '@postybirb/database';\nimport {\n  AccountId,\n  Description,\n  DescriptionType,\n  DescriptionValue,\n  DynamicObject,\n  EntityId,\n  IDescriptionPreviewResult,\n  ISubmission,\n  ISubmissionMetadata,\n  IWebsiteFormFields,\n  NULL_ACCOUNT_ID,\n  SubmissionId,\n  SubmissionMetadataType,\n  SubmissionType,\n  TipTapNode,\n  ValidationResult,\n} from '@postybirb/types';\nimport { AccountService } from '../account/account.service';\nimport { PostyBirbService } from '../common/service/postybirb-service';\nimport { Account, Submission, WebsiteOptions } from '../drizzle/models';\nimport { PostyBirbDatabase } from '../drizzle/postybirb-database/postybirb-database';\nimport { FormGeneratorService } from '../form-generator/form-generator.service';\nimport { PostParsersService } from '../post-parsers/post-parsers.service';\nimport { SubmissionService } from '../submission/services/submission.service';\nimport { UserSpecifiedWebsiteOptionsService } from '../user-specified-website-options/user-specified-website-options.service';\nimport {\n  isBlockNoteFormat,\n  migrateDescription,\n} from '../utils/blocknote-to-tiptap';\nimport { ValidationService } from '../validation/validation.service';\nimport DefaultWebsite from '../websites/implementations/default/default.website';\nimport { DefaultWebsiteOptions } from '../websites/models/default-website-options';\nimport { WebsiteRegistryService } from '../websites/website-registry.service';\nimport { CreateWebsiteOptionsDto } from './dtos/create-website-options.dto';\nimport { PreviewDescriptionDto } from './dtos/preview-description.dto';\nimport { UpdateSubmissionWebsiteOptionsDto } from './dtos/update-submission-website-options.dto';\nimport { UpdateWebsiteOptionsDto } from './dtos/update-website-options.dto';\nimport { ValidateWebsiteOptionsDto } from './dtos/validate-website-options.dto';\n\n@Injectable()\nexport class WebsiteOptionsService\n  extends PostyBirbService<'WebsiteOptionsSchema'>\n  implements OnModuleInit\n{\n  private readonly submissionRepository = new PostyBirbDatabase(\n    'SubmissionSchema',\n  );\n\n  constructor(\n    @Inject(forwardRef(() => SubmissionService))\n    private readonly submissionService: SubmissionService,\n    private readonly accountService: AccountService,\n    private readonly userSpecifiedOptionsService: UserSpecifiedWebsiteOptionsService,\n    private readonly formGeneratorService: FormGeneratorService,\n    private readonly validationService: ValidationService,\n    private readonly postParsersService: PostParsersService,\n    private readonly websiteRegistry: WebsiteRegistryService,\n  ) {\n    super(\n      new PostyBirbDatabase('WebsiteOptionsSchema', {\n        account: true,\n        submission: true,\n      }),\n    );\n\n    this.repository.subscribe('CustomShortcutSchema', (ids, action) => {\n      if (action === 'delete') {\n        for (const id of ids) {\n          this.onCustomShortcutDelete(id).catch((err) =>\n            this.logger.error(\n              `Error handling custom shortcut delete for id '${id}': ${err.message}`,\n              err.stack,\n            ),\n          );\n        }\n      }\n    });\n  }\n\n  async onModuleInit() {\n    await this.migrateBlockNoteDescriptions();\n  }\n\n  /**\n   * One-time migration: convert any BlockNote-format descriptions\n   * (stored as arrays) to TipTap format ({ type: 'doc', content: [] }).\n   * Covers website options, custom shortcuts, and user-specified website options.\n   */\n  private async migrateBlockNoteDescriptions() {\n    let migrated = 0;\n\n    // 1. Migrate website options\n    const options = await this.findAll();\n    for (const option of options) {\n      const descValue = option.data?.description as\n        | DescriptionValue\n        | undefined;\n      const desc = descValue?.description;\n      if (desc && isBlockNoteFormat(desc)) {\n        const converted = migrateDescription(desc);\n        await this.repository.update(option.id, {\n          data: {\n            ...option.data,\n            description: {\n              ...descValue,\n              description: converted,\n            },\n          },\n        });\n        migrated++;\n      }\n    }\n\n    // 2. Migrate custom shortcuts\n    const customShortcutRepo = new PostyBirbDatabase('CustomShortcutSchema');\n    const shortcuts = await customShortcutRepo.findAll();\n    for (const shortcut of shortcuts) {\n      const desc = (shortcut as DynamicObject).shortcut;\n      if (desc && isBlockNoteFormat(desc)) {\n        const converted = migrateDescription(desc);\n        await customShortcutRepo.update(shortcut.id, {\n          shortcut: converted,\n        } as unknown);\n        migrated++;\n      }\n    }\n\n    // 3. Migrate user-specified website options\n    const userOptsRepo = new PostyBirbDatabase(\n      'UserSpecifiedWebsiteOptionsSchema',\n    );\n    const userOpts = await userOptsRepo.findAll();\n    for (const userOpt of userOpts) {\n      const opts = (userOpt as DynamicObject).options as DynamicObject;\n      if (opts?.description) {\n        const descValue = opts.description as DescriptionValue | undefined;\n        const desc = descValue?.description;\n        if (desc && isBlockNoteFormat(desc)) {\n          const converted = migrateDescription(desc);\n          await userOptsRepo.update(userOpt.id, {\n            options: {\n              ...opts,\n              description: {\n                ...descValue,\n                description: converted,\n              },\n            },\n          } as unknown);\n          migrated++;\n        }\n      }\n    }\n\n    if (migrated > 0) {\n      this.logger.info(\n        `Migrated ${migrated} BlockNote description(s) to TipTap format`,\n      );\n    }\n  }\n\n  /**\n   * Creates a submission option for a submission.\n   * No longer remember why this is a separate method from create.\n   *\n   * @param {ISubmission} submission\n   * @param {AccountId} accountId\n   * @param {DynamicObject} data\n   * @param {string} [title]\n   * @return {*}  {Promise<WebsiteOptions>}\n   */\n  async createOption(\n    submission: ISubmission,\n    accountId: AccountId,\n    data: DynamicObject,\n    title?: string,\n  ): Promise<WebsiteOptions> {\n    const option = await this.createOptionInsertObject(\n      submission,\n      accountId,\n      data,\n      title,\n    );\n    return this.repository.insert(option);\n  }\n\n  /**\n   * Creates a submission option for a submission.\n   *\n   * @param {Submission} submission\n   * @param {AccountId} accountId\n   * @param {DynamicObject} data\n   * @param {string} [title]\n   */\n  async createOptionInsertObject(\n    submission: ISubmission,\n    accountId: AccountId,\n    data: DynamicObject,\n    title?: string,\n  ): Promise<Insert<'WebsiteOptionsSchema'>> {\n    const account = await this.accountService.findById(accountId, {\n      failOnMissing: true,\n    });\n    const isDefault = accountId === NULL_ACCOUNT_ID;\n\n    const userDefinedDefaultOptions =\n      await this.userSpecifiedOptionsService.findByAccountAndSubmissionType(\n        accountId,\n        submission.type,\n      );\n\n    const formFields = isDefault\n      ? await this.formGeneratorService.getDefaultForm(submission.type)\n      : await this.formGeneratorService.generateForm({\n          accountId: account.id,\n          type: submission.type,\n        });\n\n    // Populate with the form fields to get the default values\n    const websiteData: IWebsiteFormFields = {\n      ...Object.entries(formFields).reduce(\n        (acc, [key, field]) => ({\n          ...acc,\n          [key]: field.defaultValue,\n        }),\n        {} as IWebsiteFormFields,\n      ),\n    };\n\n    const mergedData: IWebsiteFormFields = {\n      ...(isDefault ? new DefaultWebsiteOptions() : {}), // Only merge default options if this is the default option\n      ...websiteData, // Merge default form fields\n      ...(userDefinedDefaultOptions?.options ?? {}), // Merge user defined options\n      ...data, // Merge user defined data\n      title, // Override title (optional)\n    };\n\n    // For non-default options, keep rating as undefined to represent\n    // \"inherit from default\" mode unless explicitly provided in data.\n    if (!isDefault && !data.rating) {\n      mergedData.rating = undefined as unknown as typeof mergedData.rating;\n    }\n\n    const option: Insert<'WebsiteOptionsSchema'> = {\n      submissionId: submission.id,\n      accountId: account.id,\n      data: mergedData,\n      isDefault,\n    };\n\n    return option;\n  }\n\n  /**\n   * The default create method for WebsiteOptions.\n   * Performs user saved options and other merging operations.\n   * Performs and update if it already exists.\n   *\n   * @param {CreateWebsiteOptionsDto} createDto\n   * @return {*}\n   */\n  async create(createDto: CreateWebsiteOptionsDto) {\n    const account = await this.accountService.findById(createDto.accountId, {\n      failOnMissing: true,\n    });\n\n    let submission: ISubmission<SubmissionMetadataType>;\n    try {\n      submission = await this.submissionRepository.findById(\n        createDto.submissionId,\n        { failOnMissing: true },\n      );\n    } catch (err) {\n      throw new NotFoundException(\n        `Submission ${createDto.submissionId} not found.`,\n      );\n    }\n\n    const exists = await this.repository.findOne({\n      where: (wo, { and, eq }) =>\n        and(eq(wo.submissionId, submission.id), eq(wo.accountId, account.id)),\n    });\n    if (exists) {\n      // Opt to just update the existing option\n      return this.update(exists.id, { data: createDto.data });\n    }\n\n    const formFields =\n      account.id === NULL_ACCOUNT_ID\n        ? await this.formGeneratorService.getDefaultForm(submission.type)\n        : await this.formGeneratorService.generateForm({\n            accountId: account.id,\n            type: submission.type,\n          });\n    // Populate with the form fields to get the default values\n    const websiteData: IWebsiteFormFields = {\n      ...Object.entries(formFields).reduce(\n        (acc, [key, field]) => ({\n          ...acc,\n          [key]:\n            createDto.data?.[key as keyof IWebsiteFormFields] === undefined\n              ? field.defaultValue\n              : createDto.data?.[key as keyof IWebsiteFormFields],\n        }),\n        {} as IWebsiteFormFields,\n      ),\n    };\n\n    const isDefault = account.id === NULL_ACCOUNT_ID;\n\n    // For non-default options, keep rating as undefined to represent\n    // \"inherit from default\" mode unless explicitly provided in the DTO.\n    if (!isDefault && createDto.data?.rating === undefined) {\n      websiteData.rating = undefined as unknown as typeof websiteData.rating;\n    }\n\n    const record = await this.repository.insert({\n      submissionId: submission.id,\n      data: websiteData,\n      accountId: account.id,\n      isDefault,\n    });\n    this.submissionService.emit();\n    return record;\n  }\n\n  async update(id: EntityId, update: UpdateWebsiteOptionsDto) {\n    this.logger.withMetadata(update).info(`Updating WebsiteOptions '${id}'`);\n    const result = await this.repository.update(id, update);\n    this.submissionService.emit();\n    return result;\n  }\n\n  /**\n   * Creates the default submission option that stores shared data\n   * across multiple submission options.\n   *\n   * @param {Submission<ISubmissionMetadata>} submission\n   * @param {string} title\n   * @param {Partial<IWebsiteFormFields>} [defaultOptions] - Optional default options to merge\n   * @return {*}  {Promise<WebsiteOptions>}\n   */\n  async createDefaultSubmissionOptions(\n    submission: ISubmission<ISubmissionMetadata>,\n    title: string,\n    defaultOptions?: Partial<IWebsiteFormFields>,\n  ): Promise<WebsiteOptions> {\n    this.logger\n      .withMetadata({ id: submission.id })\n      .info('Creating Default Website Options');\n\n    const options: Insert<'WebsiteOptionsSchema'> = {\n      isDefault: true,\n      submissionId: submission.id,\n      accountId: NULL_ACCOUNT_ID,\n      data: await this.populateDefaultWebsiteOptions(\n        NULL_ACCOUNT_ID,\n        submission.type,\n        title,\n        defaultOptions,\n      ),\n    };\n\n    return this.repository.insert(options);\n  }\n\n  private async populateDefaultWebsiteOptions(\n    accountId: AccountId,\n    type: SubmissionType,\n    title?: string,\n    defaultOptions?: Partial<IWebsiteFormFields>,\n  ): Promise<IWebsiteFormFields> {\n    const userSpecifiedOptions =\n      (\n        await this.userSpecifiedOptionsService.findByAccountAndSubmissionType(\n          NULL_ACCOUNT_ID,\n          type,\n        )\n      )?.options ?? {};\n\n    const websiteFormFields: IWebsiteFormFields = {\n      ...new DefaultWebsiteOptions(),\n      ...userSpecifiedOptions,\n      title,\n    };\n\n    // Merge provided default options (tags, description, rating)\n    if (defaultOptions) {\n      if (defaultOptions.tags) {\n        websiteFormFields.tags = {\n          overrideDefault: false,\n          tags: defaultOptions.tags.tags ?? defaultOptions.tags.tags ?? [],\n        };\n      }\n      if (defaultOptions.description) {\n        websiteFormFields.description = defaultOptions.description;\n      }\n      if (defaultOptions.rating) {\n        websiteFormFields.rating = defaultOptions.rating;\n      }\n    }\n\n    return websiteFormFields;\n  }\n\n  /**\n   * Validates a submission option against a website instance.\n   * @param {ValidateWebsiteOptionsDto} validate\n   * @return {Promise<ValidationResult>}\n   */\n  async validateWebsiteOption(\n    validate: ValidateWebsiteOptionsDto,\n  ): Promise<ValidationResult> {\n    const { websiteOptionId, submissionId } = validate;\n    const submission = await this.submissionService.findById(submissionId, {\n      failOnMissing: true,\n    });\n    const websiteOption = submission.options.find(\n      (option) => option.id === websiteOptionId,\n    );\n    return this.validationService.validate(submission, websiteOption);\n  }\n\n  /**\n   * Validates all submission options for a submission.\n   * Accepts either a submission ID (will fetch from DB) or a Submission object directly.\n   * When a Submission object is provided, it avoids a redundant database query.\n   * @param {SubmissionId | Submission} submissionOrId\n   * @return {*}  {Promise<ValidationResult[]>}\n   */\n  async validateSubmission(\n    submissionOrId: SubmissionId | Submission,\n  ): Promise<ValidationResult[]> {\n    const submission =\n      typeof submissionOrId === 'string'\n        ? await this.submissionService.findById(submissionOrId)\n        : submissionOrId;\n    return this.validationService.validateSubmission(submission);\n  }\n\n  /**\n   * Previews the parsed description for a specific website option.\n   * Parses the description the same way it would be parsed during posting,\n   * and returns both the output format type and the rendered string.\n   * @param {PreviewDescriptionDto} dto\n   * @return {Promise<IDescriptionPreviewResult>}\n   */\n  async previewDescription(\n    dto: PreviewDescriptionDto,\n  ): Promise<IDescriptionPreviewResult> {\n    const { websiteOptionId, submissionId } = dto;\n    const submission = await this.submissionService.findById(submissionId, {\n      failOnMissing: true,\n    });\n    const websiteOption = submission.options.find(\n      (option) => option.id === websiteOptionId,\n    );\n    if (!websiteOption) {\n      throw new NotFoundException(\n        `Website option ${websiteOptionId} not found`,\n      );\n    }\n\n    const website = websiteOption.isDefault\n      ? new DefaultWebsite(new Account(websiteOption.account))\n      : this.websiteRegistry.findInstance(websiteOption.account);\n\n    if (!website) {\n      throw new NotFoundException(\n        `Website instance for account ${websiteOption.accountId} not found`,\n      );\n    }\n\n    const data = await this.postParsersService.parse(\n      submission,\n      website,\n      websiteOption,\n    );\n\n    // Determine the description output type using the same logic as the parser\n    const defaultOptions = submission.options.find((o) => o.isDefault);\n    const defaultOpts = Object.assign(new DefaultWebsiteOptions(), {\n      ...defaultOptions.data,\n    });\n    const websiteOpts = Object.assign(website.getModelFor(submission.type), {\n      ...websiteOption.data,\n    });\n    const mergedOptions = websiteOpts.mergeDefaults(defaultOpts);\n    const { descriptionType } = mergedOptions.getFormFieldFor('description');\n\n    return {\n      descriptionType: descriptionType as DescriptionType,\n      description: data.options.description ?? '',\n    };\n  }\n\n  async updateSubmissionOptions(\n    submissionId: SubmissionId,\n    updateDto: UpdateSubmissionWebsiteOptionsDto,\n  ) {\n    const submission = await this.submissionService.findById(submissionId, {\n      failOnMissing: true,\n    });\n\n    const { remove, add } = updateDto;\n    if (remove?.length) {\n      const items = submission.options;\n      const removableIds = [];\n      for (const id of remove) {\n        const option = items.find((opt) => opt.id === id);\n        if (option) {\n          removableIds.push(id);\n        }\n      }\n      this.logger.debug(\n        `Removing option(s) [${removableIds.join(', ')}] from submission ${submissionId}`,\n      );\n      await this.repository.deleteById(removableIds);\n    }\n\n    if (add?.length) {\n      const options = await Promise.all(\n        add.map((dto) =>\n          this.createOptionInsertObject(submission, dto.accountId, dto.data),\n        ),\n      );\n      await this.repository.insert(options);\n    }\n\n    this.submissionService.emit();\n    return this.submissionService.findById(submissionId);\n  }\n\n  private async onCustomShortcutDelete(id: EntityId) {\n    const websiteOptions = await this.findAll();\n    for (const option of websiteOptions) {\n      const { data } = option;\n      const descValue: DescriptionValue | undefined = data?.description;\n      const doc: Description | undefined = descValue?.description;\n      const blocks = doc?.content;\n\n      if (!blocks || !Array.isArray(blocks) || blocks.length === 0) {\n        continue;\n      }\n\n      const { changed, filtered } = this.filterCustomShortcut(\n        blocks,\n        String(id),\n      );\n      if (changed) {\n        const updatedDescription: DescriptionValue = {\n          ...(descValue as DescriptionValue),\n          description: { type: 'doc', content: filtered },\n        };\n\n        await this.repository.update(option.id, {\n          data: {\n            ...data,\n            description: updatedDescription,\n          },\n        });\n        this.submissionService.emit();\n      }\n    }\n  }\n\n  /**\n   * Removes inline customShortcut items matching the given id from a TipTap content array.\n   * Simple recursive filter without whitespace normalization.\n   */\n  public filterCustomShortcut(\n    blocks: TipTapNode[],\n    deleteId: string,\n  ): {\n    changed: boolean;\n    filtered: TipTapNode[];\n  } {\n    let changed = false;\n\n    const isObject = (v: unknown): v is Record<string, unknown> =>\n      typeof v === 'object' && v !== null;\n\n    const filterInline = (content: unknown[]): unknown[] => {\n      const out: unknown[] = [];\n      for (const node of content) {\n        if (!isObject(node)) {\n          out.push(node);\n          continue;\n        }\n\n        const {\n          type,\n          attrs,\n          content: nodeContent,\n        } = node as {\n          type?: string;\n          attrs?: Record<string, unknown>;\n          content?: unknown[];\n        };\n\n        if (type === 'customShortcut' && String(attrs?.id ?? '') === deleteId) {\n          changed = true;\n          continue; // drop this inline\n        }\n\n        // Recurse if this inline node has its own content\n        if (Array.isArray(nodeContent)) {\n          const clone = { ...node } as Record<string, unknown> & {\n            content?: unknown[];\n          };\n          clone.content = filterInline(nodeContent);\n          out.push(clone);\n        } else {\n          out.push(node);\n        }\n      }\n      return out;\n    };\n\n    const filterBlocks = (arr: TipTapNode[]): TipTapNode[] =>\n      arr.map((blk) => {\n        const clone: TipTapNode = { ...blk };\n        if (Array.isArray(clone.content)) {\n          clone.content = filterInline(clone.content) as TipTapNode[];\n        }\n        return clone;\n      });\n\n    const filtered = filterBlocks(blocks);\n    return { changed, filtered };\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/commons/post-builder.spec.ts",
    "content": "import { IFileBuffer } from '@postybirb/types';\nimport { FormFile } from '../../../../../../libs/http/src/lib/form-file'; // Direct import to avoid electron loading\nimport { CancellableToken } from '../../post/models/cancellable-token';\nimport { PostingFile } from '../../post/models/posting-file';\nimport { PostBuilder } from './post-builder';\n\n// Mocks\nconst mockWebsite = {\n  account: { id: 'test-account' },\n  constructor: { name: 'MockWebsite' },\n};\n\njest.mock('@postybirb/logger', () => ({\n  Logger: () => ({\n    withMetadata: () => ({ debug: jest.fn() }),\n    debug: jest.fn(),\n  }),\n}));\njest.mock('@postybirb/http', () => ({\n  Http: {\n    post: jest.fn().mockResolvedValue({ statusCode: 200, body: { id: '123' } }),\n  },\n  FormFile: FormFile,\n}));\n\nfunction createPostingFile(overrides = {}) {\n  // Minimal IFileBuffer\n  const fileBuffer: IFileBuffer = {\n    id: 'file1',\n    buffer: Buffer.from('test'),\n    mimeType: 'image/png',\n    width: 100,\n    height: 100,\n    fileName: 'file1.png',\n    ...overrides,\n    submissionFileId: '',\n    size: 0,\n    createdAt: '',\n    updatedAt: '',\n  };\n  return new PostingFile('file1', fileBuffer);\n}\n\ndescribe('PostBuilder', () => {\n  let builder: PostBuilder;\n  let token: CancellableToken;\n\n  beforeEach(() => {\n    token = new CancellableToken();\n    builder = new PostBuilder(mockWebsite as any, token);\n  });\n\n  it('should set headers', () => {\n    builder.withHeader('X-Test', 'abc');\n    expect((builder as any).headers['X-Test']).toBe('abc');\n  });\n\n  it('should set post type', () => {\n    builder.asMultipart();\n    expect((builder as any).postType).toBe('multipart');\n    builder.asJson();\n    expect((builder as any).postType).toBe('json');\n    builder.asUrlEncoded();\n    expect((builder as any).postType).toBe('urlencoded');\n  });\n\n  it('should merge data with withData', () => {\n    builder.withData({ a: 1, b: 2 });\n    builder.withData({ b: 3, c: 4 });\n    expect((builder as any).data).toEqual({ a: 1, b: 3, c: 4 });\n  });\n\n  it('should set, get, and remove fields', () => {\n    builder.setField('foo', 'bar');\n    expect(builder.getField('foo')).toBe('bar');\n    builder.removeField('foo');\n    expect(builder.getField('foo')).toBeUndefined();\n  });\n\n  it('should set fields conditionally', () => {\n    builder.setConditional('x', true, 1, 2);\n    expect((builder as any).data['x']).toBe(1);\n    builder.setConditional('y', false, 1, 2);\n    expect((builder as any).data['y']).toBe(2);\n    builder.setConditional('z', false, 1);\n    expect((builder as any).data['z']).toBeUndefined();\n  });\n\n  it('should iterate with forEach', () => {\n    builder.forEach(['a', 'b'], (item, idx, b) => {\n      b.setField(`item${idx}`, item);\n    });\n    expect((builder as any).data['item0']).toBe('a');\n    expect((builder as any).data['item1']).toBe('b');\n  });\n\n  it('should add files and thumbnails', () => {\n    const file = createPostingFile();\n    builder.addFile('file', file);\n    expect((builder as any).data['file']).toBeInstanceOf(Object); // FormFile\n    builder.addFiles('files', [file, file]);\n    expect(Array.isArray((builder as any).data['files'])).toBe(true);\n    builder.addThumbnail('thumb', file);\n    expect((builder as any).data['thumb']).toBeInstanceOf(Object); // FormFile\n  });\n\n  it('should add image as thumbnail if no thumbnail', () => {\n    const file = createPostingFile();\n    builder.addThumbnail('thumb', file);\n    expect((builder as any).data['thumb']).toBeInstanceOf(Object); // FormFile\n  });\n\n  it('should set empty string as thumbnail for non-image', () => {\n    const file = createPostingFile({\n      fileName: 'file1.txt',\n      mimeType: 'text/plain',\n    });\n    builder.addThumbnail('thumb', file);\n    expect((builder as any).data['thumb']).toBe('');\n  });\n\n  it('should call whenTrue only if predicate is true', () => {\n    const cb = jest.fn();\n    builder.whenTrue(true, cb);\n    expect(cb).toHaveBeenCalled();\n    cb.mockClear();\n    builder.whenTrue(false, cb);\n    expect(cb).not.toHaveBeenCalled();\n  });\n\n  it('should build data for json and multipart', () => {\n    builder.setField('a', true).setField('b', [true, false, undefined]);\n    expect(builder.build()).toEqual({ a: true, b: [true, false, undefined] });\n    builder.asMultipart();\n    expect(builder.build()).toEqual({ a: 'true', b: ['true', 'false'] });\n  });\n\n  it('should sanitize file fields for logging', () => {\n    const file = createPostingFile();\n    builder.addFile('file', file);\n    const data = { file: builder.getField('file'), other: 123 };\n    const sanitized = (builder as any).sanitizeDataForLogging(data);\n    expect(typeof sanitized.file).toBe('string');\n    expect(sanitized.other).toBe(123);\n  });\n\n  it('should throw if cancelled before send', async () => {\n    token.cancel();\n    await expect(builder.send('http://test')).rejects.toThrow(\n      'Task was cancelled.',\n    );\n  });\n\n  it('should call Http.post and return value on send', async () => {\n    const result = await builder.send<{ id: string }>('http://test');\n    expect(result.body.id).toBe('123');\n  });\n\n  it('should convert PostingFile to FormFile', () => {\n    const file = createPostingFile();\n    const builder = new PostBuilder(mockWebsite as any, token);\n    builder.setField('file', file);\n    expect((builder as any).data['file']).toBeInstanceOf(FormFile);\n\n    builder.addFile('file2', file);\n    expect((builder as any).data['file2']).toBeInstanceOf(FormFile);\n  });\n});\n"
  },
  {
    "path": "apps/client-server/src/app/websites/commons/post-builder.ts",
    "content": "import {\n  FormFile,\n  Http,\n  HttpRequestOptions,\n  PostOptions,\n} from '@postybirb/http';\nimport { Logger } from '@postybirb/logger';\nimport { FileType, PostResponse } from '@postybirb/types';\nimport { CancellableToken } from '../../post/models/cancellable-token';\nimport { PostingFile } from '../../post/models/posting-file';\nimport { UnknownWebsite } from '../website';\n\n/**\n * Represents a field value that can be stored in the post data.\n */\ntype FieldValue = string | number | boolean | null | undefined | object;\n\n/**\n * Represents a value that can be either a single field value or an array of field values.\n */\ntype Value = FieldValue | FieldValue[];\n\n/**\n * A builder class for constructing HTTP POST requests to various websites.\n * Uses the builder pattern to allow fluent method chaining for configuring\n * request data, headers, and content type.\n *\n * @example\n * ```typescript\n * const response = await new PostBuilder(website, cancellationToken)\n *   .asMultipart()\n *   .withHeader('Authorization', 'Bearer token')\n *   .setField('title', 'My Post')\n *   .addFile('image', postingFile)\n *   .send<ApiResponse>('https://api.example.com/posts');\n * ```\n */\nexport class PostBuilder {\n  private readonly logger = Logger('PostBuilder');\n\n  /**\n   * The type of POST request to send (json, multipart, or urlencoded).\n   * @private\n   */\n  private postType: PostOptions['type'] = 'json';\n\n  /**\n   * The data payload that will be sent in the POST request.\n   * @private\n   */\n  private data: Record<string, Value> = {};\n\n  /**\n   * Custom HTTP request options to be send with the request.\n   * @private\n   * @type {HttpRequestOptions}\n   */\n  private readonly httpRequestOptions: HttpRequestOptions = {};\n\n  /**\n   * HTTP headers to include with the request.\n   * @private\n   */\n  private readonly headers: Record<string, string> = {};\n\n  /**\n   * Set of field names that are expected to contain file data based on input.\n   * Used to enhance logging and debugging by identifying which fields\n   * are intended for file uploads.\n   * @private\n   */\n  private readonly fileFields = new Set<string>();\n\n  /**\n   * When true, the request will be sent via Electron's BrowserWindow.loadURL\n   * with raw data bytes instead of using net.request ClientRequest.\n   * @private\n   */\n  private rawData = false;\n\n  /**\n   * Creates a new PostBuilder instance.\n   *\n   * @param website - The website instance for which the post is being built\n   * @param cancellationToken - Token used to cancel the request if needed\n   */\n  constructor(\n    private readonly website: UnknownWebsite,\n    private readonly cancellationToken: CancellableToken,\n  ) {}\n\n  /**\n   * Adds an HTTP header to the request.\n   *\n   * @param key - The header name\n   * @param value - The header value\n   * @returns The PostBuilder instance for method chaining\n   *\n   * @example\n   * ```typescript\n   * builder.withHeader('Content-Type', 'application/json')\n   *        .withHeader('Authorization', 'Bearer token');\n   * ```\n   */\n  withHeader(key: string, value: string) {\n    this.headers[key] = value;\n    return this;\n  }\n\n  /**\n   * Adds multiple headers to the request.\n   * Merges the provided headers with existing ones.\n   *\n   * @param headers - Object containing key-value pairs of headers\n   * @returns The PostBuilder instance for method chaining\n   *\n   * @example\n   * ```typescript\n   * builder.withHeaders({\n   *   'Content-Type': 'application/json',\n   *   'Authorization': 'Bearer token'\n   * });\n   * ```\n   */\n  withHeaders(headers: Record<string, string>) {\n    Object.entries(headers).forEach(([key, value]) => {\n      this.headers[key] = value;\n    });\n    return this;\n  }\n\n  /**\n   * Configures the request to use multipart/form-data encoding.\n   * This is typically used when uploading files or sending binary data.\n   *\n   * @returns The PostBuilder instance for method chaining\n   *\n   * @example\n   * ```typescript\n   * builder.asMultipart().addFile('image', file);\n   * ```\n   */\n  asMultipart() {\n    this.postType = 'multipart';\n    return this;\n  }\n\n  /**\n   * Configures the request to use JSON encoding (default).\n   * The request body will be sent as JSON with appropriate Content-Type header.\n   *\n   * @returns The PostBuilder instance for method chaining\n   */\n  asJson() {\n    this.postType = 'json';\n    return this;\n  }\n\n  /**\n   * Configures the request to use URL-encoded form data.\n   * The request body will be sent as application/x-www-form-urlencoded.\n   *\n   * @returns The PostBuilder instance for method chaining\n   */\n  asUrlEncoded(skipIndex = false) {\n    this.postType = 'urlencoded';\n    this.httpRequestOptions.skipUrlEncodedIndexing = skipIndex;\n    return this;\n  }\n\n  /**\n   * Configures the request to be sent via Electron's BrowserWindow.loadURL\n   * with raw data bytes instead of the standard net.request flow.\n   * Can be combined with any content type (multipart, json, urlencoded).\n   *\n   * @returns The PostBuilder instance for method chaining\n   *\n   * @example\n   * ```typescript\n   * builder.asMultipart().asRawData().addFile('image', file).send(url);\n   * ```\n   */\n  asRawData() {\n    this.rawData = true;\n    return this;\n  }\n\n  /**\n   * Merges the provided data object with the existing request data.\n   * Existing keys will be overwritten with new values.\n   *\n   * @param data - Object containing key-value pairs to add to the request\n   * @returns The PostBuilder instance for method chaining\n   *\n   * @example\n   * ```typescript\n   * builder.withData({\n   *   title: 'My Post',\n   *   description: 'Post description',\n   *   tags: ['tag1', 'tag2']\n   * });\n   * ```\n   */\n  withData(data: Record<string, Value>) {\n    this.data = { ...this.data, ...data };\n    return this;\n  }\n\n  getField<T>(key: string): T | undefined {\n    return this.data[key] as T | undefined;\n  }\n\n  removeField(key: string) {\n    delete this.data[key];\n    if (this.fileFields.has(key)) {\n      this.fileFields.delete(key);\n    }\n    return this;\n  }\n\n  /**\n   * Sets a single field in the request data.\n   * Handles null values by converting them to undefined.\n   *\n   * @param key - The field name\n   * @param value - The field value (can be a single value or array)\n   * @returns The PostBuilder instance for method chaining\n   *\n   * @example\n   * ```typescript\n   * builder.setField('title', 'My Post Title')\n   *        .setField('tags', ['art', 'digital', 'illustration']);\n   * ```\n   */\n  setField(key: string, value: Value) {\n    this.insert(key, value);\n    return this;\n  }\n\n  /**\n   * Conditionally sets a field based on a predicate.\n   * Useful for setting fields based on user preferences or feature flags.\n   *\n   * @param key - The field name\n   * @param predicate - Boolean condition to evaluate\n   * @param truthy - Value to set if predicate is true\n   * @param falsy - Value to set if predicate is false (optional)\n   * @returns The PostBuilder instance for method chaining\n   *\n   * @example\n   * ```typescript\n   * builder.setConditional('nsfw', post.isNsfw, true, false)\n   *        .setConditional('rating', post.rating > 0, post.rating);\n   * ```\n   */\n  setConditional(\n    key: string,\n    predicate: boolean,\n    truthy: Value,\n    falsy?: Value,\n  ) {\n    this.insert(key, predicate ? truthy : falsy);\n    return this;\n  }\n\n  /**\n   * Iterates over an array and executes a callback for each item.\n   *\n   * @param items - Array of items to iterate over\n   * @param callback - Function to execute for each item\n   * @returns The PostBuilder instance for method chaining\n   *\n   * @example\n   * ```typescript\n   * builder.forEach(options.matureContent, (item, index, b) => {\n   *   b.setField(`attributes[${item}]`, 'true');\n   * });\n   * ```\n   */\n  forEach<T>(\n    items: T[] | undefined | null,\n    callback: (item: T, index: number, builder: PostBuilder) => void,\n  ) {\n    if (items) {\n      items.forEach((item, index) => callback(item, index, this));\n    }\n    return this;\n  }\n\n  /**\n   * Adds a file to the request data using the specified field name.\n   * The file is converted to the appropriate post format.\n   *\n   * @param key - The field name for the file\n   * @param file - The PostingFile instance to add\n   * @returns The PostBuilder instance for method chaining\n   *\n   * @example\n   * ```typescript\n   * builder.addFile('artwork', postingFile)\n   *        .addFile('reference', referenceFile);\n   * ```\n   */\n  addFile(key: string, file: PostingFile | FormFile) {\n    this.insert(key, file);\n    return this;\n  }\n\n  /**\n   * Adds multiple files to the request data under the specified field name.\n   * Each file is converted to the appropriate post format.\n   *\n   * @param key - The field name for the files\n   * @param files - Array of PostingFile instances to add\n   * @returns The PostBuilder instance for method chaining\n   *\n   * @example\n   * ```typescript\n   * builder.addFiles('images', [file1, file2, file3]);\n   * ```\n   */\n  addFiles(key: string, files: PostingFile[]) {\n    this.data[key] = files.map((file) => file.toPostFormat());\n    this.fileFields.add(key);\n    return this;\n  }\n\n  /**\n   * Adds a thumbnail to the request data.\n   * If the file has a thumbnail, it uses that; otherwise, for image files,\n   * it uses the original file as the thumbnail.\n   *\n   * @param key - The field name for the thumbnail\n   * @param file - The PostingFile instance from which to extract the thumbnail\n   * @returns The PostBuilder instance for method chaining\n   *\n   * @example\n   * ```typescript\n   * builder.addFile('video', videoFile)\n   *        .addThumbnail('thumbnail', videoFile);\n   * ```\n   */\n  addThumbnail(key: string, file: PostingFile) {\n    if (file.thumbnail) {\n      this.data[key] = file.thumbnailToPostFormat();\n      this.fileFields.add(key);\n    } else if (file.fileType === FileType.IMAGE) {\n      this.data[key] = file.toPostFormat();\n      this.fileFields.add(key);\n    } else {\n      this.data[key] = '';\n    }\n    return this;\n  }\n\n  /**\n   * Conditionally executes a callback based on a predicate.\n   *\n   * @param predicate - Boolean condition to evaluate\n   * @param callback - Function to execute if predicate is true\n   * @returns The PostBuilder instance for method chaining\n   *\n   * @example\n   * ```typescript\n   * builder.whenTrue(rating !== 'general', (b) => {\n   *   b.removeField('explicit');\n   * });\n   * ```\n   */\n  whenTrue(predicate: boolean, callback: (builder: PostBuilder) => void) {\n    if (predicate) {\n      callback(this);\n    }\n    return this;\n  }\n\n  /**\n   * Sends the constructed POST request to the specified URL.\n   * Validates the response and handles cancellation.\n   *\n   * @template ReturnValue - The expected type of the response body\n   * @param url - The URL to send the POST request to\n   * @returns Promise resolving to the response body\n   * @throws {Error} If the request is cancelled or the response is invalid\n   *\n   * @example\n   * ```typescript\n   * interface ApiResponse {\n   *   id: string;\n   *   status: 'success' | 'error';\n   * }\n   *\n   * const response = await builder.send<ApiResponse>('https://api.example.com/posts');\n   * console.log(response.body.id);\n   * ```\n   */\n  async send<ReturnValue>(url: string) {\n    this.cancellationToken.throwIfCancelled();\n    const data = this.build();\n    this.logger\n      .withMetadata({\n        website: this.website.constructor.name,\n        postType: this.postType,\n        url,\n        headers: Object.keys(this.headers),\n        data: this.sanitizeDataForLogging(data),\n        httpRequestOptions: this.httpRequestOptions,\n      })\n      .debug(`Sending ${this.postType} request to ${url} with data:`);\n\n    const maxRetries = 2;\n    let attempt = 0;\n    let lastError: unknown;\n\n    while (attempt <= maxRetries) {\n      try {\n        const value = await Http.post<ReturnValue>(url, {\n          partition: this.website.account.id,\n          type: this.postType,\n          data,\n          headers: this.headers,\n          options: this.httpRequestOptions,\n          uploadAsRawData: this.rawData,\n        });\n        this.logger.debug(`Received response from ${url}:`, value.statusCode);\n        PostResponse.validateBody(this.website, value, undefined, url);\n        return value;\n        // eslint-disable-next-line @typescript-eslint/no-explicit-any\n      } catch (error: any) {\n        const knownErrors = ['ECONNRESET', 'ERR_CONNECTION_RESET'];\n        let isKnownError = false;\n        for (const knownError of knownErrors) {\n          const isKnown =\n            error &&\n            (error.code === knownError ||\n              (typeof error.message === 'string' &&\n                error.message.includes(knownError)));\n          if (isKnown) {\n            attempt++;\n            lastError = error;\n            if (attempt > maxRetries) break;\n            this.logger.debug(\n              `Retrying request to ${url} due to ${knownError} (attempt ${attempt})`,\n            );\n            isKnownError = true;\n            break;\n          }\n        }\n\n        // If the error is not a known retryable error, log it and throw\n        if (!isKnownError) {\n          this.logger.error(\n            `Failed to send request to ${url} after ${attempt} attempts:`,\n            error,\n          );\n          throw error;\n        }\n        // If known error, continue loop (unless maxRetries exceeded)\n        await new Promise((resolve) => {\n          // Wait for 1 second before retrying\n          setTimeout(resolve, 1_000);\n        });\n      }\n    }\n    throw lastError;\n  }\n\n  /**\n   * Builds and returns the final data object that will be sent in the request.\n   * Handles special formatting for multipart requests:\n   * - Removes undefined values\n   * - Converts boolean values to strings\n   * - Filters undefined values from arrays\n   * - Converts boolean values in arrays to strings\n   *\n   * @returns The processed data object ready for transmission\n   *\n   * @example\n   * ```typescript\n   * const data = builder.build();\n   * // For multipart: { \"nsfw\": \"true\", \"tags\": [\"art\", \"digital\"] }\n   * // For JSON: { \"nsfw\": true, \"tags\": [\"art\", \"digital\"] }\n   * ```\n   */\n  public build(): Record<string, Value> {\n    const data = { ...this.data };\n    if (this.postType === 'multipart') {\n      Object.keys(data).forEach((key) => {\n        // If the value is undefined and we don't allow it, delete the key\n        // This is necessary for multipart/form-data where undefined values are not allowed\n        if (data[key] === undefined) {\n          delete data[key];\n        }\n\n        if (typeof data[key] === 'boolean') {\n          // Convert boolean values to string for multipart/form-data\n          data[key] = data[key] ? 'true' : 'false';\n        }\n\n        if (Array.isArray(data[key])) {\n          // If the value is an array, filter out undefined values\n          data[key] = (data[key] as FieldValue[])\n            .filter((v) => v !== undefined)\n            .map((v) => {\n              // Convert boolean values to string for multipart/form-data\n              if (typeof v === 'boolean') {\n                return v ? 'true' : 'false';\n              }\n              return v;\n            });\n        }\n      });\n    }\n\n    return data;\n  }\n\n  /**\n   * Converts a PostingFile or FieldValue to the appropriate format for posting.\n   * If the value is a PostingFile, it converts it to a FormFile.\n   *\n   * @param value - The value to convert\n   * @returns The converted value in the appropriate format\n   */\n  private convert(value: FieldValue | PostingFile): FieldValue | FormFile {\n    if (value instanceof PostingFile) {\n      return value.toPostFormat();\n    }\n    return value;\n  }\n\n  /**\n   * Inserts a key-value pair into the data object.\n   * If the value is a PostingFile, it converts it to the appropriate format.\n   * If the value is a FormFile, it adds the key to the fileFields set.\n   *\n   * @param key - The field name\n   * @param value - The field value (can be a PostingFile or FieldValue)\n   */\n  private insert(\n    key: string,\n    value: FieldValue | PostingFile | FormFile,\n  ): void {\n    const v = this.convert(value);\n    this.data[key] = v;\n    if (v instanceof FormFile) {\n      this.fileFields.add(key);\n    }\n  }\n\n  /**\n   * Exposes ability to retrieve data for logging outside of the builder\n   * if needed.\n   */\n  public getSanitizedData(): Record<string, Value> {\n    return this.sanitizeDataForLogging(this.data);\n  }\n\n  private sanitizeDataForLogging(\n    data: Record<string, Value>,\n  ): Record<string, Value> {\n    const sanitizedData: Record<string, Value> = {};\n    for (const [key, value] of Object.entries(data)) {\n      if (this.fileFields.has(key)) {\n        // For file fields, we don't log the actual file content\n        if (Array.isArray(value)) {\n          sanitizedData[key] = value.map((v) =>\n            v instanceof FormFile ? v.toString() : v,\n          );\n        } else {\n          sanitizedData[key] = value.toString();\n        }\n      } else {\n        sanitizedData[key] = value;\n      }\n    }\n    return sanitizedData;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/commons/validator-passthru.ts",
    "content": "import { IWebsiteFormFields, SimpleValidationResult } from '@postybirb/types';\n\nexport async function validatorPassthru(): Promise<\n  SimpleValidationResult<IWebsiteFormFields>\n> {\n  return {};\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/commons/validator.ts",
    "content": "import {\n  IWebsiteFormFields,\n  SimpleValidationResult,\n  ValidationMessage,\n  ValidationMessages,\n} from '@postybirb/types';\n\ntype KeysToOmit =\n  | 'mergeDefaults'\n  | 'getFormFieldFor'\n  | 'getFormFields'\n  | 'getProcessedTags';\n\ntype ValidationArray<Fields extends IWebsiteFormFields> = ValidationMessage<\n  Fields,\n  keyof ValidationMessages\n>[];\n\nexport class SubmissionValidator<Fields extends IWebsiteFormFields = never> {\n  protected readonly warnings: ValidationArray<Fields> = [];\n\n  protected readonly errors: ValidationArray<Fields> = [];\n\n  /**\n   * Adds error to the validation result\n   *\n   * @param id - Error localization message id. {@link ValidationMessages}\n   * @param values - Values to fill in the message\n   * @param field - Associates the error to a input field\n   */\n  error<T extends keyof ValidationMessages>(\n    id: T,\n    values: ValidationMessages[T],\n    field?: keyof Omit<Fields, KeysToOmit>,\n  ) {\n    this.errors.push({ id, values, field });\n  }\n\n  /**\n   * Adds warning to the validation result\n   *\n   * @param id - Warning localization message id. {@link ValidationMessages}\n   * @param values - Values to fill in the message\n   * @param field - Associates the warning to a input field\n   */\n  warning<T extends keyof ValidationMessages>(\n    id: T,\n    values: ValidationMessages[T],\n    field?: keyof Omit<Fields, KeysToOmit>,\n  ) {\n    this.warnings.push({ id, values, field });\n  }\n\n  /**\n   * Returns validation result. Should be used in the onValidateFileSubmission or onValidateMessageSubmission\n   */\n  get result(): SimpleValidationResult<Fields> {\n    return { errors: this.errors, warnings: this.warnings };\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/decorators/disable-ads.decorator.ts",
    "content": "import { injectWebsiteDecoratorProps } from './website-decorator-props';\n\nexport function DisableAds() {\n  return function website(constructor) {\n    injectWebsiteDecoratorProps(constructor, {\n      allowAd: false,\n    });\n  };\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/decorators/login-flow.decorator.ts",
    "content": "import { CustomLoginType, UserLoginType } from '@postybirb/types';\nimport { Class } from 'type-fest';\nimport { UnknownWebsite } from '../website';\nimport { injectWebsiteDecoratorProps } from './website-decorator-props';\n\n/**\n * Identifies the website as having a user login flow.\n * Meaning that they will login to the website using the website url provided.\n * @param {string} url\n */\nexport function UserLoginFlow(url: string) {\n  return function website(constructor: Class<UnknownWebsite>) {\n    const loginFlow: UserLoginType = {\n      type: 'user',\n      url,\n    };\n\n    injectWebsiteDecoratorProps(constructor, { loginFlow });\n  };\n}\n\n/**\n * Identifies the website as having a custom login flow.\n * Meaning that they will login through a custom provided form / component.\n * Defaults the name of the class if no name is provided.\n * @param {string} loginComponentName\n */\nexport function CustomLoginFlow(loginComponentName?: string) {\n  return function website(constructor: Class<UnknownWebsite>) {\n    const loginFlow: CustomLoginType = {\n      type: 'custom',\n      loginComponentName: loginComponentName ?? constructor.name,\n    };\n\n    injectWebsiteDecoratorProps(constructor, { loginFlow });\n  };\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/decorators/supports-files.decorator.ts",
    "content": "import { ISubmissionFile, WebsiteFileOptions } from '@postybirb/types';\nimport {\n  getFileType,\n  getFileTypeFromMimeType,\n} from '@postybirb/utils/file-type';\nimport { parse } from 'path';\nimport { Class } from 'type-fest';\nimport { getDynamicFileSizeLimits } from '../models/website-modifiers/with-dynamic-file-size-limits';\nimport { UnknownWebsite } from '../website';\nimport { injectWebsiteDecoratorProps } from './website-decorator-props';\n\nexport function SupportsFiles(\n  websiteFileOptions: Omit<WebsiteFileOptions, 'supportedFileTypes'>,\n);\nexport function SupportsFiles(acceptedMimeTypes: string[]);\nexport function SupportsFiles(\n  websiteFileOptionsOrMimeTypes:\n    | Omit<WebsiteFileOptions, 'supportedFileTypes'>\n    | string[],\n) {\n  return function website(constructor: Class<UnknownWebsite>) {\n    let websiteFileOptions: WebsiteFileOptions = Array.isArray(\n      websiteFileOptionsOrMimeTypes,\n    )\n      ? {\n          acceptedMimeTypes: websiteFileOptionsOrMimeTypes,\n          supportedFileTypes: [],\n        }\n      : { ...websiteFileOptionsOrMimeTypes, supportedFileTypes: [] };\n\n    websiteFileOptions = {\n      acceptedFileSizes: {},\n      acceptedMimeTypes: [],\n      acceptsExternalSourceUrls: false,\n      fileBatchSize: 1,\n      ...websiteFileOptions,\n    };\n\n    websiteFileOptions.acceptedMimeTypes.forEach((mimeType) => {\n      const fileType = getFileTypeFromMimeType(mimeType);\n      if (!websiteFileOptions.supportedFileTypes.includes(fileType)) {\n        websiteFileOptions.supportedFileTypes.push(fileType);\n      }\n    });\n\n    injectWebsiteDecoratorProps(constructor, {\n      fileOptions: websiteFileOptions,\n    });\n  };\n}\n\nexport function getSupportedFileSize(\n  instance: UnknownWebsite,\n  file: ISubmissionFile,\n) {\n  const acceptedFileSizes =\n    instance.decoratedProps.fileOptions?.acceptedFileSizes;\n\n  const dynamicFileSizeLimits = getDynamicFileSizeLimits(instance);\n\n  if (!acceptedFileSizes && !dynamicFileSizeLimits) {\n    return undefined;\n  }\n\n  const limits = { ...acceptedFileSizes, ...dynamicFileSizeLimits };\n\n  return (\n    limits[file.mimeType] ??\n    limits[`${file.mimeType.split('/')[0]}/*`] ??\n    limits[parse(file.fileName).ext] ??\n    limits[getFileType(file.fileName)] ??\n    limits['*'] ??\n    Number.MAX_SAFE_INTEGER\n  );\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/decorators/supports-username-shortcut.decorator.ts",
    "content": "import { UsernameShortcut } from '@postybirb/types';\nimport { Class } from 'type-fest';\nimport { UnknownWebsite } from '../website';\nimport { injectWebsiteDecoratorProps } from './website-decorator-props';\n\n/**\n * Sets a username shortcut for a website.\n * @param {UsernameShortcut} usernameShortcut\n */\nexport function SupportsUsernameShortcut(usernameShortcut: UsernameShortcut) {\n  return function website(constructor: Class<UnknownWebsite>) {\n    injectWebsiteDecoratorProps(constructor, { usernameShortcut });\n  };\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/decorators/website-decorator-props.ts",
    "content": "import {\n  CustomLoginType,\n  IWebsiteMetadata,\n  UserLoginType,\n  UsernameShortcut,\n  WebsiteFileOptions,\n} from '@postybirb/types';\nimport { LogLayer } from 'loglayer';\nimport { Class } from 'type-fest';\nimport { UnknownWebsite } from '../website';\n\nexport type WebsiteDecoratorProps = {\n  /**\n   * Set by {@link SupportsFiles}\n   *\n   * Defines the file options for a website.\n   * @type {WebsiteFileOptions}\n   */\n  fileOptions?: WebsiteFileOptions;\n\n  /**\n   * Set by {@link UserLoginFlow} or {@link CustomLoginFlow}\n   *\n   * Defines login flow properties for a website.\n   * This is used to determine how a user will login to a website.\n   * @type {UserLoginType} - User will login through a webview using the provided url.\n   * @type {CustomLoginType} - User will login through a custom login flow created by the implementer.\n   * @type {(UserLoginType | CustomLoginType)}\n   */\n  loginFlow: UserLoginType | CustomLoginType;\n\n  /**\n   * Set by {@link WebsiteMetadata}\n   *\n   * Defines the metadata for a website.\n   * This is usually for display or internal Ids.\n   * @type {IWebsiteMetadata}\n   */\n  metadata: IWebsiteMetadata;\n\n  /**\n   * Set by {@SupportsUsernameShortcut}\n   *\n   * Defines the username shortcut for a website.\n   * This is used to modify links to users for websites that support it.\n   * @type {UsernameShortcut}\n   */\n  usernameShortcut?: UsernameShortcut;\n\n  /**\n   * Disable Ads in description by using {@link DisableAdSupport}\n   *\n   * @type {boolean}\n   */\n  allowAd: boolean;\n};\n\nexport function defaultWebsiteDecoratorProps(): WebsiteDecoratorProps {\n  return {\n    fileOptions: undefined,\n    loginFlow: undefined,\n    metadata: undefined,\n    allowAd: true,\n  };\n}\n\n/**\n * Injects basic website decorator properties into a website instance.\n *\n * @param {Class<UnknownWebsite>} constructor\n * @param {WebsiteDecoratorProps} props\n */\nexport function injectWebsiteDecoratorProps(\n  constructor: Class<UnknownWebsite>,\n  props: Partial<WebsiteDecoratorProps>,\n): void {\n  if (!constructor.prototype.decoratedProps) {\n    Object.assign(constructor.prototype, {\n      decoratedProps: defaultWebsiteDecoratorProps(),\n    });\n  }\n\n  Object.entries(props).forEach(([key, value]) => {\n    if (value !== undefined) {\n      // eslint-disable-next-line no-param-reassign\n      constructor.prototype.decoratedProps[key] = value;\n    }\n  });\n}\n\nexport function validateWebsiteDecoratorProps(\n  logger: LogLayer,\n  websiteName: string,\n  props: WebsiteDecoratorProps,\n): boolean {\n  if (!props.loginFlow) {\n    logger\n      .withContext({ websiteName })\n      .error(\n        'Website is missing login flow. Please set a login flow using UserLoginFlow or CustomLoginFlow decorators.',\n      );\n    return false;\n  }\n\n  if (!props.metadata) {\n    logger\n      .withContext({ websiteName })\n      .error(\n        'Website is missing metadata. Please set metadata using WebsiteMetadata decorator.',\n      );\n    return false;\n  }\n\n  if (!props.metadata.name) {\n    logger.withContext({ websiteName }).error(`Missing metadata field 'name'`);\n  }\n\n  return true;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/decorators/website-metadata.decorator.ts",
    "content": "import { IWebsiteMetadata } from '@postybirb/types';\nimport { Class } from 'type-fest';\nimport { UnknownWebsite } from '../website';\nimport { injectWebsiteDecoratorProps } from './website-decorator-props';\n\nexport function WebsiteMetadata(metadata: IWebsiteMetadata) {\n  return function website(constructor: Class<UnknownWebsite>) {\n    const m = { ...metadata };\n    // Determine default login refresh\n    if (!metadata.refreshInterval) {\n      // Default (1 hour)\n      m.refreshInterval = 60 * 60_000;\n    }\n\n    injectWebsiteDecoratorProps(constructor, { metadata: m });\n  };\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/dtos/oauth-website-request.dto.ts",
    "content": "import { ApiProperty } from '@nestjs/swagger';\nimport { DynamicObject, IOAuthWebsiteRequestDto } from '@postybirb/types';\nimport { IsObject, IsString } from 'class-validator';\n\nexport class OAuthWebsiteRequestDto<T extends DynamicObject>\n  implements IOAuthWebsiteRequestDto<T>\n{\n  @ApiProperty()\n  @IsString()\n  id: string;\n\n  @ApiProperty({\n    type: Object,\n  })\n  @IsObject()\n  data: T;\n\n  @ApiProperty()\n  @IsString()\n  route: string;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/artconomy/artconomy.website.ts",
    "content": "import { Http } from '@postybirb/http';\nimport {\n  ILoginState,\n  ImageResizeProps,\n  ISubmissionFile,\n  PostData,\n  PostResponse,\n  SubmissionRating,\n} from '@postybirb/types';\nimport { CancellableToken } from '../../../post/models/cancellable-token';\nimport { PostingFile } from '../../../post/models/posting-file';\nimport FileSize from '../../../utils/filesize.util';\nimport { PostBuilder } from '../../commons/post-builder';\nimport { validatorPassthru } from '../../commons/validator-passthru';\nimport { UserLoginFlow } from '../../decorators/login-flow.decorator';\nimport { SupportsFiles } from '../../decorators/supports-files.decorator';\nimport { SupportsUsernameShortcut } from '../../decorators/supports-username-shortcut.decorator';\nimport { WebsiteMetadata } from '../../decorators/website-metadata.decorator';\nimport { DataPropertyAccessibility } from '../../models/data-property-accessibility';\nimport { FileWebsite } from '../../models/website-modifiers/file-website';\nimport { MessageWebsite } from '../../models/website-modifiers/message-website';\nimport { Website } from '../../website';\nimport { ArtconomyAccountData } from './models/artconomy-account-data';\nimport { ArtconomyFileSubmission } from './models/artconomy-file-submission';\nimport { ArtconomyMessageSubmission } from './models/artconomy-message-submission';\n\n@WebsiteMetadata({\n  name: 'artconomy',\n  displayName: 'Artconomy',\n})\n@UserLoginFlow('https://artconomy.com/auth/login')\n@SupportsUsernameShortcut({\n  id: 'artconomy',\n  url: 'https://artconomy.com/profile/$1/about',\n})\n@SupportsFiles({\n  acceptedMimeTypes: [\n    'image/png',\n    'image/jpeg',\n    'image/jpg',\n    'image/gif',\n    'video/mp4',\n    'application/msword',\n    'application/rtf',\n    'text/plain',\n    'audio/mp3',\n  ],\n  acceptedFileSizes: {\n    '*': FileSize.megabytes(49),\n  },\n})\nexport default class Artconomy\n  extends Website<ArtconomyAccountData>\n  implements\n    FileWebsite<ArtconomyFileSubmission>,\n    MessageWebsite<ArtconomyMessageSubmission>\n{\n  protected BASE_URL = 'https://artconomy.com';\n\n  public externallyAccessibleWebsiteDataProperties: DataPropertyAccessibility<ArtconomyAccountData> =\n    {};\n\n  public async onLogin(): Promise<ILoginState> {\n    try {\n      const authCheck = await Http.get<{ username: string; id: number }>(\n        `${this.BASE_URL}/api/profiles/data/requester/`,\n        {\n          partition: this.accountId,\n          headers: {\n            'Content-Type': 'application/json',\n          },\n        },\n      );\n\n      if (authCheck.statusCode === 200 && authCheck.body.username !== '_') {\n        // Get CSRF token from cookies\n        const cookies = await Http.getWebsiteCookies(\n          this.accountId,\n          this.BASE_URL,\n        );\n        const csrfCookie = cookies.find((c) => c.name === 'csrftoken');\n        await this.setWebsiteData({\n          id: authCheck.body.id,\n          username: authCheck.body.username,\n          csrfToken: csrfCookie?.value || '',\n        });\n        return this.loginState.setLogin(true, authCheck.body.username);\n      }\n\n      return this.loginState.setLogin(false, null);\n    } catch (error) {\n      return this.loginState.setLogin(false, null);\n    }\n  }\n\n  createFileModel(): ArtconomyFileSubmission {\n    return new ArtconomyFileSubmission();\n  }\n\n  calculateImageResize(file: ISubmissionFile): ImageResizeProps {\n    return undefined;\n  }\n\n  async onPostFileSubmission(\n    postData: PostData<ArtconomyFileSubmission>,\n    files: PostingFile[],\n    cancellationToken: CancellableToken,\n  ): Promise<PostResponse> {\n    const { id, username, csrfToken } = this.getWebsiteData();\n\n    if (!id || !username || !csrfToken) {\n      return PostResponse.fromWebsite(this).withException(\n        new Error('Not properly logged in to Artconomy'),\n      );\n    }\n\n    // Upload primary asset using PostBuilder\n    const primaryUpload = await new PostBuilder(this, cancellationToken)\n      .asMultipart()\n      .addFile('files[]', files[0])\n      .withHeader('X-CSRFTOKEN', csrfToken)\n      .withHeader('Referer', this.BASE_URL)\n      .send<{ id: string }>(`${this.BASE_URL}/api/lib/asset/`);\n\n    if (!primaryUpload.body?.id) {\n      return PostResponse.fromWebsite(this)\n        .withException(\n          new Error(\n            `Asset upload failed: ${primaryUpload.statusMessage || 'Unknown error'}`,\n          ),\n        )\n        .withAdditionalInfo(primaryUpload.body);\n    }\n\n    const primaryAsset = primaryUpload.body.id;\n    let thumbnailAsset: string | null = null;\n\n    // Upload thumbnail if available\n    if (files[0].thumbnail) {\n      const thumbnailUpload = await new PostBuilder(this, cancellationToken)\n        .asMultipart()\n        .addThumbnail('files[]', files[0])\n        .withHeader('X-CSRFTOKEN', csrfToken)\n        .withHeader('Referer', this.BASE_URL)\n        .send<{ id: string }>(`${this.BASE_URL}/api/lib/asset/`);\n\n      if (!thumbnailUpload.body?.id) {\n        return PostResponse.fromWebsite(this)\n          .withException(\n            new Error(\n              `Thumbnail upload failed: ${thumbnailUpload.statusMessage || 'Unknown error'}`,\n            ),\n          )\n          .withAdditionalInfo(thumbnailUpload.body);\n      }\n      thumbnailAsset = thumbnailUpload.body.id;\n    }\n\n    cancellationToken.throwIfCancelled();\n\n    // Create submission using PostBuilder\n    const postResponse = await new PostBuilder(this, cancellationToken)\n      .asJson()\n      .setField('file', primaryAsset)\n      .setField('preview', thumbnailAsset)\n      .setField('title', postData.options.title)\n      .setField('caption', postData.options.description)\n      .setField('tags', postData.options.tags)\n      .setField('rating', this.getRating(postData.options.rating))\n      .setField('private', postData.options.isPrivate)\n      .setField('comments_disabled', postData.options.commentsDisabled)\n      .setConditional('artists', postData.options.isArtist, [id], [])\n      .withHeader('X-CSRFTOKEN', csrfToken)\n      .withHeader('Referer', this.BASE_URL)\n      .send<{\n        id: string;\n      }>(`${this.BASE_URL}/api/profiles/account/${username}/submissions/`);\n\n    if (!postResponse.body?.id) {\n      return PostResponse.fromWebsite(this)\n        .withException(\n          new Error(\n            `Submission creation failed: ${postResponse.statusMessage || 'Unknown error'}`,\n          ),\n        )\n        .withAdditionalInfo(postResponse.body);\n    }\n\n    return PostResponse.fromWebsite(this)\n      .withSourceUrl(`${this.BASE_URL}/submissions/${postResponse.body.id}`)\n      .withMessage('File posted successfully');\n  }\n\n  onValidateFileSubmission = validatorPassthru;\n\n  createMessageModel(): ArtconomyMessageSubmission {\n    return new ArtconomyMessageSubmission();\n  }\n\n  async onPostMessageSubmission(\n    postData: PostData<ArtconomyMessageSubmission>,\n    cancellationToken: CancellableToken,\n  ): Promise<PostResponse> {\n    cancellationToken.throwIfCancelled();\n\n    const { username, csrfToken } = this.getWebsiteData();\n\n    if (!username || !csrfToken) {\n      return PostResponse.fromWebsite(this).withException(\n        new Error('Not properly logged in to Artconomy'),\n      );\n    }\n\n    const postResponse = await new PostBuilder(this, cancellationToken)\n      .asJson()\n      .setField('subject', postData.options.title)\n      .setField('body', postData.options.description)\n      .withHeader('X-CSRFTOKEN', csrfToken)\n      .withHeader('Referer', this.BASE_URL)\n      .send<{\n        id: number;\n      }>(`${this.BASE_URL}/api/profiles/account/${username}/journals/`);\n\n    if (!postResponse.body?.id) {\n      return PostResponse.fromWebsite(this)\n        .withException(\n          new Error(\n            `Journal creation failed: ${postResponse.statusMessage || 'Unknown error'}`,\n          ),\n        )\n        .withAdditionalInfo(postResponse.body);\n    }\n\n    return PostResponse.fromWebsite(this)\n      .withSourceUrl(\n        `${this.BASE_URL}/profile/${username}/journals/${postResponse.body.id}`,\n      )\n      .withMessage('Journal posted successfully');\n  }\n\n  onValidateMessageSubmission = validatorPassthru;\n\n  private getRating(rating: SubmissionRating): number {\n    switch (rating) {\n      case SubmissionRating.GENERAL:\n        return 0;\n      case SubmissionRating.MATURE:\n        return 1;\n      case SubmissionRating.ADULT:\n        return 2;\n      case SubmissionRating.EXTREME:\n        return 3;\n      default:\n        // Safest assumption\n        return 2;\n    }\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/artconomy/models/artconomy-account-data.ts",
    "content": "import { SelectOption } from '@postybirb/form-builder'; \n\nexport type ArtconomyAccountData = { \n  id?: number;\n  username?: string;\n  csrfToken?: string;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/artconomy/models/artconomy-file-submission.ts",
    "content": "import {\n  BooleanField,\n  DescriptionField,\n  RatingField,\n  TagField,\n} from '@postybirb/form-builder';\nimport {\n  DescriptionType,\n  DescriptionValue,\n  SubmissionRating,\n  TagValue,\n} from '@postybirb/types';\nimport { BaseWebsiteOptions } from '../../../models/base-website-options';\n\nexport class ArtconomyFileSubmission extends BaseWebsiteOptions {\n  @DescriptionField({\n    descriptionType: DescriptionType.MARKDOWN,\n    maxDescriptionLength: 2000,\n  })\n  description: DescriptionValue;\n\n  @TagField({\n    minTags: 5,\n  })\n  tags: TagValue;\n\n  @RatingField({\n    options: [\n      { value: SubmissionRating.GENERAL, label: 'Clean/Safe' },\n      { value: SubmissionRating.MATURE, label: 'Risque' },\n      { value: SubmissionRating.ADULT, label: 'Adult' },\n      { value: SubmissionRating.EXTREME, label: 'Offensive/Disturbing' },\n    ],\n  })\n  rating: SubmissionRating;\n\n  @BooleanField({\n    label: 'private',\n    section: 'website',\n    span: 6,\n    defaultValue: false,\n  })\n  isPrivate: boolean;\n\n  @BooleanField({\n    label: 'disableComments',\n    section: 'website',\n    span: 6,\n    defaultValue: false,\n  })\n  commentsDisabled: boolean;\n\n  @BooleanField({\n    label: 'originalWork',\n    section: 'website',\n    span: 6,\n    defaultValue: true,\n  })\n  isArtist: boolean;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/artconomy/models/artconomy-message-submission.ts",
    "content": "import { DescriptionField } from '@postybirb/form-builder';\nimport { DescriptionType, DescriptionValue } from '@postybirb/types';\nimport { BaseWebsiteOptions } from '../../../models/base-website-options';\n\nexport class ArtconomyMessageSubmission extends BaseWebsiteOptions {\n  @DescriptionField({\n    descriptionType: DescriptionType.HTML\n  })\n  description: DescriptionValue;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/aryion/aryion.website.ts",
    "content": "import { SelectOption } from '@postybirb/form-builder';\nimport { Http } from '@postybirb/http';\nimport {\n  FileType,\n  ILoginState,\n  ImageResizeProps,\n  PostData,\n  PostResponse,\n  SimpleValidationResult,\n} from '@postybirb/types';\nimport { HTMLElement, parse } from 'node-html-parser';\nimport { CancellableToken } from '../../../post/models/cancellable-token';\nimport { PostingFile } from '../../../post/models/posting-file';\nimport FileSize from '../../../utils/filesize.util';\nimport { SelectOptionUtil } from '../../../utils/select-option.util';\nimport { PostBuilder } from '../../commons/post-builder';\nimport { UserLoginFlow } from '../../decorators/login-flow.decorator';\nimport { SupportsFiles } from '../../decorators/supports-files.decorator';\nimport { WebsiteMetadata } from '../../decorators/website-metadata.decorator';\nimport { DataPropertyAccessibility } from '../../models/data-property-accessibility';\nimport { FileWebsite } from '../../models/website-modifiers/file-website';\nimport { Website } from '../../website';\nimport { AryionAccountData } from './models/aryion-account-data';\nimport { AryionFileSubmission } from './models/aryion-file-submission';\n\n@WebsiteMetadata({\n  name: 'aryion',\n  displayName: 'Aryion',\n})\n@UserLoginFlow('https://aryion.com/forum/ucp.php?mode=login')\n@SupportsFiles({\n  acceptedMimeTypes: [\n    'image/jpeg',\n    'image/jpg',\n    'image/gif',\n    'image/png',\n    'application/msword',\n    'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n    'application/vnd.ms-excel',\n    'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n    'application/x-shockwave-flash',\n    'application/vnd.visio',\n    'text/plain',\n    'application/rtf',\n    'video/x-msvideo',\n    'video/mpeg',\n    'video/x-flv',\n    'video/mp4',\n    'application/pdf',\n  ],\n  acceptedFileSizes: {\n    [FileType.IMAGE]: FileSize.megabytes(20),\n    [FileType.VIDEO]: FileSize.megabytes(100),\n    [FileType.TEXT]: FileSize.megabytes(100),\n    'application/pdf': FileSize.megabytes(100),\n  },\n})\nexport default class Aryion\n  extends Website<AryionAccountData>\n  implements FileWebsite<AryionFileSubmission>\n{\n  protected BASE_URL = 'https://aryion.com';\n\n  public externallyAccessibleWebsiteDataProperties: DataPropertyAccessibility<AryionAccountData> =\n    {\n      folders: true,\n    };\n\n  public async onLogin(): Promise<ILoginState> {\n    const res = await Http.get<string>(`${this.BASE_URL}/g4/treeview.php`, {\n      partition: this.accountId,\n    });\n\n    if (\n      res.body.includes('user-link') &&\n      !res.body.includes('Login to read messages')\n    ) {\n      const $ = parse(res.body);\n      const userLink = $.querySelector('.user-link');\n      const username = userLink ? userLink.text : 'Unknown User';\n      this.loginState.setLogin(true, username);\n      await this.getFolders($);\n    } else {\n      this.loginState.logout();\n    }\n\n    return this.loginState.getState();\n  }\n\n  private async getFolders($: HTMLElement): Promise<void> {\n    const folders: SelectOption[] = [];\n    const treeviews = $.querySelectorAll('.treeview');\n\n    treeviews.forEach((treeview) => {\n      // Process each top-level <li> element\n      const topLevelItems = treeview.querySelectorAll(':scope > li');\n      topLevelItems.forEach((li) => {\n        this.parseFolderItem(li, folders);\n      });\n    });\n\n    this.websiteDataStore.setData({\n      ...this.websiteDataStore.getData(),\n      folders,\n    });\n  }\n\n  private parseFolderItem(li: HTMLElement, parent: SelectOption[]): void {\n    // Find the span element that contains the folder info\n    const folderSpan = li.querySelector(':scope > span');\n    if (!folderSpan) return;\n\n    const dataTid = folderSpan.getAttribute('data-tid');\n    const folderName = folderSpan.text.trim();\n\n    if (!dataTid || !folderName) return;\n\n    // Check if this folder has children (look for a <ul> sibling)\n    const childrenUl = li.querySelector(':scope > ul');\n\n    if (childrenUl) {\n      // This is a parent folder with children\n      const childItems: SelectOption[] = [];\n\n      // Process each child <li> element\n      const childLis = childrenUl.querySelectorAll(':scope > li');\n      childLis.forEach((childLi) => {\n        this.parseFolderItem(childLi, childItems);\n      });\n\n      // Create a group entry for this folder\n      parent.push({\n        label: folderName,\n        items: childItems,\n        value: dataTid,\n      });\n    } else {\n      // This is a leaf folder (no children)\n      parent.push({\n        value: dataTid,\n        label: folderName,\n      });\n    }\n  }\n\n  createFileModel(): AryionFileSubmission {\n    return new AryionFileSubmission();\n  }\n\n  calculateImageResize(): ImageResizeProps {\n    return undefined;\n  }\n\n  async onPostFileSubmission(\n    postData: PostData<AryionFileSubmission>,\n    files: PostingFile[],\n    cancellationToken: CancellableToken,\n  ): Promise<PostResponse> {\n    cancellationToken.throwIfCancelled();\n\n    const { options } = postData;\n    const file = files[0];\n\n    // Filter out 'vore' and 'non-vore' tags from the tags list\n    const filteredTags = options.tags\n      .filter((tag) => !tag.toLowerCase().match(/^vore$/i))\n      .filter((tag) => !tag.toLowerCase().match(/^non-vore$/i));\n\n    const builder = new PostBuilder(this, cancellationToken)\n      .asMultipart()\n      .addFile('file', file)\n      .addFile('thumb', file)\n      .setField('desc', options.description)\n      .setField('title', options.title)\n      .setField('tags', filteredTags.join('\\n'))\n      .setField('reqtag[]', options.requiredTag === '1' ? 'Non-Vore' : '')\n      .setField('view_perm', options.viewPermissions)\n      .setField('comment_perm', options.commentPermissions)\n      .setField('tag_perm', options.tagPermissions)\n      .setField('scrap', options.scraps ? 'on' : '')\n      .setField('parentid', options.folder)\n      .setField('action', 'new-item')\n      .setField('MAX_FILE_SIZE', '104857600');\n\n    const result = await builder.send<string>(\n      `${this.BASE_URL}/g4/itemaction.php`,\n    );\n\n    try {\n      // Split errors/warnings if they exist and handle them separately\n      const responses = result.body\n        .trim()\n        .split('\\n')\n        .map((r) => r?.trim());\n\n      if (responses.length > 1 && responses[0].indexOf('Warning:') === -1) {\n        return PostResponse.fromWebsite(this)\n          .withAdditionalInfo(result.body)\n          .withException(new Error('Server returned warnings or errors'));\n      }\n\n      // Parse the JSON response\n      const jsonResponse = responses[responses.length - 1].replace(\n        /(<textarea>|<\\/textarea>)/g,\n        '',\n      );\n      const json = JSON.parse(jsonResponse);\n\n      if (json.id) {\n        return PostResponse.fromWebsite(this).withSourceUrl(\n          `${this.BASE_URL}${json.url}`,\n        );\n      }\n    } catch (err) {\n      // If JSON parsing fails, return the raw response\n    }\n\n    return PostResponse.fromWebsite(this)\n      .withAdditionalInfo({\n        body: result.body,\n        statusCode: result.statusCode,\n      })\n      .withException(new Error('Failed to post'));\n  }\n\n  async onValidateFileSubmission(\n    postData: PostData<AryionFileSubmission>,\n  ): Promise<SimpleValidationResult> {\n    const validator = this.createValidator<AryionFileSubmission>();\n    const { options } = postData;\n\n    // Validate required folder selection\n    if (options.folder) {\n      const folderExists = SelectOptionUtil.findOptionById(\n        this.websiteDataStore.getData()?.folders ?? [],\n        options.folder,\n      );\n      if (!folderExists) {\n        validator.error('validation.folder.missing-or-invalid', {}, 'folder');\n      }\n    }\n\n    return validator.result;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/aryion/models/aryion-account-data.ts",
    "content": "import { SelectOption } from '@postybirb/form-builder'; \n\nexport type AryionAccountData = { \n  folders: SelectOption[];\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/aryion/models/aryion-file-submission.ts",
    "content": "import {\n  BooleanField,\n  DescriptionField,\n  RadioField,\n  SelectField,\n} from '@postybirb/form-builder';\nimport {\n  DefaultDescriptionValue,\n  DescriptionType,\n  DescriptionValue,\n} from '@postybirb/types';\nimport { BaseWebsiteOptions } from '../../../models/base-website-options';\n\nexport class AryionFileSubmission extends BaseWebsiteOptions {\n  @DescriptionField({\n    descriptionType: DescriptionType.BBCODE,\n  })\n  description: DescriptionValue = DefaultDescriptionValue();\n\n  @SelectField({\n    label: 'folder',\n    options: [],\n    required: true,\n    section: 'website',\n    span: 12,\n    derive: [\n      {\n        key: 'folders',\n        populate: 'options',\n      },\n    ],\n  })\n  folder = '';\n\n  @RadioField({\n    label: 'requiredTag',\n    section: 'website',\n    span: 6,\n    options: [\n      { value: '0', label: 'Vore' },\n      { value: '1', label: 'Non-Vore' },\n    ],\n    required: true,\n  })\n  requiredTag: string;\n\n  @RadioField({\n    label: 'viewPermissions',\n    section: 'website',\n    span: 6,\n    options: [\n      { value: 'ALL', label: 'Everyone' },\n      { value: 'USER', label: 'Registered Users' },\n      { value: 'SELF', label: 'Self Only' },\n    ],\n  })\n  viewPermissions = 'ALL';\n\n  @RadioField({\n    label: 'commentPermissions',\n    section: 'website',\n    span: 6,\n    options: [\n      { value: 'USER', label: 'Registered Users' },\n      { value: 'BLACK', label: 'All But Blocked' },\n      { value: 'WHITE', label: 'Friends Only' },\n      { value: 'SELF', label: 'Self Only' },\n      { value: 'NONE', label: 'Nobody' },\n    ],\n  })\n  commentPermissions = 'USER';\n\n  @RadioField({\n    label: 'tagPermissions',\n    section: 'website',\n    span: 6,\n    options: [\n      { value: 'USER', label: 'Registered Users' },\n      { value: 'BLACK', label: 'All But Blocked' },\n      { value: 'WHITE', label: 'Friends Only' },\n      { value: 'SELF', label: 'Self Only' },\n    ],\n  })\n  tagPermissions = 'USER';\n\n  @BooleanField({\n    label: 'scraps',\n    section: 'website',\n    span: 6,\n  })\n  scraps = false;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/bluesky/bluesky.website.ts",
    "content": "// eslint-disable-next-line max-classes-per-file\nimport {\n  $Typed,\n  AppBskyActorGetProfile,\n  AppBskyEmbedImages,\n  AppBskyEmbedVideo,\n  AppBskyFeedThreadgate,\n  AppBskyVideoGetJobStatus,\n  AppBskyVideoGetUploadLimits,\n  AppBskyVideoUploadVideo,\n  AtpAgent,\n  AtUri,\n  BlobRef,\n  ComAtprotoLabelDefs,\n  RichText,\n} from '@atproto/api';\nimport { ReplyRef } from '@atproto/api/dist/client/types/app/bsky/feed/post';\nimport { JobStatus } from '@atproto/api/dist/client/types/app/bsky/video/defs';\nimport {\n  BlueskyAccountData,\n  BlueskyOAuthRoutes,\n  FileType,\n  ILoginState,\n  ImageResizeProps,\n  ISubmissionFile,\n  OAuthRouteHandlers,\n  PostData,\n  PostResponse,\n  SimpleValidationResult,\n  SubmissionRating,\n} from '@postybirb/types';\nimport { calculateImageResize, getFileTypeFromMimeType } from '@postybirb/utils/file-type';\nimport { v4 } from 'uuid';\nimport { BaseConverter } from '../../../post-parsers/models/description-node/converters/base-converter';\nimport { PlainTextConverter } from '../../../post-parsers/models/description-node/converters/plaintext-converter';\nimport { ConversionContext } from '../../../post-parsers/models/description-node/description-node.base';\nimport { TipTapNode } from '../../../post-parsers/models/description-node/description-node.types';\nimport { CancellableToken } from '../../../post/models/cancellable-token';\nimport { PostingFile } from '../../../post/models/posting-file';\nimport FileSize from '../../../utils/filesize.util';\nimport { SubmissionValidator } from '../../commons/validator';\nimport { DisableAds } from '../../decorators/disable-ads.decorator';\nimport { CustomLoginFlow } from '../../decorators/login-flow.decorator';\nimport { SupportsFiles } from '../../decorators/supports-files.decorator';\nimport { SupportsUsernameShortcut } from '../../decorators/supports-username-shortcut.decorator';\nimport { WebsiteMetadata } from '../../decorators/website-metadata.decorator';\nimport { DataPropertyAccessibility } from '../../models/data-property-accessibility';\nimport { FileWebsite } from '../../models/website-modifiers/file-website';\nimport { MessageWebsite } from '../../models/website-modifiers/message-website';\nimport { OAuthWebsite } from '../../models/website-modifiers/oauth-website';\nimport { WithCustomDescriptionParser } from '../../models/website-modifiers/with-custom-description-parser';\nimport { Website } from '../../website';\nimport { BlueskyFileSubmission } from './models/bluesky-file-submission';\nimport { BlueskyMessageSubmission } from './models/bluesky-message-submission';\n\n@WebsiteMetadata({ name: 'bluesky', displayName: 'BlueSky' })\n@CustomLoginFlow()\n@SupportsUsernameShortcut({\n  id: 'bluesky',\n  url: 'https://bsky.app/profile/$1',\n  convert: (websiteName, shortcut) => {\n    if (websiteName === 'bluesky' && shortcut === 'bluesky') {\n      return '@$1';\n    }\n\n    return undefined;\n  },\n})\n@SupportsFiles({\n  acceptedMimeTypes: [\n    'image/png',\n    'image/jpeg',\n    'image/gif',\n    'video/mp4',\n    'video/mov',\n    'video/webm',\n  ],\n  acceptedFileSizes: {\n    '*': 1_000_000,\n    [FileType.VIDEO]: FileSize.megabytes(50),\n  },\n  fileBatchSize: 4,\n})\n@DisableAds()\nexport default class Bluesky\n  extends Website<BlueskyAccountData>\n  implements\n    FileWebsite<BlueskyFileSubmission>,\n    MessageWebsite<BlueskyMessageSubmission>,\n    OAuthWebsite<BlueskyOAuthRoutes>,\n    WithCustomDescriptionParser\n{\n  onAuthRoute: OAuthRouteHandlers<BlueskyOAuthRoutes> = {\n    login: async (request) => {\n      await this.setWebsiteData(request);\n      const result = await this.onLogin();\n      return { result: result.isLoggedIn };\n    },\n  };\n\n  protected BASE_URL = 'https://bsky.app/';\n\n  readonly MAX_CHARS = 300;\n\n  public externallyAccessibleWebsiteDataProperties: DataPropertyAccessibility<BlueskyAccountData> =\n    { username: true, password: true };\n\n  private agent?: AtpAgent;\n\n  private getLoggedInAgent(): AtpAgent {\n    if (!this.agent.hasSession) throw new Error('Not logged in');\n    return this.agent;\n  }\n\n  public async onLogin(): Promise<ILoginState> {\n    const { username, password, serviceUrl } = this.websiteDataStore.getData();\n\n    if (!username || !password) return this.loginState.logout();\n\n    this.agent = new AtpAgent({ service: serviceUrl ?? 'https://bsky.social' });\n\n    return this.agent\n      .login({ identifier: username, password })\n      .then((res) => {\n        if (!res.success) return this.loginState.logout();\n\n        return this.loginState.setLogin(true, res.data.handle);\n      })\n      .catch((error) => {\n        this.logger.error(error);\n        return this.loginState.logout();\n      });\n  }\n\n  createFileModel(): BlueskyFileSubmission {\n    return new BlueskyFileSubmission();\n  }\n\n  calculateImageResize(file: ISubmissionFile): ImageResizeProps {\n    // https://github.com/bluesky-social/social-app/blob/main/src/lib/constants.ts\n    return calculateImageResize(file, {\n      maxWidth: 2000,\n      maxHeight: 2000,\n    });\n  }\n\n  getDescriptionConverter(): BaseConverter {\n    return new BlueskyConverter();\n  }\n\n  async onPostFileSubmission(\n    postData: PostData<BlueskyFileSubmission>,\n    files: PostingFile[],\n    cancellationToken: CancellableToken,\n  ): Promise<PostResponse> {\n    cancellationToken.throwIfCancelled();\n\n    const agent = this.getLoggedInAgent();\n    const profile = await agent.getProfile({ actor: agent.session.did });\n    const reply = await this.getReplyRef(agent, postData.options.replyToUrl);\n\n    const embed = await this.uploadEmbeds(agent, files, cancellationToken);\n    const postResult = await this.post(postData, agent, embed, reply);\n\n    return this.createPostResponse(postResult, profile, postData, agent);\n  }\n\n  async onPostMessageSubmission(\n    postData: PostData<BlueskyMessageSubmission>,\n    cancellationToken: CancellableToken,\n  ): Promise<PostResponse> {\n    cancellationToken.throwIfCancelled();\n\n    const agent = this.getLoggedInAgent();\n    const profile = await agent.getProfile({ actor: agent.session.did });\n    const reply = await this.getReplyRef(agent, postData.options.replyToUrl);\n    const postResult = await this.post(postData, agent, undefined, reply);\n\n    return this.createPostResponse(postResult, profile, postData, agent);\n  }\n\n  private async post(\n    postData: PostData<BlueskyFileSubmission>,\n    agent: AtpAgent,\n    embed:\n      | undefined\n      | $Typed<AppBskyEmbedImages.Main>\n      | $Typed<AppBskyEmbedVideo.Main>,\n    reply: ReplyRef,\n  ) {\n    let labels: $Typed<ComAtprotoLabelDefs.SelfLabels> | undefined;\n    if (postData.options.labelRating) {\n      labels = {\n        values: [{ val: postData.options.labelRating }],\n        $type: 'com.atproto.label.defs#selfLabels',\n      };\n    }\n\n    const rt = await this.getRichText(postData.options.description);\n\n    const postResult = await agent.post({\n      text: rt.text,\n      facets: rt.facets,\n      embed,\n      labels,\n\n      // Bsky throws error if we provide undefined reply unlike labels and embed\n      ...(reply ? { reply } : {}),\n    });\n    return postResult;\n  }\n\n  private createPostResponse(\n    postResult: { uri: string },\n    profile: AppBskyActorGetProfile.Response,\n    postData: PostData<BlueskyMessageSubmission | BlueskyFileSubmission>,\n    agent: AtpAgent,\n  ) {\n    if (postResult && postResult.uri) {\n      // Generate a permanent URL\n      const hostname = this.getWebsiteData().appViewUrl ?? this.BASE_URL;\n      const origin = `${hostname.includes('://') ? '' : 'https://'}${hostname}`;\n      const postId = postResult.uri.slice(postResult.uri.lastIndexOf('/') + 1);\n      const friendlyUrl = `${origin.padEnd(origin.length, '/')}profile/${profile.data.did}/post/${postId}`;\n\n      // After the post has been made, check to see if we need to set a ThreadGate; these are the options to control who can reply to your post, and need additional calls\n      if (postData.options.whoCanReply) {\n        this.createThreadgate(\n          agent,\n          postResult.uri,\n          postData.options.whoCanReply,\n        );\n      }\n\n      const response =\n        PostResponse.fromWebsite(this).withSourceUrl(friendlyUrl);\n\n      return response;\n    }\n\n    return PostResponse.fromWebsite(this)\n      .withAdditionalInfo({ postResult })\n      .withException(new Error('Unknown error occured'));\n  }\n\n  createMessageModel(): BlueskyMessageSubmission {\n    return new BlueskyMessageSubmission();\n  }\n\n  async onValidateFileSubmission(\n    postData: PostData<BlueskyFileSubmission>,\n  ): Promise<SimpleValidationResult> {\n    const validator = this.createValidator<BlueskyFileSubmission>();\n\n    this.validateRating(postData, validator);\n    await this.validateDescription(postData, validator);\n    this.validateReplyToUrl(postData, validator);\n\n    const { images, videos, other, gifs } = this.countFileTypes(\n      postData.submission.files,\n    );\n\n    // first condition also includes the case where there are gifs and videos\n    if (\n      (images !== 0 && videos !== 0) ||\n      (images > 1 && gifs !== 0) ||\n      videos > 1 ||\n      gifs > 1 ||\n      other !== 0\n    ) {\n      validator.error(\n        'validation.file.bluesky.unsupported-combination-of-files',\n        {},\n      );\n    }\n\n    if (gifs > 0) {\n      validator.warning('validation.file.bluesky.gif-conversion', {});\n    }\n\n    return validator.result;\n  }\n\n  async onValidateMessageSubmission(\n    postData: PostData<BlueskyMessageSubmission>,\n  ): Promise<SimpleValidationResult> {\n    const validator = this.createValidator<BlueskyMessageSubmission>();\n\n    await this.validateDescription(postData, validator);\n    this.validateReplyToUrl(postData, validator);\n    this.validateRating(postData, validator);\n\n    return validator.result;\n  }\n\n  private validateRating(\n    postData: PostData<BlueskyMessageSubmission | BlueskyFileSubmission>,\n    validator: SubmissionValidator<\n      BlueskyMessageSubmission | BlueskyFileSubmission\n    >,\n  ) {\n    // Since bluesky rating is not mapped as other sited do\n    // we should add warning, so users will not post unlabeled images\n    const { rating } = postData.options;\n    if (rating) {\n      // Dont really want to make warning for undefined rating\n      // This is handled by default part validator\n      if (\n        !postData.options.labelRating &&\n        rating !== SubmissionRating.GENERAL\n      ) {\n        validator.warning(\n          'validation.file.bluesky.rating-matches-default',\n          {},\n          'labelRating',\n        );\n      }\n    }\n  }\n\n  private async validateDescription(\n    postData: PostData<BlueskyMessageSubmission | BlueskyFileSubmission>,\n    validator: SubmissionValidator<\n      BlueskyMessageSubmission | BlueskyFileSubmission\n    >,\n  ): Promise<void> {\n    const { description } = postData.options;\n\n    const rt = await this.getRichText(description);\n\n    if (rt.graphemeLength > this.MAX_CHARS) {\n      validator.error(\n        'validation.description.max-length',\n        { maxLength: this.MAX_CHARS, currentLength: rt.graphemeLength },\n        'description',\n      );\n    }\n  }\n\n  private validateReplyToUrl(\n    postData: PostData<BlueskyMessageSubmission | BlueskyFileSubmission>,\n    validator: SubmissionValidator<\n      BlueskyMessageSubmission | BlueskyFileSubmission\n    >,\n  ): void {\n    const url = postData.options.replyToUrl;\n    if (url?.trim() && !this.getPostIdFromUrl(url)) {\n      validator.error(\n        'validation.file.bluesky.invalid-reply-url',\n        {},\n        'replyToUrl',\n      );\n    }\n  }\n\n  private async getReplyRef(\n    agent: AtpAgent,\n    url?: string,\n  ): Promise<ReplyRef | null> {\n    if (!url?.trim()) return null;\n\n    const postId = this.getPostIdFromUrl(url);\n    if (!postId) throw new Error(`Invalid reply to url '${url}'`);\n\n    // cf. https://atproto.com/blog/create-post#replies\n    const parent = await agent.getPost(postId);\n    const { reply } = parent.value;\n    const root = reply ? reply.root : parent;\n    return {\n      root: { uri: root.uri, cid: root.cid },\n      parent: { uri: parent.uri, cid: parent.cid },\n    };\n  }\n\n  private getPostIdFromUrl(url: string): { repo: string; rkey: string } | null {\n    // A regular web link like https://bsky.app/profile/{repo}/post/{id}\n    const link = /\\/profile\\/([^/]+)\\/post\\/([a-zA-Z0-9.\\-_~]+)/.exec(url);\n    if (link) return { repo: link[1], rkey: link[2] };\n\n    // Protocol link like at://did:plc:{repo}/app.bsky.feed.post/{id}\n    const at =\n      /(did:plc:[a-zA-Z0-9.\\-_~]+)\\/.+\\.post\\/([a-zA-Z0-9.\\-_~]+)/.exec(url);\n    if (at) return { repo: at[1], rkey: at[2] };\n\n    return null;\n  }\n\n  private countFileTypes(files: (PostingFile | ISubmissionFile)[]): {\n    images: number;\n    videos: number;\n    other: number;\n    gifs: number;\n  } {\n    const counts = { images: 0, videos: 0, other: 0, gifs: 0 };\n    for (const file of files) {\n      const fileType = getFileTypeFromMimeType(file.mimeType);\n\n      if (fileType === FileType.VIDEO) {\n        ++counts.videos;\n      } else if (\n        file.fileName.endsWith('.gif') ||\n        file.mimeType.startsWith('image/gif')\n      ) {\n        ++counts.gifs;\n      } else if (fileType === FileType.IMAGE) {\n        ++counts.images;\n      } else {\n        ++counts.other;\n      }\n    }\n    return counts;\n  }\n\n  private createThreadgate(\n    agent: AtpAgent,\n    postUri: string,\n    whoCanReply: NonNullable<BlueskyFileSubmission['whoCanReply']>,\n  ) {\n    const allow: (\n      | $Typed<AppBskyFeedThreadgate.MentionRule>\n      | $Typed<AppBskyFeedThreadgate.FollowingRule>\n      | $Typed<AppBskyFeedThreadgate.ListRule>\n    )[] = [];\n\n    switch (whoCanReply) {\n      case 'mention':\n        allow.push({ $type: 'app.bsky.feed.threadgate#mentionRule' });\n        break;\n      case 'following':\n        allow.push({ $type: 'app.bsky.feed.threadgate#followingRule' });\n        break;\n      case 'mention,following':\n        allow.push({ $type: 'app.bsky.feed.threadgate#followingRule' });\n        allow.push({ $type: 'app.bsky.feed.threadgate#mentionRule' });\n        break;\n      default: // Leave the array empty and this sets no one - nobody mode\n        break;\n    }\n\n    agent.app.bsky.feed.threadgate.create(\n      { repo: agent.session.did, rkey: new AtUri(postUri).rkey },\n      { post: postUri, createdAt: new Date().toISOString(), allow },\n    );\n  }\n\n  private async uploadEmbeds(\n    agent: AtpAgent,\n    files: PostingFile[],\n    cancellationToken: CancellableToken,\n  ): Promise<$Typed<AppBskyEmbedImages.Main> | $Typed<AppBskyEmbedVideo.Main>> {\n    // Bluesky supports either images or a video as an embed\n    // GIFs must be treated as video on bsky\n    const fileCount = this.countFileTypes(files);\n\n    if (fileCount.videos === 0 && fileCount.gifs === 0) {\n      const uploadedImages: AppBskyEmbedImages.Image[] = [];\n      for (const file of files) {\n        cancellationToken.throwIfCancelled();\n\n        const altText = file.metadata.altText || '';\n        const ref = await this.uploadImage(agent, file);\n\n        uploadedImages.push({\n          image: ref,\n          alt: altText,\n          aspectRatio: { height: file.height, width: file.width },\n        });\n      }\n\n      return { images: uploadedImages, $type: 'app.bsky.embed.images' };\n    }\n\n    for (const file of files) {\n      cancellationToken.throwIfCancelled();\n\n      if (\n        file.fileType === FileType.VIDEO ||\n        file.fileType === FileType.IMAGE\n      ) {\n        // Only IMAGE file type left is a GIF\n        const altText = file.metadata.altText || '';\n        this.checkVideoUploadLimits(agent);\n        const ref = await this.uploadVideo(agent, file);\n        return { video: ref, alt: altText, $type: 'app.bsky.embed.video' };\n      }\n    }\n\n    throw new Error('No files to upload found');\n  }\n\n  private async uploadImage(\n    agent: AtpAgent,\n    file: PostingFile,\n  ): Promise<BlobRef | undefined> {\n    const blobUpload = await agent.uploadBlob(file.buffer, {\n      encoding: file.mimeType,\n    });\n\n    if (blobUpload.success) {\n      // response has blob.ref\n      return blobUpload.data.blob;\n    }\n\n    throw new Error('Failed to upload image');\n  }\n\n  // EDIT: https://docs.bsky.app/docs/tutorials/video#recommended-method\n  // Its recommeneded by bsky to go this way\n  // The way it works is simple, you get token from pds and then pass it to 3th party service\n  // That compresses video and uploads it to said pds for you while you are quering status\n\n  // There's video methods in the API, but they are utterly non-functional in\n  // many ways: wrong lexicon entries and overeager validation thereof that\n  // prevents passing required parameters, picking the wrong host to upload to\n  // (must be video.bsky.app, NOT some bsky.network host that'll just 404 at the\n  // path) and not doing the proper service authentication dance. So we instead\n  // follow what the website does here, which is the way that actually works.\n  // We also use the same inconsistent header capitalization as they do.\n  private async checkVideoUploadLimits(agent: AtpAgent): Promise<void> {\n    const token = await this.getAuthToken(\n      agent,\n      'did:web:video.bsky.app',\n      'app.bsky.video.getUploadLimits',\n    );\n\n    const url = 'https://video.bsky.app/xrpc/app.bsky.video.getUploadLimits';\n    const req: RequestInit = {\n      method: 'GET',\n      headers: {\n        Accept: '*/*',\n        authorization: `Bearer ${token}`,\n        'atproto-accept-labelers': 'did:plc:ar7c4by46qjdydhdevvrndac;redact',\n      },\n    };\n    const uploadLimits =\n      await this.checkFetchResult<AppBskyVideoGetUploadLimits.OutputSchema>(\n        fetch(url, req),\n      ).catch((err) => {\n        this.logger.error(err);\n        throw new Error('Getting video upload limits failed', { cause: err });\n      });\n\n    this.logger.debug(`Upload limits: ${JSON.stringify(uploadLimits)}`);\n    if (!uploadLimits.canUpload) {\n      throw new Error(`Not allowed to upload: ${uploadLimits.message}`);\n    }\n  }\n\n  private async uploadVideo(\n    agent: AtpAgent,\n    file: PostingFile,\n  ): Promise<BlobRef> {\n    const token = await this.getAuthToken(\n      agent,\n      `did:web:${agent.pdsUrl.hostname}`,\n      'com.atproto.repo.uploadBlob',\n    );\n    const did = encodeURIComponent(agent.did);\n    const name = encodeURIComponent(this.generateVideoName());\n    const url = `https://video.bsky.app/xrpc/app.bsky.video.uploadVideo?did=${did}&name=${name}`;\n    const req: RequestInit = {\n      method: 'POST',\n      body: file.buffer as RequestInit['body'],\n      headers: {\n        Authorization: `Bearer ${token}`,\n        'Content-Type': file.mimeType,\n      },\n    };\n    // Uploading an already-processed video returns 409 conflict, but a valid\n    // response that contains a job id at top level.\n    const videoUpload = await this.checkFetchResult<\n      JobStatus | AppBskyVideoUploadVideo.OutputSchema\n    >(fetch(url, req), true).catch((err) => {\n      this.logger.error(err);\n      throw new Error('Checking video processing status failed', {\n        cause: err,\n      });\n    });\n    this.logger.debug(`Video upload: ${JSON.stringify(videoUpload)}`);\n    return this.waitForVideoProcessing(\n      'jobStatus' in videoUpload\n        ? videoUpload?.jobStatus?.jobId\n        : videoUpload?.jobId,\n    );\n  }\n\n  private generateVideoName(): string {\n    const characters =\n      'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';\n    let name = '';\n    for (let i = 0; i < 12; ++i) {\n      name += characters[Math.floor(Math.random() * characters.length)];\n    }\n    return `${name}.mp4`;\n  }\n\n  private async waitForVideoProcessing(jobId: string): Promise<BlobRef> {\n    const encodedJobId = encodeURIComponent(jobId);\n    const url = `https://video.bsky.app/xrpc/app.bsky.video.getJobStatus?jobId=${encodedJobId}`;\n    let jobStatus: JobStatus;\n    do {\n      await new Promise((r) => {\n        setTimeout(r, 4000);\n      });\n      this.logger.debug(`Polling video processing status at ${url}`);\n      const req: RequestInit = {\n        method: 'GET',\n        headers: {\n          'atproto-accept-labelers': 'did:plc:ar7c4by46qjdydhdevvrndac;redact',\n        },\n      };\n      const res =\n        await this.checkFetchResult<AppBskyVideoGetJobStatus.OutputSchema>(\n          fetch(url, req),\n        ).catch((err) => {\n          this.logger.error(err);\n          throw new Error('Checking video processing status failed', {\n            cause: err,\n          });\n        });\n\n      this.logger.debug(`Job status: ${JSON.stringify(res)}`);\n      jobStatus = res.jobStatus;\n    } while (\n      jobStatus.state !== 'JOB_STATE_COMPLETED' &&\n      jobStatus.state !== 'JOB_STATE_FAILED'\n    );\n\n    if (jobStatus.state === 'JOB_STATE_COMPLETED') {\n      if (jobStatus.blob) return jobStatus.blob;\n\n      throw new Error('No blob ref after video processing');\n    }\n\n    throw new Error(`Video processing failed: ${jobStatus.message}`);\n  }\n\n  private async checkFetchResult<T>(\n    promise: Promise<Response>,\n    allowConflict = false,\n  ) {\n    const res = await promise;\n    if (\n      (res.status >= 200 && res.status < 300) ||\n      (allowConflict && res.status === 409)\n    ) {\n      try {\n        const body = await res.json();\n        return body as T;\n      } catch (err) {\n        throw new Error(`Failed to get JSON body: ${err}`);\n      }\n    } else {\n      const body = await this.tryGetErrorBody(res);\n      throw new Error(\n        `Failed with status ${res.status} ${res.statusText}: ${body})}`,\n      );\n    }\n  }\n\n  private async getAuthToken(\n    agent: AtpAgent,\n    aud: string,\n    lxm: string,\n  ): Promise<string> {\n    this.logger.debug(`Get auth token for ${aud}::${lxm}`);\n    const auth = await agent.com.atproto.server\n      .getServiceAuth({\n        aud,\n        lxm,\n        exp: Date.now() / 1000 + 60 * 5, // 5 minutes\n      })\n      .catch((err) => {\n        this.logger.error(err);\n        throw new Error(`Auth for ${aud}::${lxm} failed`, { cause: err });\n      });\n\n    if (!auth.success) {\n      throw new Error(`Auth for ${aud}::${lxm} not successful`);\n    }\n\n    return auth.data.token;\n  }\n\n  private async tryGetErrorBody(res: Response): Promise<string> {\n    try {\n      const body = await res.text();\n      return body;\n    } catch (e) {\n      return `(error getting body: ${e})`;\n    }\n  }\n\n  async getRichText(description: string) {\n    const { text, links } = JSON.parse(description) as DescriptionWithLinks;\n    const rt = new RichText({ text });\n    await rt.detectFacets(\n      this.agent ?? new AtpAgent({ service: 'https://bsky.social' }),\n    );\n\n    // Convert UTF-16 indices to UTF-8 byte offsets\n    if (links && links.length > 0) {\n      const encoder = new TextEncoder();\n\n      // Encode the entire text once for efficiency\n      const textBytes = encoder.encode(text);\n\n      const byteLinks = links.map((link) => {\n        const startSlice = text.slice(0, link.start);\n        const startByte = encoder.encode(startSlice).byteLength;\n\n        const endSlice = text.slice(0, link.end);\n        const endByte = encoder.encode(endSlice).byteLength;\n\n        return {\n          start: startByte,\n          end: endByte,\n          href: link.href,\n        };\n      });\n\n      rt.facets ??= [];\n\n      for (const byteLink of byteLinks) {\n        // Skip if the byte range is invalid\n        if (\n          byteLink.start >= byteLink.end ||\n          byteLink.end > textBytes.byteLength\n        ) {\n          continue;\n        }\n\n        // Check for overlaps with existing facets\n        const hasOverlap = rt.facets.some(\n          (facet) =>\n            byteLink.start < facet.index.byteEnd &&\n            byteLink.end > facet.index.byteStart,\n        );\n\n        if (!hasOverlap) {\n          rt.facets.push({\n            index: {\n              byteStart: byteLink.start,\n              byteEnd: byteLink.end,\n            },\n            features: [\n              {\n                $type: 'app.bsky.richtext.facet#link',\n                uri: byteLink.href,\n              },\n            ],\n          });\n        }\n      }\n\n      // Sort facets\n      if (rt.facets.length > 0) {\n        rt.facets.sort((a, b) => a.index.byteStart - b.index.byteStart);\n      } else {\n        rt.facets = undefined;\n      }\n    }\n\n    return rt;\n  }\n}\n\nclass BlueskyConverter extends PlainTextConverter {\n  private links: { href: string; content: string; id: string }[] = [];\n\n  /**\n   * Override text node conversion to intercept link marks.\n   * In TipTap, links are marks on text nodes, not separate inline nodes.\n   */\n  convertTextNode(node: TipTapNode, context: ConversionContext): string {\n    const marks = node.marks ?? [];\n    const linkMark = marks.find((m) => m.type === 'link');\n\n    if (linkMark) {\n      // Get the plain text (without the link URL appended)\n      const content = node.text ?? '';\n      const id = v4();\n      this.links.push({\n        href: linkMark.attrs?.href ?? '',\n        content,\n        id,\n      });\n      return id;\n    }\n\n    return super.convertTextNode(node, context);\n  }\n\n  convertBlocks(nodes: TipTapNode[], context: ConversionContext): string {\n    // When plaintext encouters the default description it uses convertRawBlocks which calls convertBlock\n    // which returns json that ends up in user posts\n    if (nodes === context.defaultDescription)\n      return super.convertBlocks(nodes, context);\n\n    this.links = [];\n    let text = super.convertBlocks(nodes, context);\n\n    const newLinks: RichTextLinkPosition[] = [];\n\n    for (const link of this.links) {\n      const start = text.indexOf(link.id);\n      const end = start + link.content.length;\n      text = text.replace(link.id, link.content);\n      newLinks.push({ start, end, href: link.href });\n    }\n\n    return JSON.stringify({\n      text,\n      links: newLinks,\n    } satisfies DescriptionWithLinks);\n  }\n}\n\ninterface RichTextLinkPosition {\n  start: number;\n  end: number;\n  href: string;\n}\n\ninterface DescriptionWithLinks {\n  text: string;\n  links: RichTextLinkPosition[];\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/bluesky/models/bluesky-file-submission.ts",
    "content": "import {\n  DescriptionField,\n  SelectField,\n  TagField,\n  TextField,\n} from '@postybirb/form-builder';\nimport {\n  DefaultTagValue,\n  DescriptionType,\n  DescriptionValue,\n  TagValue,\n} from '@postybirb/types';\nimport { BaseWebsiteOptions } from '../../../models/base-website-options';\n\nexport class BlueskyFileSubmission extends BaseWebsiteOptions {\n  @DescriptionField({\n    descriptionType: DescriptionType.CUSTOM,\n    maxDescriptionLength: Infinity, // Custom length calculation is handled by validation logic\n    expectsInlineTags: true,\n    expectsInlineTitle: true,\n  })\n  description: DescriptionValue;\n\n  @TagField({\n    spaceReplacer: '_',\n  })\n  tags: TagValue = DefaultTagValue();\n\n  override processTag(tag: string) {\n    return `${tag.replaceAll(/[^a-z0-9]/gi, '_')}`;\n  }\n\n  // Note: in v3 it was label_rating\n  @SelectField({\n    label: 'rating',\n    options: [\n      { value: '', label: 'Suitable for all ages' },\n      { value: 'sexual', label: 'Adult: Suggestive' },\n      { value: 'nudity', label: 'Adult: Nudity' },\n      { value: 'porn', label: 'Adult: Porn' },\n    ],\n  })\n  labelRating: '' | 'sexual' | 'nudity' | 'porn';\n\n  @TextField({ label: 'replyToUrl', section: 'website', span: 12 })\n  replyToUrl?: string;\n\n  // Note: in v3 it was threadgate\n  @SelectField({\n    label: 'whoCanReply',\n    section: 'website',\n    span: 12,\n    options: [\n      { value: '', label: 'Everybody' },\n      { value: 'nobody', label: 'Nobody' },\n      { value: 'mention', label: 'Mentioned Users' },\n      { value: 'following', label: 'Followed Users' },\n      { value: 'mention,following', label: 'Mentioned & Followed Users' },\n    ],\n  })\n  whoCanReply?: '' | 'nobody' | 'mention' | 'following' | 'mention,following';\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/bluesky/models/bluesky-message-submission.ts",
    "content": "import { BlueskyFileSubmission } from './bluesky-file-submission';\n\nexport class BlueskyMessageSubmission extends BlueskyFileSubmission {}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/cara/cara.website.ts",
    "content": "import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';\nimport { Http } from '@postybirb/http';\nimport {\n  FileType,\n  ILoginState,\n  ImageResizeProps,\n  ISubmissionFile,\n  PostData,\n  PostResponse,\n  SimpleValidationResult,\n  SubmissionRating,\n} from '@postybirb/types';\nimport parse from 'node-html-parser';\nimport { v4 as uuid } from 'uuid';\nimport { CancellableToken } from '../../../post/models/cancellable-token';\nimport { PostingFile } from '../../../post/models/posting-file';\nimport { PostBuilder } from '../../commons/post-builder';\nimport { DisableAds } from '../../decorators/disable-ads.decorator';\nimport { UserLoginFlow } from '../../decorators/login-flow.decorator';\nimport { SupportsFiles } from '../../decorators/supports-files.decorator';\nimport { SupportsUsernameShortcut } from '../../decorators/supports-username-shortcut.decorator';\nimport { WebsiteMetadata } from '../../decorators/website-metadata.decorator';\nimport { DataPropertyAccessibility } from '../../models/data-property-accessibility';\nimport { FileWebsite } from '../../models/website-modifiers/file-website';\nimport { MessageWebsite } from '../../models/website-modifiers/message-website';\nimport { Website } from '../../website';\nimport { CaraAccountData } from './models/cara-account-data';\nimport { CaraFileSubmission } from './models/cara-file-submission';\nimport { CaraMessageSubmission } from './models/cara-message-submission';\n\ntype CaraPostResult = {\n  data: {\n    id: string;\n    authorId: string;\n    name: string;\n    slug: string;\n    photo: string;\n    createdAt: string;\n    content: string;\n    images: string[];\n    likeCounter: number;\n    bookmarked: boolean;\n    commentCounter: number;\n    liked: boolean;\n    status: string;\n    addToPortfolio: boolean;\n    mentions: unknown[];\n    coverImage: string;\n    title: string;\n    isRepost: boolean;\n    quotedPostId: string | null;\n    quotedPost: unknown | null;\n    repostCounter: number;\n    reposted: boolean;\n    embeddedLink: boolean;\n    author_status: string;\n    publicCoffeeBadge: boolean;\n    pinnedCommentId: string | null;\n  };\n};\n\ntype S3UploadCredentials = {\n  token: {\n    Credentials: {\n      AccessKeyId: string;\n      SecretAccessKey: string;\n      SessionToken: string;\n    };\n  };\n  key: string;\n  bucket: string;\n  region: string;\n};\n\ntype S3UploadRequest = {\n  filename: string;\n  filetype: string;\n  _nextS3: { strategy: string };\n  uploadType: string;\n  postId: string;\n  name: string;\n};\n\ntype CaraMediaItem = {\n  id: string;\n  src: string;\n  isCoverImg: boolean;\n  order: number;\n  mediaSrc: null;\n  isEmbed: boolean;\n  embedType: null;\n  isAiAutoFlagged: null;\n  tags: Array<{\n    value: string;\n    type: string;\n  }>;\n};\n\ntype CaraUploadResult = CaraMediaItem[];\n\n@WebsiteMetadata({\n  name: 'cara',\n  displayName: 'Cara',\n})\n@UserLoginFlow('https://cara.app/login')\n@SupportsFiles({\n  fileBatchSize: 4,\n  acceptedMimeTypes: [\n    'image/png',\n    'image/jpeg',\n    'image/jpg',\n    'image/bmp',\n    'image/jpeg',\n    'image/apng',\n    'image/webp',\n    'image/svg+xml',\n    'image/heic',\n    'image/gif',\n    'image/heif',\n    'image/x-icon',\n    'image/vnd.microsoft.icon',\n    'image/x-xbitmap',\n    'image/bmp',\n    'image/tiff',\n    'image/jpeg',\n    'image/avif',\n  ],\n})\n@SupportsUsernameShortcut({\n  id: 'cara',\n  url: 'https://cara.app/$1',\n  convert: (websiteName, shortcut) => {\n    if (websiteName === 'cara' && shortcut === 'cara') {\n      return '@$1';\n    }\n\n    return undefined;\n  },\n})\n@DisableAds()\nexport default class Cara\n  extends Website<CaraAccountData>\n  implements\n    FileWebsite<CaraFileSubmission>,\n    MessageWebsite<CaraMessageSubmission>\n{\n  protected BASE_URL = 'https://cara.app';\n\n  public externallyAccessibleWebsiteDataProperties: DataPropertyAccessibility<CaraAccountData> =\n    {};\n\n  public async onLogin(): Promise<ILoginState> {\n    const { body, responseUrl } = await Http.get<string>(\n      `${this.BASE_URL}/settings`,\n      {\n        partition: this.accountId,\n      },\n    );\n\n    if (!responseUrl?.includes('/login')) {\n      const $ = parse(body);\n      const username =\n        $.querySelector('input[name=\"slug\"]')?.getAttribute('value') ??\n        'Unknown';\n      return this.loginState.setLogin(true, username);\n    }\n\n    return this.loginState.logout();\n  }\n\n  createFileModel(): CaraFileSubmission {\n    return new CaraFileSubmission();\n  }\n\n  calculateImageResize(file: ISubmissionFile): ImageResizeProps {\n    return undefined;\n  }\n\n  /**\n   * Request S3 upload credentials from Cara API\n   */\n  private async getS3UploadCredentials(\n    uploadRequest: S3UploadRequest,\n  ): Promise<S3UploadCredentials> {\n    const response = await Http.post<S3UploadCredentials>(\n      `${this.BASE_URL}/api/s3-upload`,\n      {\n        partition: this.accountId,\n        type: 'json',\n        data: uploadRequest,\n      },\n    );\n\n    if (response.statusCode !== 200 || !response.body) {\n      throw new Error(\n        `Failed to get S3 upload credentials for ${uploadRequest.filename}`,\n      );\n    }\n\n    return response.body;\n  }\n\n  /**\n   * Upload a file to S3 using provided credentials\n   */\n  private async uploadToS3(\n    credentials: S3UploadCredentials,\n    buffer: Buffer,\n    mimeType: string,\n  ): Promise<void> {\n    const { AccessKeyId, SecretAccessKey, SessionToken } =\n      credentials.token.Credentials;\n\n    const client = new S3Client({\n      region: credentials.region,\n      credentials: {\n        accessKeyId: AccessKeyId,\n        secretAccessKey: SecretAccessKey,\n        sessionToken: SessionToken,\n      },\n    });\n\n    try {\n      await client.send(\n        new PutObjectCommand({\n          Bucket: credentials.bucket,\n          Key: credentials.key,\n          Body: buffer,\n          ContentType: mimeType || 'application/octet-stream',\n        }),\n      );\n    } catch (error) {\n      throw new Error(`Failed to upload file to S3: ${String(error)}`);\n    }\n  }\n\n  /**\n   * Upload an image file to Cara (both primary image and thumbnail)\n   */\n  private async uploadImage(\n    file: PostingFile,\n    postId: string,\n    username: string,\n    uploadCover: boolean,\n    order: number,\n    cancellationToken: CancellableToken,\n  ): Promise<CaraUploadResult> {\n    cancellationToken.throwIfCancelled();\n\n    // Upload primary image\n    const primaryImageRequest: S3UploadRequest = {\n      filename: file.fileName,\n      filetype: file.mimeType,\n      _nextS3: { strategy: 'aws-sdk' },\n      uploadType: 'POST_CONTENT',\n      postId,\n      name: username,\n    };\n\n    const primaryCredentials =\n      await this.getS3UploadCredentials(primaryImageRequest);\n\n    cancellationToken.throwIfCancelled();\n\n    await this.uploadToS3(primaryCredentials, file.buffer, file.mimeType);\n\n    cancellationToken.throwIfCancelled();\n\n    // Create primary media item\n    const primaryMedia: CaraMediaItem = {\n      id: uuid(),\n      src: primaryCredentials.key,\n      isCoverImg: false,\n      order,\n      mediaSrc: null,\n      isEmbed: false,\n      embedType: null,\n      isAiAutoFlagged: null,\n      tags: [],\n    };\n\n    let coverMedia: CaraMediaItem | undefined;\n    if (uploadCover) {\n      // Upload cover image (thumbnail)\n      const coverImageRequest: S3UploadRequest = {\n        filename: 'post-cover-image.jpeg',\n        filetype: '',\n        _nextS3: { strategy: 'aws-sdk' },\n        uploadType: 'POST_CONTENT',\n        postId,\n        name: username,\n      };\n\n      const coverCredentials =\n        await this.getS3UploadCredentials(coverImageRequest);\n\n      cancellationToken.throwIfCancelled();\n\n      const thumbnailBuffer = file.thumbnail?.buffer || file.buffer;\n      const thumbnailMimeType = file.thumbnail?.mimeType || file.mimeType;\n\n      await this.uploadToS3(\n        coverCredentials,\n        thumbnailBuffer,\n        thumbnailMimeType,\n      );\n\n      // Create cover media item\n      coverMedia = {\n        id: 'cover',\n        src: coverCredentials.key,\n        isCoverImg: true,\n        order: -1,\n        mediaSrc: null,\n        isEmbed: false,\n        embedType: null,\n        isAiAutoFlagged: null,\n        tags: [],\n      };\n    }\n\n    if (uploadCover && coverMedia) {\n      return [primaryMedia, coverMedia];\n    }\n    return [primaryMedia];\n  }\n\n  async onPostFileSubmission(\n    postData: PostData<CaraFileSubmission>,\n    files: PostingFile[],\n    cancellationToken: CancellableToken,\n  ): Promise<PostResponse> {\n    const hasImage = files.some((f) => f.fileType === FileType.IMAGE);\n\n    // Generate a post ID for the upload\n    const postId = uuid();\n    const username = this.loginState.username || 'unknown';\n\n    const builder = new PostBuilder(this, cancellationToken)\n      .asJson()\n      .setField('addToPortfolio', postData.options.addToPortfolio)\n      .setField('content', postData.options.description)\n      .setField('title', '')\n      .setField('hasEmbed', false)\n      .setField('hasImage', hasImage)\n      .setField('mentions', [])\n      .setField('quotedId', null)\n      .setField('tags', []);\n\n    const post = await builder.send<CaraPostResult>(\n      `${this.BASE_URL}/api/posts`,\n    );\n\n    const imageUploads: CaraUploadResult = [];\n\n    for (let i = 0; i < files.length; i++) {\n      const file = files[i];\n      const uploadResult = await this.uploadImage(\n        file,\n        postId,\n        username,\n        i === 0, // Only upload cover for the first image\n        i, // Order index\n        cancellationToken,\n      );\n      imageUploads.push(...uploadResult);\n    }\n\n    const mediaBuilder = new PostBuilder(this, cancellationToken)\n      .asJson()\n      .withData({\n        addToPortfolio: postData.options.addToPortfolio,\n        postMedia: imageUploads,\n      });\n\n    const media = await mediaBuilder.send<CaraPostResult>(\n      `${this.BASE_URL}/api/posts/${post.body.data.id}/media`,\n    );\n\n    if (post.body?.data?.id) {\n      const publishBuilder = new PostBuilder(this, cancellationToken).asJson();\n\n      const publish = await publishBuilder.send<object>(\n        `${this.BASE_URL}/api/posts/${post.body.data.id}/publish`,\n      );\n\n      return PostResponse.fromWebsite(this)\n        .withAdditionalInfo(publish.body)\n        .withSourceUrl(`${this.BASE_URL}/post/${post.body.data.id}`);\n    }\n\n    return PostResponse.fromWebsite(this)\n      .withAdditionalInfo({\n        body: post.body,\n        statusCode: post.statusCode,\n      })\n      .withException(new Error('Failed to post'));\n  }\n\n  async onValidateFileSubmission(\n    postData: PostData<CaraFileSubmission>,\n  ): Promise<SimpleValidationResult> {\n    const validator = this.createValidator<CaraFileSubmission>();\n    const { rating } = postData.options;\n    if (rating !== SubmissionRating.GENERAL) {\n      validator.error(\n        'validation.rating.unsupported-rating',\n        { rating },\n        'rating',\n      );\n    }\n    return validator.result;\n  }\n\n  createMessageModel(): CaraMessageSubmission {\n    return new CaraMessageSubmission();\n  }\n\n  async onPostMessageSubmission(\n    postData: PostData<CaraMessageSubmission>,\n    cancellationToken: CancellableToken,\n  ): Promise<PostResponse> {\n    const builder = new PostBuilder(this, cancellationToken)\n      .asJson()\n      .setField('addToPortfolio', false)\n      .setField('content', postData.options.description)\n      .setField('title', '')\n      .setField('hasEmbed', false)\n      .setField('hasImage', false)\n      .setField('mentions', [])\n      .setField('quotedId', null)\n      .setField('tags', []);\n\n    const post = await builder.send<CaraPostResult>(\n      `${this.BASE_URL}/api/posts`,\n    );\n\n    if (post.body?.data?.id) {\n      const publishBuilder = new PostBuilder(this, cancellationToken).asJson();\n\n      const publish = await publishBuilder.send<object>(\n        `${this.BASE_URL}/api/posts/${post.body.data.id}/publish`,\n      );\n\n      return PostResponse.fromWebsite(this)\n        .withAdditionalInfo(publish.body)\n        .withSourceUrl(`${this.BASE_URL}/post/${post.body.data.id}`);\n    }\n\n    return PostResponse.fromWebsite(this)\n      .withAdditionalInfo({\n        body: post.body,\n        statusCode: post.statusCode,\n      })\n      .withException(new Error('Failed to post'));\n  }\n\n  async onValidateMessageSubmission(\n    postData: PostData<CaraMessageSubmission>,\n  ): Promise<SimpleValidationResult> {\n    const validator = this.createValidator<CaraFileSubmission>();\n    const { rating } = postData.options;\n    if (rating !== SubmissionRating.GENERAL) {\n      validator.error(\n        'validation.rating.unsupported-rating',\n        { rating },\n        'rating',\n      );\n    }\n    return validator.result;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/cara/models/cara-account-data.ts",
    "content": "// eslint-disable-next-line @typescript-eslint/ban-types\nexport type CaraAccountData = {};\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/cara/models/cara-file-submission.ts",
    "content": "import {\n  BooleanField,\n  DescriptionField,\n  TagField,\n} from '@postybirb/form-builder';\nimport {\n  DefaultTagValue,\n  DescriptionType,\n  DescriptionValue,\n  TagValue,\n} from '@postybirb/types';\nimport { BaseWebsiteOptions } from '../../../models/base-website-options';\n\nexport class CaraFileSubmission extends BaseWebsiteOptions {\n  @DescriptionField({\n    descriptionType: DescriptionType.PLAINTEXT,\n    maxDescriptionLength: 5000,\n    expectsInlineTags: true,\n    expectsInlineTitle: true,\n  })\n  description: DescriptionValue;\n\n  @BooleanField({\n    label: 'addToPortfolio',\n  })\n  addToPortfolio = false;\n\n  @TagField({\n    section: 'common',\n    order: 3,\n    span: 12,\n    spaceReplacer: ' ',\n  })\n  tags: TagValue = DefaultTagValue();\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/cara/models/cara-message-submission.ts",
    "content": "import { DescriptionField, TagField } from '@postybirb/form-builder';\nimport {\n  DefaultTagValue,\n  DescriptionType,\n  DescriptionValue,\n  TagValue,\n} from '@postybirb/types';\nimport { BaseWebsiteOptions } from '../../../models/base-website-options';\n\nexport class CaraMessageSubmission extends BaseWebsiteOptions {\n  @DescriptionField({\n    descriptionType: DescriptionType.PLAINTEXT,\n    maxDescriptionLength: 5000,\n    expectsInlineTags: true,\n    expectsInlineTitle: true,\n  })\n  description: DescriptionValue;\n\n  @TagField({\n    section: 'common',\n    order: 3,\n    span: 12,\n    spaceReplacer: ' ',\n  })\n  tags: TagValue = DefaultTagValue();\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/custom/custom.website.ts",
    "content": "import {\n  CustomAccountData,\n  DescriptionType,\n  ILoginState,\n  ImageResizeProps,\n  IPostResponse,\n  ISubmissionFile,\n  PostData,\n  PostResponse,\n} from '@postybirb/types';\nimport { CancellableToken } from '../../../post/models/cancellable-token';\nimport { PostingFile } from '../../../post/models/posting-file';\nimport { PostBuilder } from '../../commons/post-builder';\nimport { validatorPassthru } from '../../commons/validator-passthru';\nimport { CustomLoginFlow } from '../../decorators/login-flow.decorator';\nimport { SupportsFiles } from '../../decorators/supports-files.decorator';\nimport { WebsiteMetadata } from '../../decorators/website-metadata.decorator';\nimport { DataPropertyAccessibility } from '../../models/data-property-accessibility';\nimport {\n  FileWebsite,\n  PostBatchData,\n} from '../../models/website-modifiers/file-website';\nimport { MessageWebsite } from '../../models/website-modifiers/message-website';\nimport { WithRuntimeDescriptionParser } from '../../models/website-modifiers/with-runtime-description-parser';\nimport { Website } from '../../website';\nimport { CustomFileSubmission } from './models/custom-file-submission';\nimport { CustomMessageSubmission } from './models/custom-message-submission';\n\n@WebsiteMetadata({\n  name: 'custom',\n  displayName: 'Custom',\n})\n@CustomLoginFlow()\n@SupportsFiles([])\nexport default class Custom\n  extends Website<CustomAccountData>\n  implements\n    FileWebsite<CustomFileSubmission>,\n    MessageWebsite<CustomMessageSubmission>,\n    WithRuntimeDescriptionParser\n{\n  protected BASE_URL = '';\n\n  public externallyAccessibleWebsiteDataProperties: DataPropertyAccessibility<CustomAccountData> =\n    {\n      descriptionField: true,\n      descriptionType: true,\n      fileField: true,\n      fileUrl: true,\n      headers: true,\n      notificationUrl: true,\n      ratingField: true,\n      tagField: true,\n      thumbnailField: true,\n      titleField: true,\n      altTextField: true,\n      fileBatchLimit: true,\n    };\n\n  public async onLogin(): Promise<ILoginState> {\n    const data = this.websiteDataStore.getData();\n    // HACK: Ensure any initial data is processed\n    this.onWebsiteDataChange(data);\n\n    // Check if we have either a file URL or notification URL configured\n    if (data?.fileUrl || data?.notificationUrl) {\n      const displayName = data.fileUrl || data.notificationUrl;\n      return this.loginState.setLogin(true, displayName);\n    }\n\n    return this.loginState.setLogin(false, null);\n  }\n\n  async onWebsiteDataChange(newData: CustomAccountData): Promise<void> {\n    this.logger.info('Website data updated');\n    this.decoratedProps.fileOptions.fileBatchSize = Math.max(\n      newData.fileBatchLimit || 1,\n      1,\n    );\n  }\n\n  createFileModel(): CustomFileSubmission {\n    return new CustomFileSubmission();\n  }\n\n  calculateImageResize(file: ISubmissionFile): ImageResizeProps {\n    return undefined;\n  }\n\n  async onPostFileSubmission(\n    postData: PostData<CustomFileSubmission>,\n    files: PostingFile[],\n    cancellationToken: CancellableToken,\n    batch: PostBatchData,\n  ): Promise<IPostResponse> {\n    try {\n      cancellationToken.throwIfCancelled();\n\n      const data = this.websiteDataStore.getData();\n\n      if (!data?.fileUrl) {\n        throw new Error('Custom website was not provided a File Posting URL.');\n      }\n\n      const { options } = postData;\n\n      // Prepare form data using the custom field mappings\n      const builder = new PostBuilder(this, cancellationToken)\n        .asMultipart()\n        .setField(data.titleField || 'title', options.title)\n        .setField(data.descriptionField || 'description', options.description)\n        .setField(data.tagField || 'tags', options.tags.join(','))\n        .setField(data.ratingField || 'rating', options.rating);\n\n      // Add files\n      const fileFieldName = data.fileField || 'file';\n      if (files.length > 1) {\n        builder.addFiles(fileFieldName, files);\n      } else {\n        builder.addFile(fileFieldName, files[0]);\n      }\n\n      if (data.thumbnailField) {\n        builder.setConditional(\n          data.thumbnailField,\n          !!files[0]?.thumbnail,\n          files[0].thumbnailToPostFormat(),\n        );\n      }\n\n      // Add alt text if provided (this would need to be custom data for PostingFile)\n      if (data.altTextField) {\n        files.forEach((file, index) => {\n          const { altText } = file.metadata;\n          if (files.length === 1) {\n            builder.setField(data.altTextField, altText);\n          } else {\n            builder.setField(`${data.altTextField}[${index}]`, altText);\n          }\n        });\n      }\n\n      // Add custom headers\n      if (data.headers) {\n        data.headers.forEach((header) => {\n          if (header.name && header.value) {\n            builder.withHeader(header.name, header.value);\n          }\n        });\n      }\n\n      const result = await builder.send<unknown>(data.fileUrl);\n\n      if (result.statusCode === 200) {\n        return PostResponse.fromWebsite(this).withAdditionalInfo({\n          ...result,\n          sentBody: builder.getSanitizedData(),\n        });\n      }\n\n      return PostResponse.fromWebsite(this)\n        .withAdditionalInfo({\n          body: result.body,\n          sentBody: builder.getSanitizedData(),\n          statusCode: result.statusCode,\n        })\n        .withException(new Error('Failed to post to custom webhook'));\n    } catch (error) {\n      this.logger.error(\n        'Unexpected error during custom file submission',\n        error,\n      );\n      return PostResponse.fromWebsite(this)\n        .withException(\n          error instanceof Error ? error : new Error(String(error)),\n        )\n        .withAdditionalInfo({\n          fileCount: files.length,\n          batchIndex: batch.index,\n          totalBatches: batch.totalBatches,\n        });\n    }\n  }\n\n  onValidateFileSubmission = validatorPassthru;\n\n  createMessageModel(): CustomMessageSubmission {\n    return new CustomMessageSubmission();\n  }\n\n  async onPostMessageSubmission(\n    postData: PostData<CustomMessageSubmission>,\n    cancellationToken: CancellableToken,\n  ): Promise<IPostResponse> {\n    try {\n      cancellationToken.throwIfCancelled();\n\n      const data = this.websiteDataStore.getData();\n\n      if (!data?.notificationUrl) {\n        throw new Error(\n          'Custom website was not provided a Notification Posting URL.',\n        );\n      }\n\n      const { options } = postData;\n\n      // Prepare form data using the custom field mappings\n      const builder = new PostBuilder(this, cancellationToken)\n        .asMultipart()\n        .setField(data.titleField || 'title', options.title)\n        .setField(data.descriptionField || 'description', options.description)\n        .setField(data.tagField || 'tags', options.tags.join(','))\n        .setField(data.ratingField || 'rating', options.rating);\n\n      // Add custom headers\n      if (data.headers) {\n        data.headers.forEach((header) => {\n          if (header.name && header.value) {\n            builder.withHeader(header.name, header.value);\n          }\n        });\n      }\n\n      const result = await builder.send<unknown>(data.notificationUrl);\n\n      if (result.statusCode === 200) {\n        return PostResponse.fromWebsite(this).withAdditionalInfo(result.body);\n      }\n\n      return PostResponse.fromWebsite(this)\n        .withAdditionalInfo({\n          body: result.body,\n          statusCode: result.statusCode,\n        })\n        .withException(new Error('Failed to post to custom webhook'));\n    } catch (error) {\n      this.logger.error(\n        'Unexpected error during custom message submission',\n        error,\n      );\n      return PostResponse.fromWebsite(this).withException(\n        error instanceof Error ? error : new Error(String(error)),\n      );\n    }\n  }\n\n  onValidateMessageSubmission = validatorPassthru;\n\n  getRuntimeParser(): DescriptionType {\n    const { descriptionType } = this.getWebsiteData();\n    switch (descriptionType) {\n      case 'bbcode':\n        return DescriptionType.BBCODE;\n      case 'md':\n        return DescriptionType.MARKDOWN;\n      case 'text':\n        return DescriptionType.PLAINTEXT;\n      case 'html':\n      default:\n        return DescriptionType.HTML;\n    }\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/custom/models/custom-file-submission.ts",
    "content": "import { DescriptionField } from '@postybirb/form-builder';\nimport { DescriptionType, DescriptionValue } from '@postybirb/types';\nimport { BaseWebsiteOptions } from '../../../models/base-website-options';\n\nexport class CustomFileSubmission extends BaseWebsiteOptions {\n  @DescriptionField({\n    descriptionType: DescriptionType.RUNTIME,\n  })\n  description: DescriptionValue;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/custom/models/custom-message-submission.ts",
    "content": "import { DescriptionField } from '@postybirb/form-builder';\nimport { DescriptionType, DescriptionValue } from '@postybirb/types';\nimport { BaseWebsiteOptions } from '../../../models/base-website-options';\n\nexport class CustomMessageSubmission extends BaseWebsiteOptions {\n  @DescriptionField({\n    descriptionType: DescriptionType.RUNTIME,\n  })\n  description: DescriptionValue;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/default/default.website.ts",
    "content": "import {\n  DynamicObject,\n  ILoginState,\n  ImageResizeProps,\n  IPostResponse,\n  ISubmissionFile,\n  PostData,\n  SimpleValidationResult,\n} from '@postybirb/types';\nimport { CancellableToken } from '../../../post/models/cancellable-token';\nimport { PostingFile } from '../../../post/models/posting-file';\nimport { WebsiteMetadata } from '../../decorators/website-metadata.decorator';\nimport { BaseWebsiteOptions } from '../../models/base-website-options';\nimport { DefaultWebsiteOptions } from '../../models/default-website-options';\nimport { FileWebsite } from '../../models/website-modifiers/file-website';\nimport { MessageWebsite } from '../../models/website-modifiers/message-website';\nimport { Website } from '../../website';\n\n// This is a stub used for filling in for places where we have a null account\n// but need to have a website instance.\n@WebsiteMetadata({ name: 'default', displayName: 'Default' })\nexport default class DefaultWebsite\n  extends Website<DynamicObject>\n  implements\n    FileWebsite<DefaultWebsiteOptions>,\n    MessageWebsite<DefaultWebsiteOptions>\n{\n  createMessageModel(): BaseWebsiteOptions {\n    return new DefaultWebsiteOptions();\n  }\n\n  createFileModel(): BaseWebsiteOptions {\n    return new DefaultWebsiteOptions();\n  }\n\n  onPostMessageSubmission(\n    postData: PostData<DefaultWebsiteOptions>,\n    cancellationToken: CancellableToken,\n  ): Promise<IPostResponse> {\n    throw new Error('Method not implemented.');\n  }\n\n  async onValidateMessageSubmission(\n    postData: PostData<DefaultWebsiteOptions>,\n  ): Promise<SimpleValidationResult> {\n    return {};\n  }\n\n  calculateImageResize(file: ISubmissionFile): ImageResizeProps | undefined {\n    throw new Error('Method not implemented.');\n  }\n\n  onPostFileSubmission(\n    postData: PostData<DefaultWebsiteOptions>,\n    files: PostingFile[],\n    cancellationToken: CancellableToken,\n  ): Promise<IPostResponse> {\n    throw new Error('Method not implemented.');\n  }\n\n  async onValidateFileSubmission(\n    postData: PostData<DefaultWebsiteOptions>,\n  ): Promise<SimpleValidationResult> {\n    return {};\n  }\n\n  protected BASE_URL: string;\n\n  public externallyAccessibleWebsiteDataProperties: DynamicObject = {};\n\n  public onLogin(): Promise<ILoginState> {\n    throw new Error('Method not implemented.');\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/derpibooru/derpibooru.website.ts",
    "content": "import FileSize from '../../../utils/filesize.util';\nimport { UserLoginFlow } from '../../decorators/login-flow.decorator';\nimport { SupportsFiles } from '../../decorators/supports-files.decorator';\nimport { SupportsUsernameShortcut } from '../../decorators/supports-username-shortcut.decorator';\nimport { WebsiteMetadata } from '../../decorators/website-metadata.decorator';\nimport { PhilomenaWebsite } from '../philomena/philomena.website';\nimport { DerpibooruFileSubmission } from './models/derpibooru-file-submission';\n\n@WebsiteMetadata({\n  name: 'derpibooru',\n  displayName: 'Derpibooru',\n})\n@UserLoginFlow('https://derpibooru.org/sessions/new')\n@SupportsFiles({\n  acceptedMimeTypes: [\n    'image/jpeg',\n    'image/png',\n    'image/svg+xml',\n    'image/gif',\n    'video/webm',\n  ],\n  acceptedFileSizes: {\n    maxBytes: FileSize.megabytes(100),\n  },\n  fileBatchSize: 1,\n  acceptsExternalSourceUrls: true,\n})\n@SupportsUsernameShortcut({\n  id: 'derpibooru',\n  url: 'https://derpibooru.org/profiles/$1',\n})\nexport default class Derpibooru extends PhilomenaWebsite<DerpibooruFileSubmission> {\n  protected BASE_URL = 'https://derpibooru.org';\n\n  createFileModel(): DerpibooruFileSubmission {\n    return new DerpibooruFileSubmission();\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/derpibooru/models/derpibooru-file-submission.ts",
    "content": "import { PhilomenaFileSubmission } from '../../philomena/models/philomena-file-submission';\n\nexport class DerpibooruFileSubmission extends PhilomenaFileSubmission {}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/deviant-art/deviant-art-description-converter.ts",
    "content": "/* eslint-disable import/no-extraneous-dependencies */\nimport { Blockquote } from '@tiptap/extension-blockquote';\nimport { Bold } from '@tiptap/extension-bold';\nimport { Color } from '@tiptap/extension-color';\nimport { Document } from '@tiptap/extension-document';\nimport { HardBreak } from '@tiptap/extension-hard-break';\nimport { Heading } from '@tiptap/extension-heading';\nimport { HorizontalRule } from '@tiptap/extension-horizontal-rule';\nimport { Italic } from '@tiptap/extension-italic';\nimport { Link } from '@tiptap/extension-link';\nimport { Paragraph } from '@tiptap/extension-paragraph';\nimport { Strike } from '@tiptap/extension-strike';\nimport { Text } from '@tiptap/extension-text';\nimport { TextAlign } from '@tiptap/extension-text-align';\nimport { TextStyle } from '@tiptap/extension-text-style';\nimport { Underline } from '@tiptap/extension-underline';\nimport { generateJSON } from '@tiptap/html/dist/server';\n\nconst extensions = [\n  Text,\n  Document,\n  Paragraph,\n  Bold,\n  Italic,\n  Strike,\n  Underline,\n  HardBreak,\n  Blockquote,\n  Color,\n  TextStyle,\n  Heading,\n  HorizontalRule.configure({\n    HTMLAttributes: {\n      'data-ruler': '1',\n    },\n  }),\n  Link.configure({\n    openOnClick: false,\n    autolink: true,\n    linkOnPaste: true,\n    protocols: ['https', 'http', 'mailto'],\n    validate(url) {\n      return /^(#|http|mailto)/.test(url);\n    },\n    HTMLAttributes: {\n      rel: 'noopener noreferrer nofollow ugc',\n      target: '_blank',\n    },\n  }),\n  TextAlign.extend({\n    name: 'da-text-align',\n    addCommands() {\n      const parentCommands = this.parent?.();\n      return {\n        unsetTextAlign: parentCommands?.unsetTextAlign,\n        setTextAlign: (alignment) => (object) => {\n          if (!parentCommands || !parentCommands.setTextAlign) {\n            return false;\n          }\n          return parentCommands.setTextAlign(alignment)(object);\n        },\n      };\n    },\n  }).configure({\n    types: ['heading', 'paragraph'],\n  }),\n];\n\nexport class DeviantArtDescriptionConverter {\n  static convert(html: string): string {\n    const document = generateJSON(html || '<div></div>', extensions);\n    return JSON.stringify({\n      version: 1,\n      document,\n    });\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/deviant-art/deviant-art.website.ts",
    "content": "import { SelectOption, SelectOptionSingle } from '@postybirb/form-builder';\nimport { Http } from '@postybirb/http';\nimport {\n  FileType,\n  ILoginState,\n  ImageResizeProps,\n  ISubmissionFile,\n  PostData,\n  PostResponse,\n  SimpleValidationResult,\n  SubmissionRating,\n} from '@postybirb/types';\nimport { CancellableToken } from '../../../post/models/cancellable-token';\nimport { PostingFile } from '../../../post/models/posting-file';\nimport FileSize from '../../../utils/filesize.util';\nimport { SelectOptionUtil } from '../../../utils/select-option.util';\nimport { PostBuilder } from '../../commons/post-builder';\nimport { validatorPassthru } from '../../commons/validator-passthru';\nimport { UserLoginFlow } from '../../decorators/login-flow.decorator';\nimport { SupportsFiles } from '../../decorators/supports-files.decorator';\nimport { SupportsUsernameShortcut } from '../../decorators/supports-username-shortcut.decorator';\nimport { WebsiteMetadata } from '../../decorators/website-metadata.decorator';\nimport { DataPropertyAccessibility } from '../../models/data-property-accessibility';\nimport { FileWebsite } from '../../models/website-modifiers/file-website';\nimport { MessageWebsite } from '../../models/website-modifiers/message-website';\nimport { Website } from '../../website';\nimport { DeviantArtDescriptionConverter } from './deviant-art-description-converter';\nimport { DeviantArtAccountData } from './models/deviant-art-account-data';\nimport { DeviantArtFileSubmission } from './models/deviant-art-file-submission';\nimport { DeviantArtMessageSubmission } from './models/deviant-art-message-submission';\n\ninterface DeviantArtFolder {\n  description: string;\n  folderId: number;\n  hasSubfolders: boolean;\n  name: string;\n  parentId: string | null;\n  gallectionUuid: string;\n}\n\n@WebsiteMetadata({\n  name: 'deviant-art',\n  displayName: 'DeviantArt',\n})\n@UserLoginFlow('https://www.deviantart.com/users/login')\n@SupportsUsernameShortcut({\n  id: 'deviantart',\n  url: 'https://deviantart.com/$1',\n})\n@SupportsFiles({\n  fileBatchSize: 10,\n  acceptedFileSizes: {\n    [FileType.VIDEO]: FileSize.megabytes(200),\n    [FileType.IMAGE]: FileSize.megabytes(30),\n    [FileType.TEXT]: FileSize.megabytes(30),\n    [FileType.AUDIO]: FileSize.megabytes(30),\n  },\n  acceptedMimeTypes: [\n    'image/jpeg',\n    'image/jpg',\n    'image/png',\n    'image/bmp',\n    'video/x-flv',\n    'text/plain',\n    'application/rtf',\n    'application/vnd.oasis.opendocument.text',\n    'application/x-shockwave-flash',\n    'image/tiff',\n    'image/gif',\n  ],\n})\nexport default class DeviantArt\n  extends Website<DeviantArtAccountData>\n  implements\n    FileWebsite<DeviantArtFileSubmission>,\n    MessageWebsite<DeviantArtMessageSubmission>\n{\n  protected BASE_URL = 'https://www.deviantart.com';\n\n  private readonly DA_API_VERSION: number = 20230710;\n\n  public externallyAccessibleWebsiteDataProperties: DataPropertyAccessibility<DeviantArtAccountData> =\n    {\n      folders: true,\n    };\n\n  public async onLogin(): Promise<ILoginState> {\n    const res = await Http.get<string>(this.BASE_URL, {\n      partition: this.accountId,\n    });\n    const cookies = await Http.getWebsiteCookies(this.accountId, this.BASE_URL);\n    const userInfoCookie = cookies.find((c) => c.name === 'userinfo');\n    if (userInfoCookie) {\n      const userInfo = JSON.parse(\n        decodeURIComponent(userInfoCookie.value).split(';')[1],\n      );\n      if (userInfo && userInfo.username) {\n        await this.getFolders(userInfo.username);\n        return this.loginState.setLogin(true, userInfo.username);\n      }\n    }\n\n    return this.loginState.setLogin(false, null);\n  }\n\n  private async getCSRF(accountId = this.accountId) {\n    const url = await Http.get<string>(this.BASE_URL, {\n      partition: accountId,\n    });\n    return url.body.match(/window.__CSRF_TOKEN__ = '(.*)'/)?.[1];\n  }\n\n  private async getFolders(username: string) {\n    try {\n      const csrf = await this.getCSRF();\n      const { body } = await Http.get<{ results: DeviantArtFolder[] }>(\n        `${\n          this.BASE_URL\n        }/_puppy/dashared/gallection/folders?offset=0&limit=250&type=gallery&with_all_folder=true&with_permissions=true&username=${encodeURIComponent(\n          username,\n        )}&da_minor_version=20230710&csrf_token=${csrf}`,\n        { partition: this.accountId },\n      );\n\n      const { results } = body;\n      const folders: SelectOption[] = [];\n      const childrenByParentId: Record<string, DeviantArtFolder[]> = {};\n\n      // First pass: group folders by their parent ID\n      results.forEach((folder) => {\n        const parentKey = folder.parentId || 'null'; // Use 'null' string for root folders\n        if (!childrenByParentId[parentKey]) {\n          childrenByParentId[parentKey] = [];\n        }\n        childrenByParentId[parentKey].push(folder);\n      });\n\n      // Recursive function to build the tree\n      const buildTree = (parentId: string | null): SelectOption[] => {\n        const parentKey = parentId || 'null';\n        const children = childrenByParentId[parentKey] || [];\n\n        return children.map((folder) => {\n          const subChildren = buildTree(folder.folderId.toString());\n\n          if (subChildren.length > 0) {\n            // This folder has children, create a group\n            return {\n              label: folder.name,\n              value: folder.folderId.toString(),\n              items: subChildren,\n            };\n          }\n          // This is a leaf folder\n          return {\n            label: folder.name,\n            value: folder.folderId.toString(),\n          };\n        });\n      };\n\n      // Build the tree starting from root folders (parentId = null)\n      folders.push(...buildTree(null));\n\n      this.setWebsiteData({\n        folders,\n      });\n    } catch (e) {\n      this.logger.withError(e).error('Failed to get folders');\n    }\n  }\n\n  createFileModel(): DeviantArtFileSubmission {\n    return new DeviantArtFileSubmission();\n  }\n\n  calculateImageResize(file: ISubmissionFile): ImageResizeProps {\n    return undefined;\n  }\n\n  async onPostFileSubmission(\n    postData: PostData<DeviantArtFileSubmission>,\n    files: PostingFile[],\n    cancellationToken: CancellableToken,\n  ): Promise<PostResponse> {\n    cancellationToken.throwIfCancelled();\n\n    // File upload step\n    const uploadBuilder = new PostBuilder(this, cancellationToken)\n      .asMultipart()\n      .setField('da_minor_version', this.DA_API_VERSION)\n      .setField('csrf_token', await this.getCSRF())\n      .setField('use_defaults', 'true')\n      .setField('folder_name', 'Saved Submissions')\n      .addFile('upload_file', files[0]);\n\n    const fileUpload = await uploadBuilder.send<{\n      deviationId: number;\n      status: string;\n      stashId: number;\n      privateId: number;\n      size: number;\n      cursor: string;\n      title: string;\n    }>(`${this.BASE_URL}/_puppy/dashared/deviation/submit/upload/deviation`);\n\n    if (fileUpload.body.status !== 'success') {\n      return PostResponse.fromWebsite(this)\n        .withAdditionalInfo(fileUpload.body)\n        .withException(new Error('Failed to upload file.'));\n    }\n\n    const additionalUploads: Array<{\n      deviationId: number;\n      fileId: number;\n      attachment: object;\n      deviation: object;\n    }> = [];\n    if (files.length > 1) {\n      let index = 0;\n      const csrf = await this.getCSRF();\n      for (const file of files.slice(1)) {\n        index++;\n        cancellationToken.throwIfCancelled();\n        const upload = await new PostBuilder(this, cancellationToken)\n          .asMultipart()\n          .addFile('attachment_file', file)\n          .setField('da_minor_version', this.DA_API_VERSION)\n          .setField('type', 'additional_image')\n          .setField('position', index)\n          .setField('csrf_token', csrf)\n          .setField('deviationid', fileUpload.body.deviationId)\n          .send<{\n            deviationId: number;\n            fileId: number;\n            attachment: object;\n            deviation: object;\n          }>(`${this.BASE_URL}/_puppy/dashared/deviation/attachments/add`);\n        additionalUploads.push(upload.body);\n      }\n    }\n\n    // Determine if submission is mature\n    const mature =\n      postData.options.isMature ||\n      postData.options.rating === SubmissionRating.ADULT ||\n      postData.options.rating === SubmissionRating.MATURE ||\n      postData.options.rating === SubmissionRating.EXTREME;\n\n    const folders = this.getWebsiteData().folders as SelectOptionSingle[];\n    const featured = folders.find((f) => f.label === 'Featured');\n\n    // Prepare update data\n    const updateBuilder = new PostBuilder(this, cancellationToken)\n      .asJson()\n      .withData({\n        allow_comments: !postData.options.disableComments,\n        allow_free_download: postData.options.allowFreeDownload,\n        deviationid: fileUpload.body.deviationId,\n        da_minor_version: this.DA_API_VERSION,\n        display_resolution: 0,\n        editorRaw: DeviantArtDescriptionConverter.convert(\n          postData.options.description,\n        ),\n        editor_v3: '',\n        galleryids:\n          postData.options.folders.length > 0\n            ? postData.options.folders\n            : featured\n              ? [featured.value]\n              : [],\n        is_ai_generated: postData.options.isAIGenerated ?? false,\n        is_scrap: postData.options.scraps,\n        license_options: {\n          creative_commons: postData.options.isCreativeCommons ?? false,\n          commercial: postData.options.isCommercialUse ?? false,\n          modify: postData.options.allowModifications || 'no',\n        },\n        location_tag: null,\n        noai: postData.options.noAI ?? true,\n        subject_tag_types: '_empty',\n        subject_tags: '_empty',\n        tags: postData.options.tags,\n        tierids: '_empty',\n        title: this.stripInvalidCharacters(postData.options.title),\n        csrf_token: await this.getCSRF(),\n      })\n      .setConditional('pcp_price_points', postData.options.allowFreeDownload, 0)\n      .setConditional('is_mature', mature, true);\n\n    // Update submission details\n    const update = await updateBuilder.send<{\n      status: string;\n      url: string;\n      deviationId: number;\n    }>(`${this.BASE_URL}/_napi/shared_api/deviation/update`);\n\n    if (update.body.status !== 'success') {\n      return PostResponse.fromWebsite(this)\n        .withAdditionalInfo(update.body)\n        .withException(new Error('Failed to upload file.'));\n    }\n\n    // Publish the submission\n    const publishBuilder = new PostBuilder(this, cancellationToken)\n      .asJson()\n      .setField('da_minor_version', this.DA_API_VERSION)\n      .setField('csrf_token', await this.getCSRF())\n      .setField('stashid', update.body.deviationId);\n    const publish = await publishBuilder.send<{\n      status: string;\n      url: string;\n      deviationId: number;\n    }>(`${this.BASE_URL}/_puppy/dashared/deviation/publish`);\n\n    if (publish.body.status !== 'success') {\n      return PostResponse.fromWebsite(this)\n        .withAdditionalInfo(publish.body)\n        .withException(new Error('Failed to upload file.'));\n    }\n\n    return PostResponse.fromWebsite(this).withSourceUrl(publish.body.url);\n  }\n\n  async onValidateFileSubmission(\n    postData: PostData<DeviantArtFileSubmission>,\n  ): Promise<SimpleValidationResult> {\n    const validator = this.createValidator<DeviantArtFileSubmission>();\n    const { options } = postData;\n\n    // Validate required folder selection\n    const selectedFolders = options.folders ?? [];\n    const validFolders = this.websiteDataStore.getData().folders ?? [];\n    if (selectedFolders.length) {\n      const hasMissingFolders = selectedFolders.some(\n        (folder) => !SelectOptionUtil.findOptionById(validFolders, folder),\n      );\n\n      if (hasMissingFolders) {\n        validator.error('validation.folder.missing-or-invalid', {}, 'folders');\n      }\n    }\n\n    return validator.result;\n  }\n\n  createMessageModel(): DeviantArtMessageSubmission {\n    return new DeviantArtMessageSubmission();\n  }\n\n  async onPostMessageSubmission(\n    postData: PostData<DeviantArtMessageSubmission>,\n    cancellationToken: CancellableToken,\n  ): Promise<PostResponse> {\n    cancellationToken.throwIfCancelled();\n    const commonFormData = {\n      csrf_token: await this.getCSRF(),\n      da_minor_version: this.DA_API_VERSION,\n    };\n\n    const builder = new PostBuilder(this, cancellationToken).asJson().withData({\n      ...commonFormData,\n      editorRaw: DeviantArtDescriptionConverter.convert(\n        postData.options.description,\n      ),\n      title: this.stripInvalidCharacters(postData.options.title),\n    });\n\n    const create = await builder.send<{\n      deviation: {\n        deviationId: number;\n        url: string;\n      };\n    }>(`${this.BASE_URL}/_napi/shared_api/journal/create`);\n\n    if (!create.body.deviation?.deviationId) {\n      return PostResponse.fromWebsite(this)\n        .withAdditionalInfo({\n          body: create.body,\n          statusCode: create.statusCode,\n        })\n        .withException(new Error('Failed to create post'));\n    }\n\n    const publish = await new PostBuilder(this, cancellationToken)\n      .asJson()\n      .withData({\n        ...commonFormData,\n        deviationid: create.body.deviation.deviationId,\n        featured: true,\n      })\n      .send<{\n        deviation: {\n          deviationId: number;\n          url: string;\n        };\n      }>(`${this.BASE_URL}/_puppy/dashared/journal/publish`);\n\n    if (!publish.body.deviation?.deviationId) {\n      return PostResponse.fromWebsite(this)\n        .withAdditionalInfo({\n          body: publish.body,\n          statusCode: publish.statusCode,\n        })\n        .withException(new Error('Failed to publish post'));\n    }\n\n    return PostResponse.fromWebsite(this)\n      .withAdditionalInfo({\n        body: publish.body,\n        statusCode: publish.statusCode,\n      })\n      .withSourceUrl(publish.body.deviation.url);\n  }\n\n  onValidateMessageSubmission = validatorPassthru;\n\n  private stripInvalidCharacters(title: string) {\n    const validRegex = /^[A-Za-z0-9\\s_$!?:.,'+\\-=~`@#%^*[\\]()/{}\\\\|]$/;\n    if (!title) return '';\n    let sanitized = '';\n    for (let i = 0; i < title.length; i++) {\n      const char = title[i];\n      if (validRegex.test(char)) {\n        sanitized += char;\n      }\n    }\n    return sanitized;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/deviant-art/models/deviant-art-account-data.ts",
    "content": "import { SelectOption } from '@postybirb/form-builder'; \n\nexport type DeviantArtAccountData = { \n  folders: SelectOption[];\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/deviant-art/models/deviant-art-file-submission.ts",
    "content": "import {\n  BooleanField,\n  DescriptionField,\n  RatingField,\n  SelectField,\n  TagField,\n  TitleField,\n} from '@postybirb/form-builder';\nimport {\n  DescriptionType,\n  DescriptionValue,\n  SubmissionRating,\n  TagValue,\n} from '@postybirb/types';\nimport { BaseWebsiteOptions } from '../../../models/base-website-options';\n\nexport class DeviantArtFileSubmission extends BaseWebsiteOptions {\n  @RatingField({\n    hidden: true,\n  })\n  rating: SubmissionRating;\n\n  @TitleField({\n    maxLength: 50,\n  })\n  title: string;\n\n  @TagField({\n    maxTags: 30,\n  })\n  tags: TagValue;\n\n  @DescriptionField({\n    descriptionType: DescriptionType.HTML,\n  })\n  description: DescriptionValue;\n\n  @SelectField({\n    section: 'website',\n    span: 6,\n    label: 'folder',\n    derive: [\n      {\n        key: 'folders',\n        populate: 'options',\n      },\n    ],\n    options: [],\n    allowMultiple: true,\n  })\n  folders: string[] = [];\n\n  @SelectField({\n    label: 'displayResolution',\n    options: [\n      { value: 'original', label: 'Original' },\n      { value: 'max_800', label: 'Maximum 800px' },\n      { value: 'max_1200', label: 'Maximum 1200px' },\n      { value: 'max_1800', label: 'Maximum 1800px' },\n    ],\n    section: 'website',\n    span: 6,\n  })\n  displayResolution = 'original';\n\n  @BooleanField({\n    label: 'scraps',\n    section: 'website',\n    span: 6,\n  })\n  scraps = false;\n\n  @BooleanField({\n    label: 'disableComments',\n    section: 'website',\n    span: 6,\n  })\n  disableComments = false;\n\n  @BooleanField({\n    label: 'allowFreeDownload',\n    section: 'website',\n    span: 6,\n  })\n  allowFreeDownload = true;\n\n  @BooleanField({\n    label: 'matureContent',\n    section: 'website',\n    span: 6,\n  })\n  isMature = false;\n\n  @BooleanField({\n    label: 'noAI',\n    section: 'website',\n    span: 6,\n  })\n  noAI = true;\n\n  @BooleanField({\n    label: 'aIGenerated',\n    section: 'website',\n    span: 6,\n  })\n  isAIGenerated = false;\n\n  @BooleanField({\n    label: 'isCreativeCommons',\n    section: 'website',\n    span: 6,\n  })\n  isCreativeCommons = false;\n\n  @BooleanField({\n    label: 'allowCommercialUse',\n    section: 'website',\n    span: 6,\n  })\n  isCommercialUse = false;\n\n  @SelectField({\n    label: 'allowModifications',\n    options: [\n      { value: 'yes', label: 'Yes' },\n      { value: 'share', label: 'Share Alike' },\n      { value: 'no', label: 'No' },\n    ],\n    section: 'website',\n    span: 6,\n  })\n  allowModifications = 'no';\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/deviant-art/models/deviant-art-message-submission.ts",
    "content": "import { DescriptionField, TitleField } from '@postybirb/form-builder';\nimport { DescriptionType, DescriptionValue } from '@postybirb/types';\nimport { BaseWebsiteOptions } from '../../../models/base-website-options';\n\nexport class DeviantArtMessageSubmission extends BaseWebsiteOptions {\n  @TitleField({\n    maxLength: 50,\n  })\n  title: string;\n\n  @DescriptionField({\n    descriptionType: DescriptionType.HTML,\n  })\n  description: DescriptionValue;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/discord/discord.website.ts",
    "content": "import { Http, HttpResponse } from '@postybirb/http';\nimport {\n  DiscordAccountData,\n  ILoginState,\n  ImageResizeProps,\n  IPostResponse,\n  PostData,\n  PostResponse,\n} from '@postybirb/types';\nimport { CancellableToken } from '../../../post/models/cancellable-token';\nimport { PostingFile } from '../../../post/models/posting-file';\nimport FileSize from '../../../utils/filesize.util';\nimport { validatorPassthru } from '../../commons/validator-passthru';\nimport { DisableAds } from '../../decorators/disable-ads.decorator';\nimport { CustomLoginFlow } from '../../decorators/login-flow.decorator';\nimport { SupportsFiles } from '../../decorators/supports-files.decorator';\nimport { WebsiteMetadata } from '../../decorators/website-metadata.decorator';\nimport { DataPropertyAccessibility } from '../../models/data-property-accessibility';\nimport {\n  FileWebsite,\n  PostBatchData,\n} from '../../models/website-modifiers/file-website';\nimport { MessageWebsite } from '../../models/website-modifiers/message-website';\nimport {\n  DynamicFileSizeLimits,\n  WithDynamicFileSizeLimits,\n} from '../../models/website-modifiers/with-dynamic-file-size-limits';\nimport { Website } from '../../website';\nimport { DiscordFileSubmission } from './models/discord-file-submission';\nimport { DiscordMessageSubmission } from './models/discord-message-submission';\n\n@WebsiteMetadata({\n  name: 'discord',\n  displayName: 'Discord',\n})\n@CustomLoginFlow()\n@SupportsFiles({\n  acceptedMimeTypes: [],\n  fileBatchSize: 10,\n})\n@DisableAds()\nexport default class Discord\n  extends Website<DiscordAccountData>\n  implements\n    FileWebsite<DiscordFileSubmission>,\n    MessageWebsite<DiscordMessageSubmission>,\n    WithDynamicFileSizeLimits\n{\n  protected BASE_URL: string;\n\n  public externallyAccessibleWebsiteDataProperties: DataPropertyAccessibility<DiscordAccountData> =\n    {\n      webhook: true,\n      serverLevel: true,\n      isForum: true,\n    };\n\n  public async onLogin(): Promise<ILoginState> {\n    const data = this.websiteDataStore.getData();\n    if (data.webhook) {\n      return this.loginState.setLogin(true, this.account.name);\n    }\n\n    if (data.serverLevel > 0) {\n      // NOTE: Not entirely sure if this is a safe thing to do, but it does\n      // avoid having to create additional custom validation logic.\n      if (data.serverLevel === 2) {\n        this.decoratedProps.fileOptions.acceptedFileSizes['*'] =\n          FileSize.megabytes(50);\n      }\n      if (data.serverLevel === 3) {\n        this.decoratedProps.fileOptions.acceptedFileSizes['*'] =\n          FileSize.megabytes(100);\n      }\n    }\n\n    return this.loginState.setLogin(false, null);\n  }\n\n  createMessageModel(): DiscordMessageSubmission {\n    return new DiscordMessageSubmission();\n  }\n\n  createFileModel(): DiscordFileSubmission {\n    return new DiscordFileSubmission();\n  }\n\n  calculateImageResize(): ImageResizeProps {\n    return undefined;\n  }\n\n  getDynamicFileSizeLimits(): DynamicFileSizeLimits {\n    const data = this.websiteDataStore.getData();\n    switch (data.serverLevel) {\n      case 2:\n        return { '*': FileSize.megabytes(50) };\n      case 3:\n        return { '*': FileSize.megabytes(100) };\n      case 0:\n      case 1:\n      default:\n        return { '*': FileSize.megabytes(10) };\n    }\n  }\n\n  onPostFileSubmission(\n    postData: PostData<DiscordFileSubmission>,\n    files: PostingFile[],\n    cancellationToken: CancellableToken,\n    batch: PostBatchData,\n  ): Promise<IPostResponse> {\n    cancellationToken.throwIfCancelled();\n    const { webhook } = this.websiteDataStore.getData();\n    const payload = {\n      ...(batch.index === 0\n        ? {\n            ...this.buildDescription(\n              postData.options.title,\n              postData.options.description,\n              postData.options.useTitle,\n            ),\n          }\n        : {}),\n      attachments: [],\n    };\n\n    const formData: {\n      [key: string]: unknown;\n    } = {};\n    const { isSpoiler } = postData.options;\n    files.forEach((file, i) => {\n      const postableFile = file.toPostFormat();\n      if (isSpoiler) {\n        postableFile.setFileName(`SPOILER_${postableFile.fileName}`);\n      }\n      formData[`files[${i}]`] = postableFile;\n      payload.attachments.push({\n        id: i,\n        filename: postableFile.fileName,\n        description: file.metadata.altText,\n      });\n    });\n\n    formData.payload_json = JSON.stringify(payload);\n    cancellationToken.throwIfCancelled();\n    return Http.post(webhook, {\n      partition: undefined,\n      type: 'multipart',\n      data: formData,\n    })\n      .then((res) => this.handleResponse(res))\n      .catch((error) => this.handleError(error, payload));\n  }\n\n  onValidateFileSubmission = validatorPassthru;\n\n  onPostMessageSubmission(\n    postData: PostData<DiscordMessageSubmission>,\n    cancellationToken: CancellableToken,\n  ): Promise<IPostResponse> {\n    cancellationToken.throwIfCancelled();\n    const { webhook } = this.websiteDataStore.getData();\n    const messageData = this.buildDescription(\n      postData.options.title,\n      postData.options.description,\n      postData.options.useTitle,\n    );\n    cancellationToken.throwIfCancelled();\n    return Http.post(webhook, {\n      partition: undefined,\n      type: 'json',\n      data: messageData,\n    })\n      .then((res) => this.handleResponse(res))\n      .catch((error) => this.handleError(error, messageData));\n  }\n\n  onValidateMessageSubmission = validatorPassthru;\n\n  private handleResponse(res: HttpResponse<unknown>): IPostResponse {\n    if (res.statusCode >= 300) {\n      throw new Error(\n        `Failed to post message: ${res.statusCode ?? -1} ${res.body}`,\n      );\n    }\n    return PostResponse.fromWebsite(this).withAdditionalInfo(res.body);\n  }\n\n  private handleError(error: Error, payload: unknown): IPostResponse {\n    this.logger.error(\n      'Failed to post message',\n      error.message,\n      error.stack,\n      JSON.stringify(payload, null, 1),\n    );\n    return PostResponse.fromWebsite(this)\n      .withException(error)\n      .withAdditionalInfo(payload);\n  }\n\n  private buildDescription(\n    title: string,\n    description: string,\n    useTitle: boolean,\n  ) {\n    if (!description && !useTitle) {\n      throw new Error('No content to post');\n    }\n\n    const mentions =\n      description?.match(/(<){0,1}@(&){0,1}[a-zA-Z0-9]+(>){0,1}/g) || [];\n    const { isForum } = this.websiteDataStore.getData();\n\n    return {\n      content: mentions.length ? mentions.join(' ') : undefined,\n      allowed_mentions: {\n        parse: ['everyone', 'users', 'roles'],\n      },\n      embeds: [\n        {\n          title: useTitle ? title : undefined,\n          description: description?.length ? description : undefined,\n        },\n      ],\n      thread_name: isForum ? title || 'PostyBirb Post' : undefined,\n    };\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/discord/models/discord-file-submission.ts",
    "content": "import {\n  BooleanField,\n  DescriptionField,\n  TagField,\n} from '@postybirb/form-builder';\nimport { DescriptionType, DescriptionValue, TagValue } from '@postybirb/types';\nimport { BaseWebsiteOptions } from '../../../models/base-website-options';\n\nexport class DiscordFileSubmission extends BaseWebsiteOptions {\n  @DescriptionField({\n    descriptionType: DescriptionType.MARKDOWN,\n    maxDescriptionLength: 2000,\n    expectsInlineTitle: true,\n  })\n  description: DescriptionValue;\n\n  @TagField({\n    hidden: true,\n  })\n  tags: TagValue;\n\n  @BooleanField({ label: 'spoiler', section: 'website', span: 6 })\n  isSpoiler = false;\n\n  @BooleanField({ label: 'useTitle', section: 'website', span: 6 })\n  useTitle = true;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/discord/models/discord-message-submission.ts",
    "content": "import {\n  BooleanField,\n  DescriptionField,\n  TagField,\n} from '@postybirb/form-builder';\nimport { DescriptionType, DescriptionValue, TagValue } from '@postybirb/types';\nimport { BaseWebsiteOptions } from '../../../models/base-website-options';\n\nexport class DiscordMessageSubmission extends BaseWebsiteOptions {\n  @DescriptionField({\n    descriptionType: DescriptionType.MARKDOWN,\n    maxDescriptionLength: 2000,\n    expectsInlineTitle: true,\n  })\n  description: DescriptionValue;\n\n  @TagField({\n    hidden: true,\n  })\n  tags: TagValue;\n\n  @BooleanField({ label: 'useTitle', section: 'website' })\n  useTitle = true;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/e621/e621.website.ts",
    "content": "// eslint-disable-next-line max-classes-per-file\nimport { Http } from '@postybirb/http';\nimport {\n  E621AccountData,\n  E621OAuthRoutes,\n  E621TagCategory,\n  ILoginState,\n  ImageResizeProps,\n  ISubmissionFile,\n  OAuthRouteHandlers,\n  PostData,\n  PostResponse,\n  SimpleValidationResult,\n  SubmissionRating,\n  TipTapNode,\n} from '@postybirb/types';\nimport { app } from 'electron';\nimport { BaseConverter } from '../../../post-parsers/models/description-node/converters/base-converter';\nimport { BBCodeConverter } from '../../../post-parsers/models/description-node/converters/bbcode-converter';\nimport { ConversionContext } from '../../../post-parsers/models/description-node/description-node.base';\nimport { CancellableToken } from '../../../post/models/cancellable-token';\nimport { PostingFile } from '../../../post/models/posting-file';\nimport FileSize from '../../../utils/filesize.util';\nimport { SubmissionValidator } from '../../commons/validator';\nimport { DisableAds } from '../../decorators/disable-ads.decorator';\nimport { CustomLoginFlow } from '../../decorators/login-flow.decorator';\nimport { SupportsFiles } from '../../decorators/supports-files.decorator';\nimport { SupportsUsernameShortcut } from '../../decorators/supports-username-shortcut.decorator';\nimport { WebsiteMetadata } from '../../decorators/website-metadata.decorator';\nimport { DataPropertyAccessibility } from '../../models/data-property-accessibility';\nimport { FileWebsite } from '../../models/website-modifiers/file-website';\nimport { OAuthWebsite } from '../../models/website-modifiers/oauth-website';\nimport { WithCustomDescriptionParser } from '../../models/website-modifiers/with-custom-description-parser';\nimport { Website } from '../../website';\nimport { E621FileSubmission } from './models/e621-file-submission';\n\n@WebsiteMetadata({\n  name: 'e621',\n  displayName: 'e621',\n})\n@CustomLoginFlow('e621')\n@SupportsFiles({\n  acceptedMimeTypes: ['image/png', 'image/jpeg', 'image/gif', 'video/webm'],\n  acceptedFileSizes: { '*': FileSize.megabytes(100) },\n  acceptsExternalSourceUrls: true,\n  fileBatchSize: 1,\n})\n@SupportsUsernameShortcut({\n  id: 'e621',\n  url: 'https://e621.net/user/show/$1',\n\n  convert: (websiteName, shortcut) => {\n    if (websiteName === 'e621' && shortcut === 'e6') return `@$1`;\n    return undefined;\n  },\n})\n@DisableAds()\nexport default class E621\n  extends Website<E621AccountData>\n  implements\n    FileWebsite<E621FileSubmission>,\n    OAuthWebsite<E621OAuthRoutes>,\n    WithCustomDescriptionParser\n{\n  protected BASE_URL = 'https://e621.net/';\n\n  public externallyAccessibleWebsiteDataProperties: DataPropertyAccessibility<E621AccountData> =\n    {\n      username: true,\n      key: true,\n    };\n\n  public async onLogin(): Promise<ILoginState> {\n    const data = this.websiteDataStore.getData();\n    if (data.username) return this.loginState.setLogin(true, data.username);\n\n    return this.loginState.logout();\n  }\n\n  onAuthRoute: OAuthRouteHandlers<E621OAuthRoutes> = {\n    login: async (data) => {\n      // This check is only run at account creation stage because v3 did this. Maybe its worth moving to the onLogin?\n      try {\n        const response = await Http.get(\n          `https://e621.net/posts.json?login=${encodeURIComponent(data.username)}&api_key=${\n            data.key\n          }&limit=1`,\n          { partition: '' },\n        );\n        if (response.statusCode !== 200) throw new Error('Login failed');\n      } catch (e) {\n        this.logger.withError(e).error('onAuthRoute.login failed');\n        return { result: false };\n      }\n\n      await this.setWebsiteData(data);\n      const result = await this.onLogin();\n      return { result: result.isLoggedIn };\n    },\n  };\n\n  createFileModel(): E621FileSubmission {\n    return new E621FileSubmission();\n  }\n\n  calculateImageResize(file: ISubmissionFile): ImageResizeProps {\n    return undefined;\n  }\n\n  getDescriptionConverter(): BaseConverter {\n    return new E621Converter();\n  }\n\n  private readonly headers = { 'User-Agent': `PostyBirb/${app.getVersion()}` };\n\n  private async request<T>(\n    cancellableToken: CancellableToken,\n    method: 'get' | 'post',\n    url: string,\n    form?: Record<string, unknown>,\n  ) {\n    cancellableToken.throwIfCancelled();\n\n    if (method === 'get') {\n      return Http.get<T>(`${this.BASE_URL}${url}`, { partition: '' });\n    }\n\n    return Http.post<T>(`${this.BASE_URL}${url}`, {\n      partition: '',\n      type: 'multipart',\n      data: form,\n      headers: this.headers,\n    });\n  }\n\n  async onPostFileSubmission(\n    postData: PostData<E621FileSubmission>,\n    files: PostingFile[],\n    cancellableToken: CancellableToken,\n  ): Promise<PostResponse> {\n    cancellableToken.throwIfCancelled();\n    const accountData = this.websiteDataStore.getData();\n    const file = files[0];\n\n    const { description } = postData.options;\n\n    const formData = {\n      login: accountData.username,\n      api_key: accountData.key,\n      'upload[tag_string]': postData.options.tags.join(' ').trim(),\n      'upload[file]': file.toPostFormat(),\n      'upload[rating]': this.getRating(postData.options.rating),\n      'upload[description]': description,\n      'upload[parent_id]': postData.options.parentId || '',\n      'upload[source]': file.metadata.sourceUrls\n        .filter((s) => !!s)\n        .slice(0, 10)\n        .join('%0A'),\n      file: files[0].toPostFormat(),\n      thumb: files[0].thumbnailToPostFormat(),\n      title: postData.options.title,\n      rating: postData.options.rating,\n    };\n\n    const result = await this.request<{\n      success: boolean;\n      location: string;\n      reason: string;\n      message: string;\n    }>(cancellableToken, 'post', `/uploads.json`, formData);\n\n    if (result.body.success && result.body.location) {\n      return PostResponse.fromWebsite(this)\n        .withAdditionalInfo(result.body)\n        .withSourceUrl(`https://e621.net${result.body.location}`);\n    }\n\n    return PostResponse.fromWebsite(this)\n      .withAdditionalInfo({\n        body: result.body,\n        statusCode: result.statusCode,\n      })\n      .withException(\n        new Error(\n          `${result.body.reason || ''} || ${result.body.message || ''}`,\n        ),\n      );\n  }\n\n  async onValidateFileSubmission(\n    postData: PostData<E621FileSubmission>,\n  ): Promise<SimpleValidationResult> {\n    const validator = this.createValidator<E621FileSubmission>();\n\n    await this.validateTags(postData, validator);\n    await this.validateUserFeedback(validator);\n\n    return validator.result;\n  }\n\n  private getRating(rating: SubmissionRating) {\n    switch (rating) {\n      case SubmissionRating.MATURE:\n        return 'q';\n      case SubmissionRating.ADULT:\n      case SubmissionRating.EXTREME:\n        return 'e';\n      case SubmissionRating.GENERAL:\n      default:\n        return 's';\n    }\n  }\n\n  private async validateUserFeedback(\n    validator: SubmissionValidator<E621FileSubmission>,\n  ) {\n    try {\n      const { username } = this.websiteDataStore.getData();\n      const feedbacks = await this.getUserFeedback(username);\n\n      if (Array.isArray(feedbacks)) {\n        for (const feedback of feedbacks) {\n          if (feedback.category === E621UserFeedbackCategory.Positive) continue;\n\n          const updatedAt = new Date(feedback.updated_at);\n          const week =\n            /* ms */ 1000 *\n            /* sec */ 60 *\n            /* min */ 60 *\n            /* hour */ 60 *\n            /* day */ 24 *\n            /* week */ 7;\n\n          if (Date.now() - updatedAt.getTime() > week) continue;\n\n          validator.warning('validation.file.e621.user-feedback.recent', {\n            username,\n            negativeOrNeutral: feedback.category,\n            feedback:\n              feedback.body.length > 100\n                ? `${feedback.body.slice(0, 100)}...`\n                : feedback.body,\n          });\n        }\n      }\n    } catch (error) {\n      this.logger.withError(error).error('Failed to get user feedback');\n      validator.warning('validation.file.e621.user-feedback.network-error', {});\n    }\n  }\n\n  private async validateTags(\n    submissionPart: PostData<E621FileSubmission>,\n    validator: SubmissionValidator<E621FileSubmission>,\n  ) {\n    const { tags } = submissionPart.options;\n\n    if (tags.length) {\n      try {\n        const tagsMeta = await this.getTagMetadata(tags);\n        const context: TagCheckingContext = {\n          ifYouWantToCreateNotice: true,\n          generalTags: 0,\n          validator,\n        };\n\n        if (Array.isArray(tagsMeta)) {\n          // All tags do exists but may be still invalid\n          const tagsSet = new Set(tags);\n\n          for (const tagMeta of tagsMeta) {\n            tagsSet.delete(tagMeta.name);\n            this.validateTag(tagMeta, context);\n          }\n\n          // Missing tags are invalid\n          for (const tag of tagsSet) this.tagIsInvalid(context, tag);\n        } else {\n          // No results are produced, all tags are invalid\n          for (const tag of tags) this.tagIsInvalid(context, tag);\n        }\n\n        if (context.generalTags < 10) {\n          validator.warning(\n            'validation.file.e621.tags.recommended',\n            { generalTags: context.generalTags },\n            'tags',\n          );\n        }\n      } catch (error) {\n        this.logger.withError(error).error('Failed to validate tags');\n        validator.warning(\n          'validation.file.e621.tags.network-error',\n          {},\n          'tags',\n        );\n      }\n    }\n  }\n\n  private tagIsInvalid(context: TagCheckingContext, tag: string) {\n    context.validator.warning(\n      context.ifYouWantToCreateNotice\n        ? 'validation.file.e621.tags.missing-create'\n        : 'validation.file.e621.tags.missing',\n      { tag },\n      'tags',\n    );\n    context.ifYouWantToCreateNotice = false;\n  }\n\n  private validateTag(tag: E621Tag, context: TagCheckingContext) {\n    if (tag.category === E621TagCategory.Invalid) {\n      context.validator.error(\n        'validation.file.e621.tags.invalid',\n        { tag: tag.name },\n        'tags',\n      );\n    }\n\n    if (tag.post_count < 2) {\n      context.validator.warning(\n        'validation.file.e621.tags.low-use',\n        { tag: tag.name, postCount: tag.post_count },\n        'tags',\n      );\n    }\n\n    if (tag.category === E621TagCategory.General) context.generalTags++;\n  }\n\n  private async getUserFeedback(username: string) {\n    return this.getMetadata<E621UserFeedbacksEmpty | E621UserFeedbacks>(\n      `/user_feedbacks.json?search[user_name]=${username}`,\n    );\n  }\n\n  private async getTagMetadata(formattedTags: string[]) {\n    return this.getMetadata<E621TagsEmpty | E621Tags>(\n      `/tags.json?search[name]=${formattedTags\n        .map((e) => encodeURIComponent(e))\n        .join(',')}&limit=320`,\n    );\n  }\n\n  private metadataCache = new Map<string, object>();\n\n  private async getMetadata<T extends object>(url: string) {\n    const cached = this.metadataCache.get(url) as T;\n    if (cached) return cached;\n\n    const response = await this.request<object>(\n      new CancellableToken(),\n      'get',\n      url,\n    );\n\n    if (response.statusCode !== 200) throw new Error(response.statusMessage);\n\n    const result = response.body;\n    this.metadataCache.set(url, result);\n\n    return result;\n  }\n}\n\n// Spec: https://e621.net/help/dtext\nclass E621Converter extends BBCodeConverter {\n  convertBlockNode(node: TipTapNode, context: ConversionContext): string {\n    const attrs = node.attrs ?? {};\n\n    // E621 does not support text align\n    delete attrs.textAlign;\n\n    return super.convertBlockNode(node, context);\n  }\n\n  convertBlocks(nodes: TipTapNode[], context: ConversionContext): string {\n    const text = super.convertBlocks(nodes, context);\n\n    return text\n      .replace(/\\[url=([^\\]]*)\\]([^[]*)\\[\\/url\\]/g, '\"$2\":[$1]')\n      .replace(/\\[h(\\d)](.+)\\[\\/h\\d]/g, 'h$1. $2');\n  }\n}\n\ninterface TagCheckingContext {\n  ifYouWantToCreateNotice: boolean;\n  generalTags: number;\n  validator: SubmissionValidator<E621FileSubmission>;\n}\n\n// Source: https://e621.net/tags.json?search[name]=nonexistenttag\ninterface E621TagsEmpty {\n  tags: [];\n}\n\n// Source https://e621.net/tags.json?search[name]=furry\ntype E621Tags = E621Tag[];\n\n// Source https://e621.net/tags.json?search[name]=furry\ninterface E621Tag {\n  id: number;\n  name: string;\n  post_count: number;\n\n  // example: 'anthro 2 duo 2 female 2 furry 2 male 2 male/female 2 mammal 2 beach 1 beatrice_doodledox 1 big_butt 1 bikini 1 credits 1 drew 1 hand_on_butt 1 hi_res 1 human 1 humanoid 1 lion 1 signature 1 spicebunny 1 spicebxnny 1 text 1 this 1 two-piece_swimsuit 1 underwear 1'\n  related_tags: string;\n\n  // example: '2025-01-20T00:16:49.927+03:00'\n  related_tags_updated_at: string;\n\n  category: E621TagCategory;\n\n  // example: false\n  is_locked: boolean;\n\n  // example: '2020-03-05T13:49:37.994+03:00'\n  created_at: string;\n\n  // example: '2025-01-20T00:16:49.928+03:00'\n  updated_at: string;\n}\n\n// Source: https://e621.net/user_feedbacks.json\ninterface E621UserFeedbacksEmpty {\n  user_feedbacks: [];\n}\n\n// Source: https://e621.net/user_feedbacks.json?search[user_name]=fishys1\ntype E621UserFeedbacks = E621UserFeedback[];\n\n// Source: https://e621.net/user_feedbacks\nenum E621UserFeedbackCategory {\n  Neutral = 'neutral',\n  Negative = 'negative',\n  Positive = 'positive',\n}\n\n// Source: https://e621.net/user_feedbacks.json?search[user_name]=fishys1\ninterface E621UserFeedback {\n  id: number;\n  user_id: number;\n  creator_id: number;\n  // example: '2025-01-04T06:55:21.562+03:00'\n  created_at: string;\n  // example: 'Please do not post advertisements for you YCH auctions.  \"[1]\":/posts/5263398 \"[2]\":/posts/5280084\\n\\n[section=Advertising]\\n* Do not promote any external sites, resources, products, or services.\\n* If you are an artist or content owner, you are permitted to advertise products and services you may offer. You may do so in the \"description\" field of your posts, on the artist page, and in your profile description.\\n\\nIf you wish to promote your products or services through a banner ad, please contact `ads@dragonfru.it` with any questions. See the \"advertisement help page\":/help/advertising for more information.\\n\\n\"[Code of Conduct - Advertising]\":/wiki_pages/e621:rules#advertising\\n[/section]\\n'\n  body: string;\n  category: E621UserFeedbackCategory;\n  // example: '2025-01-04T06:55:21.562+03:00'\n  updated_at: string;\n  updater_id: string;\n  is_deleted: boolean;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/e621/models/e621-file-submission.ts",
    "content": "import { DescriptionField, TagField, TextField } from '@postybirb/form-builder';\nimport {\n  DefaultTagValue,\n  DescriptionType,\n  DescriptionValue,\n  TagValue,\n} from '@postybirb/types';\nimport { BaseWebsiteOptions } from '../../../models/base-website-options';\n\nexport class E621FileSubmission extends BaseWebsiteOptions {\n  @DescriptionField({\n    descriptionType: DescriptionType.CUSTOM,\n    expectsInlineTitle: true,\n  })\n  description: DescriptionValue;\n\n  @TagField({\n    minTags: 4,\n    spaceReplacer: '_',\n    searchProviderId: 'e621',\n  })\n  tags: TagValue = DefaultTagValue();\n\n  @TextField({\n    label: 'parentId',\n    section: 'website',\n    span: 12,\n  })\n  parentId?: string;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/firefish/firefish.website.ts",
    "content": "import { FileType } from '@postybirb/types';\nimport FileSize from '../../../utils/filesize.util';\nimport { DisableAds } from '../../decorators/disable-ads.decorator';\nimport { CustomLoginFlow } from '../../decorators/login-flow.decorator';\nimport { SupportsFiles } from '../../decorators/supports-files.decorator';\nimport { WebsiteMetadata } from '../../decorators/website-metadata.decorator';\nimport { MegalodonWebsite } from '../megalodon/megalodon.website';\n\n@WebsiteMetadata({\n  name: 'firefish',\n  displayName: 'Firefish',\n})\n@CustomLoginFlow()\n@SupportsFiles({\n  acceptedMimeTypes: [\n    'image/png',\n    'image/jpeg',\n    'image/gif',\n    'image/webp',\n    'image/avif',\n    'image/heic',\n    'image/heif',\n    'video/mp4',\n    'video/webm',\n    'video/x-m4v',\n    'video/quicktime',\n    'application/msword',\n    'application/rtf',\n    'text/plain',\n    'audio/mpeg', // mp3\n    'audio/wav',\n    'audio/ogg', // ogg, oga\n    'audio/opus',\n    'audio/aac',\n    'audio/mp4',\n    'video/3gpp',\n    'audio/x-ms-wma',\n  ],\n  acceptedFileSizes: {\n    [FileType.IMAGE]: FileSize.megabytes(16),\n    [FileType.AUDIO]: FileSize.megabytes(100),\n    [FileType.VIDEO]: FileSize.megabytes(200),\n  },\n  fileBatchSize: 4,\n})\n@DisableAds()\nexport default class Firefish extends MegalodonWebsite {\n  protected getMegalodonInstanceType(): 'firefish' {\n    return 'firefish';\n  }\n\n  protected getDefaultMaxDescriptionLength(): number {\n    return 500;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/friendica/friendica.website.ts",
    "content": "import { FileType } from '@postybirb/types';\nimport FileSize from '../../../utils/filesize.util';\nimport { DisableAds } from '../../decorators/disable-ads.decorator';\nimport { CustomLoginFlow } from '../../decorators/login-flow.decorator';\nimport { SupportsFiles } from '../../decorators/supports-files.decorator';\nimport { WebsiteMetadata } from '../../decorators/website-metadata.decorator';\nimport { MegalodonWebsite } from '../megalodon/megalodon.website';\n\n@WebsiteMetadata({\n  name: 'friendica',\n  displayName: 'Friendica',\n})\n@CustomLoginFlow()\n@SupportsFiles({\n  acceptedMimeTypes: [\n    'image/png',\n    'image/jpeg',\n    'image/gif',\n    'image/webp',\n    'image/avif',\n    'image/heic',\n    'image/heif',\n    'video/mp4',\n    'video/webm',\n    'video/x-m4v',\n    'video/quicktime',\n    'application/msword',\n    'application/rtf',\n    'text/plain',\n    'audio/mpeg', // mp3\n    'audio/wav',\n    'audio/ogg', // ogg, oga\n    'audio/opus',\n    'audio/aac',\n    'audio/mp4',\n    'video/3gpp',\n    'audio/x-ms-wma',\n  ],\n  acceptedFileSizes: {\n    [FileType.IMAGE]: FileSize.megabytes(16),\n    [FileType.AUDIO]: FileSize.megabytes(100),\n    [FileType.VIDEO]: FileSize.megabytes(200),\n  },\n  fileBatchSize: 4,\n})\n@DisableAds()\nexport default class Friendica extends MegalodonWebsite {\n  protected getMegalodonInstanceType(): 'friendica' {\n    return 'friendica';\n  }\n\n  protected getDefaultMaxDescriptionLength(): number {\n    return 500;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/fur-affinity/fur-affinity.website.ts",
    "content": "import { SelectOption } from '@postybirb/form-builder';\nimport { Http } from '@postybirb/http';\nimport {\n  FileType,\n  ILoginState,\n  ImageResizeProps,\n  IPostResponse,\n  PostData,\n  PostResponse,\n  SimpleValidationResult,\n  SubmissionRating,\n} from '@postybirb/types';\nimport { HTMLElement, parse } from 'node-html-parser';\nimport { CancellableToken } from '../../../post/models/cancellable-token';\nimport { PostingFile } from '../../../post/models/posting-file';\nimport FileSize from '../../../utils/filesize.util';\nimport HtmlParserUtil from '../../../utils/html-parser.util';\nimport { PostBuilder } from '../../commons/post-builder';\nimport { validatorPassthru } from '../../commons/validator-passthru';\nimport { UserLoginFlow } from '../../decorators/login-flow.decorator';\nimport { SupportsFiles } from '../../decorators/supports-files.decorator';\nimport { SupportsUsernameShortcut } from '../../decorators/supports-username-shortcut.decorator';\nimport { WebsiteMetadata } from '../../decorators/website-metadata.decorator';\nimport { DataPropertyAccessibility } from '../../models/data-property-accessibility';\nimport { FileWebsite } from '../../models/website-modifiers/file-website';\nimport { MessageWebsite } from '../../models/website-modifiers/message-website';\nimport { Website } from '../../website';\nimport { FurAffinityAccountData } from './models/fur-affinity-account-data';\nimport { FurAffinityFileSubmission } from './models/fur-affinity-file-submission';\nimport { FurAffinityMessageSubmission } from './models/fur-affinity-message-submission';\n\n@WebsiteMetadata({\n  name: 'fur-affinity',\n  displayName: 'Fur Affinity',\n  minimumPostWaitInterval: 70_000,\n})\n@UserLoginFlow('https://furaffinity.net/login')\n@SupportsUsernameShortcut({\n  id: 'furaffinity',\n  url: 'https://furaffinity.net/user/$1',\n  convert: (websiteName, shortcut) => {\n    if (websiteName === 'fur-affinity' && shortcut === 'furaffinity') {\n      return ':icon$1:';\n    }\n\n    return undefined;\n  },\n})\n@SupportsFiles({\n  acceptedMimeTypes: [\n    'image/jpg',\n    'image/jpeg',\n    'image/gif',\n    'image/png',\n    'image/jpeg',\n    'image/jpg',\n    'image/swf',\n    'application/msword',\n    'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n    'application/rtf',\n    'text/plain',\n    'application/pdf',\n    'application/vnd.oasis.opendocument.text',\n    'audio/midi',\n    'audio/wav',\n    'audio/mp3',\n    'audio/mpeg',\n    'video/mpeg',\n  ],\n  acceptedFileSizes: {\n    '*': FileSize.megabytes(10),\n  },\n})\nexport default class FurAffinity\n  extends Website<FurAffinityAccountData>\n  implements\n    FileWebsite<FurAffinityFileSubmission>,\n    MessageWebsite<FurAffinityMessageSubmission>\n{\n  protected BASE_URL = 'https://www.furaffinity.net';\n\n  public externallyAccessibleWebsiteDataProperties: DataPropertyAccessibility<FurAffinityAccountData> =\n    {\n      folders: true,\n    };\n\n  public async onLogin(): Promise<ILoginState> {\n    try {\n      const res = await Http.get<string>(\n        `${this.BASE_URL}/controls/submissions`,\n        { partition: this.accountId },\n      );\n\n      if (res.body.includes('logout-link')) {\n        const $ = parse(res.body);\n        this.getFolders($);\n        const username = $.querySelector('.loggedin_user_avatar')?.getAttribute('alt');\n        if (!username) {\n          this.logger.warn('Failed to find loggedin_user_avatar element during login');\n        }\n        return this.loginState.setLogin(true, username ?? null);\n      }\n\n      return this.loginState.setLogin(false, null);\n    } catch (e) {\n      this.logger.error('Failed to login', e);\n      return this.loginState.setLogin(false, null);\n    }\n  }\n\n  private getFolders($: HTMLElement) {\n    const folders: SelectOption[] = [];\n    const flatFolders: SelectOption[] = [];\n\n    const folderSelect = $.querySelector('select[name=assign_folder_id]');\n    if (!folderSelect) {\n      this.logger.warn('Failed to find folder select element during login');\n      return;\n    }\n    folderSelect.children.forEach((el) => {\n      if (el.tagName === 'OPTION') {\n        if (el.getAttribute('value') === '0') {\n          return;\n        }\n        const folder: SelectOption = {\n          value: el.getAttribute('value'),\n          label: el.textContent,\n        };\n        folders.push(folder);\n        flatFolders.push(folder);\n      } else {\n        const optgroup: SelectOption = {\n          label: el.getAttribute('label'),\n          items: [],\n        };\n        [...el.children].forEach((opt) => {\n          const f: SelectOption = {\n            value: opt.getAttribute('value'),\n            label: opt.textContent,\n          };\n          optgroup.items.push(f);\n          flatFolders.push(f);\n        });\n        folders.push(optgroup);\n      }\n    });\n\n    this.setWebsiteData({\n      folders: flatFolders,\n    });\n  }\n\n  createFileModel(): FurAffinityFileSubmission {\n    return new FurAffinityFileSubmission();\n  }\n\n  calculateImageResize(): ImageResizeProps {\n    return undefined;\n  }\n\n  private processForError(body: string): string | undefined {\n    if (body.includes('redirect-message')) {\n      const $ = parse(body);\n      let msg = $.querySelector('.redirect-message')?.textContent?.trim();\n\n      if (msg?.includes('CAPTCHA')) {\n        msg =\n          'You need at least 11+ posts on your account before you can use PostyBirb with Fur Affinity.';\n      }\n\n      return msg;\n    }\n\n    return undefined;\n  }\n\n  async onPostFileSubmission(\n    postData: PostData<FurAffinityFileSubmission>,\n    files: PostingFile[],\n    cancellationToken: CancellableToken,\n  ): Promise<IPostResponse> {\n    const part1 = await Http.get<string>(`${this.BASE_URL}/submit/`, {\n      partition: this.accountId,\n      headers: {\n        Referer: 'https://www.furaffinity.net/submit/',\n      },\n    });\n\n    PostResponse.validateBody(this, part1);\n    const err = this.processForError(part1.body);\n    if (err) {\n      return PostResponse.fromWebsite(this)\n        .withException(new Error(err))\n        .withAdditionalInfo(part1.body);\n    }\n\n    const key =\n      parse(part1.body)\n        .querySelector('#upload_form input[name=\"key\"]')\n        ?.getAttribute('value') ??\n      parse(part1.body)\n        .querySelector('#myform input[name=\"key\"]')\n        ?.getAttribute('value');\n    if (!key) {\n      return PostResponse.fromWebsite(this)\n        .withException(new Error('Failed to retrieve key for file submission'))\n        .withAdditionalInfo(part1.body)\n        .atStage('part 1');\n    }\n\n    // In theory, post-manager handles the alt file\n    const part2 = await new PostBuilder(this, cancellationToken)\n      .asMultipart()\n      .setField('key', key)\n      .setField('submission_type', this.getContentType(files[0].fileType))\n      .addFile('submission', files[0])\n      .addThumbnail('thumbnail', files[0])\n      .withHeader('Referer', 'https://www.furaffinity.net/submit/')\n      .send<string>(`${this.BASE_URL}/submit/upload`);\n\n    const err2 = this.processForError(part2.body);\n    if (err2) {\n      return PostResponse.fromWebsite(this)\n        .withException(new Error(err2))\n        .withAdditionalInfo(part2.body)\n        .atStage('part 2');\n    }\n\n    const finalizeKey = parse(part2.body)\n      .querySelector('#myform input[name=\"key\"]')\n      ?.getAttribute('value');\n\n    if (!finalizeKey) {\n      return PostResponse.fromWebsite(this)\n        .withException(new Error('Failed to retrieve key for file submission'))\n        .withAdditionalInfo(part2.body)\n        .atStage('finalize key get');\n    }\n\n    const builder = new PostBuilder(this, cancellationToken)\n      .asUrlEncoded()\n      .setField('key', finalizeKey)\n      .setField('title', postData.options.title)\n      .setField('message', postData.options.description)\n      .setField('keywords', postData.options.tags.join(' '))\n      .setField('rating', this.getRating(postData.options.rating))\n      .setField('atype', postData.options.theme)\n      .setField('species', postData.options.species)\n      .setField('gender', postData.options.gender)\n      .setConditional(\n        'cat',\n        files[0].fileType === FileType.IMAGE,\n        postData.options.category,\n        this.getContentCategory(files[0].fileType),\n      )\n      .setConditional('lock_comments', postData.options.disableComments, 'on')\n      .setConditional('scrap', postData.options.scraps, '1')\n      .setConditional(\n        'folder_ids',\n        (postData.options.folders ?? []).length > 0,\n        postData.options.folders,\n      );\n\n    const postResponse = await builder.send<string>(\n      `${this.BASE_URL}/submit/finalize`,\n    );\n\n    if (!postResponse?.responseUrl?.includes('?upload-successful')) {\n      const err3 = this.processForError(postResponse.body);\n      if (err3) {\n        return PostResponse.fromWebsite(this)\n          .withMessage(err3)\n          .withException(new Error(err3))\n          .withAdditionalInfo(postResponse.body);\n      }\n\n      return PostResponse.fromWebsite(this)\n        .withException(new Error('Failed to post file submission'))\n        .withAdditionalInfo(postResponse.body);\n    }\n\n    const $ = parse(postResponse.body);\n    const submissionUrl =\n      $.querySelector('#submissionImg')?.getAttribute('src');\n    return PostResponse.fromWebsite(this)\n      .withSourceUrl(postResponse.responseUrl.replace('?upload-successful', ''))\n      .withMessage('File posted successfully')\n      .withAdditionalInfo(postResponse.body);\n  }\n\n  async onValidateFileSubmission(\n    postData: PostData<FurAffinityFileSubmission>,\n  ): Promise<SimpleValidationResult> {\n    const validator = this.createValidator<FurAffinityFileSubmission>();\n\n    const tags = postData.options.tags.filter((t) => t.length > 0);\n    if (tags.length < 3) {\n      validator.error(\n        'validation.tags.min-tags',\n        {\n          currentLength: tags.length,\n          minLength: 3,\n        },\n        'tags',\n      );\n    }\n\n    return validator.result;\n  }\n\n  createMessageModel(): FurAffinityMessageSubmission {\n    return new FurAffinityMessageSubmission();\n  }\n\n  async onPostMessageSubmission(\n    postData: PostData<FurAffinityMessageSubmission>,\n    cancellationToken: CancellableToken,\n  ): Promise<IPostResponse> {\n    const page = await Http.get<string>(`${this.BASE_URL}/controls/journal`, {\n      partition: this.accountId,\n    });\n    PostResponse.validateBody(this, page);\n\n    const key = parse(page.body)\n      .querySelector('#journal-form input[name=\"key\"]')\n      ?.getAttribute('value');\n    if (!key) {\n      return PostResponse.fromWebsite(this)\n        .withException(\n          new Error('Failed to retrieve key for journal submission'),\n        )\n        .withAdditionalInfo(page.body);\n    }\n    const key2 = HtmlParserUtil.getInputValue(\n      page.body.split('action=\"/controls/journal/\"').pop(),\n      'key',\n    );\n    const builder = new PostBuilder(this, cancellationToken)\n      .asUrlEncoded()\n      .setField('key', key)\n      .setField('message', postData.options.description)\n      .setField('subject', postData.options.title)\n      .setField('id', '0')\n      .setField('do', 'update')\n      .setConditional('make_featured', postData.options.feature, 'on');\n\n    const post = await builder.send<string>(\n      `${this.BASE_URL}/controls/journal/`,\n    );\n\n    if (post.body.includes('journal-title')) {\n      return PostResponse.fromWebsite(this)\n        .withAdditionalInfo(post.body)\n        .withSourceUrl(post.responseUrl);\n    }\n\n    return PostResponse.fromWebsite(this)\n      .withException(new Error('Failed to post journal'))\n      .withAdditionalInfo(post.body);\n  }\n\n  private getContentType(type: FileType) {\n    switch (type) {\n      case FileType.TEXT:\n        return 'story';\n      case FileType.VIDEO:\n        return 'flash';\n      case FileType.AUDIO:\n        return 'music';\n      case FileType.IMAGE:\n      default:\n        return 'submission';\n    }\n  }\n\n  private getContentCategory(type: FileType) {\n    switch (type) {\n      case FileType.TEXT:\n        return '13';\n      case FileType.VIDEO:\n        return '7';\n      case FileType.AUDIO:\n        return '16';\n      case FileType.IMAGE:\n      default:\n        return '1';\n    }\n  }\n\n  private getRating(rating: SubmissionRating) {\n    switch (rating) {\n      case SubmissionRating.ADULT:\n      case SubmissionRating.EXTREME:\n        return '1';\n      case SubmissionRating.MATURE:\n        return '2';\n      case SubmissionRating.GENERAL:\n      default:\n        return '0';\n    }\n  }\n\n  onValidateMessageSubmission = validatorPassthru;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/fur-affinity/models/fur-affinity-account-data.ts",
    "content": "import { SelectOption } from \"@postybirb/form-builder\";\n\nexport type FurAffinityAccountData = {\n  folders: SelectOption[];\n};\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/fur-affinity/models/fur-affinity-categories.ts",
    "content": "import { SelectOption } from '@postybirb/form-builder';\n\n// Run this script on https://www.furaffinity.net/submit/finalize/ to update categories\n// JSON.stringify(Array.prototype.map.call(document.querySelector(\"select[name='cat']\"), (e) => ({ value: e.value, label: e.label, parent: e.parentNode?.label, })).reduce((e, c) => { if (!c.parent) { e.push({ value: c.value, label: c.label }); return e; }; const f = e.find((e) => e.label === c.parent) ?? e[e.push({ label: c.parent, items: [] }) - 1]; f.items.push({ value: c.value, label: c.label }); return e; }, []))\n\nexport const FurAffinityCategories: SelectOption[] = [\n  {\n    label: 'Visual Art',\n    items: [\n      { value: '1', label: 'All' },\n      { value: '34', label: '3D Models' },\n      { value: '2', label: 'Artwork (Digital)' },\n      { value: '3', label: 'Artwork (Traditional)' },\n      { value: '4', label: 'Cel Shading' },\n      { value: '5', label: 'Crafting' },\n      { value: '6', label: 'Designs' },\n      { value: '32', label: 'Food / Recipes' },\n      { value: '8', label: 'Fursuiting' },\n      { value: '9', label: 'Icons' },\n      { value: '10', label: 'Mosaics' },\n      { value: '11', label: 'Photography' },\n      { value: '36', label: 'Pixel Art' },\n      { value: '12', label: 'Sculpting' },\n      { value: '35', label: 'Virtual Photography' },\n    ],\n  },\n  {\n    label: 'Animation & Media',\n    items: [\n      { value: '37', label: '2D Animation' },\n      { value: '38', label: '3D Animation' },\n      { value: '39', label: 'Pixel Animation' },\n      { value: '7', label: 'Flash' },\n      { value: '40', label: 'Interactive Media' },\n    ],\n  },\n  {\n    label: 'Readable Art',\n    items: [\n      { value: '13', label: 'Story' },\n      { value: '14', label: 'Poetry' },\n      { value: '15', label: 'Prose' },\n    ],\n  },\n  {\n    label: 'Audio Art',\n    items: [\n      { value: '16', label: 'Music' },\n      { value: '17', label: 'Podcasts' },\n    ],\n  },\n  {\n    label: 'Downloadable',\n    items: [\n      { value: '18', label: 'Skins' },\n      { value: '19', label: 'Handhelds' },\n      { value: '20', label: 'Resources' },\n    ],\n  },\n  {\n    label: 'Other Stuff',\n    items: [\n      { value: '21', label: 'Adoptables' },\n      { value: '22', label: 'Auctions' },\n      { value: '23', label: 'Contests' },\n      { value: '24', label: 'Current Events' },\n      { value: '26', label: 'Stockart' },\n      { value: '27', label: 'Screenshots' },\n      { value: '28', label: 'Scraps' },\n      { value: '29', label: 'Wallpaper' },\n      { value: '30', label: 'YCH / Sale' },\n      { value: '31', label: 'Other' },\n    ],\n  },\n];\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/fur-affinity/models/fur-affinity-file-submission.ts",
    "content": "import {\n  BooleanField,\n  DescriptionField,\n  RatingField,\n  SelectField,\n  TagField,\n  TitleField,\n} from '@postybirb/form-builder';\nimport {\n  DescriptionType,\n  DescriptionValue,\n  SubmissionRating,\n  TagValue,\n} from '@postybirb/types';\nimport { BaseWebsiteOptions } from '../../../models/base-website-options';\nimport { FurAffinityAccountData } from './fur-affinity-account-data';\nimport { FurAffinityCategories } from './fur-affinity-categories';\nimport { FurAffinitySpecies } from './fur-affinity-species-options';\nimport { FurAffinityThemes } from './fur-affinity-themes';\n\nexport class FurAffinityFileSubmission extends BaseWebsiteOptions {\n  @TitleField({ maxLength: 60 })\n  title: string;\n\n  @DescriptionField({\n    descriptionType: DescriptionType.BBCODE,\n  })\n  description: DescriptionValue;\n\n  @TagField({\n    spaceReplacer: '_',\n  })\n  tags: TagValue;\n\n  @RatingField({\n    options: [\n      { value: SubmissionRating.GENERAL, label: 'General' },\n      { value: SubmissionRating.MATURE, label: 'Mature' },\n      { value: SubmissionRating.ADULT, label: 'Adult' },\n    ],\n  })\n  rating: SubmissionRating;\n\n  @SelectField({\n    label: 'category',\n    defaultValue: '1',\n    options: FurAffinityCategories,\n    section: 'website',\n    span: 6,\n  })\n  category: string;\n\n  @SelectField({\n    label: 'theme',\n    defaultValue: '1',\n    options: FurAffinityThemes,\n    section: 'website',\n    span: 6,\n  })\n  theme: string;\n\n  @SelectField({\n    label: 'species',\n    defaultValue: '1',\n    options: FurAffinitySpecies,\n    section: 'website',\n    span: 6,\n  })\n  species: string;\n\n  @SelectField({\n    label: 'gender',\n    defaultValue: '0',\n    options: [\n      { value: '0', label: 'Any' },\n      { value: '2', label: 'Male' },\n      { value: '3', label: 'Female' },\n      { value: '4', label: 'Herm' },\n      { value: '11', label: 'Intersex' },\n      { value: '8', label: 'Trans (Male)' },\n      { value: '9', label: 'Trans (Female)' },\n      { value: '10', label: 'Non-Binary' },\n      { value: '6', label: 'Multiple characters' },\n      { value: '7', label: 'Other / Not Specified' },\n    ],\n    section: 'website',\n    span: 6,\n  })\n  gender: string;\n\n  @SelectField<FurAffinityAccountData>({\n    label: 'folder',\n    allowMultiple: true,\n    options: [],\n    derive: [\n      {\n        key: 'folders',\n        populate: 'options',\n      },\n    ],\n    section: 'website',\n    span: 12,\n  })\n  folders: string[];\n\n  @BooleanField({\n    label: 'disableComments',\n    defaultValue: false,\n    section: 'website',\n    span: 6,\n  })\n  disableComments: boolean;\n\n  @BooleanField({\n    label: 'scraps',\n    defaultValue: false,\n    section: 'website',\n    span: 6,\n  })\n  scraps: boolean;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/fur-affinity/models/fur-affinity-message-submission.ts",
    "content": "import {\n  BooleanField,\n  DescriptionField,\n  TagField,\n  TitleField,\n} from '@postybirb/form-builder';\nimport { DescriptionType, DescriptionValue, TagValue } from '@postybirb/types';\nimport { BaseWebsiteOptions } from '../../../models/base-website-options';\n\nexport class FurAffinityMessageSubmission extends BaseWebsiteOptions {\n  @TitleField({ maxLength: 60 })\n  title: string;\n\n  @DescriptionField({\n    descriptionType: DescriptionType.BBCODE,\n  })\n  description: DescriptionValue;\n\n  @TagField({\n    hidden: true,\n  })\n  tags: TagValue;\n\n  @BooleanField({\n    section: 'website',\n    span: 6,\n    label: 'feature',\n    defaultValue: true,\n  })\n  feature: boolean;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/fur-affinity/models/fur-affinity-species-options.ts",
    "content": "import { SelectOption } from '@postybirb/form-builder';\n\n// Run this script on https://www.furaffinity.net/submit/finalize/ to update species\n// JSON.stringify(Array.prototype.map.call(document.querySelector(\"select[name='species']\"), (e) => ({ value: e.value, label: e.label, parent: e.parentNode?.label, })).reduce((e, c) => { if (!c.parent) { e.push({ value: c.value, label: c.label }); return e; }; const f = e.find((e) => e.label === c.parent) ?? e[e.push({ label: c.parent, items: [] }) - 1]; f.items.push({ value: c.value, label: c.label }); return e; }, []))\n\nexport const FurAffinitySpecies: SelectOption[] = [\n  { value: '1', label: 'Unspecified / Any' },\n  {\n    label: 'General',\n    items: [\n      { value: '10001', label: 'Airborne Vehicle' },\n      { value: '5001', label: 'Alien (Other)' },\n      { value: '1000', label: 'Amphibian (Other)' },\n      { value: '2000', label: 'Aquatic (Other)' },\n      { value: '3000', label: 'Avian (Other)' },\n      { value: '6002', label: 'Bear (Other)' },\n      { value: '6007', label: 'Bovine (Other)' },\n      { value: '6017', label: 'Canine (Other)' },\n      { value: '6018', label: 'Cervine (Other)' },\n      { value: '6010', label: 'Dog (Other)' },\n      { value: '4000', label: 'Dragon (Other)' },\n      { value: '10009', label: 'Equine (Other)' },\n      { value: '5000', label: 'Exotic (Other)' },\n      { value: '6030', label: 'Feline (Other)' },\n      { value: '6075', label: 'Fox (Other)' },\n      { value: '10007', label: 'Goo / Slime' },\n      { value: '10002', label: 'Hybrid Species' },\n      { value: '10006', label: 'Inanimate' },\n      { value: '8003', label: 'Insect (Other)' },\n      { value: '10003', label: 'Land Vehicle' },\n      { value: '6000', label: 'Mammal (Other)' },\n      { value: '6042', label: 'Marsupial (Other)' },\n      { value: '6051', label: 'Mustelid (Other)' },\n      { value: '10008', label: 'Plant / Fungus' },\n      { value: '6058', label: 'Primate (Other)' },\n      { value: '7000', label: 'Reptilian (Other)' },\n      { value: '10004', label: 'Robot / Android / Cyborg' },\n      { value: '6067', label: 'Rodent (Other)' },\n      { value: '10005', label: 'Sea Vehicle' },\n      { value: '5025', label: 'Taur (Other)' },\n      { value: '6015', label: 'Vulpine (Other)' },\n    ],\n  },\n  {\n    label: 'Fandom Creations',\n    items: [\n      { value: '11014', label: 'Original Species' },\n      { value: '11015', label: 'Fan Species / Character' },\n      { value: '11001', label: 'Aeromorph' },\n      { value: '11002', label: 'Angel Dragon' },\n      { value: '11012', label: 'Avali' },\n      { value: '5003', label: 'Chakat' },\n      { value: '5005', label: 'Citra' },\n      { value: '5006', label: 'Crux' },\n      { value: '5009', label: 'Dracat' },\n      { value: '11003', label: 'Dutch Angel Dragon' },\n      { value: '11011', label: 'Felkin' },\n      { value: '11004', label: 'Ferrin' },\n      { value: '11005', label: 'Jogauni' },\n      { value: '5014', label: 'Langurhali' },\n      { value: '11006', label: 'Nevrean' },\n      { value: '11007', label: 'Protogen' },\n      { value: '11016', label: 'Rexouium' },\n      { value: '5021', label: 'Sergal' },\n      { value: '11010', label: 'Synx' },\n      { value: '11013', label: 'Wickerbeast' },\n      { value: '11009', label: 'Yinglet' },\n      { value: '11008', label: 'Zorgoia' },\n    ],\n  },\n  {\n    label: 'Mythological / Fantasy',\n    items: [\n      { value: '12001', label: 'Angel' },\n      { value: '12002', label: 'Centaur' },\n      { value: '12003', label: 'Cerberus' },\n      { value: '12038', label: 'Changeling / Shape Shifter' },\n      { value: '12004', label: 'Chimera' },\n      { value: '12005', label: 'Chupacabra' },\n      { value: '12006', label: 'Cockatrice' },\n      { value: '5007', label: 'Daemon' },\n      { value: '12007', label: 'Demon' },\n      { value: '12008', label: 'Displacer Beast' },\n      { value: '12009', label: 'Dragonborn' },\n      { value: '12010', label: 'Drow' },\n      { value: '12011', label: 'Dwarf' },\n      { value: '4001', label: 'Eastern Dragon' },\n      { value: '5011', label: 'Elf' },\n      { value: '5012', label: 'Gargoyle' },\n      { value: '12012', label: 'Goblin' },\n      { value: '12013', label: 'Golem' },\n      { value: '3007', label: 'Gryphon' },\n      { value: '12014', label: 'Harpy' },\n      { value: '12015', label: 'Hellhound' },\n      { value: '12016', label: 'Hippogriff' },\n      { value: '12017', label: 'Hobbit' },\n      { value: '4002', label: 'Hydra' },\n      { value: '12018', label: 'Imp' },\n      { value: '12019', label: 'Incubus' },\n      { value: '12020', label: 'Jackalope' },\n      { value: '12021', label: 'Kirin' },\n      { value: '12022', label: 'Kitsune' },\n      { value: '12023', label: 'Kobold' },\n      { value: '12024', label: 'Lamia' },\n      { value: '12025', label: 'Manticore' },\n      { value: '12026', label: 'Minotaur' },\n      { value: '5016', label: 'Naga' },\n      { value: '12027', label: 'Nephilim' },\n      { value: '5018', label: 'Orc' },\n      { value: '12028', label: 'Pegasus' },\n      { value: '12029', label: 'Peryton' },\n      { value: '3010', label: 'Phoenix' },\n      { value: '12030', label: 'Sasquatch' },\n      { value: '5020', label: 'Satyr' },\n      { value: '12031', label: 'Sphinx' },\n      { value: '12032', label: 'Succubus' },\n      { value: '12033', label: 'Tiefling' },\n      { value: '12034', label: 'Troll' },\n      { value: '5023', label: 'Unicorn' },\n      { value: '12035', label: 'Water Dragon' },\n      { value: '12036', label: 'Werewolf / Lycanthrope' },\n      { value: '4004', label: 'Western Dragon' },\n      { value: '4005', label: 'Wyvern' },\n      { value: '12037', label: 'Yokai' },\n    ],\n  },\n  {\n    label: 'Games / Media',\n    items: [\n      { value: '13001', label: 'Alicorn (MLP)' },\n      { value: '5002', label: 'Argonian' },\n      { value: '13002', label: 'Asari' },\n      { value: '13003', label: 'Bangaa' },\n      { value: '13004', label: 'Bubble Dragon' },\n      { value: '13005', label: 'Burmecian' },\n      { value: '13006', label: 'Charr' },\n      { value: '13007', label: 'Chiss' },\n      { value: '5004', label: 'Chocobo' },\n      { value: '13008', label: 'Deathclaw' },\n      { value: '5008', label: 'Digimon' },\n      { value: '5010', label: 'Draenei' },\n      { value: '13009', label: 'Drell' },\n      { value: '13010', label: 'Elcor' },\n      { value: '13011', label: 'Ewok' },\n      { value: '13012', label: 'Hanar' },\n      { value: '13013', label: 'Hrothgar' },\n      { value: '5013', label: 'Iksar' },\n      { value: '5015', label: 'Kaiju / Giant Monster' },\n      { value: '13041', label: 'Kelpie' },\n      { value: '13014', label: 'Kemonomimi' },\n      { value: '13015', label: 'Khajiit' },\n      { value: '13016', label: 'Koopa' },\n      { value: '13017', label: 'Krogan' },\n      { value: '13018', label: 'Lombax' },\n      { value: '13019', label: 'Mimiga' },\n      { value: '13020', label: 'Mobian' },\n      { value: '5017', label: 'Moogle' },\n      { value: '13021', label: 'Neopet' },\n      { value: '13022', label: 'Nu Mou' },\n      { value: '5019', label: 'Pokemon' },\n      { value: '13023', label: 'Pony (MLP)' },\n      { value: '13024', label: 'Protoss' },\n      { value: '13025', label: 'Quarian' },\n      { value: '13026', label: 'Ronso' },\n      { value: '13027', label: 'Salarian' },\n      { value: '13028', label: 'Sangheili / Elites' },\n      { value: '13029', label: 'Tauntaun' },\n      { value: '13030', label: 'Tauren' },\n      { value: '13031', label: 'Trandoshan' },\n      { value: '13032', label: 'Transformer' },\n      { value: '13033', label: 'Turian' },\n      { value: '13034', label: \"Twi'lek\" },\n      { value: '13035', label: 'Viera' },\n      { value: '13036', label: 'Wookiee' },\n      { value: '5024', label: 'Xenomorph' },\n      { value: '13037', label: 'Yautja / Predator' },\n      { value: '13038', label: 'Yordle' },\n      { value: '13039', label: 'Yoshi' },\n      { value: '13040', label: 'Zerg' },\n    ],\n  },\n  {\n    label: 'Animal Kingdom',\n    items: [\n      { value: '14001', label: 'Aardvark' },\n      { value: '14002', label: 'Aardwolf' },\n      { value: '14003', label: 'African Wild Dog' },\n      { value: '14004', label: 'Akita' },\n      { value: '14005', label: 'Albatross' },\n      { value: '7001', label: 'Alligator / Crocodile' },\n      { value: '14006', label: 'Alpaca' },\n      { value: '14007', label: 'Anaconda' },\n      { value: '14008', label: 'Anteater' },\n      { value: '6004', label: 'Antelope' },\n      { value: '8000', label: 'Arachnid' },\n      { value: '14009', label: 'Arctic Fox' },\n      { value: '14010', label: 'Armadillo' },\n      { value: '14011', label: 'Axolotl' },\n      { value: '14012', label: 'Baboon' },\n      { value: '6045', label: 'Badger' },\n      { value: '6001', label: 'Bat' },\n      { value: '6064', label: 'Beaver' },\n      { value: '14013', label: 'Bee' },\n      { value: '14014', label: 'Binturong' },\n      { value: '14015', label: 'Bison' },\n      { value: '14016', label: 'Blue Jay' },\n      { value: '14017', label: 'Border Collie' },\n      { value: '14018', label: 'Brown Bear' },\n      { value: '14019', label: 'Buffalo' },\n      { value: '14020', label: 'Buffalo / Bison' },\n      { value: '14021', label: 'Bull Terrier' },\n      { value: '14022', label: 'Butterfly' },\n      { value: '14023', label: 'Caiman' },\n      { value: '6074', label: 'Camel' },\n      { value: '14024', label: 'Capybara' },\n      { value: '14025', label: 'Caribou' },\n      { value: '14026', label: 'Caterpillar' },\n      { value: '2001', label: 'Cephalopod' },\n      { value: '14027', label: 'Chameleon' },\n      { value: '6021', label: 'Cheetah' },\n      { value: '14028', label: 'Chicken' },\n      { value: '14029', label: 'Chimpanzee' },\n      { value: '14030', label: 'Chinchilla' },\n      { value: '14031', label: 'Chipmunk' },\n      { value: '14032', label: 'Civet' },\n      { value: '14033', label: 'Clouded Leopard' },\n      { value: '14034', label: 'Coatimundi' },\n      { value: '14035', label: 'Cockatiel' },\n      { value: '14036', label: 'Corgi' },\n      { value: '3001', label: 'Corvid' },\n      { value: '6022', label: 'Cougar / Puma' },\n      { value: '6003', label: 'Cow' },\n      { value: '6008', label: 'Coyote' },\n      { value: '14037', label: 'Crab' },\n      { value: '14038', label: 'Crane' },\n      { value: '14039', label: 'Crayfish' },\n      { value: '3002', label: 'Crow' },\n      { value: '14040', label: 'Crustacean' },\n      { value: '14041', label: 'Dalmatian' },\n      { value: '14042', label: 'Deer' },\n      { value: '14043', label: 'Dhole' },\n      { value: '6011', label: 'Dingo' },\n      { value: '8001', label: 'Dinosaur' },\n      { value: '6009', label: 'Doberman' },\n      { value: '2002', label: 'Dolphin' },\n      { value: '6019', label: 'Donkey / Mule' },\n      { value: '3003', label: 'Duck' },\n      { value: '3004', label: 'Eagle' },\n      { value: '14044', label: 'Eel' },\n      { value: '14045', label: 'Elephant' },\n      { value: '3005', label: 'Falcon' },\n      { value: '6072', label: 'Fennec' },\n      { value: '6046', label: 'Ferret' },\n      { value: '14046', label: 'Finch' },\n      { value: '2005', label: 'Fish' },\n      { value: '14047', label: 'Flamingo' },\n      { value: '14048', label: 'Fossa' },\n      { value: '1001', label: 'Frog' },\n      { value: '6005', label: 'Gazelle' },\n      { value: '7003', label: 'Gecko' },\n      { value: '14049', label: 'Genet' },\n      { value: '6012', label: 'German Shepherd' },\n      { value: '14050', label: 'Gibbon' },\n      { value: '6031', label: 'Giraffe' },\n      { value: '6006', label: 'Goat' },\n      { value: '3006', label: 'Goose' },\n      { value: '6054', label: 'Gorilla' },\n      { value: '14051', label: 'Gray Fox' },\n      { value: '14052', label: 'Great Dane' },\n      { value: '14053', label: 'Grizzly Bear' },\n      { value: '14054', label: 'Guinea Pig' },\n      { value: '14055', label: 'Hamster' },\n      { value: '3008', label: 'Hawk' },\n      { value: '6032', label: 'Hedgehog' },\n      { value: '14056', label: 'Heron' },\n      { value: '6033', label: 'Hippopotamus' },\n      { value: '14057', label: 'Honeybee / Bumblebee' },\n      { value: '6034', label: 'Horse' },\n      { value: '6020', label: 'Housecat' },\n      { value: '6055', label: 'Human' },\n      { value: '14058', label: 'Humanoid' },\n      { value: '14059', label: 'Hummingbird' },\n      { value: '6014', label: 'Husky' },\n      { value: '6035', label: 'Hyena' },\n      { value: '7004', label: 'Iguana' },\n      { value: '14060', label: 'Impala' },\n      { value: '6013', label: 'Jackal' },\n      { value: '6023', label: 'Jaguar' },\n      { value: '6038', label: 'Kangaroo' },\n      { value: '14061', label: 'Kangaroo Mouse' },\n      { value: '14062', label: 'Kangaroo Rat' },\n      { value: '14063', label: 'Kinkajou' },\n      { value: '14064', label: 'Kit Fox' },\n      { value: '6039', label: 'Koala' },\n      { value: '14065', label: 'Kodiak Bear' },\n      { value: '14066', label: 'Komodo Dragon' },\n      { value: '14067', label: 'Labrador' },\n      { value: '6056', label: 'Lemur' },\n      { value: '6024', label: 'Leopard' },\n      { value: '14068', label: 'Liger' },\n      { value: '14069', label: 'Linsang' },\n      { value: '6025', label: 'Lion' },\n      { value: '7005', label: 'Lizard' },\n      { value: '6036', label: 'Llama' },\n      { value: '14070', label: 'Lobster' },\n      { value: '14071', label: 'Longhair Cat' },\n      { value: '6026', label: 'Lynx' },\n      { value: '14072', label: 'Magpie' },\n      { value: '14073', label: 'Maine Coon' },\n      { value: '14074', label: 'Malamute' },\n      { value: '14075', label: 'Mammal - Feline' },\n      { value: '14076', label: 'Mammal - Herd' },\n      { value: '14077', label: 'Mammal - Marsupial' },\n      { value: '14078', label: 'Mammal - Mustelid' },\n      { value: '14079', label: 'Mammal - Other Predator' },\n      { value: '14080', label: 'Mammal - Prey' },\n      { value: '14081', label: 'Mammal - Primate' },\n      { value: '14082', label: 'Mammal - Rodent' },\n      { value: '14083', label: 'Manatee' },\n      { value: '14084', label: 'Mandrill' },\n      { value: '14085', label: 'Maned Wolf' },\n      { value: '8004', label: 'Mantid' },\n      { value: '14086', label: 'Marmoset' },\n      { value: '14087', label: 'Marten' },\n      { value: '6043', label: 'Meerkat' },\n      { value: '6048', label: 'Mink' },\n      { value: '14088', label: 'Mole' },\n      { value: '6044', label: 'Mongoose' },\n      { value: '14089', label: 'Monitor Lizard' },\n      { value: '6057', label: 'Monkey' },\n      { value: '14090', label: 'Moose' },\n      { value: '14091', label: 'Moth' },\n      { value: '6065', label: 'Mouse' },\n      { value: '14092', label: 'Musk Deer' },\n      { value: '14093', label: 'Musk Ox' },\n      { value: '1002', label: 'Newt' },\n      { value: '6027', label: 'Ocelot' },\n      { value: '14094', label: 'Octopus' },\n      { value: '14095', label: 'Okapi' },\n      { value: '14096', label: 'Olingo' },\n      { value: '6037', label: 'Opossum' },\n      { value: '14097', label: 'Orangutan' },\n      { value: '14098', label: 'Orca' },\n      { value: '14099', label: 'Oryx' },\n      { value: '14100', label: 'Ostrich' },\n      { value: '6047', label: 'Otter' },\n      { value: '3009', label: 'Owl' },\n      { value: '6052', label: 'Panda' },\n      { value: '14101', label: 'Pangolin' },\n      { value: '6028', label: 'Panther' },\n      { value: '14102', label: 'Parakeet' },\n      { value: '14103', label: 'Parrot / Macaw' },\n      { value: '14104', label: 'Peacock' },\n      { value: '14105', label: 'Penguin' },\n      { value: '14106', label: 'Persian Cat' },\n      { value: '6053', label: 'Pig / Swine' },\n      { value: '14107', label: 'Pigeon' },\n      { value: '14108', label: 'Pika' },\n      { value: '14109', label: 'Pine Marten' },\n      { value: '14110', label: 'Platypus' },\n      { value: '14111', label: 'Polar Bear' },\n      { value: '6073', label: 'Pony' },\n      { value: '14112', label: 'Poodle' },\n      { value: '14113', label: 'Porcupine' },\n      { value: '2004', label: 'Porpoise' },\n      { value: '14114', label: 'Procyonid' },\n      { value: '14115', label: 'Puffin' },\n      { value: '6040', label: 'Quoll' },\n      { value: '6059', label: 'Rabbit / Hare' },\n      { value: '6060', label: 'Raccoon' },\n      { value: '6061', label: 'Rat' },\n      { value: '14116', label: 'Ray' },\n      { value: '14117', label: 'Red Fox' },\n      { value: '6062', label: 'Red Panda' },\n      { value: '14118', label: 'Reindeer' },\n      { value: '14119', label: 'Reptillian' },\n      { value: '6063', label: 'Rhinoceros' },\n      { value: '14120', label: 'Robin' },\n      { value: '14121', label: 'Rottweiler' },\n      { value: '14122', label: 'Sabercats' },\n      { value: '14123', label: 'Sabertooth' },\n      { value: '1003', label: 'Salamander' },\n      { value: '8005', label: 'Scorpion' },\n      { value: '14124', label: 'Seagull' },\n      { value: '14125', label: 'Seahorse' },\n      { value: '6068', label: 'Seal' },\n      { value: '14126', label: 'Secretary Bird' },\n      { value: '4003', label: 'Serpent Dragon' },\n      { value: '14127', label: 'Serval' },\n      { value: '2006', label: 'Shark' },\n      { value: '14128', label: 'Sheep' },\n      { value: '14129', label: 'Shiba Inu' },\n      { value: '14130', label: 'Shorthair Cat' },\n      { value: '14131', label: 'Shrew' },\n      { value: '14132', label: 'Siamese' },\n      { value: '14133', label: 'Sifaka' },\n      { value: '14134', label: 'Silver Fox' },\n      { value: '6069', label: 'Skunk' },\n      { value: '14135', label: 'Sloth' },\n      { value: '14136', label: 'Snail' },\n      { value: '7006', label: 'Snake / Serpent' },\n      { value: '14137', label: 'Snow Leopard' },\n      { value: '14138', label: 'Sparrow' },\n      { value: '14139', label: 'Squid' },\n      { value: '6070', label: 'Squirrel' },\n      { value: '14140', label: 'Stoat' },\n      { value: '14141', label: 'Stork' },\n      { value: '14142', label: 'Sugar Glider' },\n      { value: '14143', label: 'Sun Bear' },\n      { value: '3011', label: 'Swan' },\n      { value: '14144', label: 'Swift Fox' },\n      { value: '5022', label: 'Tanuki' },\n      { value: '14145', label: 'Tapir' },\n      { value: '14146', label: 'Tasmanian Devil' },\n      { value: '14147', label: 'Thylacine' },\n      { value: '6029', label: 'Tiger' },\n      { value: '14148', label: 'Toucan' },\n      { value: '7007', label: 'Turtle / Tortoise' },\n      { value: '14149', label: 'Vulture' },\n      { value: '6041', label: 'Wallaby' },\n      { value: '14150', label: 'Walrus' },\n      { value: '14151', label: 'Wasp' },\n      { value: '6049', label: 'Weasel' },\n      { value: '2003', label: 'Whale' },\n      { value: '6016', label: 'Wolf' },\n      { value: '6050', label: 'Wolverine' },\n      { value: '6071', label: 'Zebra' },\n    ],\n  },\n];\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/fur-affinity/models/fur-affinity-themes.ts",
    "content": "import { SelectOption } from '@postybirb/form-builder';\n\n// Run this script on https://www.furaffinity.net/submit/finalize/ to update themes\n// JSON.stringify(Array.prototype.map.call(document.querySelector(\"select[name='atype']\"), (e) => ({ value: e.value, label: e.label, parent: e.parentNode?.label, })).reduce((e, c) => { if (!c.parent) { e.push({ value: c.value, label: c.label }); return e; }; const f = e.find((e) => e.label === c.parent) ?? e[e.push({ label: c.parent, items: [] }) - 1]; f.items.push({ value: c.value, label: c.label }); return e; }, []))\n\nexport const FurAffinityThemes: SelectOption[] = [\n  {\n    label: 'General Things',\n    items: [\n      { value: '1', label: 'All' },\n      { value: '2', label: 'Abstract' },\n      { value: '3', label: 'Animal related (non-anthro)' },\n      { value: '4', label: 'Anime' },\n      { value: '5', label: 'Comics' },\n      { value: '6', label: 'Doodle' },\n      { value: '7', label: 'Fanart' },\n      { value: '8', label: 'Fantasy' },\n      { value: '9', label: 'Human' },\n      { value: '10', label: 'Portraits' },\n      { value: '11', label: 'Scenery' },\n      { value: '12', label: 'Still Life' },\n      { value: '13', label: 'Tutorials' },\n      { value: '14', label: 'Miscellaneous' },\n    ],\n  },\n  {\n    label: 'Specialty',\n    items: [\n      { value: '100', label: 'General Furry Art' },\n      { value: '122', label: 'ABDL' },\n      { value: '101', label: 'Baby fur' },\n      { value: '102', label: 'Bondage' },\n      { value: '103', label: 'Digimon' },\n      { value: '104', label: 'Fat Furs' },\n      { value: '105', label: 'Fetish Other' },\n      { value: '106', label: 'Fursuit' },\n      { value: '119', label: 'Gore / Macabre Art' },\n      { value: '107', label: 'Hyper' },\n      { value: '121', label: 'Hypnosis' },\n      { value: '108', label: 'Inflation' },\n      { value: '109', label: 'Macro / Micro' },\n      { value: '110', label: 'Muscle' },\n      { value: '111', label: 'My Little Pony / Brony' },\n      { value: '112', label: 'Paw' },\n      { value: '113', label: 'Pokemon' },\n      { value: '114', label: 'Pregnancy' },\n      { value: '115', label: 'Sonic' },\n      { value: '116', label: 'Transformation' },\n      { value: '120', label: 'TF / TG' },\n      { value: '117', label: 'Vore' },\n      { value: '118', label: 'Water Sports' },\n    ],\n  },\n  {\n    label: 'Music',\n    items: [\n      { value: '201', label: 'Techno' },\n      { value: '202', label: 'Trance' },\n      { value: '203', label: 'House' },\n      { value: '204', label: '90s' },\n      { value: '205', label: '80s' },\n      { value: '206', label: '70s' },\n      { value: '207', label: '60s' },\n      { value: '208', label: 'Pre-60s' },\n      { value: '209', label: 'Classical' },\n      { value: '210', label: 'Game Music' },\n      { value: '211', label: 'Rock' },\n      { value: '212', label: 'Pop' },\n      { value: '213', label: 'Rap' },\n      { value: '214', label: 'Industrial' },\n      { value: '200', label: 'Other Music' },\n    ],\n  },\n];\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/furbooru/furbooru.website.ts",
    "content": "import { PostData, PostResponse } from '@postybirb/types';\nimport { CancellableToken } from '../../../post/models/cancellable-token';\nimport { PostingFile } from '../../../post/models/posting-file';\nimport FileSize from '../../../utils/filesize.util';\nimport { DisableAds } from '../../decorators/disable-ads.decorator';\nimport { UserLoginFlow } from '../../decorators/login-flow.decorator';\nimport { SupportsFiles } from '../../decorators/supports-files.decorator';\nimport { SupportsUsernameShortcut } from '../../decorators/supports-username-shortcut.decorator';\nimport { WebsiteMetadata } from '../../decorators/website-metadata.decorator';\nimport { PhilomenaWebsite } from '../philomena/philomena.website';\nimport { FurbooruFileSubmission } from './models/furbooru-file-submission';\n\n@WebsiteMetadata({ name: 'furbooru', displayName: 'Furbooru' })\n@UserLoginFlow('https://furbooru.org/session/new')\n@SupportsFiles({\n  acceptedMimeTypes: [\n    'image/png',\n    'image/jpeg',\n    'image/jpg',\n    'image/svg+xml',\n    'image/gif',\n    'video/webm',\n  ],\n  acceptedFileSizes: { '*': FileSize.megabytes(100) },\n})\n@DisableAds()\n@SupportsUsernameShortcut({\n  id: 'furbooru',\n  url: 'https://furbooru.org/profiles/$1',\n})\nexport default class Furbooru extends PhilomenaWebsite<FurbooruFileSubmission> {\n  protected BASE_URL = 'https://furbooru.org';\n\n  createFileModel(): FurbooruFileSubmission {\n    return new FurbooruFileSubmission();\n  }\n\n  async onPostFileSubmission(\n    postData: PostData<FurbooruFileSubmission>,\n    files: PostingFile[],\n    cancellationToken: CancellableToken,\n  ): Promise<PostResponse> {\n    try {\n      const result = await super.onPostFileSubmission(\n        postData,\n        files,\n        cancellationToken,\n      );\n      return result;\n    } catch (err) {\n      // Users have reported it working on a second attempt\n      this.logger?.warn(err, 'Furbooru Post Retry');\n      const retry = await super.onPostFileSubmission(\n        postData,\n        files,\n        cancellationToken,\n      );\n      return retry;\n    }\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/furbooru/models/furbooru-file-submission.ts",
    "content": "import { TagField } from '@postybirb/form-builder';\nimport { TagValue } from '@postybirb/types';\nimport { PhilomenaFileSubmission } from '../../philomena/models/philomena-file-submission';\n\nexport class FurbooruFileSubmission extends PhilomenaFileSubmission {\n  @TagField({\n    minTags: 5,\n  })\n  tags: TagValue;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/gotosocial/gotosocial.website.ts",
    "content": "import { FileType } from '@postybirb/types';\nimport FileSize from '../../../utils/filesize.util';\nimport { DisableAds } from '../../decorators/disable-ads.decorator';\nimport { CustomLoginFlow } from '../../decorators/login-flow.decorator';\nimport { SupportsFiles } from '../../decorators/supports-files.decorator';\nimport { WebsiteMetadata } from '../../decorators/website-metadata.decorator';\nimport { MegalodonWebsite } from '../megalodon/megalodon.website';\n\n@WebsiteMetadata({\n  name: 'gotosocial',\n  displayName: 'GoToSocial',\n})\n@CustomLoginFlow()\n@SupportsFiles({\n  acceptedMimeTypes: [\n    'image/png',\n    'image/jpeg',\n    'image/gif',\n    'image/webp',\n    'image/avif',\n    'image/heic',\n    'image/heif',\n    'video/mp4',\n    'video/webm',\n    'video/x-m4v',\n    'video/quicktime',\n    'application/msword',\n    'application/rtf',\n    'text/plain',\n    'audio/mpeg', // mp3\n    'audio/wav',\n    'audio/ogg', // ogg, oga\n    'audio/opus',\n    'audio/aac',\n    'audio/mp4',\n    'video/3gpp',\n    'audio/x-ms-wma',\n  ],\n  acceptedFileSizes: {\n    [FileType.IMAGE]: FileSize.megabytes(16),\n    [FileType.AUDIO]: FileSize.megabytes(100),\n    [FileType.VIDEO]: FileSize.megabytes(200),\n  },\n  fileBatchSize: 4,\n})\n@DisableAds()\nexport default class GoToSocial extends MegalodonWebsite {\n  protected getMegalodonInstanceType(): 'gotosocial' {\n    return 'gotosocial';\n  }\n\n  protected getDefaultMaxDescriptionLength(): number {\n    return 500;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/hentai-foundry/hentai-foundry.website.ts",
    "content": "import { Http } from '@postybirb/http';\nimport {\n  ILoginState,\n  ImageResizeProps,\n  IPostResponse,\n  ISubmissionFile,\n  PostData,\n  PostResponse,\n} from '@postybirb/types';\nimport { calculateImageResize } from '@postybirb/utils/file-type';\nimport { parse } from 'node-html-parser';\nimport { CancellableToken } from '../../../post/models/cancellable-token';\nimport { PostingFile } from '../../../post/models/posting-file';\nimport FileSize from '../../../utils/filesize.util';\nimport { PostBuilder } from '../../commons/post-builder';\nimport { validatorPassthru } from '../../commons/validator-passthru';\nimport { UserLoginFlow } from '../../decorators/login-flow.decorator';\nimport { SupportsFiles } from '../../decorators/supports-files.decorator';\nimport { SupportsUsernameShortcut } from '../../decorators/supports-username-shortcut.decorator';\nimport { WebsiteMetadata } from '../../decorators/website-metadata.decorator';\nimport { DataPropertyAccessibility } from '../../models/data-property-accessibility';\nimport { FileWebsite } from '../../models/website-modifiers/file-website';\nimport { MessageWebsite } from '../../models/website-modifiers/message-website';\nimport { Website } from '../../website';\nimport { HentaiFoundryAccountData } from './models/hentai-foundry-account-data';\nimport { HentaiFoundryFileSubmission } from './models/hentai-foundry-file-submission';\nimport { HentaiFoundryMessageSubmission } from './models/hentai-foundry-message-submission';\n\n@WebsiteMetadata({\n  name: 'hentai-foundry',\n  displayName: 'H Foundry',\n})\n@UserLoginFlow('https://www.hentai-foundry.com')\n@SupportsUsernameShortcut({\n  id: 'h-foundry',\n  url: 'https://www.hentai-foundry.com/user/$1',\n})\n@SupportsFiles({\n  acceptedMimeTypes: [\n    'image/png',\n    'image/jpeg',\n    'image/jpg',\n    'image/gif',\n    'image/svg+xml',\n  ],\n  acceptedFileSizes: {\n    '*': FileSize.megabytes(2),\n  },\n})\nexport default class HentaiFoundry\n  extends Website<HentaiFoundryAccountData>\n  implements\n    FileWebsite<HentaiFoundryFileSubmission>,\n    MessageWebsite<HentaiFoundryMessageSubmission>\n{\n  protected BASE_URL = 'https://www.hentai-foundry.com';\n\n  public externallyAccessibleWebsiteDataProperties: DataPropertyAccessibility<HentaiFoundryAccountData> =\n    {\n      folders: true,\n    };\n\n  public async onLogin(): Promise<ILoginState> {\n    try {\n      const res = await Http.get<string>(this.BASE_URL, {\n        partition: this.accountId,\n      });\n\n      if (res.body?.includes('Logout')) {\n        const $ = parse(res.body);\n        const username =\n          res.body.match(/class=.navlink. href=.\\/user\\/(.*?)\\//)?.[1] ||\n          'Unknown';\n        return this.loginState.setLogin(true, username);\n      }\n\n      return this.loginState.setLogin(false, null);\n    } catch (e) {\n      this.logger.error('Failed to login', e);\n      return this.loginState.setLogin(false, null);\n    }\n  }\n\n  createFileModel(): HentaiFoundryFileSubmission {\n    return new HentaiFoundryFileSubmission();\n  }\n\n  createMessageModel(): HentaiFoundryMessageSubmission {\n    return new HentaiFoundryMessageSubmission();\n  }\n\n  calculateImageResize(file: ISubmissionFile): ImageResizeProps | undefined {\n    return calculateImageResize(file, {\n      maxWidth: 1500,\n      maxHeight: 1500,\n      maxBytes: FileSize.megabytes(2),\n    });\n  }\n\n  async onPostFileSubmission(\n    postData: PostData<HentaiFoundryFileSubmission>,\n    files: PostingFile[],\n    cancellationToken: CancellableToken,\n  ): Promise<IPostResponse> {\n    // Get the form page first\n    const page = await Http.get<string>(`${this.BASE_URL}/pictures/create`, {\n      partition: this.accountId,\n    });\n\n    PostResponse.validateBody(this, page);\n\n    const csrfToken = parse(page.body)\n      .querySelector('input[name=\"YII_CSRF_TOKEN\"]')\n      ?.getAttribute('value');\n    const userId = parse(page.body)\n      .querySelector('input[name=\"Pictures[user_id]\"]')\n      ?.getAttribute('value');\n\n    if (!csrfToken || !userId) {\n      return PostResponse.fromWebsite(this)\n        .withAdditionalInfo(page.body)\n        .withException(new Error('Failed to retrieve CSRF token or user ID'));\n    }\n\n    const builder = new PostBuilder(this, cancellationToken)\n      .asMultipart()\n      .setField('YII_CSRF_TOKEN', csrfToken)\n      .setField('Pictures[user_id]', userId)\n      .setField('Pictures[title]', postData.options.title)\n      .setField('Pictures[description]', postData.options.description)\n      .setField(\n        'Pictures[edit_tags]',\n        this.formatTags(postData.options.tags).join(', '),\n      )\n      .addFile('Pictures[fileupload]', files[0])\n      .setField('Pictures[submissionPolicyAgree]', '1')\n      .setField('yt0', 'Create')\n      .setConditional('Pictures[is_scrap]', postData.options.scraps, '1', '0')\n      .setConditional(\n        'Pictures[comments_type]',\n        postData.options.disableComments,\n        '-1',\n        '0',\n      )\n      .setField('Pictures[categoryHier]', postData.options.category || '')\n      .setField('Pictures[rating_nudity]', postData.options.nudityRating)\n      .setField('Pictures[rating_violence]', postData.options.violenceRating)\n      .setField('Pictures[rating_profanity]', postData.options.profanityRating)\n      .setField('Pictures[rating_racism]', postData.options.racismRating)\n      .setField('Pictures[rating_sex]', postData.options.sexRating)\n      .setField('Pictures[rating_spoilers]', postData.options.spoilersRating)\n      .setConditional('Pictures[rating_yaoi]', postData.options.yaoi, '1', '0')\n      .setConditional('Pictures[rating_yuri]', postData.options.yuri, '1', '0')\n      .setConditional('Pictures[rating_teen]', postData.options.teen, '1', '0')\n      .setConditional('Pictures[rating_guro]', postData.options.guro, '1', '0')\n      .setConditional(\n        'Pictures[rating_furry]',\n        postData.options.furry,\n        '1',\n        '0',\n      )\n      .setConditional(\n        'Pictures[rating_beast]',\n        postData.options.beast,\n        '1',\n        '0',\n      )\n      .setConditional('Pictures[rating_male]', postData.options.male, '1', '0')\n      .setConditional(\n        'Pictures[rating_female]',\n        postData.options.female,\n        '1',\n        '0',\n      )\n      .setConditional('Pictures[rating_futa]', postData.options.futa, '1', '0')\n      .setConditional(\n        'Pictures[rating_other]',\n        postData.options.other,\n        '1',\n        '0',\n      )\n      .setConditional('Pictures[rating_scat]', postData.options.scat, '1', '0')\n      .setConditional(\n        'Pictures[rating_incest]',\n        postData.options.incest,\n        '1',\n        '0',\n      )\n      .setConditional('Pictures[rating_rape]', postData.options.rape, '1', '0')\n      .setField('Pictures[media_id]', postData.options.media)\n      .setField('Pictures[time_taken]', postData.options.timeTaken || '')\n      .setField('Pictures[reference]', postData.options.reference || '')\n      .setField('Pictures[license_id]', '0');\n\n    const postResponse = await builder.send<string>(\n      `${this.BASE_URL}/pictures/create`,\n    );\n\n    if (!postResponse.body.includes('Pictures_title')) {\n      return PostResponse.fromWebsite(this)\n        .withSourceUrl(postResponse.responseUrl)\n        .withMessage('File posted successfully')\n        .withAdditionalInfo(postResponse.body);\n    }\n\n    return PostResponse.fromWebsite(this)\n      .withException(new Error('Failed to post file submission'))\n      .withAdditionalInfo(postResponse.body);\n  }\n\n  async onPostMessageSubmission(\n    postData: PostData<HentaiFoundryMessageSubmission>,\n    cancellationToken: CancellableToken,\n  ): Promise<IPostResponse> {\n    const page = await Http.get<string>(`${this.BASE_URL}/UserBlogs/create`, {\n      partition: this.accountId,\n    });\n\n    PostResponse.validateBody(this, page);\n\n    const csrfToken = parse(page.body)\n      .querySelector('input[name=\"YII_CSRF_TOKEN\"]')\n      ?.getAttribute('value');\n\n    const builder = new PostBuilder(this, cancellationToken)\n      .asMultipart()\n      .setField('YII_CSRF_TOKEN', csrfToken)\n      .setField('UserBlogs[blog_title]', postData.options.title)\n      .setField('UserBlogs[blog_body]', postData.options.description);\n\n    const postResponse = await builder.send<string>(\n      `${this.BASE_URL}/UserBlogs/create`,\n    );\n\n    if (postResponse.responseUrl) {\n      return PostResponse.fromWebsite(this)\n        .withSourceUrl(postResponse.responseUrl)\n        .withMessage('Blog post created successfully')\n        .withAdditionalInfo(postResponse.body);\n    }\n\n    return PostResponse.fromWebsite(this)\n      .withException(new Error('Failed to post blog'))\n      .withAdditionalInfo(postResponse.body);\n  }\n\n  private formatTags(tags: string[]): string[] {\n    const maxLength = 500;\n    const formattedTags = tags.filter((tag) => tag.length >= 3);\n    const tagString = formattedTags.join(', ').trim();\n\n    if (tagString.length > maxLength) {\n      return tagString\n        .substring(0, maxLength)\n        .split(', ')\n        .filter((tag) => tag.length >= 3);\n    }\n\n    return formattedTags;\n  }\n\n  onValidateFileSubmission = validatorPassthru;\n\n  onValidateMessageSubmission = validatorPassthru;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/hentai-foundry/models/hentai-foundry-account-data.ts",
    "content": "import { SelectOption } from '@postybirb/form-builder'; \n\nexport type HentaiFoundryAccountData = { \n  folders: SelectOption[];\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/hentai-foundry/models/hentai-foundry-categories.ts",
    "content": "import { SelectOption } from \"@postybirb/form-builder\";\n\nexport const HentaiFoundryCategories: SelectOption[] = [\n    { value: '2', label: 'Anime & Manga' },\n    { value: '4', label: 'Cartoons' },\n    { value: '5', label: 'Original' },\n    { value: '6', label: 'Misc' },\n    { value: '7', label: 'Tutorials' },\n    { value: '8', label: 'Furries (Anthro)' },\n    { value: '9', label: 'Anal' },\n    { value: '10', label: 'Bondage' },\n    { value: '11', label: 'Bukkake' },\n    { value: '12', label: 'Crossdressing' },\n    { value: '13', label: 'Fantasy (Witches, Elves...)' },\n    { value: '14', label: 'Futanari (Dickgirls)' },\n    { value: '15', label: 'Guro (Gore)' },\n    { value: '16', label: 'Historic Setting' },\n    { value: '17', label: 'Neko Shoujo & Kemonomimi' },\n    { value: '18', label: 'Pregnant' },\n    { value: '19', label: 'Tentacles' },\n    { value: '20', label: 'Uniforms' },\n    { value: '21', label: 'Megaman/Rockman' },\n    { value: '22', label: 'Macross' },\n    { value: '23', label: 'Danny Phantom' },\n    { value: '24', label: 'Games' },\n    { value: '25', label: 'Inuyasha' },\n    { value: '26', label: 'Megaman/Rockman' },\n    { value: '27', label: 'Sonic the Hedgehog' },\n    { value: '28', label: 'Sonic the Hedgehog' },\n    { value: '29', label: 'Sonic the Hedgehog' },\n    { value: '30', label: 'Kingdom Hearts' },\n    { value: '31', label: 'Hi Hi Puffy AmiYumi' },\n    { value: '32', label: 'Mario' },\n    { value: '33', label: 'Pokemon' },\n    { value: '34', label: 'Pokémon' },\n    { value: '35', label: 'Master of Mosquiton' },\n    { value: '36', label: 'Ranma 1/2' },\n    { value: '37', label: 'Real People' },\n    { value: '38', label: 'TV & Movies' },\n    { value: '39', label: 'Pirates of the Carribean' },\n    { value: '40', label: 'Comics' },\n    { value: '41', label: 'Spy vs. Spy' },\n    { value: '42', label: 'Sora' },\n    { value: '43', label: 'Riku' },\n    { value: '44', label: 'Kairi' },\n    { value: '45', label: 'Final Fantasy' },\n    { value: '46', label: 'Fullmetal Alchemist' },\n    { value: '47', label: 'Card Captor Sakura' },\n    { value: '48', label: 'Dragonball Series' },\n    { value: '49', label: 'Naruto' },\n    { value: '50', label: 'Yugioh' },\n    { value: '51', label: 'Phoenix Wright: Ace Attorney' },\n    { value: '52', label: 'Ragnarok Online' },\n    { value: '53', label: 'Tales of...' },\n    { value: '54', label: 'Gyakuten Saiban' },\n    { value: '55', label: 'Animaniacs' },\n    { value: '56', label: 'Winx Club' },\n    { value: '57', label: '.hack series' },\n    { value: '58', label: 'Tailspin' },\n    { value: '59', label: 'Tiny Toon Adventures' },\n    { value: '60', label: 'Avatar: The Last Airbender' },\n    { value: '61', label: 'Duke Nukem' },\n    { value: '62', label: 'Hellsing' },\n    { value: '63', label: 'Gummi Bears' },\n    { value: '64', label: 'Lupin III' },\n    { value: '65', label: 'Teen Titans' },\n    { value: '66', label: 'Teen Titans' },\n    { value: '67', label: 'Books' },\n    { value: '68', label: 'Oral' },\n    { value: '69', label: 'Blow Job' },\n    { value: '70', label: 'Cunnilingus' },\n    { value: '71', label: 'Gang Bang' },\n    { value: '72', label: 'Beetlejuice' },\n    { value: '73', label: 'Smurfs' },\n    { value: '74', label: 'Web Comics' },\n    { value: '75', label: 'Comics' },\n    { value: '76', label: 'Code Name Kids Next Door' },\n    { value: '77', label: 'Kids Next Door' },\n    { value: '78', label: 'Ben 10' },\n    { value: '79', label: 'Legend of Zelda  (all games)' },\n    { value: '80', label: 'Starfox' },\n    { value: '81', label: 'Tomb Raider' },\n    { value: '82', label: 'Futurama' },\n    { value: '83', label: 'Catscratch' },\n    { value: '84', label: \"Dexter's Laboratory\" },\n    { value: '85', label: 'Aladdin' },\n    { value: '89', label: 'Detention' },\n    { value: '90', label: 'Super Smash Bros.' },\n    { value: '91', label: \"Rocko's Modern Life\" },\n    { value: '92', label: 'Xaolin Showdown' },\n    { value: '93', label: 'Inspector Gadget' },\n    { value: '94', label: 'Sly Cooper' },\n    { value: '95', label: 'Teenage Mutant Ninja turtles' },\n    { value: '96', label: 'Kim Possible' },\n    { value: '97', label: \"Foster's Home for Imaginary Friends\" },\n    { value: '98', label: 'Nightmare Before Christmas' },\n    { value: '99', label: 'Corpse Bride' },\n    { value: '100', label: 'Mortal Kombat' },\n    { value: '101', label: 'Neopets' },\n    { value: '102', label: 'Gaia' },\n    { value: '103', label: 'Weiß Kreuz' },\n    { value: '104', label: 'Sailor Moon' },\n    { value: '105', label: 'Slayers' },\n    { value: '106', label: 'Yu Yu Hakusho' },\n    { value: '107', label: 'Rescue Rangers' },\n    { value: '108', label: 'Invader Zim' },\n    { value: '109', label: 'Wrestling' },\n    { value: '110', label: 'Lady Death' },\n    { value: '111', label: 'Boondocks' },\n    { value: '112', label: 'Totally Spies' },\n    { value: '113', label: 'Charlie and the Chocolate Factory' },\n    { value: '114', label: 'Super Robot Monkey Team' },\n    { value: '115', label: 'Lilo & Stitch' },\n    { value: '116', label: 'Robin Hood (Disney)' },\n    { value: '117', label: 'Chrono Crusade' },\n    { value: '119', label: 'Get Backers' },\n    { value: '120', label: 'D.N. Angel' },\n    { value: '121', label: 'Ouran High School Host Club' },\n    { value: '122', label: 'Gundam' },\n    { value: '123', label: 'Gundam Wing' },\n    { value: '124', label: 'The Left Hand of Darkness' },\n    { value: '125', label: 'X-Men' },\n    { value: '126', label: 'X-Men' },\n    { value: '127', label: 'Keroro Gunso' },\n    { value: '128', label: 'Bleach' },\n    { value: '129', label: 'Shaman King' },\n    { value: '130', label: 'Powerpuff Girls' },\n    { value: '131', label: 'Digimon' },\n    { value: '132', label: 'Di Gi Charat' },\n    { value: '133', label: 'Fairly Odd Parents' },\n    { value: '134', label: 'Dead or Alive' },\n    { value: '135', label: 'Good Omens' },\n    { value: '136', label: 'Valkyrie Profile' },\n    { value: '137', label: 'Fat People' },\n    { value: '138', label: 'She-Ra: Princess of Power' },\n    { value: '139', label: 'Inflationism' },\n    { value: '148', label: 'Soul Calibur' },\n    { value: '149', label: 'Metal Gear Solid' },\n    { value: '150', label: 'Family Guy' },\n    { value: '151', label: 'Dragon Quest' },\n    { value: '152', label: 'Sci-fi (Aliens, Robots...)' },\n    { value: '153', label: 'Scooby Doo' },\n    { value: '154', label: 'Fire Emblem' },\n    { value: '155', label: 'Disney Misc.' },\n    { value: '156', label: 'Who Framed Roger Rabbit' },\n    { value: '157', label: 'Incredibles' },\n    { value: '158', label: 'Code Lyoko' },\n    { value: '159', label: 'The Replacements' },\n    { value: '160', label: 'Tokyo Mew Mew' },\n    { value: '161', label: 'Mew Mew Power' },\n    { value: '162', label: 'Hoodwinked' },\n    { value: '163', label: 'Pin-ups' },\n    { value: '164', label: 'Daria' },\n    { value: '165', label: 'American Dragon: Jake Long' },\n    { value: '166', label: 'Nadia Secret of Blue Water' },\n    { value: '167', label: 'Mascots/Logos/Commercial characters/etc.' },\n    { value: '168', label: 'Suikoden' },\n    { value: '169', label: 'Shadow Hearts' },\n    { value: '170', label: 'Star Wars' },\n    { value: '171', label: 'Princess \"Peach\" Toadstool' },\n    { value: '172', label: 'The Dark Crystal' },\n    { value: '173', label: 'The Grim Adventures of Billy and Mandy' },\n    { value: '174', label: 'Xenosaga' },\n    { value: '175', label: 'Snowboard Kids' },\n    { value: '176', label: 'The Jetsons' },\n    { value: '177', label: 'Justice League' },\n    { value: '178', label: 'Harry Potter' },\n    { value: '179', label: 'Harry Potter' },\n    { value: '180', label: 'Yin Yang Yo!' },\n    { value: '181', label: 'Beet the Vandel Buster' },\n    { value: '182', label: 'Godzilla' },\n    { value: '183', label: 'Happy Tree Friends' },\n    { value: '184', label: 'The life and times of Juniper Lee' },\n    { value: '185', label: 'Resident Evil' },\n    { value: '186', label: 'Vampire Hunter D' },\n    { value: '187', label: 'Saiyuki' },\n    { value: '188', label: 'King of Fighters' },\n    { value: '189', label: 'Street Fighter' },\n    { value: '190', label: 'Guilty Gear' },\n    { value: '191', label: 'Johnny Bravo' },\n    { value: '192', label: 'Sponge Bob Squarepants' },\n    { value: '193', label: 'Castlevania' },\n    { value: '194', label: 'Warcraft' },\n    { value: '195', label: 'Shuriken School' },\n    { value: '196', label: 'One Piece' },\n    { value: '197', label: 'Crash Bandicoot Series' },\n    { value: '198', label: 'Jackie Chan Adventures' },\n    { value: '199', label: 'Tenchi Muyo (All series)' },\n    { value: '200', label: 'All Grown Up' },\n    { value: '201', label: 'Atomic Betty' },\n    { value: '202', label: 'As Told by Ginger' },\n    { value: '203', label: 'Jimmy Neutron' },\n    { value: '204', label: 'Simpsons' },\n    { value: '205', label: 'The Simpsons' },\n    { value: '206', label: 'My Dad the Rock Star' },\n    { value: '207', label: 'Darkstalkers' },\n    { value: '208', label: 'Dora the Explorer' },\n    { value: '209', label: 'Chrono Trigger/Chrono Cross' },\n    { value: '210', label: 'Flyff (Fly For Fun)' },\n    { value: '211', label: 'Johnny Test' },\n    { value: '212', label: 'Elfen Lied' },\n    { value: '213', label: 'Record of the Lodoss Wars' },\n    { value: '214', label: 'Secret of Mana' },\n    { value: '215', label: '8-Bit Theatre' },\n    { value: '216', label: 'W.I.T.C.H.' },\n    { value: '217', label: 'Gunstar Heroes' },\n    { value: '218', label: 'Haunting Ground' },\n    { value: '219', label: 'Killer Instinct' },\n    { value: '220', label: 'Ratchet & Clank' },\n    { value: '221', label: 'My Life As a Teenage Robot' },\n    { value: '222', label: 'Duel masters' },\n    { value: '223', label: 'Peter Pan' },\n    { value: '224', label: 'Beyblade' },\n    { value: '225', label: 'Tiny Show Fairy Sugar' },\n    { value: '226', label: 'Chicchana Yukitsukai Sugar' },\n    { value: '227', label: 'A Little Snow Fairy Sugar' },\n    { value: '228', label: 'Witch Hunter Robin' },\n    { value: '229', label: 'Drawn Together' },\n    { value: '230', label: 'Jak and Daxter' },\n    { value: '231', label: 'Mospeada' },\n    { value: '232', label: 'Robotech: The Third Generation' },\n    { value: '233', label: 'Lensman' },\n    { value: '234', label: 'D&D' },\n    { value: '235', label: 'Dungeons and Dragons' },\n    { value: '236', label: 'Amy Rose' },\n    { value: '237', label: 'Rouge the Bat' },\n    { value: '238', label: 'Sonic' },\n    { value: '239', label: 'Sonic & Amy' },\n    { value: '240', label: 'Amy & Rogue' },\n    { value: '241', label: 'Cream' },\n    { value: '242', label: 'Knuckes' },\n    { value: '243', label: 'Warioware' },\n    { value: '244', label: 'Gorillaz (band)' },\n    { value: '245', label: 'Halo' },\n    { value: '246', label: 'Bobobo-bo Bo-bobo' },\n    { value: '247', label: 'Ed, Edd n Eddy' },\n    { value: '248', label: 'Johnny the Homicidal Maniac' },\n    { value: '249', label: 'JTHM' },\n    { value: '250', label: 'Ash' },\n    { value: '251', label: 'Misty' },\n    { value: '252', label: 'Brock' },\n    { value: '253', label: 'May' },\n    { value: '254', label: 'Max' },\n    { value: '255', label: 'Ash & Misty' },\n    { value: '256', label: 'Pokemon' },\n    { value: '257', label: 'Pokemon & Humans' },\n    { value: '258', label: 'Ruby Gloom' },\n    { value: '259', label: 'Naruto' },\n    { value: '260', label: 'Hinata' },\n    { value: '261', label: 'Sakura Haruno' },\n    { value: '262', label: 'Naruto & Sakura' },\n    { value: '263', label: 'Naruto & Hinata' },\n    { value: '264', label: 'Sakura & Sasuke' },\n    { value: '265', label: 'Naruto & Sasuke' },\n    { value: '266', label: 'Sasuke' },\n    { value: '267', label: 'Trollz' },\n    { value: '268', label: 'Kappa Mikey' },\n    { value: '269', label: \"The X's\" },\n    { value: '270', label: 'Hey Arnold!' },\n    { value: '271', label: 'Maple Story' },\n    { value: '272', label: 'Hunter x Hunter' },\n    { value: '273', label: 'Berserk' },\n    { value: '274', label: 'Legacy of Kain' },\n    { value: '275', label: 'Galaxy Angel' },\n    { value: '276', label: 'Robotboy' },\n    { value: '277', label: 'Spiderman' },\n    { value: '278', label: 'The Venture Bros.' },\n    { value: '279', label: 'Half-Life' },\n    { value: '280', label: 'Martin Mystery' },\n    { value: '281', label: 'God of War' },\n    { value: '282', label: 'Metal Slug' },\n    { value: '283', label: 'Power Puff Girls Z' },\n    { value: '284', label: 'FLCL' },\n    { value: '285', label: 'Sabrina, the Teenage Witch' },\n    { value: '286', label: 'Braceface' },\n    { value: '287', label: 'Wild Thornberrys' },\n    { value: '288', label: 'Doctor Who' },\n    { value: '289', label: 'Doremi' },\n    { value: '290', label: 'Metroid' },\n    { value: '291', label: 'Oban Star-Racers' },\n    { value: '293', label: 'Zombie Powder' },\n    { value: '294', label: 'Tengen Toppa Gurren-Lagann' },\n    { value: '295', label: 'Gurren-Lagann' },\n    { value: '296', label: 'Sixty-Nine' },\n    { value: '297', label: 'StarCraft' },\n    { value: '298', label: 'Everquest' },\n    { value: '299', label: 'Skies of Arcadia' },\n    { value: '300', label: 'Erin Esurance' },\n    { value: '301', label: 'Truely' },\n    { value: '302', label: 'Kameo: Elements of Power' },\n    { value: '303', label: 'Slacker Cats' },\n    { value: '304', label: 'Rugrats' },\n    { value: '305', label: 'Growing Up Creepie' },\n    { value: '306', label: 'Dino Crisis' },\n    { value: '307', label: 'Marvel, misc.' },\n    { value: '308', label: 'Disgaea' },\n    { value: '309', label: 'ThunderCats' },\n    { value: '310', label: 'Tutenstein' },\n    { value: '311', label: 'Chipmunks' },\n    { value: '312', label: 'Neon Genesis Evangelion' },\n    { value: '313', label: 'Evangelion' },\n    { value: '314', label: 'Star Ocean' },\n    { value: '315', label: 'Transformers' },\n    { value: '316', label: 'Cowboy Bebop' },\n    { value: '317', label: 'Huge Breasts' },\n    { value: '318', label: 'Batman' },\n    { value: '319', label: 'Batman' },\n    { value: '320', label: 'Rocket Power' },\n    { value: '321', label: 'Gargoyles' },\n    { value: '322', label: 'Pita-Ten' },\n    { value: '323', label: 'Sentai' },\n    { value: '324', label: 'Power Rangers' },\n    { value: '325', label: 'Power Rangers' },\n    { value: '326', label: 'Lucky Star' },\n    { value: '327', label: 'Femdom' },\n    { value: '328', label: 'Rumble Roses' },\n    { value: '329', label: 'Virtua Fighter' },\n    { value: '330', label: 'Leisure Suit Larry' },\n    { value: '331', label: 'Aeon Flux' },\n    { value: '332', label: 'LazyTown' },\n    { value: '333', label: 'Azumanga Daioh' },\n    { value: '334', label: 'Onegai Teacher' },\n    { value: '335', label: 'Blood+' },\n    { value: '336', label: 'Unreal Tournament' },\n    { value: '337', label: 'Trigun' },\n    { value: '338', label: 'Get Ed' },\n    { value: '339', label: 'Hand Maid May' },\n    { value: '340', label: 'Team Galaxy' },\n    { value: '341', label: \"My Gym Partner's a Monkey\" },\n    { value: '342', label: 'Tifa Lockhart' },\n    { value: '343', label: 'Ivy' },\n    { value: '344', label: 'Blaze' },\n    { value: '345', label: 'Rozen Maiden' },\n    { value: '346', label: 'Samurai Pizza Cats' },\n    { value: '347', label: \"Pop'n Music\" },\n    { value: '348', label: 'Trickster Online' },\n    { value: '349', label: 'Konjiki no Gash Bell' },\n    { value: '350', label: 'Zatch Bell!' },\n    { value: '351', label: 'Mass Effect' },\n    { value: '352', label: 'Ichigo Mashimaro' },\n    { value: '353', label: 'Strawberry Marshmallow' },\n    { value: '354', label: 'Strawberry Shortcake' },\n    { value: '355', label: 'ElfQuest' },\n    { value: '356', label: 'Harvest Moon' },\n    { value: '357', label: 'Trinity Blood' },\n    { value: '358', label: 'Animal Crossing' },\n    { value: '359', label: 'Dawn' },\n    { value: '360', label: 'Shakugan no Shana' },\n    { value: '361', label: 'Bondage Fairies' },\n    { value: '362', label: 'Death Note' },\n    { value: '363', label: 'Silent Hill' },\n    { value: '364', label: 'My Small Horsie' },\n    { value: '365', label: 'America Dad' },\n    { value: '366', label: 'Donald Duck' },\n    { value: '367', label: 'Solty Rei' },\n    { value: '368', label: 'Devil May Cry' },\n    { value: '369', label: 'Flintstones' },\n    { value: '370', label: 'Outlaw Star' },\n    { value: '371', label: 'Arthur' },\n    { value: '372', label: 'Caillou' },\n    { value: '373', label: 'Spyro the Dragon' },\n    { value: '374', label: 'Ghost in the Shell' },\n    { value: '375', label: 'Love Hina' },\n    { value: '376', label: 'Paranoia Agent' },\n    { value: '377', label: 'Samurai Champloo' },\n    { value: '378', label: 'Trauma Center' },\n    { value: '379', label: '6teen' },\n    { value: '380', label: 'Jet Set Radio' },\n    { value: '381', label: 'Gunsmith Cats' },\n    { value: '382', label: 'Oh! My Goddess' },\n    { value: '383', label: 'D. Gray-man' },\n    { value: '384', label: 'Bestiality' },\n    { value: '385', label: 'Lost Odyssey' },\n    { value: '386', label: 'Parasite Eve' },\n    { value: '387', label: 'Sword of Truth series' },\n    { value: '388', label: 'Grossology' },\n    { value: '389', label: 'Crayon Shin-Chan' },\n    { value: '390', label: 'RE:Play' },\n    { value: '391', label: 'Phineas and Ferb' },\n    { value: '392', label: 'Chowder' },\n    { value: '393', label: 'Princess Mononoke' },\n    { value: '394', label: 'Onegai Twins' },\n    { value: '395', label: 'The Little Mermaid' },\n    { value: '396', label: 'Pocahontas' },\n    { value: '397', label: 'School Rumble' },\n    { value: '398', label: 'Rurouni Kenshin' },\n    { value: '399', label: 'Watersports' },\n    { value: '400', label: \"Conker's Bad Fur Day\" },\n    { value: '401', label: 'The Melancholy of Haruhi Suzumiya' },\n    { value: '402', label: 'Chobits' },\n    { value: '403', label: 'My neighbor Totoro' },\n    { value: '404', label: 'Nausicaä of the Valley of the Wind' },\n    { value: '405', label: 'Laputa: Castle in the Sky' },\n    { value: '406', label: 'Spirited Away' },\n    { value: '407', label: \"Kiki's Delivery Service\" },\n    { value: '408', label: 'Porco Rosso' },\n    { value: '409', label: 'Grave of the Fireflies' },\n    { value: '410', label: 'Whisper of the Heart' },\n    { value: '411', label: 'Danny & Paulina' },\n    { value: '412', label: 'Danny & Sam' },\n    { value: '413', label: 'Casper and the Angels' },\n    { value: '414', label: 'Nadesico' },\n    { value: '415', label: 'Soul Eater' },\n    { value: '416', label: 'Bakugan Battle Brawlers' },\n    { value: '417', label: 'Jeepers Creepers' },\n    { value: '418', label: 'Cyber Team in Akihabara' },\n    { value: '419', label: 'Diablo' },\n    { value: '420', label: 'Ben 10: Alien Force' },\n    { value: '421', label: 'The Mighty B' },\n    { value: '422', label: 'Incest' },\n    { value: '424', label: 'Guitar Hero' },\n    { value: '425', label: 'Age of Conan: Hyborian Adventures' },\n    { value: '426', label: 'Rosario + Vampire' },\n    { value: '427', label: 'Earthbound' },\n    { value: '428', label: 'Class of 3000' },\n    { value: '429', label: 'Breath of Fire' },\n    { value: '430', label: 'Code Geass' },\n    { value: '431', label: 'Temari' },\n    { value: '432', label: 'Bratz' },\n    { value: '433', label: 'Total Drama Island' },\n    { value: '434', label: 'Zack & Wiki' },\n    { value: '435', label: 'Superman' },\n    { value: '436', label: 'Monster Rancher' },\n    { value: '437', label: 'Monster Rancher' },\n    { value: '438', label: 'Gunslinger Girl' },\n    { value: '439', label: 'Soul Eater2' },\n    { value: '440', label: 'Skunk Fu!' },\n    { value: '441', label: 'Lisa' },\n    { value: '442', label: 'Bart' },\n    { value: '443', label: 'Marge' },\n    { value: '444', label: 'Moude Flanders' },\n    { value: '445', label: 'Donkey Kong' },\n    { value: '446', label: 'Archie' },\n    { value: '447', label: 'Psychonauts' },\n    { value: '448', label: 'Tikal' },\n    { value: '449', label: 'Tails' },\n    { value: '450', label: 'Okami' },\n    { value: '451', label: 'Basilisk' },\n    { value: '452', label: 'Spice and Wolf' },\n    { value: '453', label: 'Cumshot' },\n    { value: '454', label: 'Goof Troop' },\n    { value: '455', label: 'Tsunade' },\n    { value: '456', label: 'Heavenly Sword' },\n    { value: '457', label: 'Warhammer' },\n    { value: '458', label: 'Jungle Book' },\n    { value: '459', label: 'Kaiba' },\n    { value: '460', label: 'Cow and Chicken' },\n    { value: '461', label: 'Metalocalypse' },\n    { value: '462', label: 'Left 4 Dead' },\n    { value: '463', label: 'Rimjob' },\n    { value: '464', label: 'Feet' },\n    { value: '465', label: 'Earthworm Jim' },\n    { value: '466', label: 'NiGHTS' },\n    { value: '467', label: 'Auto-fellatio' },\n    { value: '468', label: \"Mirror's Edge\" },\n    { value: '469', label: 'Duck Dodgers' },\n    { value: '470', label: 'The World Ends With You' },\n    { value: '471', label: 'Secret Saturdays' },\n    { value: '472', label: 'Frottage' },\n    { value: '473', label: 'Masturbation' },\n    { value: '474', label: 'Bestiality' },\n    { value: '475', label: 'Shining Force' },\n    { value: '476', label: 'BlazBlue' },\n    { value: '477', label: 'Lord of the Rings' },\n    { value: '478', label: 'Dragon Age' },\n    { value: '479', label: 'Gundam SEED/Destiny' },\n    { value: '480', label: 'Baten Kaitos' },\n    { value: '481', label: 'Double Penetration' },\n    { value: '482', label: 'Fallout' },\n    { value: '483', label: 'Scat' },\n    { value: '484', label: 'Tekken' },\n    { value: '485', label: 'Final Fight' },\n    { value: '486', label: 'Eyeshield 21' },\n    { value: '487', label: 'Borderlands' },\n    { value: '488', label: 'Touhou' },\n    { value: '489', label: 'Gunnerkrig Court' },\n    { value: '490', label: 'Wakfu' },\n    { value: '491', label: 'Wrestling' },\n    { value: '492', label: 'Wakfu' },\n    { value: '493', label: 'Dofus' },\n    { value: '494', label: \"James Cameron's Avatar\" },\n    { value: '495', label: 'The Cleveland Show' },\n    { value: '496', label: 'Bayonetta' },\n    { value: '497', label: 'Excel Saga' },\n    { value: '498', label: 'Fairy Tail' },\n    { value: '499', label: 'No More Heroes' },\n    { value: '500', label: 'Elder Scrolls' },\n    { value: '501', label: 'Adventure Time' },\n    { value: '502', label: 'K-on!' },\n    { value: '503', label: 'Camp Lazlo' },\n    { value: '504', label: 'Order of the Stick' },\n    { value: '505', label: 'Eureka Seven' },\n    { value: '507', label: 'Persona' },\n    { value: '508', label: 'Adventure Quest' },\n    { value: '509', label: 'Dragonlance' },\n    { value: '510', label: 'Panty & Stocking with Garterbelt' },\n    { value: '511', label: 'Team Fortress' },\n    { value: '512', label: 'TRON' },\n    { value: '513', label: 'Gantz' },\n    { value: '514', label: 'League of Legends' },\n    { value: '515', label: 'Hetalia Axis Powers' },\n    { value: '517', label: 'G. I. Joe' },\n    { value: '518', label: 'MSPaint Adventures' },\n    { value: '519', label: 'Portal' },\n    { value: '520', label: 'Star Trek' },\n    { value: '521', label: 'Negima!' },\n    { value: '522', label: 'Guild Wars' },\n    { value: '523', label: 'Monster Hunter' },\n    { value: '524', label: 'Rift' },\n    { value: '525', label: 'Golden Sun' },\n    { value: '526', label: 'Brandy and Mr. Whiskers' },\n    { value: '527', label: 'Corruption of Champions' },\n    { value: '528', label: 'Katawa Shoujo' },\n    { value: '530', label: 'Legend of Dragoon' },\n    { value: '531', label: 'Tera' },\n    { value: '532', label: 'Kill La Kill' },\n    { value: '534', label: 'Young Justice' },\n    { value: '535', label: 'Star Wars: The Clone Wars' },\n    { value: '536', label: 'Dota' },\n    { value: '537', label: 'Nisekoi' },\n    { value: '538', label: 'Dark Souls' },\n    { value: '539', label: 'Monkey Island' },\n    { value: '540', label: 'Dark Cloud' },\n    { value: '541', label: 'Attack on Titan' },\n    { value: '542', label: 'Lollipop Chainsaw' },\n    { value: '543', label: 'Archeage' },\n    { value: '544', label: 'Overwatch' },\n];\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/hentai-foundry/models/hentai-foundry-file-submission.ts",
    "content": "import {\n  BooleanField,\n  DescriptionField,\n  RatingField,\n  SelectField,\n  TextField,\n} from '@postybirb/form-builder';\nimport {\n  DescriptionType,\n  DescriptionValue,\n  SubmissionRating,\n} from '@postybirb/types';\nimport { BaseWebsiteOptions } from '../../../models/base-website-options';\nimport { HentaiFoundryCategories } from './hentai-foundry-categories';\n\nexport class HentaiFoundryFileSubmission extends BaseWebsiteOptions {\n  @DescriptionField({\n    descriptionType: DescriptionType.BBCODE,\n  })\n  description: DescriptionValue;\n\n  @RatingField({\n    options: [\n      { value: SubmissionRating.GENERAL, label: 'General' },\n      { value: SubmissionRating.MATURE, label: 'Mature' },\n      { value: SubmissionRating.ADULT, label: 'Adult' },\n    ],\n  })\n  rating: SubmissionRating;\n\n  @SelectField({\n    label: 'category',\n    required: true,\n    defaultValue: '',\n    options: HentaiFoundryCategories,\n    section: 'website',\n    span: 6,\n  })\n  category: string;\n\n  @SelectField({\n    label: 'nudity',\n    defaultValue: '0',\n    options: [\n      { value: '0', label: 'None' },\n      { value: '1', label: 'Mild Nudity' },\n      { value: '2', label: 'Moderate Nudity' },\n      { value: '3', label: 'Explicit Nudity' },\n    ],\n    section: 'website',\n    span: 6,\n  })\n  nudityRating: string;\n\n  @SelectField({\n    label: 'violence',\n    defaultValue: '0',\n    options: [\n      { value: '0', label: 'None' },\n      { value: '1', label: 'Comic or Mild Violence' },\n      { value: '2', label: 'Moderate Violence' },\n      { value: '3', label: 'Explicit or Graphic Violence' },\n    ],\n    section: 'website',\n    span: 6,\n  })\n  violenceRating: string;\n\n  @SelectField({\n    label: 'profanity',\n    defaultValue: '0',\n    options: [\n      { value: '0', label: 'None' },\n      { value: '1', label: 'Mild Profanity' },\n      { value: '2', label: 'Moderate Profanity' },\n      { value: '3', label: 'Proliferous or Severe Profanity' },\n    ],\n    section: 'website',\n    span: 6,\n  })\n  profanityRating: string;\n\n  @SelectField({\n    label: 'racism',\n    defaultValue: '0',\n    options: [\n      { value: '0', label: 'None' },\n      { value: '1', label: 'Mild Racist themes or content' },\n      { value: '2', label: 'Racist themes or content' },\n      { value: '3', label: 'Strong racist themes or content' },\n    ],\n    section: 'website',\n    span: 6,\n  })\n  racismRating: string;\n\n  @SelectField({\n    label: 'sexualContent',\n    defaultValue: '0',\n    options: [\n      { value: '0', label: 'None' },\n      { value: '1', label: 'Mild suggestive content' },\n      { value: '2', label: 'Moderate suggestive or sexual content' },\n      { value: '3', label: 'Explicit or adult sexual content' },\n    ],\n    section: 'website',\n    span: 6,\n  })\n  sexRating: string;\n\n  @SelectField({\n    label: 'spoilers',\n    defaultValue: '0',\n    options: [\n      { value: '0', label: 'None' },\n      { value: '1', label: 'Mild Spoiler Warning' },\n      { value: '2', label: 'Moderate Spoiler Warning' },\n      { value: '3', label: 'Major Spoiler Warning' },\n    ],\n    section: 'website',\n    span: 6,\n  })\n  spoilersRating: string;\n\n  @SelectField({\n    label: 'media',\n    defaultValue: '0',\n    options: [\n      // Traditional media - Drawings\n      { value: '1', label: 'Charcoal' },\n      { value: '2', label: 'Colored Pencil / Crayon' },\n      { value: '3', label: 'Ink or markers' },\n      { value: '4', label: 'Oil pastels' },\n      { value: '5', label: 'Graphite pencil' },\n      { value: '6', label: 'Other drawing' },\n      // Traditional media - Paintings\n      { value: '11', label: 'Airbrush' },\n      { value: '12', label: 'Acrylics' },\n      { value: '13', label: 'Oils' },\n      { value: '14', label: 'Watercolor' },\n      { value: '15', label: 'Other painting' },\n      // Traditional media - Crafts / Physical art\n      { value: '21', label: 'Plushies' },\n      { value: '22', label: 'Sculpture' },\n      { value: '23', label: 'Other crafts' },\n      // Digital media (CG)\n      { value: '31', label: '3D modelling' },\n      { value: '33', label: 'Digital drawing or painting' },\n      { value: '36', label: 'MS Paint' },\n      { value: '32', label: 'Oekaki' },\n      { value: '34', label: 'Pixel art' },\n      { value: '35', label: 'Other digital art' },\n      { value: '0', label: 'Unspecified' },\n    ],\n    section: 'website',\n    span: 6,\n  })\n  media: string;\n\n  @TextField({\n    label: 'timeTaken',\n    maxLength: 50,\n    section: 'website',\n    span: 6,\n  })\n  timeTaken: string;\n\n  @TextField({\n    label: 'reference',\n    section: 'website',\n    span: 6,\n  })\n  reference: string;\n\n  @BooleanField({\n    label: 'scraps',\n    defaultValue: false,\n    section: 'website',\n    span: 6,\n  })\n  scraps: boolean;\n\n  @BooleanField({\n    label: 'disableComments',\n    defaultValue: false,\n    section: 'website',\n    span: 6,\n  })\n  disableComments: boolean;\n\n  @BooleanField({\n    label: { untranslated: 'Yaoi' },\n    defaultValue: false,\n    section: 'website',\n    span: 6,\n  })\n  yaoi: boolean;\n\n  @BooleanField({\n    label: { untranslated: 'Yuri' },\n    defaultValue: false,\n    section: 'website',\n    span: 6,\n  })\n  yuri: boolean;\n\n  @BooleanField({\n    label: { untranslated: 'Teen' },\n    defaultValue: false,\n    section: 'website',\n    span: 6,\n  })\n  teen: boolean;\n\n  @BooleanField({\n    label: { untranslated: 'Guro' },\n    defaultValue: false,\n    section: 'website',\n    span: 6,\n  })\n  guro: boolean;\n\n  @BooleanField({\n    label: 'furry',\n    defaultValue: false,\n    section: 'website',\n    span: 6,\n  })\n  furry: boolean;\n\n  @BooleanField({\n    label: { untranslated: 'Beast' },\n    defaultValue: false,\n    section: 'website',\n    span: 6,\n  })\n  beast: boolean;\n\n  @BooleanField({\n    label: 'male',\n    defaultValue: false,\n    section: 'website',\n    span: 6,\n  })\n  male: boolean;\n\n  @BooleanField({\n    label: 'female',\n    defaultValue: false,\n    section: 'website',\n    span: 6,\n  })\n  female: boolean;\n\n  @BooleanField({\n    label: { untranslated: 'Futa' },\n    defaultValue: false,\n    section: 'website',\n    span: 6,\n  })\n  futa: boolean;\n\n  @BooleanField({\n    label: { untranslated: 'Other' },\n    defaultValue: false,\n    section: 'website',\n    span: 6,\n  })\n  other: boolean;\n\n  @BooleanField({\n    label: { untranslated: 'Scat' },\n    defaultValue: false,\n    section: 'website',\n    span: 6,\n  })\n  scat: boolean;\n\n  @BooleanField({\n    label: { untranslated: 'Incest' },\n    defaultValue: false,\n    section: 'website',\n    span: 6,\n  })\n  incest: boolean;\n\n  @BooleanField({\n    label: { untranslated: 'Rape' },\n    defaultValue: false,\n    section: 'website',\n    span: 6,\n  })\n  rape: boolean;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/hentai-foundry/models/hentai-foundry-message-submission.ts",
    "content": "import { DescriptionField } from '@postybirb/form-builder';\nimport { DescriptionType, DescriptionValue } from '@postybirb/types';\nimport { BaseWebsiteOptions } from '../../../models/base-website-options';\n\nexport class HentaiFoundryMessageSubmission extends BaseWebsiteOptions {\n  @DescriptionField({\n    descriptionType: DescriptionType.BBCODE,\n  })\n  description: DescriptionValue;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/index.ts",
    "content": "export { default as Artconomy } from './artconomy/artconomy.website';\nexport { default as Aryion } from './aryion/aryion.website';\nexport { default as Bluesky } from './bluesky/bluesky.website';\nexport { default as Cara } from './cara/cara.website';\nexport { default as Custom } from './custom/custom.website';\nexport { default as Derpibooru } from './derpibooru/derpibooru.website';\nexport { default as DeviantArt } from './deviant-art/deviant-art.website';\nexport { default as Discord } from './discord/discord.website';\nexport { default as E621 } from './e621/e621.website';\nexport { default as Firefish } from './firefish/firefish.website';\nexport { default as Friendica } from './friendica/friendica.website';\nexport { default as FurAffinity } from './fur-affinity/fur-affinity.website';\nexport { default as Furbooru } from './furbooru/furbooru.website';\nexport { default as GoToSocial } from './gotosocial/gotosocial.website';\nexport { default as HentaiFoundry } from './hentai-foundry/hentai-foundry.website';\nexport { default as Inkbunny } from './inkbunny/inkbunny.website';\nexport { default as Instagram } from './instagram/instagram.website';\nexport { default as Itaku } from './itaku/itaku.website';\nexport { default as KoFi } from './ko-fi/ko-fi.website';\nexport { default as Manebooru } from './manebooru/manebooru.website';\nexport { default as Mastodon } from './mastodon/mastodon.website';\nexport { default as Misskey } from './misskey/misskey.website';\nexport { default as Newgrounds } from './newgrounds/newgrounds.website';\nexport { default as Patreon } from './patreon/patreon.website';\nexport { default as Picarto } from './picarto/picarto.website';\nexport { default as Piczel } from './piczel/piczel.website';\nexport { default as Pillowfort } from './pillowfort/pillowfort.website';\nexport { default as Pixelfed } from './pixelfed/pixelfed.website';\nexport { default as Pixiv } from './pixiv/pixiv.website';\nexport { default as Pleroma } from './pleroma/pleroma.website';\nexport { default as Sofurry } from './sofurry/sofurry.website';\nexport { default as SubscribeStarAdult } from './subscribe-star/subscribe-star-adult.website';\nexport { default as SubscribeStar } from './subscribe-star/subscribe-star.website';\nexport { default as Telegram } from './telegram/telegram.website';\nexport { default as Toyhouse } from './toyhouse/toyhouse.website';\nexport { default as Tumblr } from './tumblr/tumblr.website';\nexport { default as Twitter } from './twitter/twitter.website';\nexport { default as Weasyl } from './weasyl/weasyl.website';\n\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/inkbunny/inkbunny.website.ts",
    "content": "import { Http } from '@postybirb/http';\nimport {\n  ILoginState,\n  ImageResizeProps,\n  InkbunnyAccountData,\n  InkbunnyOAuthRoutes,\n  IPostResponse,\n  OAuthRouteHandlers,\n  PostData,\n  PostResponse,\n  SubmissionRating,\n} from '@postybirb/types';\nimport { CancellableToken } from '../../../post/models/cancellable-token';\nimport { PostingFile } from '../../../post/models/posting-file';\nimport FileSize from '../../../utils/filesize.util';\nimport { PostBuilder } from '../../commons/post-builder';\nimport { validatorPassthru } from '../../commons/validator-passthru';\nimport { CustomLoginFlow } from '../../decorators/login-flow.decorator';\nimport { SupportsFiles } from '../../decorators/supports-files.decorator';\nimport { SupportsUsernameShortcut } from '../../decorators/supports-username-shortcut.decorator';\nimport { WebsiteMetadata } from '../../decorators/website-metadata.decorator';\nimport { DataPropertyAccessibility } from '../../models/data-property-accessibility';\nimport {\n  FileWebsite,\n  PostBatchData,\n} from '../../models/website-modifiers/file-website';\nimport { OAuthWebsite } from '../../models/website-modifiers/oauth-website';\nimport { Website } from '../../website';\nimport { InkbunnyFileSubmission } from './models/inkbunny-file-submission';\n\n@WebsiteMetadata({\n  name: 'inkbunny',\n  displayName: 'Inkbunny',\n})\n@CustomLoginFlow()\n@SupportsUsernameShortcut({\n  id: 'inkbunny',\n  url: 'https://inkbunny.net/$1',\n  convert: (websiteName, shortcut) => {\n    if (websiteName === 'inkbunny') {\n      switch (shortcut) {\n        case 'furaffinity':\n          return `[fa]$1[/fa]`;\n        case 'sofurry':\n          return `[sf]$1[/sf]`;\n        case 'deviantart':\n          return `[da]$1[/da]`;\n        case 'weasyl':\n          return `[w]$1[/w]`;\n        case 'inkbunny':\n          return `[iconname]$1[/iconname]`;\n        default:\n          return undefined;\n      }\n    }\n    return undefined;\n  },\n})\n@SupportsFiles({\n  acceptedMimeTypes: [\n    'image/png',\n    'image/jpeg',\n    'image/gif',\n    'application/x-shockwave-flash',\n    'video/x-flv',\n    'video/mp4',\n    'application/msword',\n    'application/rtf',\n    'text/plain',\n    'audio/mpeg',\n    'audio/mpeg3',\n  ],\n  acceptedFileSizes: {\n    '*': FileSize.megabytes(200),\n  },\n  fileBatchSize: 30,\n})\nexport default class Inkbunny\n  extends Website<InkbunnyAccountData>\n  implements\n    FileWebsite<InkbunnyFileSubmission>,\n    OAuthWebsite<InkbunnyOAuthRoutes>\n{\n  protected BASE_URL = 'https://inkbunny.net';\n\n  public externallyAccessibleWebsiteDataProperties: DataPropertyAccessibility<InkbunnyAccountData> =\n    {\n      folders: true,\n    };\n\n  /**\n   * OAuth route handlers for Inkbunny login.\n   * Password is only sent to Inkbunny API and never stored.\n   */\n  public onAuthRoute: OAuthRouteHandlers<InkbunnyOAuthRoutes> = {\n    login: async (request) => {\n      this.logger.info(`Attempting Inkbunny login for ${request.username}`);\n\n      const authResponse = await Http.get<{\n        sid?: string;\n        error_message?: string;\n      }>(\n        `${this.BASE_URL}/api_login.php?username=${encodeURIComponent(\n          request.username,\n        )}&password=${encodeURIComponent(request.password)}`,\n        { partition: this.accountId },\n      );\n\n      if (authResponse.body.sid) {\n        // Only store username and session ID, never the password\n        await this.setWebsiteData({\n          username: request.username,\n          sid: authResponse.body.sid,\n        });\n        this.logger.info(`Inkbunny login successful for ${request.username}`);\n      } else {\n        throw new Error(authResponse.body.error_message || 'Login failed');\n      }\n    },\n  };\n\n  public async onLogin(): Promise<ILoginState> {\n    const data = this.websiteDataStore.getData();\n\n    if (!data.username || !data.sid) {\n      return this.loginState.setLogin(false, null);\n    }\n\n    try {\n      const authCheck = await Http.post<{ error_code?: string }>(\n        `${this.BASE_URL}/api_watchlist.php`,\n        {\n          partition: this.accountId,\n          type: 'multipart',\n          data: {\n            sid: data.sid,\n            limit: 5,\n          },\n        },\n      );\n\n      if (authCheck.body && !authCheck.body.error_code) {\n        return this.loginState.setLogin(true, data.username);\n      }\n\n      return this.loginState.setLogin(false, null);\n    } catch (error) {\n      this.logger.error('Failed to check Inkbunny login status', error);\n      return this.loginState.setLogin(false, null);\n    }\n  }\n\n  createFileModel(): InkbunnyFileSubmission {\n    return new InkbunnyFileSubmission();\n  }\n\n  calculateImageResize(): ImageResizeProps {\n    return undefined;\n  }\n\n  async onPostFileSubmission(\n    postData: PostData<InkbunnyFileSubmission>,\n    files: PostingFile[],\n    cancellationToken: CancellableToken,\n    batch: PostBatchData,\n  ): Promise<IPostResponse> {\n    try {\n      cancellationToken.throwIfCancelled();\n\n      const data = this.websiteDataStore.getData();\n      const { options } = postData;\n\n      const builder = new PostBuilder(this, cancellationToken)\n        .asMultipart()\n        .setField('sid', data.sid)\n        .forEach(files, (file, index, b) => {\n          b.addFile(`uploadedfile[${index}]`, file);\n        })\n        .setConditional(\n          'uploadedthumbnail[]',\n          !!files[0].thumbnail,\n          files[0].thumbnailToPostFormat(),\n        );\n\n      const uploadResult = await builder.send<{\n        sid?: string;\n        submission_id?: string;\n        error_code?: string;\n      }>(`${this.BASE_URL}/api_upload.php`);\n\n      if (!uploadResult.body?.sid || !uploadResult.body?.submission_id) {\n        const errorMessage =\n          uploadResult.body?.error_code ||\n          'Upload failed without error message';\n        this.logger.error('Inkbunny upload failed', errorMessage);\n        return PostResponse.fromWebsite(this)\n          .withException(new Error(errorMessage))\n          .withAdditionalInfo(uploadResult.body);\n      }\n\n      // Step 2: Edit submission details\n      const ratings = this.getRating(options.rating);\n      const editBuilder = new PostBuilder(this, cancellationToken)\n        .asMultipart()\n        .setField('sid', data.sid)\n        .setField('submission_id', uploadResult.body.submission_id)\n        .setField('title', options.title)\n        .setField('desc', options.description.replace(/\\[hr\\]/g, '-----'))\n        .setField('keywords', this.formatTags(options.tags).join(',').trim())\n        .setConditional('type', !!options.category, options.category)\n        .setConditional('scraps', options.scraps, 'yes')\n        .setConditional('visibility', options.notify, 'yes', 'yes_nowatch')\n        .setConditional('guest_block', options.blockGuests, 'yes')\n        .setConditional('friends_only', options.friendsOnly, 'yes')\n        .forEach(ratings.split(','), (rating, _, b) => {\n          if (rating !== '0') {\n            b.setField(`tag[${rating}]`, 'yes');\n          }\n        });\n\n      const editResult = await editBuilder.send<{\n        error_code?: string;\n        submission_id?: string;\n      }>(`${this.BASE_URL}/api_editsubmission.php`);\n\n      if (\n        !editResult.body.submission_id ||\n        editResult.body.error_code !== undefined\n      ) {\n        const errorMessage =\n          editResult.body.error_code ||\n          'Submission edit failed without error message';\n        this.logger.error('Inkbunny submission edit failed', errorMessage);\n        return PostResponse.fromWebsite(this)\n          .withException(new Error(errorMessage))\n          .withAdditionalInfo(editResult.body);\n      }\n\n      const sourceUrl = `${this.BASE_URL}/s/${editResult.body.submission_id}`;\n      return PostResponse.fromWebsite(this)\n        .withSourceUrl(sourceUrl)\n        .withAdditionalInfo(editResult.body);\n    } catch (error) {\n      this.logger.error('Unexpected error during Inkbunny submission', error);\n      return PostResponse.fromWebsite(this)\n        .withException(\n          error instanceof Error ? error : new Error(String(error)),\n        )\n        .withAdditionalInfo({ \n          fileCount: files.length,\n          batchIndex: batch.index,\n          totalBatches: batch.totalBatches,\n        });\n    }\n  }\n\n  onValidateFileSubmission = validatorPassthru;\n\n  private formatTags(tags: string[]): string[] {\n    return tags.map((tag) =>\n      tag.trim().replace(/\\s/g, '_').replace(/\\\\/g, '/'),\n    );\n  }\n\n  private getRating(rating: SubmissionRating): string {\n    switch (rating) {\n      case SubmissionRating.GENERAL:\n        return '0';\n      case SubmissionRating.MATURE:\n        return '2';\n      case SubmissionRating.ADULT:\n      case SubmissionRating.EXTREME:\n        return '4';\n      default:\n        return rating; // potential custom value\n    }\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/inkbunny/models/inkbunny-file-submission.ts",
    "content": "import {\n  BooleanField,\n  DescriptionField,\n  RatingField,\n  SelectField,\n  TagField,\n} from '@postybirb/form-builder';\nimport {\n  DescriptionType,\n  DescriptionValue,\n  SubmissionRating,\n  TagValue,\n} from '@postybirb/types';\nimport { BaseWebsiteOptions } from '../../../models/base-website-options';\n\nexport class InkbunnyFileSubmission extends BaseWebsiteOptions {\n  @RatingField({\n    options: [\n      {\n        value: '2',\n        label: 'Nudity',\n      },\n      {\n        value: '3',\n        label: 'Violence',\n      },\n      {\n        value: '2,3',\n        label: 'Nudity + Violence',\n      },\n      {\n        value: '4',\n        label: 'Sexual',\n      },\n      {\n        value: '5',\n        label: 'Brutal',\n      },\n      {\n        value: '2,5',\n        label: 'Nudity + Brutal',\n      },\n      {\n        value: '3,4',\n        label: 'Sexual + Violent',\n      },\n      {\n        value: '4,5',\n        label: 'Sexual + Brutal',\n      },\n    ],\n  })\n  rating: SubmissionRating;\n\n  @DescriptionField({\n    descriptionType: DescriptionType.BBCODE,\n  })\n  description: DescriptionValue;\n\n  @TagField({\n    minTags: 4,\n  })\n  tags: TagValue;\n\n  @SelectField({\n    label: 'category',\n    section: 'website',\n    span: 12,\n    options: [\n      { label: 'Picture/Pinup', value: '1' },\n      { label: 'Sketch', value: '2' },\n      { label: 'Picture Series', value: '3' },\n      { label: 'Comic', value: '4' },\n      { label: 'Portfolio', value: '5' },\n      { label: 'Shockwave/Flash - Animation', value: '6' },\n      { label: 'Shockwave/Flash - Interactive', value: '7' },\n      { label: 'Video - Feature Length', value: '8' },\n      { label: 'Video - Animation/3D/CGI', value: '9' },\n      { label: 'Music - Single Track', value: '10' },\n      { label: 'Music - Album', value: '11' },\n      { label: 'Writing - Document', value: '12' },\n      { label: 'Character Sheet', value: '13' },\n      { label: 'Photography - Fursuit/Sculpture/Jewelry/etc', value: '14' },\n    ],\n  })\n  category?: string;\n\n  @BooleanField({\n    label: 'blockGuests',\n    section: 'website',\n    span: 6,\n  })\n  blockGuests = false;\n\n  @BooleanField({\n    label: 'friendsOnly',\n    section: 'website',\n    span: 6,\n  })\n  friendsOnly = false;\n\n  @BooleanField({\n    label: 'notify',\n    section: 'website',\n    span: 6,\n  })\n  notify = true;\n\n  @BooleanField({\n    label: 'scraps',\n    section: 'website',\n    span: 6,\n  })\n  scraps = false;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/instagram/instagram-api-service/instagram-api-service.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport { Logger, PostyBirbLogger } from '@postybirb/logger';\n\nconst GRAPH_API_BASE = 'https://graph.instagram.com/v21.0';\n\n/**\n * Build the OAuth redirect URI pointing to PostyBirb's own server.\n * The port must match the running PostyBirb server port.\n */\nexport function getInstagramRedirectUri(port: string | number): string {\n  return `https://localhost:${port}/api/websites/instagram/callback`;\n}\n\n/**\n * Temporary in-memory store for OAuth authorization codes.\n * Maps state nonce → { code, timestamp }.\n * Codes expire after 5 minutes.\n */\nconst pendingOAuthCodes = new Map<\n  string,\n  { code: string; timestamp: number }\n>();\n\nconst CODE_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes\n\n/**\n * Store an authorization code received from the OAuth callback.\n */\nexport function storeOAuthCode(state: string, code: string): void {\n  pendingOAuthCodes.set(state, { code, timestamp: Date.now() });\n}\n\n/**\n * Retrieve and consume a stored authorization code.\n * Returns the code if found and not expired, otherwise undefined.\n */\nexport function retrieveOAuthCode(state: string): string | undefined {\n  const entry = pendingOAuthCodes.get(state);\n  if (!entry) return undefined;\n\n  pendingOAuthCodes.delete(state);\n\n  if (Date.now() - entry.timestamp > CODE_EXPIRY_MS) {\n    return undefined;\n  }\n\n  return entry.code;\n}\n\nexport interface InstagramTokenResult {\n  accessToken: string;\n  tokenType: string;\n  expiresIn: number;\n}\n\nexport interface InstagramLongLivedTokenResult {\n  accessToken: string;\n  tokenType: string;\n  expiresIn: number; // seconds (typically ~5184000 = 60 days)\n}\n\nexport interface InstagramBusinessAccount {\n  igUserId: string;\n  igUsername: string;\n}\n\nexport interface InstagramContainerResult {\n  id: string;\n}\n\nexport type InstagramContainerStatus =\n  | 'EXPIRED'\n  | 'ERROR'\n  | 'FINISHED'\n  | 'IN_PROGRESS'\n  | 'PUBLISHED';\n\nexport interface InstagramPublishResult {\n  id: string; // Media ID\n}\n\nexport interface InstagramPublishingLimit {\n  quota_usage: number;\n  config: {\n    quota_total: number;\n    quota_duration: number;\n  };\n}\n\n/**\n * Static utility class for Instagram Graph API operations.\n * Mirrors the pattern used by TwitterApiServiceV2.\n */\nexport class InstagramApiService {\n  // eslint-disable-next-line @typescript-eslint/naming-convention\n  private static loggerInstance: PostyBirbLogger;\n\n  private static get logger(): PostyBirbLogger {\n    if (!InstagramApiService.loggerInstance) {\n      InstagramApiService.loggerInstance = Logger('InstagramApiService');\n    }\n    return InstagramApiService.loggerInstance;\n  }\n\n  // ========================================================================\n  // OAuth Flow\n  // ========================================================================\n\n  /**\n   * Generate the Instagram OAuth authorization URL.\n   * Uses the Instagram API with Instagram Login flow.\n   */\n  static getAuthUrl(appId: string, redirectUri: string, state: string): string {\n    const scopes = [\n      'instagram_business_basic',\n      'instagram_business_content_publish',\n      'instagram_business_manage_comments',\n      'instagram_business_manage_insights',\n    ].join(',');\n\n    return (\n      `https://www.instagram.com/oauth/authorize` +\n      `?client_id=${encodeURIComponent(appId)}` +\n      `&redirect_uri=${encodeURIComponent(redirectUri)}` +\n      `&scope=${encodeURIComponent(scopes)}` +\n      `&state=${encodeURIComponent(state)}` +\n      `&response_type=code` +\n      `&force_reauth=true`\n    );\n  }\n\n  /**\n   * Exchange an authorization code for a short-lived access token.\n   * Uses the Instagram API token endpoint (POST, form-urlencoded).\n   */\n  static async exchangeCodeForToken(\n    appId: string,\n    appSecret: string,\n    code: string,\n    redirectUri: string,\n  ): Promise<InstagramTokenResult> {\n    const params = new URLSearchParams({\n      client_id: appId,\n      client_secret: appSecret,\n      grant_type: 'authorization_code',\n      redirect_uri: redirectUri,\n      code,\n    });\n\n    const response = await fetch('https://api.instagram.com/oauth/access_token', {\n      method: 'POST',\n      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n      body: params.toString(),\n    });\n    const data = await response.json();\n\n    if (data.error) {\n      InstagramApiService.logger.error('Token exchange failed', data.error);\n      throw new Error(\n        data.error.message || 'Failed to exchange code for token',\n      );\n    }\n\n    return {\n      accessToken: data.access_token,\n      tokenType: data.token_type,\n      expiresIn: data.expires_in,\n    };\n  }\n\n  /**\n   * Exchange a short-lived token for a long-lived token (~60 days).\n   * Uses the Instagram Graph API ig_exchange_token grant.\n   */\n  static async getLongLivedToken(\n    appSecret: string,\n    shortLivedToken: string,\n  ): Promise<InstagramLongLivedTokenResult> {\n    const url =\n      `https://graph.instagram.com/access_token` +\n      `?grant_type=ig_exchange_token` +\n      `&client_secret=${encodeURIComponent(appSecret)}` +\n      `&access_token=${encodeURIComponent(shortLivedToken)}`;\n\n    const response = await fetch(url);\n    const data = await response.json();\n\n    if (data.error) {\n      InstagramApiService.logger.error(\n        'Long-lived token exchange failed',\n        data.error,\n      );\n      throw new Error(\n        data.error.message || 'Failed to get long-lived token',\n      );\n    }\n\n    return {\n      accessToken: data.access_token,\n      tokenType: data.token_type,\n      expiresIn: data.expires_in,\n    };\n  }\n\n  /**\n   * Refresh a still-valid long-lived token for a new 60-day token.\n   * Uses the Instagram Graph API ig_refresh_token grant.\n   */\n  static async refreshLongLivedToken(\n    accessToken: string,\n  ): Promise<InstagramLongLivedTokenResult> {\n    const url =\n      `https://graph.instagram.com/refresh_access_token` +\n      `?grant_type=ig_refresh_token` +\n      `&access_token=${encodeURIComponent(accessToken)}`;\n\n    const response = await fetch(url);\n    const data = await response.json();\n\n    if (data.error) {\n      InstagramApiService.logger.error('Token refresh failed', data.error);\n      throw new Error(data.error.message || 'Failed to refresh token');\n    }\n\n    return {\n      accessToken: data.access_token,\n      tokenType: data.token_type,\n      expiresIn: data.expires_in,\n    };\n  }\n\n  // ========================================================================\n  // Account Discovery\n  // ========================================================================\n\n  /**\n   * Get the authenticated Instagram user's ID and username.\n   * With Instagram Login, the token is already scoped to the IG account — no Facebook Page lookup needed.\n   */\n  static async getInstagramBusinessAccount(\n    accessToken: string,\n  ): Promise<InstagramBusinessAccount> {\n    const url = `${GRAPH_API_BASE}/me?fields=user_id,username&access_token=${encodeURIComponent(accessToken)}`;\n\n    const response = await fetch(url);\n    const data = await response.json();\n\n    if (data.error) {\n      throw new Error(\n        data.error.message || 'Failed to get Instagram account info',\n      );\n    }\n\n    if (!data.user_id) {\n      throw new Error(\n        'Could not retrieve Instagram user ID. Please ensure your account is a Business or Creator account.',\n      );\n    }\n\n    return {\n      igUserId: data.user_id,\n      igUsername: data.username || data.user_id,\n    };\n  }\n\n  /**\n   * Verify the access token is still valid by fetching the IG user profile.\n   */\n  static async verifyToken(\n    accessToken: string,\n  ): Promise<{ username: string } | null> {\n    try {\n      const url = `${GRAPH_API_BASE}/me?fields=username&access_token=${encodeURIComponent(accessToken)}`;\n      const response = await fetch(url);\n      const data = await response.json();\n\n      if (data.error) {\n        return null;\n      }\n\n      return { username: data.username };\n    } catch {\n      return null;\n    }\n  }\n\n  // ========================================================================\n  // Content Publishing\n  // ========================================================================\n\n  /**\n   * Create a single image media container.\n   * Instagram will fetch the image from the provided URL.\n   */\n  static async createImageContainer(\n    accessToken: string,\n    igUserId: string,\n    imageUrl: string,\n    caption?: string,\n    altText?: string,\n    isCarouselItem?: boolean,\n  ): Promise<InstagramContainerResult> {\n    const params = new URLSearchParams({\n      access_token: accessToken,\n      image_url: imageUrl,\n    });\n\n    if (caption && !isCarouselItem) {\n      params.set('caption', caption);\n    }\n    if (altText) {\n      params.set('alt_text', altText);\n    }\n    if (isCarouselItem) {\n      params.set('is_carousel_item', 'true');\n    }\n\n    const url = `${GRAPH_API_BASE}/${igUserId}/media`;\n    const response = await fetch(url, {\n      method: 'POST',\n      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n      body: params.toString(),\n    });\n    const data = await response.json();\n\n    if (data.error) {\n      InstagramApiService.logger.error(\n        'Failed to create image container',\n        data.error,\n      );\n      throw new Error(\n        data.error.message || 'Failed to create image container',\n      );\n    }\n\n    return { id: data.id };\n  }\n\n  /**\n   * Create a carousel container from child container IDs.\n   */\n  static async createCarouselContainer(\n    accessToken: string,\n    igUserId: string,\n    childIds: string[],\n    caption?: string,\n  ): Promise<InstagramContainerResult> {\n    const params = new URLSearchParams({\n      access_token: accessToken,\n      media_type: 'CAROUSEL',\n      children: childIds.join(','),\n    });\n\n    if (caption) {\n      params.set('caption', caption);\n    }\n\n    const url = `${GRAPH_API_BASE}/${igUserId}/media`;\n    const response = await fetch(url, {\n      method: 'POST',\n      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n      body: params.toString(),\n    });\n    const data = await response.json();\n\n    if (data.error) {\n      InstagramApiService.logger.error(\n        'Failed to create carousel container',\n        data.error,\n      );\n      throw new Error(\n        data.error.message || 'Failed to create carousel container',\n      );\n    }\n\n    return { id: data.id };\n  }\n\n  /**\n   * Check the status of a media container.\n   */\n  static async checkContainerStatus(\n    accessToken: string,\n    containerId: string,\n  ): Promise<InstagramContainerStatus> {\n    const url = `${GRAPH_API_BASE}/${containerId}?fields=status_code&access_token=${encodeURIComponent(accessToken)}`;\n    const response = await fetch(url);\n    const data = await response.json();\n\n    if (data.error) {\n      throw new Error(\n        data.error.message || 'Failed to check container status',\n      );\n    }\n\n    return data.status_code as InstagramContainerStatus;\n  }\n\n  /**\n   * Poll a container until it reaches FINISHED status or errors out.\n   * Instagram recommends polling once per minute, for no more than 5 minutes.\n   */\n  static async pollUntilReady(\n    accessToken: string,\n    containerId: string,\n    timeoutMs = 300_000, // 5 minutes\n    intervalMs = 10_000, // 10 seconds\n  ): Promise<void> {\n    const startTime = Date.now();\n\n    while (Date.now() - startTime < timeoutMs) {\n      const status = await InstagramApiService.checkContainerStatus(\n        accessToken,\n        containerId,\n      );\n\n      switch (status) {\n        case 'FINISHED':\n          return;\n        case 'PUBLISHED':\n          return;\n        case 'ERROR':\n          throw new Error(\n            'Instagram media container processing failed (ERROR status)',\n          );\n        case 'EXPIRED':\n          throw new Error(\n            'Instagram media container expired before publishing',\n          );\n        case 'IN_PROGRESS':\n          // Continue polling\n          break;\n        default:\n          InstagramApiService.logger.warn(\n            `Unknown container status: ${status}`,\n          );\n      }\n\n      await new Promise<void>((resolve) => {\n        setTimeout(resolve, intervalMs);\n      });\n    }\n\n    throw new Error(\n      `Instagram media container did not finish processing within ${timeoutMs / 1000}s`,\n    );\n  }\n\n  /**\n   * Publish a media container (single image, carousel, or video).\n   */\n  static async publishMedia(\n    accessToken: string,\n    igUserId: string,\n    containerId: string,\n  ): Promise<InstagramPublishResult> {\n    const params = new URLSearchParams({\n      access_token: accessToken,\n      creation_id: containerId,\n    });\n\n    const url = `${GRAPH_API_BASE}/${igUserId}/media_publish`;\n    const response = await fetch(url, {\n      method: 'POST',\n      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n      body: params.toString(),\n    });\n    const data = await response.json();\n\n    if (data.error) {\n      InstagramApiService.logger.error(\n        'Failed to publish media',\n        data.error,\n      );\n      throw new Error(data.error.message || 'Failed to publish media');\n    }\n\n    return { id: data.id };\n  }\n\n  /**\n   * Get the permalink for a published media item.\n   */\n  static async getMediaPermalink(\n    accessToken: string,\n    mediaId: string,\n  ): Promise<string | undefined> {\n    try {\n      const url = `${GRAPH_API_BASE}/${mediaId}?fields=permalink&access_token=${encodeURIComponent(accessToken)}`;\n      const response = await fetch(url);\n      const data = await response.json();\n\n      if (data.error) {\n        InstagramApiService.logger.warn(\n          'Failed to get media permalink',\n          data.error,\n        );\n        return undefined;\n      }\n\n      return data.permalink;\n    } catch {\n      return undefined;\n    }\n  }\n\n  /**\n   * Check the current publishing rate limit usage.\n   */\n  static async checkPublishingLimit(\n    accessToken: string,\n    igUserId: string,\n  ): Promise<InstagramPublishingLimit | null> {\n    try {\n      const url = `${GRAPH_API_BASE}/${igUserId}/content_publishing_limit?fields=quota_usage,config&access_token=${encodeURIComponent(accessToken)}`;\n      const response = await fetch(url);\n      const data = await response.json();\n\n      if (data.error || !data.data?.length) {\n        return null;\n      }\n\n      return data.data[0];\n    } catch {\n      return null;\n    }\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/instagram/instagram-blob-service/instagram-blob-service.ts",
    "content": "import { Logger, PostyBirbLogger } from '@postybirb/logger';\n\nconst FUNCTION_BASE_URL =\n  process.env.POSTYBIRB_CLOUD_URL || 'https://postybirb.azurewebsites.net/api';\n\ninterface UploadResponse {\n  url: string;\n  blobName: string;\n}\n\n/**\n * Temporary blob storage for Instagram image uploads.\n * Uploads images via the PostyBirb cloud server Azure Function\n * so Instagram's API can cURL them.\n * Blobs are auto-deleted by Azure Lifecycle Management policy.\n */\nexport class InstagramBlobService {\n  private static loggerInstance: PostyBirbLogger;\n\n  private static get logger(): PostyBirbLogger {\n    if (!InstagramBlobService.loggerInstance) {\n      InstagramBlobService.loggerInstance = Logger('InstagramBlobService');\n    }\n    return InstagramBlobService.loggerInstance;\n  }\n\n  /**\n   * Upload a file buffer via the cloud server function.\n   * @returns The public URL of the uploaded blob.\n   */\n  static async upload(\n    buffer: Buffer,\n    mimeType: string,\n  ): Promise<UploadResponse> {\n    const response = await fetch(`${FUNCTION_BASE_URL}/upload`, {\n      method: 'POST',\n      headers: {\n        'Content-Type': mimeType,\n      },\n      body: new Uint8Array(buffer),\n    });\n\n    if (!response.ok) {\n      const errorBody = await response.text();\n      throw new Error(\n        `Failed to upload to cloud server: ${response.status} ${errorBody}`,\n      );\n    }\n\n    const data = (await response.json()) as UploadResponse;\n    if (!data.url) {\n      throw new Error('Cloud server did not return a URL');\n    }\n\n    InstagramBlobService.logger.info(`Uploaded blob: ${data.blobName}`);\n    return data;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/instagram/instagram.website.ts",
    "content": "import {\n  FileType,\n  ILoginState,\n  ImageResizeProps,\n  InstagramAccountData,\n  InstagramOAuthRoutes,\n  ISubmissionFile,\n  OAuthRouteHandlers,\n  PostData,\n  PostResponse,\n  SimpleValidationResult,\n} from '@postybirb/types';\nimport { PostyBirbEnvConfig } from '@postybirb/utils/electron';\nimport { calculateImageResize } from '@postybirb/utils/file-type';\nimport { v4 as uuidv4 } from 'uuid';\nimport { CancellableToken } from '../../../post/models/cancellable-token';\nimport { PostingFile } from '../../../post/models/posting-file';\nimport FileSize from '../../../utils/filesize.util';\nimport { DisableAds } from '../../decorators/disable-ads.decorator';\nimport { CustomLoginFlow } from '../../decorators/login-flow.decorator';\nimport { SupportsFiles } from '../../decorators/supports-files.decorator';\nimport { SupportsUsernameShortcut } from '../../decorators/supports-username-shortcut.decorator';\nimport { WebsiteMetadata } from '../../decorators/website-metadata.decorator';\nimport { DataPropertyAccessibility } from '../../models/data-property-accessibility';\nimport { FileWebsite } from '../../models/website-modifiers/file-website';\nimport { Website } from '../../website';\nimport {\n  getInstagramRedirectUri,\n  InstagramApiService,\n  retrieveOAuthCode,\n} from './instagram-api-service/instagram-api-service';\nimport { InstagramBlobService } from './instagram-blob-service/instagram-blob-service';\nimport { InstagramFileSubmission } from './models/instagram-file-submission';\n\n/**\n * Instagram website implementation.\n *\n * Key constraints:\n * - Requires a Business or Creator Instagram account\n * - Each user creates their own Meta Developer App (Development Mode, no App Review needed)\n * - Instagram's API requires publicly accessible image URLs — files are uploaded\n *   to temporary Azure Blob Storage, Instagram cURLs them, then blobs are cleaned up\n * - Images only (v1) — JPEG format required (auto-converted via outputMimeType)\n * - Carousels support up to 10 images\n * - 100 API-published posts per 24-hour period\n * - Long-lived tokens expire after 60 days and must be refreshed\n */\n@WebsiteMetadata({\n  name: 'instagram',\n  displayName: 'Instagram',\n})\n@CustomLoginFlow()\n@SupportsUsernameShortcut({\n  id: 'instagram',\n  url: 'https://instagram.com/$1',\n})\n@SupportsFiles({\n  fileBatchSize: 10, // Carousel max\n  acceptedMimeTypes: ['image/jpeg', 'image/png', 'image/webp'],\n  acceptedFileSizes: {\n    [FileType.IMAGE]: FileSize.megabytes(30),\n  },\n})\n@DisableAds()\nexport default class Instagram\n  extends Website<InstagramAccountData>\n  implements FileWebsite<InstagramFileSubmission>\n{\n  protected BASE_URL = 'https://www.instagram.com';\n\n  public externallyAccessibleWebsiteDataProperties: DataPropertyAccessibility<InstagramAccountData> =\n    {\n      appId: true,\n      appSecret: true,\n      accessToken: false,\n      tokenExpiry: true,\n      igUserId: false,\n      igUsername: true,\n    };\n\n  // ========================================================================\n  // OAuth Route Handlers\n  // ========================================================================\n\n  public onAuthRoute: OAuthRouteHandlers<InstagramOAuthRoutes> = {\n    setAppCredentials: async ({ appId, appSecret }) => {\n      const current = this.websiteDataStore.getData();\n      await this.setWebsiteData({\n        ...(current as InstagramAccountData),\n        appId,\n        appSecret,\n      });\n      return { success: true };\n    },\n\n    getAuthUrl: async () => {\n      const { appId } = this.websiteDataStore.getData();\n      if (!appId) {\n        return {\n          success: false,\n          message: 'App ID must be set before generating auth URL',\n        };\n      }\n\n      const state = uuidv4();\n      const redirectUri = getInstagramRedirectUri(PostyBirbEnvConfig.port);\n      const url = InstagramApiService.getAuthUrl(appId, redirectUri, state);\n      return { success: true, url, state };\n    },\n\n    retrieveCode: async ({ state }) => {\n      if (!state) {\n        return { success: false, message: 'State parameter is required' };\n      }\n\n      const code = retrieveOAuthCode(state);\n      if (code) {\n        return { success: true, code };\n      }\n\n      return { success: false, message: 'No code available yet' };\n    },\n\n    exchangeCode: async ({ code }) => {\n      const { appId, appSecret } = this.websiteDataStore.getData();\n      if (!appId || !appSecret) {\n        return { success: false, message: 'App credentials are not set' };\n      }\n\n      try {\n        // Step 1: Exchange code for short-lived token\n        const redirectUri = getInstagramRedirectUri(PostyBirbEnvConfig.port);\n        const tokenResult = await InstagramApiService.exchangeCodeForToken(\n          appId,\n          appSecret,\n          code,\n          redirectUri,\n        );\n\n        // Step 2: Exchange for long-lived token\n        const longLived = await InstagramApiService.getLongLivedToken(\n          appSecret,\n          tokenResult.accessToken,\n        );\n\n        // Step 3: Discover Instagram Business account\n        const igAccount = await InstagramApiService.getInstagramBusinessAccount(\n          longLived.accessToken,\n        );\n\n        // Calculate token expiry\n        const tokenExpiry = new Date(\n          Date.now() + longLived.expiresIn * 1000,\n        ).toISOString();\n\n        // Store everything\n        const current = this.websiteDataStore.getData();\n        await this.setWebsiteData({\n          ...(current as InstagramAccountData),\n          accessToken: longLived.accessToken,\n          tokenExpiry,\n          igUserId: igAccount.igUserId,\n          igUsername: igAccount.igUsername,\n        });\n\n        await this.onLogin();\n\n        return {\n          success: true,\n          igUsername: igAccount.igUsername,\n          igUserId: igAccount.igUserId,\n          tokenExpiry,\n        };\n      } catch (e) {\n        this.logger.error('Instagram OAuth exchange failed', e);\n        return {\n          success: false,\n          message: e instanceof Error ? e.message : 'Failed to complete OAuth',\n        };\n      }\n    },\n\n    refreshToken: async () => {\n      const { accessToken } = this.websiteDataStore.getData();\n      if (!accessToken) {\n        return { success: false, message: 'No access token to refresh' };\n      }\n\n      try {\n        const result =\n          await InstagramApiService.refreshLongLivedToken(accessToken);\n        const tokenExpiry = new Date(\n          Date.now() + result.expiresIn * 1000,\n        ).toISOString();\n\n        const current = this.websiteDataStore.getData();\n        await this.setWebsiteData({\n          ...(current as InstagramAccountData),\n          accessToken: result.accessToken,\n          tokenExpiry,\n        });\n\n        return { success: true, tokenExpiry };\n      } catch (e) {\n        this.logger.error('Token refresh failed', e);\n        return {\n          success: false,\n          message: e instanceof Error ? e.message : 'Failed to refresh token',\n        };\n      }\n    },\n  };\n\n  // ========================================================================\n  // Login\n  // ========================================================================\n\n  public async onLogin(): Promise<ILoginState> {\n    const data = this.websiteDataStore.getData();\n\n    if (!data?.accessToken || !data?.igUserId || !data?.igUsername) {\n      return this.loginState.logout();\n    }\n\n    // Check if token is expired\n    if (data.tokenExpiry) {\n      const expiry = new Date(data.tokenExpiry).getTime();\n      const now = Date.now();\n\n      if (now > expiry) {\n        this.logger.warn('Instagram token has expired');\n        return this.loginState.logout();\n      }\n\n      // Auto-refresh if within 7 days of expiry\n      const sevenDays = 7 * 24 * 60 * 60 * 1000;\n      if (expiry - now < sevenDays) {\n        this.logger.info('Instagram token nearing expiry, auto-refreshing');\n        try {\n          await this.onAuthRoute.refreshToken({} as never);\n        } catch (e) {\n          this.logger.warn('Auto-refresh failed', e);\n        }\n      }\n    }\n\n    // Verify token is still valid\n    const verified = await InstagramApiService.verifyToken(data.accessToken);\n\n    if (verified) {\n      return this.loginState.setLogin(true, data.igUsername);\n    }\n\n    return this.loginState.logout();\n  }\n\n  // ========================================================================\n  // File Submission\n  // ========================================================================\n\n  createFileModel(): InstagramFileSubmission {\n    return new InstagramFileSubmission();\n  }\n\n  calculateImageResize(file: ISubmissionFile): ImageResizeProps {\n    // Instagram API only accepts JPEG. Convert any input format to JPEG.\n    // See: https://developers.facebook.com/docs/instagram-platform/content-publishing#limitations\n    const resizeProps = calculateImageResize(file, {\n      maxWidth: 1080,\n      maxBytes: FileSize.megabytes(30),\n    });\n\n    if (file.mimeType !== 'image/jpeg') {\n      return { ...resizeProps, outputMimeType: 'image/jpeg' };\n    }\n\n    return resizeProps;\n  }\n\n  async onPostFileSubmission(\n    postData: PostData<InstagramFileSubmission>,\n    files: PostingFile[],\n    cancellationToken: CancellableToken,\n  ): Promise<PostResponse> {\n    cancellationToken.throwIfCancelled();\n\n    const { accessToken, igUserId } = this.websiteDataStore.getData();\n    if (!accessToken || !igUserId) {\n      return PostResponse.fromWebsite(this).withException(\n        new Error('Instagram is not logged in'),\n      );\n    }\n\n    // Upload files to temporary blob storage so Instagram can cURL them\n    // Blobs are auto-deleted by Azure Lifecycle Management policy\n    const uploadedBlobs: Array<{ url: string; blobName: string }> = [];\n    try {\n      for (const file of files) {\n        cancellationToken.throwIfCancelled();\n        const blob = await InstagramBlobService.upload(\n          file.buffer,\n          file.mimeType,\n        );\n        uploadedBlobs.push(blob);\n      }\n\n      const filesToPost = uploadedBlobs.slice(0, 10);\n      let publishResult: { id: string };\n\n      if (filesToPost.length === 1) {\n        // Single image post\n        const altText = files[0]?.metadata?.altText || '';\n        const container = await InstagramApiService.createImageContainer(\n          accessToken,\n          igUserId,\n          filesToPost[0].url,\n          postData.options.description,\n          altText,\n        );\n\n        await InstagramApiService.pollUntilReady(accessToken, container.id);\n        cancellationToken.throwIfCancelled();\n\n        publishResult = await InstagramApiService.publishMedia(\n          accessToken,\n          igUserId,\n          container.id,\n        );\n      } else {\n        // Carousel post (2-10 images)\n        const childIds: string[] = [];\n\n        for (let i = 0; i < filesToPost.length; i++) {\n          cancellationToken.throwIfCancelled();\n          const altText = files[i]?.metadata?.altText || '';\n          const child = await InstagramApiService.createImageContainer(\n            accessToken,\n            igUserId,\n            filesToPost[i].url,\n            undefined, // No caption on carousel items\n            altText,\n            true, // is_carousel_item\n          );\n          childIds.push(child.id);\n        }\n\n        // Create carousel container\n        const carousel = await InstagramApiService.createCarouselContainer(\n          accessToken,\n          igUserId,\n          childIds,\n          postData.options.description,\n        );\n\n        // Poll until ready\n        await InstagramApiService.pollUntilReady(accessToken, carousel.id);\n        cancellationToken.throwIfCancelled();\n\n        // Publish\n        publishResult = await InstagramApiService.publishMedia(\n          accessToken,\n          igUserId,\n          carousel.id,\n        );\n      }\n\n      // Get the permalink for the published post\n      const permalink = await InstagramApiService.getMediaPermalink(\n        accessToken,\n        publishResult.id,\n      );\n\n      return PostResponse.fromWebsite(this)\n        .withAdditionalInfo(publishResult)\n        .withSourceUrl(permalink || `${this.BASE_URL}/p/${publishResult.id}/`);\n    } catch (e) {\n      this.logger.error('Instagram post failed', e);\n      return PostResponse.fromWebsite(this)\n        .withException(\n          e instanceof Error ? e : new Error('Failed to post to Instagram'),\n        )\n        .withAdditionalInfo(e);\n    }\n  }\n\n  async onValidateFileSubmission(\n    postData: PostData<InstagramFileSubmission>,\n  ): Promise<SimpleValidationResult> {\n    const validator = this.createValidator<InstagramFileSubmission>();\n\n    // Validate image aspect ratios\n    // Instagram only supports specific aspect ratios:\n    // 1:1 (square) = 1.0, 4:5 (portrait) = 0.8, 1.91:1 (landscape) = 1.91\n    const SUPPORTED_RATIOS = [\n      { name: '1:1', ratio: 1 / 1 },\n      { name: '4:5', ratio: 4 / 5 },\n      { name: '1.91:1', ratio: 1.91 / 1 },\n    ];\n    const RATIO_TOLERANCE = 0.02; // Allow small rounding differences\n\n    const files = postData.submission?.files ?? [];\n    for (const file of files) {\n      if (file.width && file.height) {\n        const ratio = file.width / file.height;\n        const isSupported = SUPPORTED_RATIOS.some(\n          (sr) => Math.abs(ratio - sr.ratio) <= RATIO_TOLERANCE,\n        );\n        if (!isSupported) {\n          validator.error('validation.file.instagram.invalid-aspect-ratio', {\n            fileName: file.fileName,\n          });\n        }\n      }\n    }\n\n    return validator.result;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/instagram/models/instagram-file-submission.ts",
    "content": "import {\n  DescriptionField,\n  RatingField,\n  TagField,\n} from '@postybirb/form-builder';\nimport {\n  DefaultTagValue,\n  DescriptionType,\n  DescriptionValue,\n  SubmissionRating,\n  TagValue,\n} from '@postybirb/types';\nimport { BaseWebsiteOptions } from '../../../models/base-website-options';\n\nexport class InstagramFileSubmission extends BaseWebsiteOptions {\n  @DescriptionField({\n    descriptionType: DescriptionType.PLAINTEXT,\n    maxDescriptionLength: 2200,\n    expectsInlineTags: true,\n    expectsInlineTitle: true,\n  })\n  description: DescriptionValue;\n\n  @TagField({\n    maxTags: 30,\n  })\n  tags: TagValue = DefaultTagValue();\n\n  @RatingField({\n    options: [\n      {\n        label: 'General',\n        value: SubmissionRating.GENERAL,\n      },\n      {\n        label: 'Sensitive',\n        value: SubmissionRating.ADULT,\n      },\n    ],\n  })\n  rating: SubmissionRating;\n\n  /**\n   * Process tags into Instagram hashtag format.\n   * Instagram hashtags are prefixed with # and have no spaces.\n   */\n  override processTag(tag: string): string {\n    const cleaned = tag.replace(/[^a-zA-Z0-9_\\u00C0-\\u024F\\u1E00-\\u1EFF]/g, '');\n    return cleaned ? `${cleaned}` : '';\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/itaku/itaku.website.ts",
    "content": "import { SelectOption } from '@postybirb/form-builder';\nimport { Http } from '@postybirb/http';\nimport {\n  FileType,\n  ILoginState,\n  ImageResizeProps,\n  PostData,\n  PostResponse,\n  SimpleValidationResult,\n  SubmissionRating,\n} from '@postybirb/types';\nimport { BrowserWindowUtils } from '@postybirb/utils/electron';\nimport { CancellableToken } from '../../../post/models/cancellable-token';\nimport { PostingFile } from '../../../post/models/posting-file';\nimport FileSize from '../../../utils/filesize.util';\nimport { PostBuilder } from '../../commons/post-builder';\nimport { validatorPassthru } from '../../commons/validator-passthru';\nimport { UserLoginFlow } from '../../decorators/login-flow.decorator';\nimport { SupportsFiles } from '../../decorators/supports-files.decorator';\nimport { SupportsUsernameShortcut } from '../../decorators/supports-username-shortcut.decorator';\nimport { WebsiteMetadata } from '../../decorators/website-metadata.decorator';\nimport { DataPropertyAccessibility } from '../../models/data-property-accessibility';\nimport { FileWebsite } from '../../models/website-modifiers/file-website';\nimport { MessageWebsite } from '../../models/website-modifiers/message-website';\nimport { Website } from '../../website';\nimport { ItakuAccountData } from './models/itaku-account-data';\nimport { ItakuFileSubmission } from './models/itaku-file-submission';\nimport { ItakuMessageSubmission } from './models/itaku-message-submission';\nimport { ItakuUserInfo } from './models/itaku-user-info';\n\ntype ItakuSessionData = {\n  token: string;\n  profile: ItakuUserInfo['profile'];\n};\n\n@WebsiteMetadata({\n  name: 'itaku',\n  displayName: 'Itaku',\n})\n@UserLoginFlow('https://itaku.ee')\n@SupportsFiles({\n  acceptedMimeTypes: [\n    'image/png',\n    'image/jpeg',\n    'image/gif',\n    'image/webp',\n    'video/mp4',\n    'video/webm',\n    'video/mov',\n  ],\n  acceptedFileSizes: {\n    [FileType.IMAGE]: FileSize.megabytes(10),\n    [FileType.VIDEO]: FileSize.megabytes(500),\n  },\n  fileBatchSize: 100,\n})\n@SupportsUsernameShortcut({\n  id: 'itaku',\n  url: 'https://itaku.ee/profile/$1',\n})\nexport default class Itaku\n  extends Website<ItakuAccountData, ItakuSessionData>\n  implements\n    FileWebsite<ItakuFileSubmission>,\n    MessageWebsite<ItakuMessageSubmission>\n{\n  protected BASE_URL = 'https://itaku.ee';\n\n  public externallyAccessibleWebsiteDataProperties: DataPropertyAccessibility<ItakuAccountData> =\n    {\n      galleryFolders: true,\n      notificationFolders: true,\n    };\n\n  public async onLogin(): Promise<ILoginState> {\n    const localStorage = await BrowserWindowUtils.getLocalStorage<{\n      token: string;\n    }>(this.accountId, this.BASE_URL);\n\n    if (localStorage.token) {\n      this.sessionData.token = localStorage.token.replace(/\"/g, '');\n      const user = await Http.get<ItakuUserInfo>(\n        `${this.BASE_URL}/api/auth/user/`,\n        {\n          partition: this.accountId,\n          headers: {\n            Authorization: `Token ${this.sessionData.token}`,\n          },\n        },\n      );\n\n      this.loginState.setLogin(true, user.body.profile.displayname);\n      this.sessionData.profile = user.body.profile;\n      await this.retrieveFolders();\n    } else {\n      this.loginState.logout();\n    }\n\n    return this.loginState;\n  }\n\n  private async retrieveFolders(): Promise<void> {\n    try {\n      const notificationFolderRes = await Http.get<\n        { id: string; num_images: number; title: string }[]\n      >(\n        `${this.BASE_URL}/api/post_folders/?owner=${this.sessionData.profile.owner}`,\n        {\n          partition: this.accountId,\n          headers: {\n            Authorization: `Token ${this.sessionData.token}`,\n          },\n        },\n      );\n\n      const notificationFolders: SelectOption[] =\n        notificationFolderRes.body.map((f) => ({\n          value: f.title,\n          label: f.title,\n        }));\n\n      const galleryFolderRes = await Http.get<{\n        count: number;\n        links: object;\n        results: {\n          group: string;\n          id: number;\n          num_images: number;\n          title: string;\n        }[];\n      }>(\n        `${this.BASE_URL}/api/galleries/?owner=${this.sessionData.profile.owner}&page_size=300`,\n        {\n          partition: this.accountId,\n          headers: {\n            Authorization: `Token ${this.sessionData.token}`,\n          },\n        },\n      );\n\n      const galleryFolders: SelectOption[] = galleryFolderRes.body.results.map(\n        (f) => ({\n          value: f.title,\n          label: f.title,\n        }),\n      );\n\n      await this.setWebsiteData({\n        notificationFolders,\n        galleryFolders,\n      });\n    } catch (error) {\n      this.logger.error('Failed to retrieve folders', error);\n    }\n  }\n\n  private convertRating(rating: SubmissionRating): string {\n    switch (rating) {\n      case SubmissionRating.MATURE:\n        return 'Questionable';\n      case SubmissionRating.ADULT:\n      case SubmissionRating.EXTREME:\n        return 'NSFW';\n      case SubmissionRating.GENERAL:\n      default:\n        return 'SFW';\n    }\n  }\n\n  createFileModel(): ItakuFileSubmission {\n    return new ItakuFileSubmission();\n  }\n\n  calculateImageResize(): ImageResizeProps {\n    return undefined;\n  }\n\n  private async uploadFile(\n    postData: PostData<ItakuFileSubmission>,\n    file: PostingFile,\n    isBatch: boolean,\n    cancellationToken: CancellableToken,\n  ): Promise<{ id: number }> {\n    const spoilerText =\n      postData.options.contentWarning || file.metadata.spoilerText;\n\n    if (\n      !(file.fileType === FileType.IMAGE || file.fileType === FileType.VIDEO)\n    ) {\n      throw new Error('Unsupported file type');\n    }\n\n    const builder = new PostBuilder(this, cancellationToken)\n      .asMultipart()\n      .withHeader('Authorization', `Token ${this.sessionData.token}`)\n      .setField('title', postData.options.title)\n      .setField('description', postData.options.description)\n      .setField('sections', JSON.stringify(postData.options.folders))\n      .setField(\n        'tags',\n        JSON.stringify(\n          postData.options.tags.map((tag) => ({ name: tag.substring(0, 59) })),\n        ),\n      )\n      .setField('maturity_rating', this.convertRating(postData.options.rating))\n      .setField('visibility', postData.options.visibility)\n      .setConditional(\n        'share_on_feed',\n        isBatch || postData.options.shareOnFeed,\n        postData.options.shareOnFeed,\n      )\n      .setConditional('content_warning', !!spoilerText, spoilerText)\n      .setConditional('image', file.fileType === FileType.IMAGE, file)\n      .setConditional('video', file.fileType === FileType.VIDEO, file);\n\n    const upload = await builder.send<{ id: number }>(\n      `${this.BASE_URL}/api/galleries/${\n        file.fileType === FileType.IMAGE ? 'images' : 'videos'\n      }/`,\n    );\n\n    return upload.body;\n  }\n\n  async postSubmission(\n    postData: PostData<ItakuFileSubmission | ItakuMessageSubmission>,\n    cancellationToken: CancellableToken,\n    uploadedFiles?: { id: number }[],\n  ): Promise<PostResponse> {\n    const builder = new PostBuilder(this, cancellationToken)\n      .asJson()\n      .setField('title', postData.options.title)\n      .setField('content', postData.options.description)\n      .setField('folders', postData.options.folders)\n      .setField('tags', postData.options.tags.join(','))\n      .setField('maturity_rating', this.convertRating(postData.options.rating))\n      .setField('visibility', postData.options.visibility)\n      .setField(\n        'gallery_images',\n        uploadedFiles?.map((file) => file.id),\n      )\n      .setConditional(\n        'content_warning',\n        !!postData.options.contentWarning,\n        postData.options.contentWarning,\n      )\n      .withHeader('Authorization', `Token ${this.sessionData.token}`);\n\n    const post = await builder.send<{ id: number }>(\n      `${this.BASE_URL}/api/posts/`,\n    );\n\n    if (!post.body.id) {\n      return PostResponse.fromWebsite(this)\n        .withMessage('Failed to post')\n        .withAdditionalInfo(post.body);\n    }\n\n    return PostResponse.fromWebsite(this)\n      .withSourceUrl(`${this.BASE_URL}/posts/${post.body.id}`)\n      .withAdditionalInfo(post.body);\n  }\n\n  async onPostFileSubmission(\n    postData: PostData<ItakuFileSubmission>,\n    files: PostingFile[],\n    cancellationToken: CancellableToken,\n  ): Promise<PostResponse> {\n    cancellationToken.throwIfCancelled();\n    const isBatch = files.length > 1;\n\n    const uploadedFiles = await Promise.all(\n      files.map((file) =>\n        this.uploadFile(postData, file, isBatch, cancellationToken),\n      ),\n    );\n\n    if (isBatch) {\n      const postResponse = await this.postSubmission(\n        postData,\n        cancellationToken,\n        uploadedFiles,\n      );\n      return postResponse;\n    }\n\n    return PostResponse.fromWebsite(this).withSourceUrl(\n      `${this.BASE_URL}/images/${uploadedFiles[0].id}`,\n    );\n  }\n\n  async onValidateFileSubmission(\n    postData: PostData<ItakuFileSubmission>,\n  ): Promise<SimpleValidationResult> {\n    const validator = this.createValidator<ItakuFileSubmission>();\n\n    const { submission, options } = postData;\n    if (!options.shareOnFeed) {\n      const filesToPost = submission.files.filter(\n        (file) => !file.metadata?.ignoredWebsites.includes(this.accountId),\n      );\n\n      if (filesToPost.length > 1) {\n        validator.error(\n          'validation.file.itaku.must-share-feed',\n          {},\n          'shareOnFeed',\n        );\n      }\n    }\n\n    return validator.result;\n  }\n\n  createMessageModel(): ItakuMessageSubmission {\n    return new ItakuMessageSubmission();\n  }\n\n  async onPostMessageSubmission(\n    postData: PostData<ItakuMessageSubmission>,\n    cancellationToken: CancellableToken,\n  ): Promise<PostResponse> {\n    cancellationToken.throwIfCancelled();\n    return this.postSubmission(postData, cancellationToken);\n  }\n\n  onValidateMessageSubmission = validatorPassthru;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/itaku/models/itaku-account-data.ts",
    "content": "import { SelectOption } from '@postybirb/form-builder';\n\nexport type ItakuAccountData = {\n  notificationFolders: SelectOption[];\n  galleryFolders: SelectOption[];\n};\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/itaku/models/itaku-file-submission.ts",
    "content": "import {\n    BooleanField,\n    DescriptionField,\n    RatingField,\n    SelectField,\n    TagField,\n    TextField,\n} from '@postybirb/form-builder';\nimport {\n    DescriptionType,\n    DescriptionValue,\n    SubmissionRating,\n    TagValue,\n} from '@postybirb/types';\nimport { BaseWebsiteOptions } from '../../../models/base-website-options';\nimport { ItakuAccountData } from './itaku-account-data';\n\nexport class ItakuFileSubmission extends BaseWebsiteOptions {\n  @RatingField({\n    options: [\n      { value: SubmissionRating.GENERAL, label: 'General' },\n      { value: SubmissionRating.MATURE, label: 'Questionable' },\n      { value: SubmissionRating.ADULT, label: 'NSFW' },\n    ],\n  })\n  rating: SubmissionRating;\n\n  @TagField({\n    maxTagLength: 59,\n    minTags: 5,\n  })\n  tags: TagValue;\n\n  @DescriptionField({\n    descriptionType: DescriptionType.PLAINTEXT,\n    maxDescriptionLength: 5000,\n  })\n  description: DescriptionValue;\n\n  @TextField({\n    label: 'contentWarning',\n    hidden: false,\n    maxLength: 30,\n  })\n  contentWarning = '';\n\n  @SelectField({\n    label: 'visibility',\n    options: [\n      { value: 'PUBLIC', label: 'Public Gallery' },\n      { value: 'PROFILE_ONLY', label: 'Profile Only' },\n      { value: 'UNLISTED', label: 'Unlisted' },\n    ],\n    section: 'website',\n    span: 6,\n  })\n  visibility = 'PUBLIC';\n\n  @SelectField<ItakuAccountData>({\n    section: 'website',\n    span: 6,\n    label: 'folder',\n    derive: [\n      {\n        key: 'galleryFolders',\n        populate: 'options',\n      },\n    ],\n    options: [],\n    allowMultiple: true,\n  })\n  folders: string[];\n\n  @BooleanField({\n    label: 'shareOnFeed',\n    section: 'website',\n    span: 6,\n  })\n  shareOnFeed = true;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/itaku/models/itaku-message-submission.ts",
    "content": "import {\n    DescriptionField,\n    SelectField,\n    TagField,\n    TextField,\n} from '@postybirb/form-builder';\nimport { DescriptionType, DescriptionValue, TagValue } from '@postybirb/types';\nimport { BaseWebsiteOptions } from '../../../models/base-website-options';\nimport { ItakuAccountData } from './itaku-account-data';\n\nexport class ItakuMessageSubmission extends BaseWebsiteOptions {\n  @TagField({\n    maxTagLength: 59,\n  })\n  tags: TagValue;\n\n  @DescriptionField({\n    descriptionType: DescriptionType.PLAINTEXT,\n    maxDescriptionLength: 5000,\n    required: true,\n  })\n  description: DescriptionValue;\n\n  @TextField({\n    label: 'contentWarning',\n    hidden: false,\n    maxLength: 30,\n  })\n  contentWarning = '';\n\n  @SelectField({\n    label: 'visibility',\n    options: [\n      { value: 'PUBLIC', label: 'Public Gallery' },\n      { value: 'PROFILE_ONLY', label: 'Profile Only' },\n      { value: 'UNLISTED', label: 'Unlisted' },\n    ],\n    section: 'website',\n    span: 6,\n  })\n  visibility = 'PUBLIC';\n\n  @SelectField<ItakuAccountData>({\n    section: 'website',\n    span: 6,\n    label: 'folder',\n    derive: [\n      {\n        key: 'notificationFolders',\n        populate: 'options',\n      },\n    ],\n    options: [],\n    allowMultiple: true,\n  })\n  folders: string[];\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/itaku/models/itaku-user-info.ts",
    "content": "export type ItakuUserInfo = {\n  profile: {\n    id: number;\n    owner_username: string;\n    is_staff: boolean;\n    is_moderator: boolean;\n    bookmarked_by_you: boolean;\n    you_follow: boolean;\n    follows_you: boolean;\n    email_verified: boolean;\n    country: string;\n    user_sites: string[];\n    blacklisted: boolean;\n    blocked: boolean;\n    submission_notifs_muted: boolean;\n    tags: string[];\n    dms_allowed: boolean;\n    num_followers: number;\n    num_following: number;\n    num_posts: number;\n    num_gallery_images: number;\n    has_unlisted_imgs_owner_only: boolean;\n    num_commissions: number;\n    num_joined: number;\n    num_images_starred: number;\n    num_bookmark_folders: number;\n    num_reshares_given: number;\n    num_comments_given: number;\n    num_tags_suggestions: number;\n    num_tags_edited: number;\n    badge_counts: unknown[];\n    displayname: string;\n    comm_info: string;\n    comm_tos: string;\n    taking_comms: boolean;\n    is_artist: boolean;\n    date_added: string;\n    date_edited: string;\n    is_supporter: boolean;\n    show_starred: boolean;\n    show_following: boolean;\n    enable_comments: boolean;\n    lead: string;\n    description: string;\n    avatar: string;\n    cover: string;\n    avatar_sm: string;\n    avatar_md: string;\n    cover_sm: string;\n    cover_lg: string;\n    mature_profile: boolean;\n    num_comments: number;\n    owner: number;\n    obj_tags: number;\n    pinned_item: unknown;\n  };\n  meta: {\n    hide_nsfw: boolean;\n    hide_questionable: boolean;\n    hide_nsfw_posts: boolean;\n    hide_nsfw_tags: boolean;\n    show_content_warnings: boolean;\n    sfw_filters_by_default: boolean;\n    reveal_nsfw: boolean;\n    ad_mode: boolean;\n    mute_submission_notifs: boolean;\n    reshare_submission_notifs_muted: boolean;\n    email_updates: boolean;\n    following_only_dms: boolean;\n    show_ads_as_supporter: boolean;\n    hide_highlighted_comments: boolean;\n    highlighted_comment_threshold: number;\n    default_new_image_description: string;\n    has_dismissed_event: boolean;\n    submission_notifs_muted_users: string[];\n    blacklisted_users: string[];\n    blacklisted_tags: string[];\n    blocked_users: string[];\n  };\n  nsfw_profile: unknown;\n};\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/ko-fi/ko-fi.website.ts",
    "content": "import { SelectOption } from '@postybirb/form-builder';\nimport { Http } from '@postybirb/http';\nimport {\n  ILoginState,\n  ImageResizeProps,\n  IPostResponse,\n  ISubmissionFile,\n  PostData,\n  PostResponse,\n} from '@postybirb/types';\nimport { BrowserWindowUtils } from '@postybirb/utils/electron';\nimport { parse } from 'node-html-parser';\nimport { CancellableToken } from '../../../post/models/cancellable-token';\nimport { PostingFile } from '../../../post/models/posting-file';\nimport { PostBuilder } from '../../commons/post-builder';\nimport { validatorPassthru } from '../../commons/validator-passthru';\nimport { UserLoginFlow } from '../../decorators/login-flow.decorator';\nimport { SupportsFiles } from '../../decorators/supports-files.decorator';\nimport { WebsiteMetadata } from '../../decorators/website-metadata.decorator';\nimport { BaseWebsiteOptions } from '../../models/base-website-options';\nimport { DataPropertyAccessibility } from '../../models/data-property-accessibility';\nimport { FileWebsite } from '../../models/website-modifiers/file-website';\nimport { MessageWebsite } from '../../models/website-modifiers/message-website';\nimport { Website } from '../../website';\nimport { KoFiAccountData } from './models/ko-fi-account-data';\nimport { KoFiFileSubmission } from './models/ko-fi-file-submission';\nimport { KoFiMessageSubmission } from './models/ko-fi-message-submission';\n\ntype KoFiSessionData = {\n  kofiAccountId?: string;\n  pageId?: string;\n};\n\n@WebsiteMetadata({\n  name: 'ko-fi',\n  displayName: 'Ko-fi',\n})\n@UserLoginFlow('https://ko-fi.com/account/login')\n@SupportsFiles({\n  fileBatchSize: 10,\n  acceptedMimeTypes: ['image/png', 'image/jpeg', 'image/jpg', 'image/gif'],\n})\nexport default class KoFi\n  extends Website<KoFiAccountData, KoFiSessionData>\n  implements\n    FileWebsite<KoFiFileSubmission>,\n    MessageWebsite<KoFiMessageSubmission>\n{\n  protected BASE_URL = 'https://ko-fi.com';\n\n  public externallyAccessibleWebsiteDataProperties: DataPropertyAccessibility<KoFiAccountData> =\n    {\n      folders: true,\n    };\n\n  public async onLogin(): Promise<ILoginState> {\n    try {\n      // Retrieve settings page to check login status\n      const res = await Http.get<string>(`${this.BASE_URL}/settings`, {\n        partition: this.accountId,\n      });\n\n      // Check if logged in by looking for login button\n      if (!res.body.includes('btn-login')) {\n        const html = parse(res.body);\n        const username = html\n          .querySelector('input[name=\"DisplayName\"]')\n          ?.getAttribute('value');\n        // Extract user ID and username\n        const kofiAccountId = html\n          .querySelector('input[id=\"handle\"]')\n          ?.getAttribute('value');\n        this.sessionData.kofiAccountId = kofiAccountId;\n        this.sessionData.pageId = this.extractId(res.body);\n\n        if (kofiAccountId) {\n          await this.retrieveAlbums(kofiAccountId);\n        } else {\n          this.logger.error('Failed to retrieve Ko-fi account Id');\n        }\n\n        return this.loginState.setLogin(true, username || 'Unknown');\n      }\n\n      return this.loginState.logout();\n    } catch (e) {\n      this.logger.error('Failed to login', e);\n      return this.loginState.logout();\n    }\n  }\n\n  calculateImageResize(file: ISubmissionFile): ImageResizeProps | undefined {\n    return undefined;\n  }\n\n  private extractId(html: string): string | null {\n    const match = html.match(/pageId:\\s*'([^']+)'/);\n    return match ? match[1] : null;\n  }\n\n  private async retrieveAlbums(id: string): Promise<void> {\n    try {\n      const { body } = await Http.get<string>(\n        `${this.BASE_URL}/${id}/gallery`,\n        {\n          partition: this.accountId,\n        },\n      );\n\n      const albums: SelectOption[] = [];\n\n      // Extract albums from gallery page\n      const html = parse(body);\n      const albumElements = html.querySelectorAll(\n        '.hz-album-each a[href^=\"/album/\"]',\n      );\n\n      for (const match of albumElements) {\n        const label = match.innerText.trim();\n        const albumId = match.getAttribute('href')?.replace('/album/', '');\n        albums.push({\n          label,\n          value: albumId,\n        });\n      }\n\n      await this.websiteDataStore.setData({ folders: albums });\n    } catch (error) {\n      this.logger.error('Failed to retrieve albums', error);\n    }\n  }\n\n  createMessageModel(): BaseWebsiteOptions {\n    return new KoFiMessageSubmission();\n  }\n\n  createFileModel(): KoFiFileSubmission {\n    return new KoFiFileSubmission();\n  }\n\n  async onPostFileSubmission(\n    postData: PostData<KoFiFileSubmission>,\n    files: PostingFile[],\n    cancellationToken: CancellableToken,\n  ): Promise<PostResponse> {\n    // Upload each file and collect image upload IDs\n    const imageUploadIds = [];\n\n    for (const file of files) {\n      // Upload the file\n      const uploadBuilder = new PostBuilder(this, cancellationToken)\n        .asMultipart()\n        .addFile('file[0]', file)\n        .setField('filenames', file.fileName);\n\n      const upload = await uploadBuilder.send<{ ExternalId: string }[]>(\n        `${this.BASE_URL}/api/media/gallery-item/upload?throwOnError=true`,\n      );\n\n      if (typeof upload.body !== 'string') {\n        imageUploadIds.push(upload.body[0].ExternalId);\n      } else {\n        return PostResponse.fromWebsite(this)\n          .withException(new Error('Failed to parse upload response'))\n          .withAdditionalInfo(upload.body);\n      }\n    }\n\n    // Create the gallery post\n    const postBuilder = new PostBuilder(this, cancellationToken)\n      .asJson()\n      .setField('Album', postData.options.album || '')\n      .setField('Audience', postData.options.audience)\n      .setField('Description', postData.options.description)\n      .setField('DisableNewComments', false)\n      .setField('EnableHiRes', postData.options.hiRes)\n      .setField('GalleryItemId', '')\n      .setField('ImageUploadIds', imageUploadIds)\n      .setField('PostToTwitter', false)\n      .setField('ScheduleEnabled', false)\n      .setField('ScheduledDate', '')\n      .setField('ScheduledTime', '')\n      .setField('Title', postData.options.title)\n      .setField('UploadAsIndividualImages', false)\n      .withHeaders({\n        Accept: 'text/html, */*',\n        Pragma: 'no-cache',\n        'Cache-Control': 'no-cache',\n        Referer: 'https://ko-fi.com/',\n        Connection: 'keep-alive',\n      });\n\n    const post = await postBuilder.send<{ success: boolean }>(\n      `${this.BASE_URL}/Gallery/AddGalleryItem`,\n    );\n\n    // Check for success in response\n    const success =\n      typeof post.body === 'string'\n        ? (post.body as string).includes(JSON.stringify({ success: true }))\n        : post.body.success;\n\n    if (success) {\n      let sourceUrl: string | undefined;\n      try {\n        // Try to find the source url\n        sourceUrl = await BrowserWindowUtils.runScriptOnPage(\n          this.accountId,\n          `${this.BASE_URL}/${this.sessionData.kofiAccountId}/posts`,\n          `return document.querySelector('#postsContainerDiv .feeditem-unit .dropdown-share-list input').value`,\n          500,\n        );\n      } catch (e) {\n        this.logger.warn(\n          'Failed to retrieve post page for source url fetch',\n          e,\n        );\n      }\n      return PostResponse.fromWebsite(this)\n        .withAdditionalInfo(post.body)\n        .withSourceUrl(sourceUrl);\n    }\n\n    return PostResponse.fromWebsite(this)\n      .withException(new Error('Post failed'))\n      .withAdditionalInfo(post.body);\n  }\n\n  onValidateFileSubmission = validatorPassthru;\n\n  async onPostMessageSubmission(\n    postData: PostData<KoFiMessageSubmission>,\n    cancellationToken: CancellableToken,\n  ): Promise<IPostResponse> {\n    const builder = new PostBuilder(this, cancellationToken)\n      .asMultipart()\n      .setField('type', 'Article')\n      .setField('blogPostId', '0')\n      .setField('scheduledDate', undefined)\n      .setField('scheduled', undefined)\n      .setField('scheduledOffset', undefined)\n      .setField('attachmentIds', undefined)\n      .setField('blogPostTitle', postData.options.title)\n      .setField('postBody', postData.options.description)\n      .setField('featuredImage', undefined)\n      .setField('noFeaturedImage', false)\n      .setField('FeaturedImageAltText', undefined)\n      .setField('embedUrl', undefined)\n      .setField('tags', postData.options.tags.join(','))\n      .setField('postAudience', postData.options.audience)\n      .setField('submit', 'publish')\n      .withHeaders({\n        Accept: 'text/html, */*',\n        Pragma: 'no-cache',\n        'Cache-Control': 'no-cache',\n        Referer: 'https://ko-fi.com/',\n        Connection: 'keep-alive',\n      });\n\n    const post = await builder.send<string>(\n      `${this.BASE_URL}/Blog/AddBlogPost`,\n    );\n\n    if (typeof post.body === 'object') {\n      const errBody = post.body as {\n        error: string;\n        friendly_error_message: string;\n        success: boolean;\n      };\n      if (errBody.success === false) {\n        return PostResponse.fromWebsite(this)\n          .withException(\n            new Error(errBody.friendly_error_message || errBody.error),\n          )\n          .withAdditionalInfo(errBody);\n      }\n    }\n\n    PostResponse.validateBody(this, post);\n    return PostResponse.fromWebsite(this)\n      .withSourceUrl(post.responseUrl)\n      .withAdditionalInfo(post.body);\n  }\n\n  onValidateMessageSubmission = validatorPassthru;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/ko-fi/models/ko-fi-account-data.ts",
    "content": "import { SelectOption } from '@postybirb/form-builder';\n\nexport interface KoFiAccountData {\n  id?: string;\n  folders?: SelectOption[];\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/ko-fi/models/ko-fi-file-submission.ts",
    "content": "import {\n  BooleanField,\n  DescriptionField,\n  RatingField,\n  SelectField,\n  TagField,\n} from '@postybirb/form-builder';\nimport {\n  DescriptionType,\n  DescriptionValue,\n  SubmissionRating,\n  TagValue,\n} from '@postybirb/types';\nimport { BaseWebsiteOptions } from '../../../models/base-website-options';\nimport { KoFiAccountData } from './ko-fi-account-data';\n\nexport class KoFiFileSubmission extends BaseWebsiteOptions {\n  @TagField({\n    hidden: true,\n  })\n  tags: TagValue;\n\n  @RatingField({\n    options: [{ value: SubmissionRating.GENERAL, label: 'General' }],\n  })\n  rating: SubmissionRating;\n\n  @DescriptionField({\n    descriptionType: DescriptionType.PLAINTEXT,\n  })\n  description: DescriptionValue;\n\n  @SelectField<KoFiAccountData>({\n    label: 'folder',\n    section: 'website',\n    span: 6,\n    options: [],\n    derive: [\n      {\n        key: 'folders',\n        populate: 'options',\n      },\n    ],\n  })\n  album?: string;\n\n  @SelectField({\n    label: 'audience',\n    section: 'website',\n    span: 6,\n    options: [\n      { value: 'public', label: 'Public' },\n      { value: 'supporter', label: 'All Supporters (One-off & Monthly)' },\n      {\n        value: 'recurringSupporter',\n        label: 'All Monthly Supporters (Members)',\n      },\n    ],\n  })\n  audience = 'public';\n\n  @BooleanField({\n    label: 'hiRes',\n    section: 'website',\n    span: 6,\n  })\n  hiRes = false;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/ko-fi/models/ko-fi-message-submission.ts",
    "content": "import { SelectField } from '@postybirb/form-builder';\nimport { BaseWebsiteOptions } from '../../../models/base-website-options';\nimport { KoFiAccountData } from './ko-fi-account-data';\n\nexport class KoFiMessageSubmission extends BaseWebsiteOptions {\n  @SelectField<KoFiAccountData>({\n    label: 'folder',\n    section: 'website',\n    span: 6,\n    options: [],\n    derive: [\n      {\n        key: 'folders',\n        populate: 'options',\n      },\n    ],\n  })\n  album?: string;\n\n  @SelectField({\n    label: 'audience',\n    section: 'website',\n    span: 6,\n    options: [\n      { value: 'public', label: 'Public' },\n      { value: 'supporter', label: 'All Supporters (One-off & Monthly)' },\n      {\n        value: 'recurringSupporter',\n        label: 'All Monthly Supporters (Members)',\n      },\n    ],\n  })\n  audience = 'public';\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/manebooru/manebooru.website.ts",
    "content": "import FileSize from '../../../utils/filesize.util';\nimport { UserLoginFlow } from '../../decorators/login-flow.decorator';\nimport { SupportsFiles } from '../../decorators/supports-files.decorator';\nimport { SupportsUsernameShortcut } from '../../decorators/supports-username-shortcut.decorator';\nimport { WebsiteMetadata } from '../../decorators/website-metadata.decorator';\nimport { PhilomenaWebsite } from '../philomena/philomena.website';\nimport { ManebooruFileSubmission } from './models/manebooru-file-submission';\n\n@WebsiteMetadata({\n  name: 'manebooru',\n  displayName: 'Manebooru',\n})\n@UserLoginFlow('https://manebooru.art/sessions/new')\n@SupportsFiles({\n  acceptedMimeTypes: [\n    'image/jpeg',\n    'image/png',\n    'image/svg+xml',\n    'image/gif',\n    'video/webm',\n  ],\n  acceptedFileSizes: {\n    maxBytes: FileSize.megabytes(100),\n  },\n  fileBatchSize: 1,\n  acceptsExternalSourceUrls: true,\n})\n@SupportsUsernameShortcut({\n  id: 'manebooru',\n  url: 'https://manebooru.art/profiles/$1',\n})\nexport default class Manebooru extends PhilomenaWebsite<ManebooruFileSubmission> {\n  protected BASE_URL = 'https://manebooru.art';\n\n  createFileModel(): ManebooruFileSubmission {\n    return new ManebooruFileSubmission();\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/manebooru/models/manebooru-file-submission.ts",
    "content": "import { PhilomenaFileSubmission } from '../../philomena/models/philomena-file-submission';\n\nexport class ManebooruFileSubmission extends PhilomenaFileSubmission {}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/mastodon/mastodon.website.ts",
    "content": "import { FileType } from '@postybirb/types';\nimport FileSize from '../../../utils/filesize.util';\nimport { DisableAds } from '../../decorators/disable-ads.decorator';\nimport { CustomLoginFlow } from '../../decorators/login-flow.decorator';\nimport { SupportsFiles } from '../../decorators/supports-files.decorator';\nimport { WebsiteMetadata } from '../../decorators/website-metadata.decorator';\nimport { MegalodonWebsite } from '../megalodon/megalodon.website';\n\n@WebsiteMetadata({\n  name: 'mastodon',\n  displayName: 'Mastodon',\n})\n@CustomLoginFlow()\n@SupportsFiles({\n  acceptedMimeTypes: [\n    'image/png',\n    'image/jpeg',\n    'image/gif',\n    'image/webp',\n    'image/avif',\n    'image/heic',\n    'image/heif',\n    'video/mp4',\n    'video/webm',\n    'video/x-m4v',\n    'video/quicktime',\n    'application/msword',\n    'application/rtf',\n    'text/plain',\n    'audio/mpeg', // mp3\n    'audio/wav',\n    'audio/ogg', // ogg, oga\n    'audio/opus',\n    'audio/aac',\n    'audio/mp4',\n    'video/3gpp',\n    'audio/x-ms-wma',\n  ],\n  acceptedFileSizes: {\n    [FileType.IMAGE]: FileSize.megabytes(16),\n    [FileType.AUDIO]: FileSize.megabytes(100),\n    [FileType.VIDEO]: FileSize.megabytes(200),\n  },\n  fileBatchSize: 4,\n})\n@DisableAds()\nexport default class Mastodon extends MegalodonWebsite {\n  protected getMegalodonInstanceType(): 'mastodon' {\n    return 'mastodon';\n  }\n\n  protected getDefaultMaxDescriptionLength(): number {\n    return 500; // Mastodon default fallback\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/megalodon/megalodon-api-service.ts",
    "content": "import generator, { MegalodonInterface } from 'megalodon';\n\nexport interface AppRegistrationData {\n  clientId: string;\n  clientSecret: string;\n}\n\nexport type FediverseInstanceTypes =\n  | 'mastodon'\n  | 'pleroma'\n  | 'friendica'\n  | 'firefish'\n  | 'gotosocial'\n  | 'pixelfed';\n\n/**\n * Service wrapper for Megalodon library operations.\n */\nexport class MegalodonApiService {\n  /**\n   * Register a new OAuth app with a Fediverse instance.\n   */\n  static async registerApp(\n    instanceUrl: string,\n    type: FediverseInstanceTypes,\n    options: {\n      client_name: string;\n      redirect_uris: string;\n      scopes: string;\n      website: string;\n    },\n  ): Promise<AppRegistrationData> {\n    const baseUrl = `https://${instanceUrl}`;\n\n    const appData = await generator(type, baseUrl).registerApp(\n      options.client_name,\n      {\n        redirect_uris: options.redirect_uris,\n        scopes: options.scopes.split(' '),\n        website: options.website,\n      },\n    );\n\n    return {\n      clientId: appData.client_id,\n      clientSecret: appData.client_secret,\n    };\n  }\n\n  /**\n   * Generate OAuth authorization URL.\n   */\n  static generateAuthUrl(\n    instanceUrl: string,\n    clientId: string,\n    redirectUri: string,\n    scopes: string,\n  ): string {\n    const baseUrl = `https://${instanceUrl}`;\n    const params = new URLSearchParams({\n      client_id: clientId,\n      redirect_uri: redirectUri,\n      response_type: 'code',\n      scope: scopes,\n    });\n\n    return `${baseUrl}/oauth/authorize?${params.toString()}`;\n  }\n\n  /**\n   * Exchange authorization code for access token.\n   */\n  static async fetchAccessToken(\n    instanceUrl: string,\n    clientId: string,\n    clientSecret: string,\n    code: string,\n    redirectUri: string,\n    type: FediverseInstanceTypes,\n  ): Promise<{ access_token: string }> {\n    const baseUrl = `https://${instanceUrl}`;\n\n    const tokenData = await generator(type, baseUrl).fetchAccessToken(\n      clientId,\n      clientSecret,\n      code,\n      redirectUri,\n    );\n\n    return {\n      access_token: tokenData.access_token,\n    };\n  }\n\n  /**\n   * Create authenticated Megalodon client.\n   */\n  static createClient(\n    instanceUrl: string,\n    accessToken: string,\n    type: FediverseInstanceTypes,\n  ): MegalodonInterface {\n    const baseUrl = `https://${instanceUrl}`;\n    return generator(type, baseUrl, accessToken);\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/megalodon/megalodon.website.ts",
    "content": "import {\n  FileType,\n  ILoginState,\n  ImageResizeProps,\n  ISubmissionFile,\n  MegalodonAccountData,\n  MegalodonOAuthRoutes,\n  OAuthRouteHandlers,\n  PostData,\n  PostResponse,\n  SimpleValidationResult,\n  SubmissionRating,\n} from '@postybirb/types';\nimport { detector, Entity } from 'megalodon';\nimport { Readable } from 'stream';\nimport { CancellableToken } from '../../../post/models/cancellable-token';\nimport { PostingFile } from '../../../post/models/posting-file';\nimport { DataPropertyAccessibility } from '../../models/data-property-accessibility';\nimport { FileWebsite } from '../../models/website-modifiers/file-website';\nimport { MessageWebsite } from '../../models/website-modifiers/message-website';\nimport { OAuthWebsite } from '../../models/website-modifiers/oauth-website';\nimport { Website } from '../../website';\nimport {\n  FediverseInstanceTypes,\n  MegalodonApiService,\n} from './megalodon-api-service';\nimport { MegalodonFileSubmission } from './models/megalodon-file-submission';\nimport { MegalodonMessageSubmission } from './models/megalodon-message-submission';\n\n/**\n * Instance configuration limits fetched from the Fediverse instance.\n * These are cached after login to avoid repeated API calls.\n */\ninterface InstanceLimits {\n  maxCharacters: number;\n  maxMediaAttachments: number;\n  imageSizeLimit?: number;\n  videoSizeLimit?: number;\n  imageMatrixLimit?: number;\n  videoMatrixLimit?: number;\n  supportedMimeTypes?: string[];\n}\n\n/**\n * Base class for all Fediverse websites using the Megalodon library.\n * Provides common OAuth flow and posting logic.\n *\n * Subclasses (Mastodon, Pleroma, etc.) can override specific behaviors.\n */\nexport abstract class MegalodonWebsite\n  extends Website<MegalodonAccountData>\n  implements\n    FileWebsite<MegalodonFileSubmission>,\n    MessageWebsite<MegalodonMessageSubmission>,\n    OAuthWebsite<MegalodonOAuthRoutes>\n{\n  protected BASE_URL = ''; // Set by instance URL\n\n  /**\n   * Cached instance configuration limits.\n   * Fetched on login and used for validation.\n   */\n  private instanceLimits: InstanceLimits | null = null;\n\n  // Subclasses can override to provide specific accessibility\n  public externallyAccessibleWebsiteDataProperties: DataPropertyAccessibility<MegalodonAccountData> =\n    {\n      instanceUrl: true,\n      clientId: true,\n      clientSecret: true,\n      username: true,\n      displayName: true,\n      instanceType: true,\n      accessToken: false, // Never expose token\n      authCode: false, // Temporary, don't expose\n    };\n\n  /**\n   * OAuth app name to register with instances.\n   * Subclasses can override for branding.\n   */\n  protected getAppName(): string {\n    return 'PostyBirb';\n  }\n\n  /**\n   * OAuth app website URL.\n   * Subclasses can override for branding.\n   */\n  protected getAppWebsite(): string {\n    return 'https://postybirb.com';\n  }\n\n  /**\n   * OAuth redirect URI.\n   * Since we're doing in-app OAuth, we use urn:ietf:wg:oauth:2.0:oob\n   */\n  protected getRedirectUri(): string {\n    return 'urn:ietf:wg:oauth:2.0:oob';\n  }\n\n  /**\n   * OAuth scopes to request.\n   * Subclasses can override for specific needs.\n   */\n  protected getScopes(): string {\n    return 'read write';\n  }\n\n  /**\n   * Get the Megalodon API instance type.\n   * Subclasses must override to specify their type.\n   */\n  protected abstract getMegalodonInstanceType(): FediverseInstanceTypes;\n\n  private async detectMegalodonInstanceType(\n    instanceUrl: string,\n  ): Promise<FediverseInstanceTypes | undefined> {\n    const instanceType = await detector(instanceUrl);\n    return instanceType;\n  }\n\n  private async getInstanceType(\n    instanceUrl: string,\n  ): Promise<FediverseInstanceTypes> {\n    const detectedType = await this.detectMegalodonInstanceType(instanceUrl);\n    return detectedType || this.getMegalodonInstanceType();\n  }\n\n  // OAuth step handlers\n  public onAuthRoute: OAuthRouteHandlers<MegalodonOAuthRoutes> = {\n    registerApp: async ({ instanceUrl }) => {\n      try {\n        const normalizedUrl = this.normalizeInstanceUrl(instanceUrl);\n\n        // Register app with the instance\n        const instanceType = await this.getInstanceType(\n          `https://${instanceUrl}`,\n        );\n        const appData = await MegalodonApiService.registerApp(\n          normalizedUrl,\n          instanceType,\n          {\n            client_name: this.getAppName(),\n            redirect_uris: this.getRedirectUri(),\n            scopes: this.getScopes(),\n            website: this.getAppWebsite(),\n          },\n        );\n\n        // Generate authorization URL\n        const authUrl = MegalodonApiService.generateAuthUrl(\n          normalizedUrl,\n          appData.clientId,\n          this.getRedirectUri(),\n          this.getScopes(),\n        );\n\n        // Store client credentials\n        const current = this.websiteDataStore.getData();\n        await this.setWebsiteData({\n          ...(current as MegalodonAccountData),\n          instanceUrl: normalizedUrl,\n          clientId: appData.clientId,\n          clientSecret: appData.clientSecret,\n          instanceType,\n        });\n\n        return {\n          success: true,\n          authorizationUrl: authUrl,\n          clientId: appData.clientId,\n          clientSecret: appData.clientSecret,\n        };\n      } catch (error) {\n        this.logger.error('Failed to register app', error);\n        return {\n          success: false,\n          message: `Failed to register with instance: ${error.message}`,\n        };\n      }\n    },\n\n    completeOAuth: async ({ authCode }) => {\n      const data = this.websiteDataStore.getData();\n\n      if (!data.clientId || !data.clientSecret || !data.instanceUrl) {\n        return {\n          success: false,\n          message:\n            'Missing client credentials. Please restart the login process.',\n        };\n      }\n\n      try {\n        // Exchange code for access token\n        const tokenData = await MegalodonApiService.fetchAccessToken(\n          data.instanceUrl,\n          data.clientId,\n          data.clientSecret,\n          authCode,\n          this.getRedirectUri(),\n          this.getMegalodonInstanceType(),\n        );\n\n        // Verify credentials and get user info\n        const client = MegalodonApiService.createClient(\n          data.instanceUrl,\n          tokenData.access_token,\n          this.getMegalodonInstanceType(),\n        );\n\n        const account = await client.verifyAccountCredentials();\n\n        // Store final credentials\n        await this.setWebsiteData({\n          ...(data as MegalodonAccountData),\n          accessToken: tokenData.access_token,\n          username: account.data.username || account.data.acct,\n          displayName: account.data.display_name,\n          authCode: undefined, // Clear temporary code\n        });\n\n        await this.onLogin();\n\n        return {\n          success: true,\n          username: account.data.username || account.data.acct,\n          displayName: account.data.display_name,\n        };\n      } catch (error) {\n        this.logger.error('Failed to complete OAuth', error);\n        return {\n          success: false,\n          message: `Failed to authenticate: ${error.message}`,\n        };\n      }\n    },\n  };\n\n  /**\n   * Normalize instance URL to consistent format.\n   */\n  private normalizeInstanceUrl(url: string): string {\n    let normalized = url.trim().toLowerCase();\n    normalized = normalized.replace(/^(https?:\\/\\/)/, '');\n    normalized = normalized.replace(/\\/$/, '');\n    return normalized;\n  }\n\n  public async onLogin(): Promise<ILoginState> {\n    const data = this.websiteDataStore.getData();\n\n    if (data?.accessToken && data?.username && data?.instanceUrl) {\n      try {\n        // Verify the token is still valid\n        const client = MegalodonApiService.createClient(\n          data.instanceUrl,\n          data.accessToken,\n          this.getMegalodonInstanceType(),\n        );\n\n        const account = await client.verifyAccountCredentials();\n\n        // Fetch instance configuration for limits\n        await this.fetchInstanceLimits(client);\n\n        // Need to manually override decorated prop\n        if (this.decoratedProps.fileOptions) {\n          this.decoratedProps.fileOptions.fileBatchSize =\n            this.instanceLimits.maxMediaAttachments || 4;\n\n          if (this.instanceLimits.supportedMimeTypes?.length) {\n            this.decoratedProps.fileOptions.acceptedMimeTypes =\n              this.instanceLimits.supportedMimeTypes;\n          }\n\n          if (this.instanceLimits.imageSizeLimit) {\n            this.decoratedProps.fileOptions.acceptedFileSizes = {\n              [FileType.IMAGE]: this.instanceLimits.imageSizeLimit,\n            };\n          }\n\n          if (this.instanceLimits.videoSizeLimit) {\n            this.decoratedProps.fileOptions.acceptedFileSizes = {\n              [FileType.VIDEO]: this.instanceLimits.videoSizeLimit,\n            };\n            this.decoratedProps.fileOptions.acceptedFileSizes = {\n              [FileType.AUDIO]: this.instanceLimits.videoSizeLimit,\n            };\n          }\n        }\n\n        return this.loginState.setLogin(\n          true,\n          `${account.data.username}@${data.instanceUrl}`,\n        );\n      } catch (error) {\n        this.logger.error('Token verification failed', error);\n        return this.loginState.logout();\n      }\n    }\n\n    return this.loginState.logout();\n  }\n\n  /**\n   * Fetch instance configuration to get dynamic limits.\n   * This is called after successful login.\n   */\n  private async fetchInstanceLimits(\n    client: ReturnType<typeof MegalodonApiService.createClient>,\n  ): Promise<void> {\n    try {\n      const instanceResponse = await client.getInstance();\n      const instance = instanceResponse.data as Entity.Instance;\n\n      // Extract limits from instance configuration\n      this.instanceLimits = {\n        maxCharacters:\n          instance.configuration?.statuses?.max_characters ||\n          this.getDefaultMaxDescriptionLength(),\n        maxMediaAttachments:\n          instance.configuration?.statuses?.max_media_attachments || 4,\n      };\n\n      // Mastodon-specific media attachment limits (if available)\n      // Type assertion needed as not all platforms include media_attachments\n      const config = instance.configuration as {\n        statuses?: { max_characters?: number; max_media_attachments?: number };\n        media_attachments?: {\n          image_size_limit?: number;\n          video_size_limit?: number;\n          image_matrix_limit?: number;\n          video_matrix_limit?: number;\n          supported_mime_types?: string[];\n        };\n      };\n\n      if (config?.media_attachments) {\n        this.instanceLimits.imageSizeLimit =\n          config.media_attachments.image_size_limit;\n        this.instanceLimits.videoSizeLimit =\n          config.media_attachments.video_size_limit;\n        this.instanceLimits.imageMatrixLimit =\n          config.media_attachments.image_matrix_limit;\n        this.instanceLimits.videoMatrixLimit =\n          config.media_attachments.video_matrix_limit;\n        this.instanceLimits.supportedMimeTypes =\n          config.media_attachments.supported_mime_types;\n      }\n\n      this.logger\n        .withMetadata({ ...this.instanceLimits })\n        .info(\n          `Fetched instance limits for ${this.websiteDataStore.getData().instanceUrl}`,\n        );\n    } catch (error) {\n      this.logger.error('Failed to fetch instance limits', error);\n      // Use defaults if fetching fails\n      this.instanceLimits = {\n        maxCharacters: this.getDefaultMaxDescriptionLength(),\n        maxMediaAttachments: 4,\n      };\n    }\n  }\n\n  /**\n   * Get the cached max characters limit from the instance.\n   * Falls back to default if not yet fetched.\n   */\n  protected getMaxDescriptionLength(): number {\n    return (\n      this.instanceLimits?.maxCharacters ||\n      this.getDefaultMaxDescriptionLength()\n    );\n  }\n\n  /**\n   * Get the default max description length for this platform.\n   * Subclasses should override this.\n   */\n  protected abstract getDefaultMaxDescriptionLength(): number;\n\n  /**\n   * Get the cached max media attachments limit from the instance.\n   */\n  protected getMaxMediaAttachments(): number {\n    return this.instanceLimits?.maxMediaAttachments || 4;\n  }\n\n  /**\n   * Get the image size limit in bytes from the instance.\n   */\n  protected getImageSizeLimit(): number | undefined {\n    return this.instanceLimits?.imageSizeLimit;\n  }\n\n  /**\n   * Get the video size limit in bytes from the instance.\n   */\n  protected getVideoSizeLimit(): number | undefined {\n    return this.instanceLimits?.videoSizeLimit;\n  }\n\n  /**\n   * Get the image matrix limit (width * height) from the instance.\n   */\n  protected getImageMatrixLimit(): number | undefined {\n    return this.instanceLimits?.imageMatrixLimit;\n  }\n\n  /**\n   * Get the video matrix limit (width * height) from the instance.\n   */\n  protected getVideoMatrixLimit(): number | undefined {\n    return this.instanceLimits?.videoMatrixLimit;\n  }\n\n  /**\n   * Get supported MIME types from the instance.\n   */\n  protected getSupportedMimeTypes(): string[] | undefined {\n    return this.instanceLimits?.supportedMimeTypes;\n  }\n\n  createFileModel(): MegalodonFileSubmission {\n    return new MegalodonFileSubmission();\n  }\n\n  createMessageModel(): MegalodonMessageSubmission {\n    return new MegalodonMessageSubmission();\n  }\n\n  calculateImageResize(file: ISubmissionFile): ImageResizeProps {\n    const imageSizeLimit = this.getImageSizeLimit();\n    const imageMatrixLimit = this.getImageMatrixLimit();\n\n    // Check if file exceeds any limits\n    const exceedsSize = imageSizeLimit && file.size > imageSizeLimit;\n    const exceedsMatrix =\n      imageMatrixLimit &&\n      file.width &&\n      file.height &&\n      file.width * file.height > imageMatrixLimit;\n\n    this.logger\n      .withMetadata({\n        fileName: file.fileName,\n        fileSize: file.size,\n        fileWidth: file.width,\n        fileHeight: file.height,\n        filePixels: file.width && file.height ? file.width * file.height : 0,\n        imageSizeLimit,\n        imageMatrixLimit,\n        exceedsSize,\n        exceedsMatrix,\n      })\n      .debug('Checking image resize requirements');\n\n    // Only return resize props if the file exceeds limits\n    if (!exceedsSize && !exceedsMatrix) {\n      return undefined;\n    }\n\n    const props: ImageResizeProps = {};\n\n    // Set size limit if exceeded\n    if (exceedsSize) {\n      props.maxBytes = imageSizeLimit;\n    }\n\n    // Calculate dimension constraints if matrix limit exceeded\n    if (exceedsMatrix && file.width && file.height) {\n      // Calculate scale factor to fit within matrix limit\n      const currentPixels = file.width * file.height;\n      const scaleFactor = Math.sqrt(imageMatrixLimit / currentPixels);\n\n      // Set max dimensions while preserving aspect ratio\n      props.width = Math.floor(file.width * scaleFactor);\n      props.height = Math.floor(file.height * scaleFactor);\n    }\n\n    this.logger\n      .withMetadata({ resizeProps: props })\n      .info('Image resize required');\n\n    return props;\n  }\n\n  async onPostFileSubmission(\n    postData: PostData<MegalodonFileSubmission>,\n    files: PostingFile[],\n    cancellationToken: CancellableToken,\n  ): Promise<PostResponse> {\n    cancellationToken.throwIfCancelled();\n\n    const data = this.websiteDataStore.getData();\n    const client = MegalodonApiService.createClient(\n      data.instanceUrl,\n      data.accessToken,\n      this.getMegalodonInstanceType(),\n    );\n\n    try {\n      // Upload media files\n      const mediaIds: string[] = [];\n      for (const file of files) {\n        cancellationToken.throwIfCancelled();\n\n        this.logger\n          .withMetadata({\n            fileName: file.fileName,\n            fileSize: file.buffer.length,\n            mimeType: file.mimeType,\n            hasAltText: !!file.metadata.altText,\n          })\n          .info('Uploading media file to Fediverse instance');\n\n        // Megalodon's uploadMedia uses form-data which expects a stream\n        // combined-stream checks for stream-like objects using isStreamLike\n        // Create a Readable stream from the buffer\n        const stream = Readable.from(file.buffer);\n\n        // Add metadata properties that form-data looks for when creating the Content-Disposition header\n        // These properties are checked by form-data to set filename and content-type\n        Object.assign(stream, {\n          path: file.fileName,\n          name: file.fileName,\n          type: file.mimeType,\n        });\n\n        const uploadResult = await client.uploadMedia(stream, {\n          description: file.metadata.altText || undefined,\n        });\n\n        this.logger\n          .withMetadata({\n            mediaId: uploadResult.data.id,\n            mediaType: uploadResult.data.type,\n          })\n          .info('Media file uploaded successfully');\n\n        mediaIds.push(uploadResult.data.id);\n      }\n\n      const isSensitiveRating =\n        postData.options.rating === SubmissionRating.ADULT ||\n        postData.options.rating === SubmissionRating.EXTREME;\n\n      // Create status with media\n      const statusResult = await client.postStatus(\n        postData.options.description || '',\n        {\n          media_ids: mediaIds,\n          sensitive: postData.options.sensitive || isSensitiveRating || false,\n          visibility: postData.options.visibility || 'public',\n          spoiler_text: postData.options.spoilerText || undefined,\n          language: postData.options.language || undefined,\n        },\n      );\n\n      const status = statusResult.data;\n      // Check if it's a Status (not ScheduledStatus)\n      if ('uri' in status) {\n        const sourceUrl = status.url || status.uri;\n        return PostResponse.fromWebsite(this)\n          .withAdditionalInfo(statusResult.data)\n          .withSourceUrl(sourceUrl);\n      }\n\n      // ScheduledStatus - post was scheduled for later\n      return PostResponse.fromWebsite(this).withAdditionalInfo({\n        message: 'Post scheduled successfully',\n        scheduled_at: status.scheduled_at,\n      });\n    } catch (error) {\n      this.logger.error('Failed to post file submission', error);\n      return PostResponse.fromWebsite(this).withException(\n        new Error(`Failed to post: ${error.message}`),\n      );\n    }\n  }\n\n  async onPostMessageSubmission(\n    postData: PostData<MegalodonMessageSubmission>,\n    cancellationToken: CancellableToken,\n  ): Promise<PostResponse> {\n    cancellationToken.throwIfCancelled();\n\n    const data = this.websiteDataStore.getData();\n    const client = MegalodonApiService.createClient(\n      data.instanceUrl,\n      data.accessToken,\n      this.getMegalodonInstanceType(),\n    );\n\n    try {\n      const isSensitiveRating =\n        postData.options.rating === SubmissionRating.ADULT ||\n        postData.options.rating === SubmissionRating.EXTREME;\n\n      const statusResult = await client.postStatus(\n        postData.options.description || '',\n        {\n          sensitive: postData.options.sensitive || isSensitiveRating || false,\n          visibility: postData.options.visibility || 'public',\n          spoiler_text: postData.options.spoilerText || undefined,\n          language: postData.options.language || undefined,\n        },\n      );\n\n      const status = statusResult.data;\n      // Check if it's a Status (not ScheduledStatus)\n      if ('uri' in status) {\n        const sourceUrl = status.url || status.uri;\n        return PostResponse.fromWebsite(this).withSourceUrl(sourceUrl);\n      }\n\n      // ScheduledStatus - post was scheduled for later\n      return PostResponse.fromWebsite(this).withAdditionalInfo({\n        message: 'Post scheduled successfully',\n        scheduled_at: status.scheduled_at,\n      });\n    } catch (error) {\n      this.logger.error('Failed to post message submission', error);\n      return PostResponse.fromWebsite(this).withException(\n        new Error(`Failed to post: ${error.message}`),\n      );\n    }\n  }\n\n  async onValidateFileSubmission(\n    postData: PostData<MegalodonFileSubmission>,\n  ): Promise<SimpleValidationResult> {\n    const validator = this.createValidator<MegalodonFileSubmission>();\n\n    // Basic validations - subclasses can add more\n    const descLength = postData.options.description?.length || 0;\n    const maxLength = this.getMaxDescriptionLength();\n\n    if (descLength > maxLength) {\n      validator.error(\n        'validation.description.max-length',\n        { currentLength: descLength, maxLength },\n        'description',\n      );\n    }\n\n    return validator.result;\n  }\n\n  async onValidateMessageSubmission(\n    postData: PostData<MegalodonMessageSubmission>,\n  ): Promise<SimpleValidationResult> {\n    const validator = this.createValidator<MegalodonMessageSubmission>();\n\n    const descLength = postData.options.description?.length || 0;\n    const maxLength = this.getMaxDescriptionLength();\n\n    if (descLength > maxLength) {\n      validator.error(\n        'validation.description.max-length',\n        { currentLength: descLength, maxLength },\n        'description',\n      );\n    }\n\n    return validator.result;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/megalodon/models/megalodon-file-submission.ts",
    "content": "import {\n  BooleanField,\n  DescriptionField,\n  SelectField,\n  TagField,\n  TextField,\n} from '@postybirb/form-builder';\nimport {\n  DefaultTagValue,\n  DescriptionType,\n  DescriptionValue,\n  TagValue,\n} from '@postybirb/types';\nimport { BaseWebsiteOptions } from '../../../models/base-website-options';\n\nexport class MegalodonFileSubmission extends BaseWebsiteOptions {\n  @DescriptionField({\n    descriptionType: DescriptionType.PLAINTEXT,\n    required: false,\n    expectsInlineTags: true,\n    expectsInlineTitle: true,\n  })\n  description: DescriptionValue;\n\n  @TagField({\n    spaceReplacer: '_',\n  })\n  tags: TagValue = DefaultTagValue();\n\n  override processTag(tag: string) {\n    return `${tag.replaceAll(/[^a-z0-9]/gi, '_')}`;\n  }\n\n  @SelectField({\n    label: 'visibility',\n    options: [\n      { value: 'public', label: 'Public' },\n      { value: 'unlisted', label: 'Unlisted' },\n      { value: 'private', label: 'Followers only' },\n      { value: 'direct', label: 'Direct' },\n    ],\n    span: 12,\n  })\n  visibility: 'public' | 'unlisted' | 'private' | 'direct' = 'public';\n\n  @TextField({\n    label: 'spoiler',\n    span: 12,\n  })\n  spoilerText?: string;\n\n  @TextField({\n    label: 'language',\n    span: 12,\n  })\n  language?: string;\n\n  @BooleanField({\n    label: 'sensitiveContent',\n    span: 3,\n  })\n  sensitive = false;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/megalodon/models/megalodon-message-submission.ts",
    "content": "import { MegalodonFileSubmission } from './megalodon-file-submission';\n\nexport class MegalodonMessageSubmission extends MegalodonFileSubmission {}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/misskey/misskey-api-service.ts",
    "content": "import { FormFile, Http } from '@postybirb/http';\nimport { Logger } from '@postybirb/logger';\n\nconst logger = Logger('MisskeyApiService');\n\n/**\n * Ensures the response body is parsed as JSON.\n * Http.post only auto-parses when content-type is application/json.\n * Some Misskey instances may return different content-types.\n */\nfunction ensureJson<T>(body: T | string): T {\n  if (typeof body === 'string') {\n    return JSON.parse(body) as T;\n  }\n  return body;\n}\n\ninterface MisskeyUser {\n  id: string;\n  username: string;\n  name?: string;\n  policies?: MisskeyPolicies;\n}\n\nexport interface MisskeyPolicies {\n  driveCapacityMb?: number;\n  maxFileSizeMb?: number;\n  uploadableFileTypes?: string[];\n  pinLimit?: number;\n  canPublicNote?: boolean;\n}\n\ninterface MisskeyMeta {\n  maxNoteTextLength?: number;\n  driveCapacityPerLocalUserMb?: number;\n  version?: string;\n}\n\ninterface MisskeyDriveFile {\n  id: string;\n  name: string;\n  type: string;\n  size: number;\n  url: string;\n}\n\ninterface MisskeyNote {\n  id: string;\n  createdAt: string;\n  text?: string;\n  uri?: string;\n  url?: string;\n  user: MisskeyUser;\n}\n\nexport class MisskeyApiService {\n  /**\n   * Build the MiAuth authorization URL.\n   */\n  static buildMiAuthUrl(\n    instanceUrl: string,\n    sessionId: string,\n    appName: string,\n    permissions: string[],\n  ): string {\n    const params = new URLSearchParams({\n      name: appName,\n      permission: permissions.join(','),\n    });\n    return `https://${instanceUrl}/miauth/${sessionId}?${params.toString()}`;\n  }\n\n  /**\n   * Check MiAuth session and retrieve access token.\n   * POST /api/miauth/{sessionId}/check\n   */\n  static async checkMiAuth(\n    instanceUrl: string,\n    sessionId: string,\n  ): Promise<{ token: string; user: MisskeyUser }> {\n    const res = await Http.post<{ token: string; user: MisskeyUser }>(\n      `https://${instanceUrl}/api/miauth/${sessionId}/check`,\n      { type: 'json', data: {} },\n    );\n\n    const body = ensureJson(res.body);\n    if (!body?.token) {\n      throw new Error('MiAuth check failed: no token received');\n    }\n\n    return body;\n  }\n\n  /**\n   * Verify credentials by fetching the current user's account info.\n   * POST /api/i\n   */\n  static async verifyCredentials(\n    instanceUrl: string,\n    token: string,\n  ): Promise<MisskeyUser> {\n    const res = await Http.post<MisskeyUser>(\n      `https://${instanceUrl}/api/i`,\n      { type: 'json', data: { i: token } },\n    );\n\n    const body = ensureJson(res.body);\n    if (!body?.username) {\n      throw new Error('Failed to verify Misskey credentials');\n    }\n\n    return body;\n  }\n\n  /**\n   * Fetch instance metadata.\n   * POST /api/meta\n   */\n  static async getInstanceMeta(\n    instanceUrl: string,\n  ): Promise<MisskeyMeta> {\n    const res = await Http.post<MisskeyMeta>(\n      `https://${instanceUrl}/api/meta`,\n      { type: 'json', data: { detail: true } },\n    );\n\n    return ensureJson(res.body) ?? {};\n  }\n\n  /**\n   * Upload a file to the user's Misskey Drive.\n   * POST /api/drive/files/create (multipart)\n   */\n  static async uploadFile(\n    instanceUrl: string,\n    token: string,\n    file: Buffer,\n    fileName: string,\n    mimeType: string,\n    options?: { comment?: string; isSensitive?: boolean },\n  ): Promise<MisskeyDriveFile> {\n    const data: Record<string, unknown> = {\n      i: token,\n      file: new FormFile(file, {\n        filename: fileName,\n        contentType: mimeType,\n      }),\n      name: fileName,\n    };\n\n    if (options?.comment) {\n      data.comment = options.comment;\n    }\n\n    if (options?.isSensitive) {\n      data.isSensitive = 'true';\n    }\n\n    const res = await Http.post<MisskeyDriveFile>(\n      `https://${instanceUrl}/api/drive/files/create`,\n      { type: 'multipart', data },\n    );\n\n    const body = ensureJson(res.body);\n    if (!body?.id) {\n      logger\n        .withMetadata({ statusCode: res.statusCode, body })\n        .error('Misskey Drive upload failed');\n      throw new Error(\n        `Failed to upload file to Misskey Drive: ${\n          (body as unknown as Record<string, unknown>)?.error\n            ? JSON.stringify((body as unknown as Record<string, unknown>).error)\n            : `HTTP ${res.statusCode}`\n        }`,\n      );\n    }\n\n    logger\n      .withMetadata({ fileId: body.id, fileName })\n      .info('File uploaded to Misskey Drive');\n\n    return body;\n  }\n\n  /**\n   * Create a note (post) on Misskey.\n   * POST /api/notes/create\n   */\n  static async createNote(\n    instanceUrl: string,\n    token: string,\n    options: {\n      text?: string;\n      fileIds?: string[];\n      visibility?: string;\n      cw?: string;\n      localOnly?: boolean;\n    },\n  ): Promise<MisskeyNote> {\n    const data: Record<string, unknown> = {\n      i: token,\n      visibility: options.visibility ?? 'public',\n    };\n\n    if (options.text) {\n      data.text = options.text;\n    }\n\n    if (options.fileIds?.length) {\n      data.fileIds = options.fileIds;\n    }\n\n    if (options.cw) {\n      data.cw = options.cw;\n    }\n\n    if (options.localOnly) {\n      data.localOnly = true;\n    }\n\n    const res = await Http.post<{ createdNote: MisskeyNote }>(\n      `https://${instanceUrl}/api/notes/create`,\n      { type: 'json', data },\n    );\n\n    const body = ensureJson(res.body);\n    if (!body?.createdNote?.id) {\n      throw new Error('Failed to create note on Misskey');\n    }\n\n    return body.createdNote;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/misskey/misskey.website.ts",
    "content": "import { Logger } from '@postybirb/logger';\nimport {\n  ILoginState,\n  ImageResizeProps,\n  ISubmissionFile,\n  MisskeyAccountData,\n  MisskeyOAuthRoutes,\n  OAuthRouteHandlers,\n  PostData,\n  PostResponse,\n  SimpleValidationResult,\n} from '@postybirb/types';\nimport { calculateImageResize } from '@postybirb/utils/file-type';\nimport { v4 as uuidv4 } from 'uuid';\nimport { CancellableToken } from '../../../post/models/cancellable-token';\nimport { PostingFile } from '../../../post/models/posting-file';\nimport FileSize from '../../../utils/filesize.util';\nimport { DisableAds } from '../../decorators/disable-ads.decorator';\nimport { CustomLoginFlow } from '../../decorators/login-flow.decorator';\nimport { SupportsFiles } from '../../decorators/supports-files.decorator';\nimport { WebsiteMetadata } from '../../decorators/website-metadata.decorator';\nimport { DataPropertyAccessibility } from '../../models/data-property-accessibility';\nimport { FileWebsite } from '../../models/website-modifiers/file-website';\nimport { MessageWebsite } from '../../models/website-modifiers/message-website';\nimport { OAuthWebsite } from '../../models/website-modifiers/oauth-website';\nimport { Website } from '../../website';\nimport { MisskeyApiService } from './misskey-api-service';\nimport { MisskeyFileSubmission } from './models/misskey-file-submission';\nimport { MisskeyMessageSubmission } from './models/misskey-message-submission';\n\nconst MIAUTH_PERMISSIONS = [\n  'read:account',\n  'write:notes',\n  'read:drive',\n  'write:drive',\n];\n\n@WebsiteMetadata({\n  name: 'misskey',\n  displayName: 'Misskey',\n})\n@CustomLoginFlow()\n@SupportsFiles({\n  acceptedMimeTypes: [\n    'image/png',\n    'image/jpeg',\n    'image/jpg',\n    'image/gif',\n    'image/webp',\n    'image/avif',\n    'image/apng',\n    'video/mp4',\n    'video/webm',\n    'audio/mpeg',\n    'audio/ogg',\n    'audio/wav',\n  ],\n  acceptedFileSizes: {\n    '*': FileSize.megabytes(30),\n  },\n  fileBatchSize: 16,\n})\n@DisableAds()\nexport default class Misskey\n  extends Website<MisskeyAccountData>\n  implements\n    FileWebsite<MisskeyFileSubmission>,\n    MessageWebsite<MisskeyMessageSubmission>,\n    OAuthWebsite<MisskeyOAuthRoutes>\n{\n  protected readonly logger = Logger('Misskey');\n\n  protected BASE_URL = '';\n\n  private maxNoteTextLength = 3000;\n\n  public externallyAccessibleWebsiteDataProperties: DataPropertyAccessibility<MisskeyAccountData> =\n    {\n      instanceUrl: true,\n      username: true,\n      accessToken: false,\n      miAuthSessionId: false,\n    };\n\n  // ========================================================================\n  // OAuth / MiAuth\n  // ========================================================================\n\n  public onAuthRoute: OAuthRouteHandlers<MisskeyOAuthRoutes> = {\n    generateAuthUrl: async ({ instanceUrl }) => {\n      try {\n        const normalizedUrl = instanceUrl\n          .trim()\n          .toLowerCase()\n          .replace(/^https?:\\/\\//, '')\n          .replace(/\\/$/, '');\n\n        const sessionId = uuidv4();\n        const authUrl = MisskeyApiService.buildMiAuthUrl(\n          normalizedUrl,\n          sessionId,\n          'PostyBirb',\n          MIAUTH_PERMISSIONS,\n        );\n\n        await this.setWebsiteData({\n          ...this.websiteDataStore.getData(),\n          instanceUrl: normalizedUrl,\n          miAuthSessionId: sessionId,\n        });\n\n        return { success: true, authUrl, sessionId };\n      } catch (error) {\n        this.logger.error('Failed to generate MiAuth URL', error);\n        return {\n          success: false,\n          message: `Failed to generate auth URL: ${error.message}`,\n        };\n      }\n    },\n\n    completeAuth: async () => {\n      const data = this.websiteDataStore.getData();\n      if (!data.instanceUrl || !data.miAuthSessionId) {\n        return {\n          success: false,\n          message: 'Missing instance URL or session. Please start over.',\n        };\n      }\n\n      try {\n        const result = await MisskeyApiService.checkMiAuth(\n          data.instanceUrl,\n          data.miAuthSessionId,\n        );\n\n        await this.setWebsiteData({\n          ...data,\n          accessToken: result.token,\n          username: result.user.username,\n          miAuthSessionId: undefined, // Clear temporary session\n        });\n\n        await this.onLogin();\n\n        return { success: true, username: result.user.username };\n      } catch (error) {\n        this.logger.error('MiAuth completion failed', error);\n        return {\n          success: false,\n          message: `Authentication failed: ${error.message}`,\n        };\n      }\n    },\n  };\n\n  // ========================================================================\n  // Login\n  // ========================================================================\n\n  public async onLogin(): Promise<ILoginState> {\n    const data = this.websiteDataStore.getData();\n\n    if (!data?.accessToken || !data?.instanceUrl) {\n      return this.loginState.logout();\n    }\n\n    try {\n      const user = await MisskeyApiService.verifyCredentials(\n        data.instanceUrl,\n        data.accessToken,\n      );\n\n      // Apply per-user policies (file size only — MIME type overrides are skipped because\n      // Misskey returns glob-style patterns like 'image/' and 'audio/*' that are incompatible\n      // with the app's exact-match MIME type handling. The static @SupportsFiles list is used.)\n      if (user.policies && this.decoratedProps.fileOptions) {\n        if (user.policies.maxFileSizeMb) {\n          this.decoratedProps.fileOptions.acceptedFileSizes = {\n            '*': FileSize.megabytes(user.policies.maxFileSizeMb),\n          };\n        }\n      }\n\n      // Fetch instance limits\n      try {\n        const meta = await MisskeyApiService.getInstanceMeta(data.instanceUrl);\n        this.logger\n          .withMetadata(meta)\n          .info(`Fetched instance metadata for ${data.instanceUrl}`);\n        if (meta.maxNoteTextLength) {\n          this.maxNoteTextLength = meta.maxNoteTextLength;\n        }\n      } catch (e) {\n        this.logger.warn('Failed to fetch instance metadata', e);\n      }\n\n      return this.loginState.setLogin(\n        true,\n        `${user.username}@${data.instanceUrl}`,\n      );\n    } catch (error) {\n      this.logger.error('Misskey login verification failed', error);\n      return this.loginState.logout();\n    }\n  }\n\n  // ========================================================================\n  // File Submission\n  // ========================================================================\n\n  createFileModel(): MisskeyFileSubmission {\n    return new MisskeyFileSubmission();\n  }\n\n  calculateImageResize(file: ISubmissionFile): ImageResizeProps | undefined {\n    return calculateImageResize(file, {\n      maxBytes:\n        this.decoratedProps.fileOptions?.acceptedFileSizes?.['*'] ||\n        FileSize.megabytes(30),\n    });\n  }\n\n  async onPostFileSubmission(\n    postData: PostData<MisskeyFileSubmission>,\n    files: PostingFile[],\n    cancellationToken: CancellableToken,\n  ): Promise<PostResponse> {\n    cancellationToken.throwIfCancelled();\n\n    const { accessToken, instanceUrl } = this.websiteDataStore.getData();\n    if (!accessToken || !instanceUrl) {\n      return PostResponse.fromWebsite(this).withException(\n        new Error('Misskey is not logged in'),\n      );\n    }\n\n    try {\n      // Upload files to Misskey Drive\n      const fileIds: string[] = [];\n      for (const file of files) {\n        cancellationToken.throwIfCancelled();\n\n        this.logger\n          .withMetadata({\n            fileName: file.fileName,\n            fileSize: file.buffer.length,\n            mimeType: file.mimeType,\n          })\n          .info('Uploading file to Misskey Drive');\n\n        const driveFile = await MisskeyApiService.uploadFile(\n          instanceUrl,\n          accessToken,\n          file.buffer,\n          file.fileName,\n          file.mimeType,\n          {\n            comment: file.metadata?.altText || undefined,\n            isSensitive: postData.options.sensitive || undefined,\n          },\n        );\n\n        fileIds.push(driveFile.id);\n      }\n\n      cancellationToken.throwIfCancelled();\n\n      // Create the note\n      const note = await MisskeyApiService.createNote(\n        instanceUrl,\n        accessToken,\n        {\n          text: postData.options.description || undefined,\n          fileIds,\n          visibility: postData.options.visibility || 'public',\n          cw: postData.options.cw || undefined,\n          localOnly: postData.options.localOnly || false,\n        },\n      );\n\n      const sourceUrl =\n        note.url || note.uri || `https://${instanceUrl}/notes/${note.id}`;\n\n      return PostResponse.fromWebsite(this)\n        .withAdditionalInfo(note)\n        .withSourceUrl(sourceUrl);\n    } catch (error) {\n      this.logger.error('Failed to post file submission to Misskey', error);\n      return PostResponse.fromWebsite(this).withException(\n        error instanceof Error ? error : new Error('Failed to post to Misskey'),\n      );\n    }\n  }\n\n  async onValidateFileSubmission(\n    postData: PostData<MisskeyFileSubmission>,\n  ): Promise<SimpleValidationResult> {\n    const validator = this.createValidator<MisskeyFileSubmission>();\n\n    const descLength = postData.options.description?.length || 0;\n    if (descLength > this.maxNoteTextLength) {\n      validator.error(\n        'validation.description.max-length',\n        {\n          currentLength: descLength,\n          maxLength: this.maxNoteTextLength,\n        },\n        'description',\n      );\n    }\n\n    return validator.result;\n  }\n\n  // ========================================================================\n  // Message Submission\n  // ========================================================================\n\n  createMessageModel(): MisskeyMessageSubmission {\n    return new MisskeyMessageSubmission();\n  }\n\n  async onPostMessageSubmission(\n    postData: PostData<MisskeyMessageSubmission>,\n    cancellationToken: CancellableToken,\n  ): Promise<PostResponse> {\n    cancellationToken.throwIfCancelled();\n\n    const { accessToken, instanceUrl } = this.websiteDataStore.getData();\n    if (!accessToken || !instanceUrl) {\n      return PostResponse.fromWebsite(this).withException(\n        new Error('Misskey is not logged in'),\n      );\n    }\n\n    try {\n      const note = await MisskeyApiService.createNote(\n        instanceUrl,\n        accessToken,\n        {\n          text: postData.options.description,\n          visibility: postData.options.visibility || 'public',\n          cw: postData.options.cw || undefined,\n          localOnly: postData.options.localOnly || false,\n        },\n      );\n\n      const sourceUrl =\n        note.url || note.uri || `https://${instanceUrl}/notes/${note.id}`;\n\n      return PostResponse.fromWebsite(this).withSourceUrl(sourceUrl);\n    } catch (error) {\n      this.logger.error('Failed to post message to Misskey', error);\n      return PostResponse.fromWebsite(this).withException(\n        error instanceof Error ? error : new Error('Failed to post to Misskey'),\n      );\n    }\n  }\n\n  async onValidateMessageSubmission(\n    postData: PostData<MisskeyMessageSubmission>,\n  ): Promise<SimpleValidationResult> {\n    const validator = this.createValidator<MisskeyMessageSubmission>();\n\n    const descLength = postData.options.description?.length || 0;\n    if (descLength > this.maxNoteTextLength) {\n      validator.error(\n        'validation.description.max-length',\n        {\n          currentLength: descLength,\n          maxLength: this.maxNoteTextLength,\n        },\n        'description',\n      );\n    }\n\n    return validator.result;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/misskey/models/misskey-file-submission.ts",
    "content": "import {\n  BooleanField,\n  DescriptionField,\n  SelectField,\n  TagField,\n  TextField,\n} from '@postybirb/form-builder';\nimport {\n  DefaultTagValue,\n  DescriptionType,\n  DescriptionValue,\n  TagValue,\n} from '@postybirb/types';\nimport { BaseWebsiteOptions } from '../../../models/base-website-options';\n\nexport class MisskeyFileSubmission extends BaseWebsiteOptions {\n  @DescriptionField({\n    descriptionType: DescriptionType.PLAINTEXT,\n    expectsInlineTags: true,\n    expectsInlineTitle: true,\n  })\n  description: DescriptionValue;\n\n  @TagField({\n    spaceReplacer: '_',\n  })\n  tags: TagValue = DefaultTagValue();\n\n  override processTag(tag: string) {\n    return `${tag.replaceAll(/[^a-z0-9]/gi, '_')}`;\n  }\n\n  @SelectField({\n    label: 'visibility',\n    options: [\n      { value: 'public', label: 'Public' },\n      { value: 'home', label: 'Home' },\n      { value: 'followers', label: 'Followers' },\n      { value: 'specified', label: 'Direct' },\n    ],\n    span: 12,\n  })\n  visibility: 'public' | 'home' | 'followers' | 'specified' = 'public';\n\n  @TextField({\n    label: 'spoiler',\n    span: 12,\n  })\n  cw?: string;\n\n  @BooleanField({\n    label: { untranslated: 'Local only' },\n    span: 6,\n  })\n  localOnly = false;\n\n  @BooleanField({\n    label: 'sensitiveContent',\n    span: 6,\n  })\n  sensitive = false;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/misskey/models/misskey-message-submission.ts",
    "content": "import { MisskeyFileSubmission } from './misskey-file-submission';\n\nexport class MisskeyMessageSubmission extends MisskeyFileSubmission {}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/newgrounds/models/newgrounds-account-data.ts",
    "content": "// eslint-disable-next-line @typescript-eslint/ban-types\nexport type NewgroundsAccountData = {};\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/newgrounds/models/newgrounds-base-submission.ts",
    "content": "import { DescriptionField, TagField } from '@postybirb/form-builder';\nimport { DescriptionType, DescriptionValue, TagValue } from '@postybirb/types';\nimport { BaseWebsiteOptions } from '../../../models/base-website-options';\n\nexport class NewgroundsBaseSubmission extends BaseWebsiteOptions {\n  @DescriptionField({\n    descriptionType: DescriptionType.HTML,\n  })\n  description: DescriptionValue;\n\n  @TagField({\n    maxTags: 12,\n    spaceReplacer: '-',\n  })\n  tags: TagValue;\n\n  protected processTag(tag: string): string {\n    return tag\n      .replace(/(\\(|\\)|:|#|;|\\]|\\[|')/g, '')\n      .replace(/_/g, '-')\n      .toLowerCase();\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/newgrounds/models/newgrounds-file-submission.ts",
    "content": "import {\n  BooleanField,\n  RadioField,\n  RatingField,\n  SelectField,\n} from '@postybirb/form-builder';\nimport { SubmissionRating } from '@postybirb/types';\nimport { NewgroundsBaseSubmission } from './newgrounds-base-submission';\n\nexport type NewgroundsRating = 'a' | 'b' | 'c';\n\nexport class NewgroundsFileSubmission extends NewgroundsBaseSubmission {\n  @RatingField({\n    hidden: true,\n  })\n  rating: SubmissionRating;\n\n  @SelectField({\n    required: true,\n    label: 'category',\n    section: 'website',\n    span: 12,\n    options: [\n      { value: '4', label: '3D Art' },\n      { value: '7', label: 'Comic' },\n      { value: '3', label: 'Fine Art' },\n      { value: '1', label: 'Illustration' },\n      { value: '5', label: 'Pixel Art' },\n      { value: '6', label: 'Other' },\n    ],\n  })\n  category: string;\n\n  @RadioField({\n    required: true,\n    label: 'nudity',\n    section: 'website',\n    span: 3,\n    options: [\n      { value: 'c', label: 'None' },\n      { value: 'b', label: 'Some' },\n      { value: 'a', label: 'Lots' },\n    ],\n  })\n  nudity: NewgroundsRating;\n\n  @RadioField({\n    required: true,\n    label: 'violence',\n    section: 'website',\n    span: 3,\n    options: [\n      { value: 'c', label: 'None' },\n      { value: 'b', label: 'Some' },\n      { value: 'a', label: 'Lots' },\n    ],\n  })\n  violence: NewgroundsRating;\n\n  @RadioField({\n    required: true,\n    label: 'explicitText',\n    section: 'website',\n    span: 3,\n    options: [\n      { value: 'c', label: 'None' },\n      { value: 'b', label: 'Some' },\n      { value: 'a', label: 'Lots' },\n    ],\n  })\n  explicitText: NewgroundsRating;\n\n  @RadioField({\n    required: true,\n    label: 'adultThemes',\n    section: 'website',\n    span: 3,\n    options: [\n      { value: 'c', label: 'None' },\n      { value: 'b', label: 'Some' },\n      { value: 'a', label: 'Lots' },\n    ],\n  })\n  adultThemes: NewgroundsRating;\n\n  @BooleanField({\n    label: 'sketch',\n    defaultValue: false,\n    section: 'website',\n    span: 6,\n  })\n  sketch: boolean;\n\n  @BooleanField({\n    label: 'isCreativeCommons',\n    defaultValue: false,\n    section: 'website',\n    span: 6,\n  })\n  creativeCommons: boolean;\n\n  @BooleanField({\n    label: 'commercial',\n    defaultValue: false,\n    section: 'website',\n    span: 6,\n  })\n  commercial: boolean;\n\n  @BooleanField({\n    label: 'modification',\n    defaultValue: false,\n    section: 'website',\n    span: 6,\n  })\n  modification: boolean;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/newgrounds/models/newgrounds-message-submission.ts",
    "content": "import { RatingField } from '@postybirb/form-builder';\nimport { SubmissionRating } from '@postybirb/types';\nimport { NewgroundsBaseSubmission } from './newgrounds-base-submission';\n\nexport class NewgroundsMessageSubmission extends NewgroundsBaseSubmission {\n  @RatingField({\n    options: [\n      { value: SubmissionRating.GENERAL, label: 'Suitable for everyone' },\n      {\n        value: 't',\n        label: 'May be inappropriate for kids under 13',\n      },\n      {\n        value: SubmissionRating.MATURE,\n        label: 'Mature subject matter. Not for kids!',\n      },\n      {\n        value: SubmissionRating.ADULT,\n        label: 'Adults only! This is NSFW and not for kids!',\n      },\n    ],\n  })\n  rating: SubmissionRating;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/newgrounds/newgrounds.website.ts",
    "content": "import { Http } from '@postybirb/http';\nimport {\n  ILoginState,\n  ImageResizeProps,\n  IPostResponse,\n  ISubmissionFile,\n  PostData,\n  PostResponse,\n  SimpleValidationResult,\n  SubmissionRating,\n} from '@postybirb/types';\nimport { BrowserWindowUtils } from '@postybirb/utils/electron';\nimport { parse } from 'node-html-parser';\nimport { CancellableToken } from '../../../post/models/cancellable-token';\nimport { PostingFile } from '../../../post/models/posting-file';\nimport FileSize from '../../../utils/filesize.util';\nimport { PostBuilder } from '../../commons/post-builder';\nimport { validatorPassthru } from '../../commons/validator-passthru';\nimport { UserLoginFlow } from '../../decorators/login-flow.decorator';\nimport { SupportsFiles } from '../../decorators/supports-files.decorator';\nimport { SupportsUsernameShortcut } from '../../decorators/supports-username-shortcut.decorator';\nimport { WebsiteMetadata } from '../../decorators/website-metadata.decorator';\nimport { DataPropertyAccessibility } from '../../models/data-property-accessibility';\nimport { FileWebsite } from '../../models/website-modifiers/file-website';\nimport { MessageWebsite } from '../../models/website-modifiers/message-website';\nimport { Website } from '../../website';\nimport { NewgroundsAccountData } from './models/newgrounds-account-data';\nimport { NewgroundsFileSubmission } from './models/newgrounds-file-submission';\nimport { NewgroundsMessageSubmission } from './models/newgrounds-message-submission';\n\ntype NewgroundsPostResponse = {\n  edit_url: string;\n  can_publish: boolean;\n  project_id: number;\n  success: string;\n};\n\n@WebsiteMetadata({\n  name: 'newgrounds',\n  displayName: 'Newgrounds',\n})\n@UserLoginFlow('https://www.newgrounds.com/login')\n@SupportsUsernameShortcut({\n  id: 'newgrounds',\n  url: 'https://$1.newgrounds.com',\n})\n@SupportsFiles({\n  acceptedMimeTypes: [\n    'image/jpeg',\n    'image/jpg',\n    'image/png',\n    'image/gif',\n    'image/bmp',\n  ],\n  acceptedFileSizes: {\n    '*': FileSize.megabytes(40),\n  },\n})\nexport default class Newgrounds\n  extends Website<NewgroundsAccountData>\n  implements\n    FileWebsite<NewgroundsFileSubmission>,\n    MessageWebsite<NewgroundsMessageSubmission>\n{\n  protected BASE_URL = 'https://www.newgrounds.com';\n\n  public externallyAccessibleWebsiteDataProperties: DataPropertyAccessibility<NewgroundsAccountData> =\n    {};\n\n  public async onLogin(): Promise<ILoginState> {\n    try {\n      const res = await Http.get<string>(this.BASE_URL, {\n        partition: this.accountId,\n      });\n\n      if (res.body.includes('activeuser')) {\n        const match = res.body.match(/\"name\":\"(.*?)\"/);\n        const username = match ? match[1] : 'Unknown';\n        return this.loginState.setLogin(true, username);\n      }\n\n      return this.loginState.logout();\n    } catch (e) {\n      this.logger.error('Failed to login', e);\n      return this.loginState.logout();\n    }\n  }\n\n  createFileModel(): NewgroundsFileSubmission {\n    return new NewgroundsFileSubmission();\n  }\n\n  calculateImageResize(file: ISubmissionFile): ImageResizeProps {\n    return undefined;\n  }\n\n  private parseDescription(text: string): string {\n    return text.replace(/<div/gm, '<p').replace(/<\\/div>/gm, '</p>');\n  }\n\n  private getSuitabilityRating(rating: SubmissionRating | string): string {\n    switch (rating) {\n      case SubmissionRating.GENERAL:\n        return 'e';\n      case SubmissionRating.MATURE:\n        return 'm';\n      case SubmissionRating.ADULT:\n      case SubmissionRating.EXTREME:\n        return 'a';\n      case 't':\n        return 't';\n      default:\n        return 'e';\n    }\n  }\n\n  private checkIsSaved(response: NewgroundsPostResponse): boolean {\n    return response.success === 'saved';\n  }\n\n  private async cleanUpFailedProject(\n    projectId: number,\n    userKey: string,\n  ): Promise<void> {\n    try {\n      await Http.post(`${this.BASE_URL}/projects/art/remove/${projectId}`, {\n        partition: this.accountId,\n        type: 'multipart',\n        data: {\n          userkey: userKey,\n        },\n      });\n    } catch (error) {\n      this.logger.error('Failed to clean up project', error);\n    }\n  }\n\n  async onPostFileSubmission(\n    postData: PostData<NewgroundsFileSubmission>,\n    files: PostingFile[],\n    cancellationToken: CancellableToken,\n  ): Promise<IPostResponse> {\n    // Step 1: Get the user key from the page\n    const userKey: string = await BrowserWindowUtils.runScriptOnPage(\n      this.accountId,\n      `${this.BASE_URL}/projects/art/new`,\n      'return PHP.get(\"uek\")',\n      300,\n    );\n\n    if (!userKey) {\n      return PostResponse.fromWebsite(this)\n        .withException(new Error('Could not get userkey'))\n        .atStage('get userkey');\n    }\n\n    cancellationToken.throwIfCancelled();\n\n    // Step 2: Initialize the project\n    const initRes = await new PostBuilder(this, cancellationToken)\n      .asMultipart()\n      .setField('PHP_SESSION_UPLOAD_PROGRESS', 'projectform')\n      .setField('init_project', '1')\n      .setField('userkey', userKey)\n      .send<NewgroundsPostResponse>(`${this.BASE_URL}/projects/art/new`);\n\n    if (\n      !initRes.body.project_id ||\n      !this.checkIsSaved(initRes.body as NewgroundsPostResponse)\n    ) {\n      return PostResponse.fromWebsite(this)\n        .withException(new Error('Could not initialize post to Newgrounds'))\n        .withAdditionalInfo(initRes.body)\n        .atStage('initialize project');\n    }\n\n    const { edit_url: editUrl, project_id: projectId } = initRes.body;\n\n    try {\n      // Step 3: Upload the file and thumbnail\n      const primaryFile = files[0];\n\n      const fileUploadBuilder = new PostBuilder(this, cancellationToken)\n        .asMultipart()\n        .setField('userkey', userKey)\n        .setField('link_icon', '1')\n        .addFile('new_image', primaryFile)\n        .setField('width', primaryFile.width)\n        .setField('height', primaryFile.height)\n        .setField(\n          'cropdata',\n          `{\"x\":0,\"y\":45,\"width\":${primaryFile.width},\"height\":${primaryFile.height}}`,\n        );\n\n      if (primaryFile.thumbnail) {\n        fileUploadBuilder.addThumbnail('thumbnail', primaryFile);\n      }\n\n      const fileUploadRes = await fileUploadBuilder.send<\n        NewgroundsPostResponse & {\n          linked_icon: number;\n          image: Record<string, unknown>;\n        }\n      >(editUrl);\n\n      if (\n        !fileUploadRes.body.image ||\n        !this.checkIsSaved(fileUploadRes.body as NewgroundsPostResponse)\n      ) {\n        await this.cleanUpFailedProject(projectId, userKey);\n        return PostResponse.fromWebsite(this)\n          .withException(new Error('Could not upload file to Newgrounds'))\n          .withAdditionalInfo(fileUploadRes.body)\n          .atStage('upload file');\n      }\n\n      const { linked_icon: linkedIcon } = fileUploadRes.body;\n\n      // Step 4: Link the image\n      const linkImageRes = await new PostBuilder(this, cancellationToken)\n        .asMultipart()\n        .setField('userkey', userKey)\n        .setField('art_image_sort', `[${linkedIcon}]`)\n        .send<NewgroundsPostResponse>(editUrl);\n\n      if (!this.checkIsSaved(linkImageRes.body)) {\n        await this.cleanUpFailedProject(projectId, userKey);\n        return PostResponse.fromWebsite(this)\n          .withException(new Error('Could not link image'))\n          .withAdditionalInfo(linkImageRes.body)\n          .atStage('link image');\n      }\n\n      // Step 5: Set the description\n      await new PostBuilder(this, cancellationToken)\n        .asMultipart()\n        .setField('PHP_SESSION_UPLOAD_PROGRESS', 'projectform')\n        .setField('userkey', userKey)\n        .setField('encoder', 'quill')\n        .setField(\n          'option[longdescription]',\n          this.parseDescription(postData.options.description),\n        )\n        .send<NewgroundsPostResponse>(editUrl);\n\n      // Step 6: Update content properties one by one (mimics website behavior)\n      const { options } = postData;\n      const updateProps = {\n        title: postData.options.title,\n        'option[tags]': postData.options.tags.join(','),\n        'option[include_in_portal]': options.sketch ? '0' : '1',\n        'option[use_creative_commons]': options.creativeCommons ? '1' : '0',\n        'option[cc_commercial]': options.commercial ? 'yes' : 'no',\n        'option[cc_modifiable]': options.modification ? 'yes' : 'no',\n        'option[genreid]': options.category,\n        'option[nudity]': options.nudity,\n        'option[violence]': options.violence,\n        'option[language_textual]': options.explicitText,\n        'option[adult_themes]': options.adultThemes,\n      };\n\n      let contentUpdateRes;\n      for (const [key, value] of Object.entries(updateProps)) {\n        // Wait between requests to avoid overwhelming the server\n        await new Promise<void>((resolve) => {\n          setTimeout(resolve, 1000);\n        });\n\n        contentUpdateRes = await new PostBuilder(this, cancellationToken)\n          .asMultipart()\n          .setField('PHP_SESSION_UPLOAD_PROGRESS', 'projectform')\n          .setField('userkey', userKey)\n          .setField(key, value)\n          .send<NewgroundsPostResponse>(editUrl);\n      }\n\n      if (!this.checkIsSaved(contentUpdateRes.body)) {\n        await this.cleanUpFailedProject(projectId, userKey);\n        return PostResponse.fromWebsite(this)\n          .withException(new Error('Could not update content'))\n          .withAdditionalInfo(contentUpdateRes.body)\n          .atStage('update content');\n      }\n\n      // Check for errors in the response\n      const resKeys = Object.entries(contentUpdateRes.body).filter(([key]) =>\n        key.endsWith('_error'),\n      );\n      if (resKeys.length > 0) {\n        await this.cleanUpFailedProject(projectId, userKey);\n        const errorMessages = resKeys.map(([, value]) => value).join('\\n');\n        return PostResponse.fromWebsite(this)\n          .withException(\n            new Error(`Could not update content:\\n${errorMessages}`),\n          )\n          .withAdditionalInfo(contentUpdateRes.body)\n          .atStage('content validation');\n      }\n\n      cancellationToken.throwIfCancelled();\n\n      // Step 7: Publish the project\n      if (contentUpdateRes.body.can_publish) {\n        const publishRes = await new PostBuilder(this, cancellationToken)\n          .asMultipart()\n          .setField('userkey', userKey)\n          .setField('submit', '1')\n          .setField('agree', 'Y')\n          .setField('__ng_design', '2015')\n          .send<string>(`${this.BASE_URL}/projects/art/${projectId}/publish`);\n\n        PostResponse.validateBody(this, publishRes);\n\n        return PostResponse.fromWebsite(this)\n          .withSourceUrl(publishRes.responseUrl)\n          .withMessage('File posted successfully')\n          .withAdditionalInfo(publishRes.body);\n      }\n\n      await this.cleanUpFailedProject(projectId, userKey);\n      return PostResponse.fromWebsite(this)\n        .withException(\n          new Error('Could not publish content. It may be missing data'),\n        )\n        .withAdditionalInfo(contentUpdateRes.body)\n        .atStage('publish check');\n    } catch (error) {\n      // Clean up on any error\n      await this.cleanUpFailedProject(projectId, userKey);\n      throw error;\n    }\n  }\n\n  async onValidateFileSubmission(\n    postData: PostData<NewgroundsFileSubmission>,\n  ): Promise<SimpleValidationResult> {\n    const validator = this.createValidator<NewgroundsFileSubmission>();\n\n    return validator.result;\n  }\n\n  createMessageModel(): NewgroundsMessageSubmission {\n    return new NewgroundsMessageSubmission();\n  }\n\n  async onPostMessageSubmission(\n    postData: PostData<NewgroundsMessageSubmission>,\n    cancellationToken: CancellableToken,\n  ): Promise<IPostResponse> {\n    // Step 1: Get the page to extract userkey\n    const page = await Http.get<string>(`${this.BASE_URL}/account/news/post`, {\n      partition: this.accountId,\n    });\n\n    PostResponse.validateBody(this, page);\n\n    // Extract userkey from the page\n    const $ = parse(page.body);\n    const userKey = $.querySelector('input[name=\"userkey\"]')?.getAttribute(\n      'value',\n    );\n\n    if (!userKey) {\n      return PostResponse.fromWebsite(this)\n        .withException(new Error('Could not retrieve userkey'))\n        .withAdditionalInfo(page.body)\n        .atStage('get userkey');\n    }\n\n    cancellationToken.throwIfCancelled();\n\n    // Step 2: Submit the news post\n    const builder = new PostBuilder(this, cancellationToken)\n      .asMultipart()\n      .setField('post_id', '')\n      .setField('userkey', userKey)\n      .setField('subject', postData.options.title)\n      .setField('emoticon', '6')\n      .setField('comments_pref', '1')\n      .setField('tag', '')\n      .setField('tags[]', postData.options.tags)\n      .setField('body', `<p>${postData.options.description}</p>`)\n      .setField(\n        'suitability',\n        this.getSuitabilityRating(postData.options.rating),\n      )\n      .withHeaders({\n        'X-Requested-With': 'XMLHttpRequest',\n        Origin: 'https://www.newgrounds.com',\n        Referer: `https://www.newgrounds.com/account/news/post`,\n        'Accept-Encoding': 'gzip, deflate, br',\n        Accept: '*/*',\n        'Content-Type': 'multipart/form-data',\n      });\n\n    const post = await builder.send<{ url: string }>(\n      `${this.BASE_URL}/account/news/post`,\n    );\n\n    // Check if the response has a URL (success indicator)\n    if (typeof post.body !== 'string' && post.body.url) {\n      return PostResponse.fromWebsite(this)\n        .withSourceUrl(post.body.url)\n        .withMessage('News post submitted successfully')\n        .withAdditionalInfo(post.body);\n    }\n\n    return PostResponse.fromWebsite(this)\n      .withException(new Error('Failed to post news'))\n      .withAdditionalInfo(post.body);\n  }\n\n  onValidateMessageSubmission = validatorPassthru;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/patreon/models/patreon-account-data.ts",
    "content": "import { SelectOption } from '@postybirb/form-builder';\n\nexport type PatreonAccountData = {\n  folders: SelectOption[];\n  collections: SelectOption[];\n};\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/patreon/models/patreon-campaign-types.ts",
    "content": "/**\n * Patreon API Campaign Response Types\n */\n\nexport interface PatreonCampaignResponse {\n  data: PatreonCampaign;\n  included?: Array<PatreonUser | PatreonReward | PatreonAccessRule>;\n  links: {\n    self: string;\n  };\n}\n\nexport interface PatreonCampaign {\n  id: string;\n  type: 'campaign';\n  attributes: PatreonCampaignAttributes;\n  relationships: PatreonCampaignRelationships;\n}\n\nexport interface PatreonCampaignAttributes {\n  avatar_photo_image_urls: Record<\n    | 'original'\n    | 'default'\n    | 'default_small'\n    | 'default_large'\n    | 'default_blurred'\n    | 'default_blurred_small'\n    | 'thumbnail'\n    | 'thumbnail_large'\n    | 'thumbnail_small',\n    string\n  >;\n  avatar_photo_url: string;\n  campaign_pledge_sum: number;\n  cover_photo_url: string;\n  cover_photo_url_sizes: Record<\n    'xsmall' | 'small' | 'medium' | 'large' | 'xlarge' | 'low_quality',\n    string\n  >;\n  created_at: string;\n  creation_count: number;\n  creation_name: string;\n  currency: string;\n  discord_server_id: string | null;\n  display_patron_goals: boolean;\n  earnings_visibility: 'public' | 'private';\n  image_small_url: string;\n  image_url: string;\n  is_charge_upfront: boolean;\n  is_charged_immediately: boolean;\n  is_monthly: boolean;\n  is_new_fandom: boolean;\n  is_nsfw: boolean;\n  is_plural: boolean;\n  main_video_embed: string | null;\n  main_video_url: string | null;\n  name: string;\n  one_liner: string | null;\n  outstanding_payment_amount_cents: number;\n  paid_member_count: number;\n  patron_count: number;\n  pay_per_name: string;\n  pledge_sum: number;\n  pledge_sum_currency: string;\n  pledge_url: string;\n  published_at: string | null;\n  should_display_chat_tab: boolean;\n  summary: string;\n  thanks_embed: string | null;\n  thanks_msg: string | null;\n  thanks_video_url: string | null;\n  url: string;\n}\n\nexport interface PatreonCampaignRelationships {\n  creator: PatreonRelationship<'user'>;\n  goals: {\n    data: PatreonRelationshipData<'goal'> | null;\n  };\n  rewards: {\n    data: Array<PatreonRelationshipData<'reward'>>;\n  };\n}\n\nexport interface PatreonUser {\n  id: string;\n  type: 'user';\n  attributes: PatreonUserAttributes;\n  relationships: {\n    campaign: PatreonRelationship<'campaign'>;\n  };\n}\n\nexport interface PatreonUserAttributes {\n  email: string;\n  first_name: string;\n  last_name: string;\n  full_name: string;\n  gender: number;\n  is_email_verified: boolean;\n  vanity: string;\n  about: string | null;\n  apple_id: string | null;\n  facebook_id: string | null;\n  discord_id: string | null;\n  google_id: string | null;\n  image_url: string;\n  thumb_url: string;\n  youtube: string | null;\n  twitter: string | null;\n  facebook: string | null;\n  twitch: string | null;\n  is_suspended: boolean;\n  is_deleted: boolean;\n  is_nuked: boolean;\n  can_see_nsfw: boolean;\n  created: string;\n  url: string;\n  has_password: boolean;\n  social_connections: PatreonSocialConnections;\n  default_country_code: string | null;\n  patron_currency: string;\n  age_verification_status: string | null;\n  current_user_block_status: 'none' | 'blocked' | 'blocking';\n}\n\nexport interface PatreonSocialConnections {\n  discord: PatreonDiscordConnection | null;\n  facebook: PatreonSocialConnection | null;\n  google: PatreonSocialConnection | null;\n  instagram: PatreonSocialConnection | null;\n  reddit: PatreonSocialConnection | null;\n  spotify: PatreonSocialConnection | null;\n  spotify_open_access: PatreonSocialConnection | null;\n  tiktok: PatreonSocialConnection | null;\n  twitch: PatreonSocialConnection | null;\n  twitter: PatreonSocialConnection | null;\n  twitter2: PatreonSocialConnection | null;\n  vimeo: PatreonSocialConnection | null;\n  youtube: PatreonSocialConnection | null;\n}\n\nexport interface PatreonDiscordConnection {\n  user_id: string;\n  scopes: string[];\n}\n\nexport type PatreonSocialConnection = Record<string, unknown> | null;\n\nexport interface PatreonReward {\n  id: string;\n  type: 'reward';\n  attributes: PatreonRewardAttributes;\n  relationships?: {\n    campaign: PatreonRelationship<'campaign'>;\n  };\n}\n\nexport interface PatreonAccessRule {\n  id: string;\n  type: 'access-rule';\n  attributes: {\n    access_rule_type:\n      | 'patrons'\n      | 'public'\n      | 'min_cents_pledged'\n      | 'non_member'\n      | 'tier';\n  };\n  relationships: {\n    tier: {\n      data: {\n        id: string;\n        type: string;\n      };\n    };\n  };\n}\n\nexport interface PatreonRewardAttributes {\n  amount: number;\n  amount_cents: number;\n  user_limit: number | null;\n  remaining: number | null;\n  description: string;\n  requires_shipping: boolean;\n  created_at: string | null;\n  url: string | null;\n  declined_patron_count?: number;\n  patron_count?: number;\n  post_count?: number;\n  discord_role_ids: string[] | null;\n  title?: string;\n  image_url: string | null;\n  edited_at?: string;\n  published?: boolean;\n  published_at?: string | null;\n  unpublished_at?: string | null;\n  currency?: string;\n  patron_amount_cents?: number;\n  patron_currency: string;\n  welcome_message?: string | null;\n  welcome_message_unsafe?: string | null;\n  welcome_video_url?: string | null;\n  welcome_video_embed?: string | null;\n  is_free_tier?: boolean;\n}\n\n// Generic relationship types\nexport interface PatreonRelationship<T extends string> {\n  data: PatreonRelationshipData<T> | PatreonRelationshipData<T>[] | null;\n  links?: {\n    related: string;\n  };\n}\n\nexport interface PatreonRelationshipData<T extends string> {\n  id: string;\n  type: T;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/patreon/models/patreon-collection-types.ts",
    "content": "export type PatreonCollectionThumbnail = {\n  original: string;\n  default: string;\n  default_blurred: string;\n  default_small: string;\n  default_large: string;\n  default_blurred_small: string;\n  thumbnail: string;\n  thumbnail_large: string;\n  thumbnail_small: string;\n  url: string;\n  width: number;\n  height: number;\n};\n\nexport type PatreonCollectionAttributes = {\n  title: string;\n  description: string;\n  created_at: string;\n  edited_at: string;\n  num_posts: number;\n  num_posts_visible_for_creation: number;\n  num_draft_posts: number;\n  num_scheduled_posts: number;\n  post_ids: string[];\n  thumbnail: PatreonCollectionThumbnail;\n  moderation_status: string;\n  post_sort_type: string;\n  default_layout: string | null;\n};\n\nexport type PatreonCollection = {\n  id: string;\n  type: 'collection';\n  attributes: PatreonCollectionAttributes;\n};\n\nexport type PatreonCollectionPaginationCursors = {\n  next: string | null;\n};\n\nexport type PatreonCollectionPagination = {\n  total: number;\n  cursors: PatreonCollectionPaginationCursors;\n};\n\nexport type PatreonCollectionMeta = {\n  pagination: PatreonCollectionPagination;\n};\n\nexport type PatreonCollectionResponse = {\n  data: PatreonCollection[];\n  meta: PatreonCollectionMeta;\n};\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/patreon/models/patreon-file-submission.ts",
    "content": "import {\n  BooleanField,\n  DateTimeField,\n  DescriptionField,\n  SelectField,\n  TagField,\n  TextField,\n} from '@postybirb/form-builder';\nimport { DefaultTagValue, DescriptionType, DescriptionValue, TagValue } from '@postybirb/types';\nimport { BaseWebsiteOptions } from '../../../models/base-website-options';\n\nexport class PatreonFileSubmission extends BaseWebsiteOptions {\n  @DescriptionField({\n    descriptionType: DescriptionType.HTML,\n  })\n  description: DescriptionValue;\n\n  @TagField({\n    maxTagLength: 25,\n    spaceReplacer: \" \",\n  })\n  tags: TagValue = DefaultTagValue();\n\n  @SelectField({\n    required: true,\n    label: 'accessTiers',\n    minSelected: 1,\n    allowMultiple: true,\n    options: [], // Populated dynamically\n    derive: [\n      {\n        key: 'folders',\n        populate: 'options',\n      },\n    ],\n    span: 12,\n  })\n  tiers: string[] = [];\n\n  @SelectField({\n    label: 'collections',\n    allowMultiple: true,\n    options: [], // Populated dynamically\n    derive: [\n      {\n        key: 'collections',\n        populate: 'options',\n      },\n    ],\n    span: 12,\n  })\n  collections: string[] = [];\n\n  @TextField({\n    label: 'teaser',\n    span: 12,\n  })\n  teaser = '';\n\n  @DateTimeField({\n    label: 'schedule',\n    showTime: true,\n    min: new Date().toISOString(),\n    span: 6,\n  })\n  schedule?: string;\n\n  @DateTimeField({\n    label: 'earlyAccess',\n    showTime: true,\n    min: new Date().toISOString(),\n    span: 6,\n  })\n  earlyAccess?: Date;\n\n  @BooleanField({\n    label: 'chargePatrons',\n    span: 3,\n  })\n  charge = false;\n\n  @BooleanField({\n    label: 'uploadThumbnail',\n    span: 3,\n  })\n  uploadThumbnail = false;\n\n  @BooleanField({\n    label: 'allAsAttachment',\n    span: 3,\n  })\n  allAsAttachment = false;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/patreon/models/patreon-media-upload-types.ts",
    "content": "export type PatreonMediaState =\n  | 'pending_upload'\n  | 'processing'\n  | 'ready'\n  | 'failed';\n\nexport type PatreonMediaOwnerType = 'post' | 'user' | 'campaign';\n\nexport type PatreonMediaOwnerRelationship =\n  | 'main'\n  | 'thumbnail'\n  | 'attachment'\n  | 'audio';\n\nexport type PatreonMediaType = 'image' | 'video' | 'audio';\n\nexport type PatreonMediaUploadAttributes = {\n  state: PatreonMediaState;\n  file_name: string;\n  size_bytes: number;\n  owner_id: string;\n  owner_type: PatreonMediaOwnerType;\n  owner_relationship: PatreonMediaOwnerRelationship;\n  media_type: PatreonMediaType;\n};\n\nexport type PatreonMediaUpload = {\n  type: 'media';\n  attributes: PatreonMediaUploadAttributes;\n};\n\nexport type PatreonMediaUploadRequest = {\n  data: PatreonMediaUpload;\n};\n\n// Response types\nexport type PatreonMediaUploadParameters = {\n  acl: string;\n  key: string;\n  bucket: string;\n  'X-Amz-Date': string;\n  'X-Amz-Algorithm': string;\n  'X-Amz-Credential': string;\n  'X-Amz-Security-Token': string;\n  policy: string;\n  'X-Amz-Signature': string;\n};\n\nexport type PatreonMediaImageUrls = {\n  url: string;\n  original: string;\n  default: string;\n  default_blurred: string;\n  default_small: string;\n  default_large: string;\n  default_blurred_small: string;\n  thumbnail: string;\n  thumbnail_large: string;\n  thumbnail_small: string;\n};\n\nexport type PatreonMediaDimensions = {\n  w: number;\n  h: number;\n};\n\nexport type PatreonMediaMetadata = {\n  dimensions: PatreonMediaDimensions;\n};\n\nexport type PatreonMediaDisplay = {\n  url: string;\n  state: PatreonMediaState;\n  media_id: number;\n};\n\nexport type PatreonMediaUploadResponseAttributes = {\n  file_name: string;\n  size_bytes: number;\n  mimetype: string | null;\n  state: PatreonMediaState;\n  owner_type: PatreonMediaOwnerType;\n  owner_id: string;\n  owner_relationship: PatreonMediaOwnerRelationship;\n  upload_expires_at: string;\n  upload_url: string;\n  upload_parameters: PatreonMediaUploadParameters;\n  download_url: string;\n  image_urls: PatreonMediaImageUrls;\n  created_at: string;\n  metadata: PatreonMediaMetadata;\n  media_type: PatreonMediaType;\n  display: PatreonMediaDisplay;\n};\n\nexport type PatreonMediaUploadResponseData = {\n  id: string;\n  type: 'media';\n  attributes: PatreonMediaUploadResponseAttributes;\n};\n\nexport type PatreonMediaUploadResponseLinks = {\n  self: string;\n};\n\nexport type PatreonMediaUploadResponse = {\n  data: PatreonMediaUploadResponseData;\n  links: PatreonMediaUploadResponseLinks;\n};\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/patreon/models/patreon-message-submission.ts",
    "content": "import {\n  BooleanField,\n  DateTimeField,\n  DescriptionField,\n  SelectField,\n  TagField,\n  TextField,\n} from '@postybirb/form-builder';\nimport { DefaultTagValue, DescriptionType, DescriptionValue, TagValue } from '@postybirb/types';\nimport { BaseWebsiteOptions } from '../../../models/base-website-options';\n\nexport class PatreonMessageSubmission extends BaseWebsiteOptions {\n  @DescriptionField({\n    descriptionType: DescriptionType.HTML,\n  })\n  description: DescriptionValue;\n  \n  @TagField({\n    maxTagLength: 25,\n    spaceReplacer: \" \",\n  })\n  tags: TagValue = DefaultTagValue();\n\n  @SelectField({\n    label: 'accessTiers',\n    minSelected: 1,\n    allowMultiple: true,\n    options: [], // Populated dynamically\n    derive: [\n      {\n        key: 'folders',\n        populate: 'options',\n      },\n    ],\n    span: 12,\n  })\n  tiers: string[] = [];\n\n  @SelectField({\n    label: 'collections',\n    allowMultiple: true,\n    options: [], // Populated dynamically\n    derive: [\n      {\n        key: 'collections',\n        populate: 'options',\n      },\n    ],\n    span: 12,\n  })\n  collections: string[] = [];\n\n  @TextField({\n    label: 'teaser',\n    span: 12,\n  })\n  teaser = '';\n\n  @DateTimeField({\n    label: 'schedule',\n    showTime: true,\n    min: new Date().toISOString(),\n    span: 6,\n  })\n  schedule?: string;\n\n  @DateTimeField({\n    label: 'earlyAccess',\n    showTime: true,\n    min: new Date().toISOString(),\n    span: 6,\n  })\n  earlyAccess?: Date;\n\n  @BooleanField({\n    label: 'chargePatrons',\n    span: 3,\n  })\n  charge = false;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/patreon/models/patreon-post-types.ts",
    "content": "/**\n *{\n    \"data\": {\n        \"id\": \"<number>\",\n        \"type\": \"post\",\n        \"attributes\": {\n            \"post_type\": \"text_only\",\n            \"post_metadata\": null\n        },\n        \"relationships\": {\n            \"drop\": {\n                \"data\": null\n            }\n        }\n    },\n    \"links\": {\n        \"self\": \"https://www.patreon.com/api/posts/<number>\"\n    }\n}\n *\n */\nexport interface PatreonNewPostResponse {\n  data: {\n    id: string;\n    type: 'post';\n    attributes: {\n      post_type: string;\n      post_metadata: unknown;\n    };\n    relationships: {\n      drop: {\n        data: object;\n      };\n    };\n  };\n  links: {\n    self: string;\n  };\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/patreon/patreon-description-converter.ts",
    "content": "/* eslint-disable import/no-extraneous-dependencies */\nimport { Node } from '@tiptap/core';\nimport { Blockquote } from '@tiptap/extension-blockquote';\nimport { Bold } from '@tiptap/extension-bold';\nimport { Color } from '@tiptap/extension-color';\nimport { Document } from '@tiptap/extension-document';\nimport { HardBreak } from '@tiptap/extension-hard-break';\nimport { Heading } from '@tiptap/extension-heading';\nimport { HorizontalRule } from '@tiptap/extension-horizontal-rule';\nimport { Italic } from '@tiptap/extension-italic';\nimport { Link } from '@tiptap/extension-link';\nimport { Paragraph } from '@tiptap/extension-paragraph';\nimport { Strike } from '@tiptap/extension-strike';\nimport { Text } from '@tiptap/extension-text';\nimport { TextAlign } from '@tiptap/extension-text-align';\nimport { TextStyle } from '@tiptap/extension-text-style';\nimport { Underline } from '@tiptap/extension-underline';\nimport { generateJSON } from '@tiptap/html/dist/server';\n\n/**\n * Custom Paragraph extension for Patreon that includes\n * nodeIndent, nodeTextAlignment, nodeLineHeight, and style attrs.\n */\nconst PatreonParagraph = Paragraph.extend({\n  addAttributes() {\n    return {\n      ...this.parent?.(),\n      nodeIndent: { default: null },\n      nodeTextAlignment: { default: null },\n      nodeLineHeight: { default: null },\n      style: { default: '' },\n    };\n  },\n});\n\n/**\n * Custom paywallBreakpoint node used by Patreon's editor schema.\n */\nconst PaywallBreakpoint = Node.create({\n  name: 'paywallBreakpoint',\n  group: 'block',\n  atom: true,\n\n  addAttributes() {\n    return {\n      paywallBreakpointCtaProps: { default: {} },\n    };\n  },\n\n  renderHTML() {\n    return ['div', { 'data-type': 'paywallBreakpoint' }];\n  },\n\n  parseHTML() {\n    return [{ tag: 'div[data-type=\"paywallBreakpoint\"]' }];\n  },\n});\n\nconst extensions = [\n  Text,\n  Document,\n  PatreonParagraph,\n  Bold,\n  Italic,\n  Strike,\n  Underline,\n  HardBreak,\n  Blockquote,\n  Color,\n  TextStyle,\n  Heading,\n  HorizontalRule,\n  Link,\n  TextAlign.configure({\n    types: ['heading', 'paragraph'],\n  }),\n  PaywallBreakpoint,\n];\n\nexport interface PatreonContentOptions {\n  postId: string;\n  isMonetized: boolean;\n  isPaidAccessSelected: boolean;\n  includePaywall: boolean;\n  currencyCode?: string;\n}\n\nexport class PatreonDescriptionConverter {\n  /**\n   * Converts HTML content into a Patreon-compatible TipTap JSON string,\n   * optionally prepending a paywallBreakpoint node.\n   */\n  static convert(html: string, options: PatreonContentOptions): string {\n    const doc = generateJSON(html || '<p></p>', extensions);\n\n    if (options.includePaywall) {\n      // Prepend the paywallBreakpoint node to the document content\n      const paywallNode = {\n        type: 'paywallBreakpoint',\n        attrs: {\n          paywallBreakpointCtaProps: {\n            currencyCode: options.currencyCode ?? 'USD',\n            isMonetized: options.isMonetized,\n            isPaidAccessSelected: options.isPaidAccessSelected,\n            isPaidMembersSelected: options.isPaidAccessSelected,\n            showCtas: false,\n            postId: options.postId,\n          },\n        },\n      };\n\n      doc.content = [paywallNode, ...(doc.content ?? [])];\n    }\n\n    return JSON.stringify(doc);\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/patreon/patreon.website.ts",
    "content": "import { SelectOption } from '@postybirb/form-builder';\nimport { FormFile, Http } from '@postybirb/http';\nimport {\n  DynamicObject,\n  FileType,\n  ILoginState,\n  ImageResizeProps,\n  ISubmissionFile,\n  PostData,\n  PostResponse,\n  SimpleValidationResult,\n} from '@postybirb/types';\nimport parse from 'node-html-parser';\nimport { parse as parseFileName } from 'path';\nimport { v4 } from 'uuid';\nimport { CancellableToken } from '../../../post/models/cancellable-token';\nimport { PostingFile } from '../../../post/models/posting-file';\nimport FileSize from '../../../utils/filesize.util';\nimport { wait } from '../../../utils/wait.util';\nimport { PostBuilder } from '../../commons/post-builder';\nimport { DisableAds } from '../../decorators/disable-ads.decorator';\nimport { UserLoginFlow } from '../../decorators/login-flow.decorator';\nimport { SupportsFiles } from '../../decorators/supports-files.decorator';\nimport { SupportsUsernameShortcut } from '../../decorators/supports-username-shortcut.decorator';\nimport { WebsiteMetadata } from '../../decorators/website-metadata.decorator';\nimport { DataPropertyAccessibility } from '../../models/data-property-accessibility';\nimport { FileWebsite } from '../../models/website-modifiers/file-website';\nimport { MessageWebsite } from '../../models/website-modifiers/message-website';\nimport { Website } from '../../website';\nimport { PatreonAccountData } from './models/patreon-account-data';\nimport { PatreonCampaignResponse } from './models/patreon-campaign-types';\nimport { PatreonCollectionResponse } from './models/patreon-collection-types';\nimport { PatreonFileSubmission } from './models/patreon-file-submission';\nimport {\n  PatreonMediaType,\n  PatreonMediaUploadRequest,\n  PatreonMediaUploadResponse,\n} from './models/patreon-media-upload-types';\nimport { PatreonMessageSubmission } from './models/patreon-message-submission';\nimport { PatreonNewPostResponse } from './models/patreon-post-types';\nimport { PatreonDescriptionConverter } from './patreon-description-converter';\n\ntype PatreonAccessRuleSegment = Array<{\n  type: 'access-rule';\n  id: string;\n  attributes: object;\n}>;\n\ntype PatreonTagSegment = Array<{\n  type: 'post_tag';\n  id: string;\n  attributes: {\n    value: string;\n    cardinality: 1;\n  };\n}>;\n\n@WebsiteMetadata({\n  name: 'patreon',\n  displayName: 'Patreon',\n  minimumPostWaitInterval: 90_000,\n})\n@UserLoginFlow('https://www.patreon.com/login')\n@SupportsFiles({\n  acceptedMimeTypes: [\n    'application/pdf',\n    'application/rtf',\n    'audio/midi',\n    'audio/mp3',\n    'audio/mpeg',\n    'audio/oga',\n    'audio/ogg',\n    'audio/wav',\n    'audio/x-wav',\n    'image/gif',\n    'image/jpeg',\n    'image/jpg',\n    'image/png',\n    'text/markdown',\n    'text/plain',\n    'video/webm',\n  ],\n  acceptedFileSizes: {\n    '*': FileSize.megabytes(200),\n  },\n})\n@SupportsUsernameShortcut({\n  id: 'patreon',\n  url: 'https://www.patreon.com/$1',\n})\n@DisableAds()\nexport default class Patreon\n  extends Website<\n    PatreonAccountData,\n    {\n      csrf: string;\n      username: string;\n      campaignId: string;\n      campaign: PatreonCampaignResponse;\n    }\n  >\n  implements\n    FileWebsite<PatreonFileSubmission>,\n    MessageWebsite<PatreonMessageSubmission>\n{\n  protected BASE_URL = 'https://www.patreon.com';\n\n  public externallyAccessibleWebsiteDataProperties: DataPropertyAccessibility<PatreonAccountData> =\n    {\n      folders: true,\n      collections: true,\n    };\n\n  public async onLogin(): Promise<ILoginState> {\n    const membershipPage = await Http.get<string>(\n      `${this.BASE_URL}/membership`,\n      {\n        partition: this.accountId,\n      },\n    );\n\n    const $membershipPage = parse(membershipPage.body);\n    const csrf = $membershipPage\n      .querySelector('meta[name=\"csrf-token\"]')\n      ?.getAttribute('content');\n\n    if (csrf) {\n      const badgesResult = await Http.get<{\n        data: Array<{\n          id: string;\n          type: string;\n          attributes: object;\n        }>;\n      }>(\n        `${this.BASE_URL}/api/badges?json-api-version=1.0&json-api-use-default-includes=false&include=[]`,\n        {\n          partition: this.accountId,\n        },\n      );\n      const campaignBadge = badgesResult.body.data?.find((badge) =>\n        badge.id.includes('campaign'),\n      );\n      if (campaignBadge) {\n        const campaignId = campaignBadge.id.split(':')[1];\n        const campaignQueryString =\n          '?fields[rewardItem]=title%2Cdescription%2Coffer_id%2Citem_type%2Cis_deleted%2Cis_ended%2Cis_published&fields[accessRule]=access_rule_type%2Camount_cents%2Cpost_count&include=post_aggregation%2Ccreator.campaign%2Ccreator.pledge_to_current_user.null%2Cconnected_socials%2Ccurrent_user_pledge.reward.null%2Ccurrent_user_pledge.campaign.null%2Crewards.items.null%2Crewards.cadence_options.null%2Crss_auth_token%2Caccess_rules.tier.null%2Cactive_offer.rewards.null%2Cscheduled_offer.rewards.null%2Ccreator.pledges.campaign.null%2Creward_items.template%2Crewards.null%2Crewards.reward_recommendations%2Cthanks_embed%2Cthanks_msg&json-api-version=1.0&json-api-use-default-includes=false';\n        const campaignResult = await Http.get<PatreonCampaignResponse>(\n          `${this.BASE_URL}/api/campaigns/${campaignId}${campaignQueryString}`,\n          { partition: this.accountId },\n        );\n\n        const username = campaignResult.body.data.attributes.name;\n        this.sessionData.username = username;\n        this.sessionData.campaignId = campaignId;\n        this.sessionData.csrf = csrf;\n        this.sessionData.campaign = campaignResult.body;\n        this.setWebsiteData({\n          folders: this.parseTiers(campaignResult.body),\n          collections: await this.loadCollections(campaignId),\n        });\n        return this.loginState.setLogin(true, username);\n      }\n    }\n\n    return this.loginState.setLogin(false, null);\n  }\n\n  private parseTiers(campaign: PatreonCampaignResponse): SelectOption[] {\n    const { included } = campaign;\n    if (!included) return [];\n    const rewards = included.filter((item) => item.type === 'reward');\n    const accessRules = included.filter((item) => item.type === 'access-rule');\n\n    return accessRules\n      .map((accessRule) => {\n        const { id, attributes, relationships } = accessRule;\n        let label: string;\n        let mutuallyExclusive = false;\n        let cost = 0;\n\n        if (attributes.access_rule_type === 'public') {\n          label = 'Everyone';\n          mutuallyExclusive = true;\n        }\n\n        if (attributes.access_rule_type === 'patrons') {\n          label = 'Patrons (All Tiers)';\n          mutuallyExclusive = true;\n          cost = 1; // Ensure this is sorted above free\n        }\n\n        if (attributes.access_rule_type === 'tier') {\n          const rewardTier = rewards.find(\n            (reward) => reward.id === relationships.tier.data.id,\n          );\n          if (rewardTier) {\n            cost = rewardTier.attributes.amount_cents;\n            label = `${\n              rewardTier.attributes.title ||\n              rewardTier.attributes.description ||\n              'Untitled Reward'\n            } (${(rewardTier.attributes.amount_cents / 100).toFixed(2)} ${rewardTier.attributes.currency})`;\n          }\n        }\n\n        if (cost === 0) {\n          mutuallyExclusive = true;\n        }\n\n        return {\n          value: id,\n          label,\n          mutuallyExclusive,\n          data: {\n            accessRules,\n            cost,\n          },\n        };\n      })\n      .filter((option) => !!option.label)\n      .sort((a, b) => a.data.cost - b.data.cost);\n  }\n\n  private async loadCollections(campaignId: string): Promise<SelectOption[]> {\n    const collectionRes = await Http.get<PatreonCollectionResponse>(\n      `${this.BASE_URL}/api/collection?filter[campaign_id]=${campaignId}&filter[must_contain_at_least_one_published_post]=false&json-api-version=1.0&json-api-use-default-includes=false`,\n      {\n        partition: this.accountId,\n        headers: {\n          'X-Csrf-Signature': this.sessionData.csrf,\n        },\n      },\n    );\n\n    if (\n      collectionRes.statusCode >= 400 &&\n      typeof collectionRes.body === 'string' // Failure returns html\n    ) {\n      return [];\n    }\n    try {\n      const collections: SelectOption[] = collectionRes.body.data.map((c) => ({\n        label: c.attributes.title,\n        value: c.id,\n      }));\n      return collections;\n    } catch (err) {\n      this.logger.error(err);\n      return [];\n    }\n  }\n\n  private getPostType(fileType: FileType): string {\n    switch (fileType) {\n      case FileType.AUDIO:\n        return 'audio_embed';\n      case FileType.IMAGE:\n        return 'image_file';\n      case FileType.VIDEO:\n        return 'video';\n      case FileType.TEXT:\n      default:\n        return 'text_only';\n    }\n  }\n\n  createFileModel(): PatreonFileSubmission {\n    return new PatreonFileSubmission();\n  }\n\n  calculateImageResize(file: ISubmissionFile): ImageResizeProps {\n    return undefined;\n  }\n\n  private async initializePost() {\n    const res = await Http.post<PatreonNewPostResponse>(\n      `${this.BASE_URL}/api/posts?fields[post]=post_type%2Cpost_metadata&include=drop&json-api-version=1.0&json-api-use-default-includes=false`,\n      {\n        partition: this.accountId,\n        type: 'json',\n        data: {\n          data: {\n            type: 'post',\n            attributes: {\n              post_type: 'text_only',\n            },\n          },\n        },\n        headers: {\n          'X-Csrf-Signature': this.sessionData.csrf,\n        },\n      },\n    );\n\n    PostResponse.validateBody(this, res);\n    return res.body;\n  }\n\n  private async finalizePost(\n    postUrl: string,\n    data: DynamicObject,\n  ): Promise<void> {\n    const res = await Http.patch(\n      `${postUrl}?json-api-version=1.0&json-api-use-default-includes=false&include=[]`,\n      {\n        partition: this.accountId,\n        type: 'json',\n        data,\n        headers: {\n          'X-Csrf-Signature': this.sessionData.csrf,\n        },\n      },\n    );\n\n    PostResponse.validateBody(this, res, 'Finalize Post');\n  }\n\n  private getMediaType(fileType: FileType): PatreonMediaType | undefined {\n    switch (fileType) {\n      case FileType.AUDIO:\n        return 'audio';\n      case FileType.IMAGE:\n        return 'image';\n      case FileType.VIDEO:\n        return 'video';\n      default:\n        return undefined;\n    }\n  }\n\n  private async uploadMedia(\n    postId: string,\n    file: FormFile,\n    fileType: FileType,\n    asAttachment: boolean,\n    cancellationToken: CancellableToken,\n  ): Promise<PatreonMediaUploadResponse> {\n    const { ext } = parseFileName(file.fileName);\n    const fileNameGUID = `${v4().toUpperCase()}${ext}`;\n    if (fileType === FileType.TEXT || fileType === FileType.UNKNOWN) {\n      // eslint-disable-next-line no-param-reassign\n      asAttachment = true;\n    }\n    const req: PatreonMediaUploadRequest = {\n      data: {\n        type: 'media',\n        attributes: {\n          state: 'pending_upload',\n          file_name: fileNameGUID,\n          size_bytes: file.buffer.length,\n          owner_id: postId,\n          owner_type: 'post',\n          owner_relationship: asAttachment\n            ? 'attachment'\n            : fileType === FileType.AUDIO\n              ? 'audio'\n              : 'main',\n          media_type: asAttachment ? undefined : this.getMediaType(fileType),\n        },\n      },\n    };\n\n    const init = await Http.post<PatreonMediaUploadResponse>(\n      `${this.BASE_URL}/api/media?json-api-version=1.0&json-api-use-default-includes=false&include=%5B%5D`,\n      {\n        partition: this.accountId,\n        type: 'json',\n        data: req,\n        headers: {\n          'X-Csrf-Signature': this.sessionData.csrf,\n        },\n      },\n    );\n\n    PostResponse.validateBody(this, init, 'Media Upload Initial Stage');\n\n    file.setFileName(fileNameGUID);\n    const builder = new PostBuilder(this, cancellationToken)\n      .asMultipart()\n      .withData(init.body.data.attributes.upload_parameters)\n      .addFile('file', file);\n\n    const upload = await builder.send(init.body.data.attributes.upload_url);\n    PostResponse.validateBody(this, upload, 'Bucket Upload');\n\n    const timeout = Date.now() + 90_000;\n    while (Date.now() <= timeout) {\n      const state = await Http.get<PatreonMediaUploadResponse>(\n        `${this.BASE_URL}/api/media/${init.body.data.id}?json-api-version=1.0&json-api-use-default-includes=false&include=[]`,\n        {\n          partition: this.accountId,\n          headers: {\n            'X-Csrf-Signature': this.sessionData.csrf,\n          },\n        },\n      );\n\n      PostResponse.validateBody(this, state, 'Verify Upload State');\n\n      if (state.body.data.attributes.state === 'ready') {\n        return state.body;\n      }\n\n      if (state.body.data.attributes.state === 'failed') {\n        // eslint-disable-next-line @typescript-eslint/no-throw-literal\n        throw PostResponse.fromWebsite(this)\n          .withException(new Error('Media upload failed'))\n          .withAdditionalInfo(state)\n          .atStage('Verify Upload State Failed');\n      }\n      await wait(2000);\n    }\n\n    throw new Error('Unable to verify media upload state');\n  }\n\n  async onPostFileSubmission(\n    postData: PostData<PatreonFileSubmission>,\n    files: PostingFile[],\n    cancellationToken: CancellableToken,\n  ): Promise<PostResponse> {\n    cancellationToken.throwIfCancelled();\n    const initializedPost = await this.initializePost();\n\n    const uploadThumbnail =\n      postData.options.uploadThumbnail || files[0].fileType === FileType.AUDIO;\n\n    const filesToUpload: { file: FormFile; fileType: FileType }[] = [];\n\n    if (\n      uploadThumbnail &&\n      files[0].thumbnail &&\n      files[0].thumbnail.mimeType.startsWith('image')\n    ) {\n      filesToUpload.push({\n        file: files[0].thumbnailToPostFormat(),\n        fileType: FileType.IMAGE,\n      });\n    }\n\n    filesToUpload.push(\n      ...files.map((f) => ({\n        file: f.toPostFormat(),\n        fileType: f.fileType,\n      })),\n    );\n\n    const uploadedFiles = await Promise.all(\n      filesToUpload.map(({ file, fileType }) =>\n        this.uploadMedia(\n          initializedPost.data.id,\n          file,\n          fileType,\n          postData.options.allAsAttachment,\n          cancellationToken,\n        ),\n      ),\n    );\n\n    const tags = this.createTagsSegment(postData.options.tags || []);\n    const accessTiers = this.createAccessRuleSegment(\n      postData.options.tiers || [],\n    );\n\n    const postAttributes = {\n      data: this.createDataSegment(\n        postData,\n        tags,\n        accessTiers,\n        postData.options.allAsAttachment\n          ? 'text_only'\n          : this.getPostType(files[0].fileType),\n        initializedPost.data.id,\n        uploadedFiles\n          .filter((f) => f.data.attributes.media_type === 'image') // Metadata only matters for image types\n          .map((f) => f.data.id),\n      ),\n      meta: this.createDefaultMetadataSegment(),\n      included: [...tags, ...accessTiers],\n    };\n\n    cancellationToken.throwIfCancelled();\n    await this.finalizePost(initializedPost.links.self, postAttributes);\n\n    return PostResponse.fromWebsite(this)\n      .withAdditionalInfo({\n        postAttributes,\n      })\n      .withSourceUrl(`${this.BASE_URL}/posts/${initializedPost.data.id}`);\n  }\n\n  async onValidateFileSubmission(\n    postData: PostData<PatreonFileSubmission>,\n  ): Promise<SimpleValidationResult> {\n    const validator = this.createValidator<PatreonFileSubmission>();\n\n    return validator.result;\n  }\n\n  createMessageModel(): PatreonMessageSubmission {\n    return new PatreonMessageSubmission();\n  }\n\n  async onPostMessageSubmission(\n    postData: PostData<PatreonMessageSubmission>,\n    cancellationToken: CancellableToken,\n  ): Promise<PostResponse> {\n    cancellationToken.throwIfCancelled();\n    const initializedPost = await this.initializePost();\n\n    const tags = this.createTagsSegment(postData.options.tags || []);\n    const accessTiers = this.createAccessRuleSegment(\n      postData.options.tiers || [],\n    );\n\n    const postAttributes = {\n      data: this.createDataSegment(\n        postData,\n        tags,\n        accessTiers,\n        'text_only',\n        initializedPost.data.id,\n      ),\n      meta: this.createDefaultMetadataSegment(),\n      included: [...tags, ...accessTiers],\n    };\n\n    cancellationToken.throwIfCancelled();\n    await this.finalizePost(initializedPost.links.self, postAttributes);\n\n    return PostResponse.fromWebsite(this)\n      .withAdditionalInfo({\n        postAttributes,\n      })\n      .withSourceUrl(`${this.BASE_URL}/posts/${initializedPost.data.id}`);\n  }\n\n  async onValidateMessageSubmission(\n    postData: PostData<PatreonMessageSubmission>,\n  ): Promise<SimpleValidationResult> {\n    const validator = this.createValidator<PatreonMessageSubmission>();\n    return validator.result;\n  }\n\n  private createTagsSegment(tags: string[]): PatreonTagSegment {\n    return tags.slice(0, 50).map((tag) => ({\n      type: 'post_tag',\n      id: `user_defined;${tag}`,\n      attributes: {\n        value: tag,\n        cardinality: 1,\n      },\n    }));\n  }\n\n  private createAccessRuleSegment(\n    patreonTiers: string[],\n  ): PatreonAccessRuleSegment {\n    return patreonTiers.map((tier) => ({\n      type: 'access-rule',\n      id: `${tier}`,\n      attributes: {},\n    }));\n  }\n\n  private createDefaultMetadataSegment() {\n    return {\n      auto_save: false,\n      send_notifications: true,\n    };\n  }\n\n  private createDataSegment(\n    postData:\n      | PostData<PatreonMessageSubmission>\n      | PostData<PatreonFileSubmission>,\n    tagSegment: PatreonTagSegment,\n    rulesSegment: PatreonAccessRuleSegment,\n    postType: string,\n    postId: string,\n    mediaIds?: string[],\n  ) {\n    const { options } = postData;\n    const {\n      description,\n      teaser,\n      title,\n      charge,\n      schedule,\n      earlyAccess,\n      collections,\n    } = options;\n\n    // Determine if any selected tier is a paid tier.\n    // Free tiers (\"Everyone\", free rewards) have cost === 0.\n    const folders = this.getWebsiteData()?.folders ?? [];\n    const selectedTierIds = new Set(\n      rulesSegment.map((rule) => rule.id),\n    );\n    const hasPaidTier = folders.some(\n      (folder) =>\n        selectedTierIds.has(String(folder.value)) &&\n        (folder.data as { cost?: number })?.cost > 0,\n    );\n\n    const dataAttributes = {\n      type: 'post',\n      attributes: {\n        comments_write_access_level: 'all',\n        content: description ?? '<p></p>',\n        is_paid: charge,\n        is_monetized: false,\n        new_post_email_type: 'preview_only',\n        paywall_display: 'post_layout',\n        post_type: postType,\n        preview_asset_type: 'default',\n        teaser_text: teaser,\n        thumbnail_position: null,\n        title,\n        video_preview_start_ms: null,\n        video_preview_end_ms: null,\n        is_preview_blurred: true,\n        allow_preview_in_rss: true,\n        scheduled_for: schedule || undefined,\n        change_visibility_at: earlyAccess || undefined,\n        post_metadata: {\n          platform: {},\n        },\n        tags: {\n          publish: !schedule,\n        },\n        content_json_string: PatreonDescriptionConverter.convert(\n          description ?? '<p></p>',\n          {\n            postId,\n            isMonetized: !!charge,\n            isPaidAccessSelected: hasPaidTier,\n            includePaywall: hasPaidTier,\n          },\n        ),\n      },\n      relationships: {\n        post_tag: tagSegment.length\n          ? {\n              data: {\n                type: 'post_tag',\n                id: tagSegment[tagSegment.length - 1].id,\n              },\n            }\n          : undefined,\n        'access-rule': rulesSegment.length\n          ? {\n              data: {\n                type: 'access-rule',\n                id: rulesSegment[rulesSegment.length - 1].id,\n              },\n            }\n          : undefined,\n        user_defined_tags: {\n          data: tagSegment.map((tag) => ({\n            id: tag.id,\n            type: 'post_tag',\n          })),\n        },\n        access_rules: {\n          data: rulesSegment.map((rule) => ({\n            id: rule.id,\n            type: 'access-rule',\n          })),\n        },\n        collections: {\n          data: collections ?? [],\n        },\n      },\n    };\n\n    return dataAttributes;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/philomena/models/philomena-account-data.ts",
    "content": "// eslint-disable-next-line @typescript-eslint/ban-types\nexport type PhilomenaAccountData = {};\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/philomena/models/philomena-file-submission.ts",
    "content": "import { DescriptionField, TagField } from '@postybirb/form-builder';\nimport { DescriptionType, DescriptionValue, TagValue } from '@postybirb/types';\nimport { BaseWebsiteOptions } from '../../../models/base-website-options';\n\n/**\n * Base file submission options for Philomena-based sites.\n * Subclasses can override field decorators to customize validation.\n */\nexport class PhilomenaFileSubmission extends BaseWebsiteOptions {\n  @DescriptionField({\n    descriptionType: DescriptionType.MARKDOWN,\n    expectsInlineTitle: true,\n  })\n  description: DescriptionValue;\n\n  @TagField({\n    minTags: 3,\n    spaceReplacer: ' ',\n    minTagLength: 1,\n    maxTagLength: 100,\n  })\n  tags: TagValue;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/philomena/philomena.website.ts",
    "content": "import { Http } from '@postybirb/http';\nimport {\n  ILoginState,\n  ImageResizeProps,\n  ISubmissionFile,\n  PostData,\n  PostResponse,\n  SubmissionRating,\n} from '@postybirb/types';\nimport parse from 'node-html-parser';\nimport { CancellableToken } from '../../../post/models/cancellable-token';\nimport { PostingFile } from '../../../post/models/posting-file';\nimport { PostBuilder } from '../../commons/post-builder';\nimport { validatorPassthru } from '../../commons/validator-passthru';\nimport { DataPropertyAccessibility } from '../../models/data-property-accessibility';\nimport { FileWebsite } from '../../models/website-modifiers/file-website';\nimport { Website } from '../../website';\nimport { PhilomenaAccountData } from './models/philomena-account-data';\nimport { PhilomenaFileSubmission } from './models/philomena-file-submission';\n\n/**\n * Base abstract class for Philomena-based booru websites.\n * Philomena is an open-source imageboard platform used by sites like\n * Derpibooru, Furbooru, and others.\n */\nexport abstract class PhilomenaWebsite<\n  TFileSubmission extends PhilomenaFileSubmission = PhilomenaFileSubmission,\n>\n  extends Website<PhilomenaAccountData>\n  implements FileWebsite<TFileSubmission>\n{\n  protected abstract BASE_URL: string;\n\n  public externallyAccessibleWebsiteDataProperties: DataPropertyAccessibility<PhilomenaAccountData> =\n    {} as DataPropertyAccessibility<PhilomenaAccountData>;\n\n  /**\n   * Check if the user is logged in by looking for the logout link\n   * and extracting the username from the data-user-name attribute.\n   */\n  public async onLogin(): Promise<ILoginState> {\n    const res = await Http.get<string>(`${this.BASE_URL}`, {\n      partition: this.accountId,\n    });\n\n    if (res.body.includes('Logout')) {\n      const document = parse(res.body);\n      const usernameElement = document.querySelector('[data-user-name]');\n      const username =\n        usernameElement?.getAttribute('data-user-name') || 'Unknown';\n      return this.loginState.setLogin(true, username);\n    }\n\n    return this.loginState.setLogin(false, null);\n  }\n\n  abstract createFileModel(): TFileSubmission;\n\n  calculateImageResize(file: ISubmissionFile): ImageResizeProps {\n    return undefined;\n  }\n\n  /**\n   * Map PostyBirb ratings to Philomena rating tags.\n   */\n  protected getRating(rating: SubmissionRating): string {\n    switch (rating) {\n      case SubmissionRating.MATURE:\n        return 'questionable';\n      case SubmissionRating.ADULT:\n      case SubmissionRating.EXTREME:\n        return 'explicit';\n      case SubmissionRating.GENERAL:\n      default:\n        return 'safe';\n    }\n  }\n\n  /**\n   * Get the list of known rating tags for Philomena sites.\n   * Can be overridden by subclasses if they have different rating tags.\n   */\n  protected getKnownRatings(): string[] {\n    return [\n      'safe',\n      'suggestive',\n      'questionable',\n      'explicit',\n      'semi-grimdark',\n      'grimdark',\n      'grotesque',\n    ];\n  }\n\n  /**\n   * Ensure tags include a rating tag if one isn't already present.\n   */\n  protected tagsWithRatingTag(\n    tags: string[],\n    rating: SubmissionRating,\n  ): string[] {\n    const ratingTag = this.getRating(rating);\n    const knownRatings = this.getKnownRatings();\n    const lowerCaseTags = tags.map((t) => t.toLowerCase());\n\n    // Add rating tag if not already present\n    if (!lowerCaseTags.includes(ratingTag)) {\n      let add = true;\n\n      for (const r of knownRatings) {\n        if (lowerCaseTags.includes(r)) {\n          add = false;\n          break;\n        }\n      }\n\n      if (add) {\n        tags.push(ratingTag);\n      }\n    }\n\n    return tags;\n  }\n\n  /**\n   * Extract form fields from the upload page.\n   * Philomena sites use CSRF tokens and other hidden fields.\n   */\n  protected async getUploadFormFields(): Promise<Record<string, string>> {\n    const uploadPage = await Http.get<string>(`${this.BASE_URL}/images/new`, {\n      partition: this.accountId,\n    });\n\n    const root = parse(uploadPage.body);\n    const form = root.querySelector('#content form');\n    const inputs = form.querySelectorAll('input, textarea, select');\n\n    return inputs.reduce((acc, input) => {\n      const name = input.getAttribute('name');\n      if (name) {\n        const value = input.getAttribute('value') || input.textContent.trim();\n        acc[name] = value;\n      }\n      return acc;\n    }, {});\n  }\n\n  /**\n   * Post a file submission to a Philomena site.\n   */\n  async onPostFileSubmission(\n    postData: PostData<TFileSubmission>,\n    files: PostingFile[],\n    cancellationToken: CancellableToken,\n  ): Promise<PostResponse> {\n    const fields = await this.getUploadFormFields();\n    const { rating, tags, description } = postData.options;\n    const tagsWithRating = this.tagsWithRatingTag([...tags], rating);\n    const file = files[0];\n\n    const builder = new PostBuilder(this, cancellationToken)\n      .asMultipart()\n      .withData(fields)\n      .setField('_method', 'post')\n      .setField('image[tag_input]', tagsWithRating.join(', ').trim())\n      .addFile('image[image]', file)\n      .setField('image[description]', description || '')\n      .setField('image[source_url]', file.metadata.sourceUrls?.[0] || '');\n\n    const result = await builder.send<string>(`${this.BASE_URL}/images`);\n\n    return PostResponse.fromWebsite(this).withAdditionalInfo(result.body);\n  }\n\n  onValidateFileSubmission = validatorPassthru;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/picarto/models/picarto-account-data.ts",
    "content": "import { SelectOption } from '@postybirb/form-builder'; \n\nexport type PicartoAccountData = { \n  folders: SelectOption[];\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/picarto/models/picarto-categories.ts",
    "content": "import { SelectOption } from '@postybirb/form-builder';\n\nconst rawCategories = [\n  'Creative',\n  'Character Design',\n  'Concept Art',\n  'Animation',\n  'Abstract Drawing',\n  'Anime',\n  'Manga',\n  'Furry',\n  'Music',\n  'Comic',\n  'Coding',\n  'Creative Writing',\n  'Game Development',\n  'Sculpture',\n  'Traditional Art',\n  'Pony',\n  'Cartoon',\n  '3D Modeling',\n  '3D VFX',\n  'Realistic',\n  'Rendering',\n  'Adult',\n  'Hentai',\n  'Illustration',\n  'Pixel Art',\n  'Caricature',\n  'Crafting',\n  'Cosplay',\n  'Food & Cooking',\n  'Watercolor',\n  'Painting',\n  'Drawing',\n  'Design',\n  'Perler',\n  'Glass Work',\n  'Jewelry',\n  'Chainmaille',\n  'Figurines',\n  'Crossstitch',\n  'Vector',\n  'Voiceacting',\n  'Papercraft',\n  'Robotics',\n  'Leatherwork',\n  'Sewing',\n  'Dancing',\n  'Woodworking',\n  'Magic',\n  'Architecture',\n  'Clothes design',\n  'Storyboarding',\n  'Nature & Landscape',\n  'Video Editing',\n  'Gaming',\n  'Education',\n  'IRL',\n  'Always ON',\n  'ASMR',\n  'Podcast',\n  'Talkshow',\n];\n\nexport const PicartoCategories: SelectOption[] = rawCategories.map((v) => ({\n  label: v,\n  value: v,\n}));\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/picarto/models/picarto-file-submission.ts",
    "content": "import {\n  BooleanField,\n  DescriptionField,\n  SelectField,\n  TagField,\n} from '@postybirb/form-builder';\nimport { DescriptionType, DescriptionValue, TagValue } from '@postybirb/types';\nimport { BaseWebsiteOptions } from '../../../models/base-website-options';\nimport { PicartoCategories } from './picarto-categories';\nimport { PicartoSoftware } from './picarto-software';\n\nexport class PicartoFileSubmission extends BaseWebsiteOptions {\n  @DescriptionField({\n    descriptionType: DescriptionType.PLAINTEXT,\n  })\n  description: DescriptionValue;\n\n  @TagField({\n    minTagLength: 1,\n    maxTags: 30,\n    maxTagLength: 30,\n    spaceReplacer: '_',\n  })\n  tags: TagValue;\n\n  @SelectField({\n    label: 'visibility',\n    section: 'website',\n    span: 6,\n    options: [\n      { value: 'PUBLIC', label: 'Public' },\n      { value: 'PRIVATE', label: 'Private' },\n      { value: 'FOLLOWER_SUBSCRIBER', label: 'Followers only' },\n      { value: 'SUBSCRIBER', label: 'Subscribers only' },\n    ],\n  })\n  visibility: 'PUBLIC' | 'PRIVATE' | 'FOLLOWER_SUBSCRIBER' | 'SUBSCRIBER' =\n    'PUBLIC';\n\n  @SelectField({\n    label: 'folder',\n    section: 'website',\n    span: 6,\n    derive: [\n      {\n        key: 'folders',\n        populate: 'options',\n      },\n    ],\n    options: [],\n  })\n  folder: string;\n\n  @SelectField({\n    label: 'commentPermissions',\n    section: 'website',\n    span: 6,\n    options: [\n      { value: 'EVERYONE', label: 'Everyone' },\n      { value: 'FOLLOWERS', label: 'Followers' },\n      { value: 'SUBSCRIBERS', label: 'Subscribers' },\n      { value: 'DISABLED', label: 'Disabled' },\n    ],\n  })\n  comments: 'EVERYONE' | 'FOLLOWERS' | 'SUBSCRIBERS' | 'DISABLED' = 'EVERYONE';\n\n  @SelectField({\n    label: 'category',\n    section: 'website',\n    span: 6,\n    options: PicartoCategories,\n  })\n  category = 'Creative';\n\n  @SelectField({\n    label: 'software',\n    section: 'website',\n    span: 12,\n    allowMultiple: true,\n    options: PicartoSoftware,\n  })\n  softwares: string[] = [];\n\n  @BooleanField({\n    label: 'allowFreeDownload',\n    section: 'website',\n    span: 6,\n  })\n  downloadSource = true;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/picarto/models/picarto-software.ts",
    "content": "import { SelectOption } from '@postybirb/form-builder';\n\nconst rawSoftware = [\n  '3D Coat',\n  '3D Slash',\n  '3DVIA Shape',\n  'Ableton Live',\n  'AC3D',\n  'Acorn',\n  'Adobe After Effects',\n  'Adobe Animate',\n  'Adobe Audition',\n  'Adobe Dreamweaver',\n  'Adobe Flash',\n  'Adobe Flash Builder',\n  'Adobe Fuse',\n  'Adobe Illustrator',\n  'Adobe Indesign',\n  'Adobe Lightroom',\n  'Adobe Muse',\n  'Adobe Photoshop',\n  'Adobe Premiere Pro',\n  'Adobe Project Felix',\n  'Adobe Spark',\n  'Adobe SpeedGrade',\n  'Adobe Story Plus',\n  'Affinity Designer',\n  'Affinity Photo',\n  'Anim8or',\n  'Animation:Master',\n  'Anime Studio',\n  'ArchiCAD',\n  'ArtRage',\n  'Aseprite',\n  'AutoCAD',\n  'Autodesk 123D',\n  'Autodesk 3ds Max',\n  'Autodesk Inventor',\n  'Autodesk Maya',\n  'Autodesk Mudbox',\n  'Autodesk Revit',\n  'Autodesk Sketchbook Pro',\n  'Autodesk Softimage',\n  'AutoQ3D',\n  'Bitwig Studio',\n  'Blender',\n  'BricsCAD',\n  'BRL-CAD',\n  'Bryce',\n  'Cakewalk Sonar',\n  'Carrara',\n  'CATIA',\n  'Cheetah3D',\n  'CINEMA 4D',\n  'CinePaint',\n  'CityEngine',\n  'Clara.io',\n  'Clip Studio Paint',\n  'Cockos Reaper',\n  'Comic Studio',\n  'Corel Painter',\n  'Cubase',\n  'Daz 3D',\n  'DesignSpark Mechanical',\n  'Electric Image Animation System',\n  'Exa Corporation',\n  'Firealpaca',\n  'FL Studio',\n  'Flux',\n  'Form-Z',\n  'fragMOTION',\n  'FreeCAD',\n  'GameMaker Studio 2',\n  'GarageBand',\n  'Geomodeller3D',\n  'Gimp',\n  'HDR Light Studio (lighting softw',\n  'Hexagon',\n  'Houdini',\n  'IRONCAD',\n  'K-3D',\n  'Krita',\n  'LightWave 3D',\n  'Logic Pro',\n  'Magix Acid',\n  'Magix Fastcut',\n  'Magix Movie Edit Pro',\n  'Magix Music Studio',\n  'Magix Samplitude',\n  'Magix Sequoia',\n  'Magix SOUND FORGE',\n  'Magix Vegas',\n  'Magix Video easy',\n  'Magix Xara Page & Layout Designe',\n  'Magix Xara Photo & Graphic Desig',\n  'Makers Empire 3D',\n  'Manga Studio',\n  'MASSIVE',\n  'MATLAB',\n  'MechDesigner',\n  'MediBang Paint',\n  'Metasequoia',\n  'MikuMikuDance',\n  'Milkshape 3D',\n  'Mischief',\n  'Modo',\n  'Moi3D',\n  'MyPaint',\n  'None',\n  'Nuendo',\n  'Onshape',\n  'Open Canvas',\n  'Open CASCADE',\n  'OpenSCAD',\n  'Other',\n  'Paint 3D',\n  'Paint Tool SAI',\n  'PaintShop Pro',\n  'Paintstorm Studio',\n  'PhotoPlus',\n  'Photoscape',\n  'Pinta',\n  'Pixelmator',\n  'Poser',\n  'Pro Tools',\n  'Pro/ENGINEER',\n  'Procreate',\n  'Propellerhead Reason',\n  'Quake Army Knife',\n  'RaySupreme',\n  'Realsoft 3D',\n  'Remo 3D',\n  'Renoise',\n  'RFEM',\n  'Rhinoceros 3D',\n  'ROBLOX Studio',\n  'Sculptris',\n  'Seamless3d',\n  'Seashore',\n  'Shade 3D',\n  'Silo',\n  'Sketchbook Pro',\n  'Sketchup',\n  'Solid Edge',\n  'solidThinking',\n  'SolidWorks',\n  'Source Filmmaker',\n  'SpaceClaim',\n  'Strata 3D',\n  'Studio One',\n  'Sweet Home 3D',\n  'Swift 3D',\n  'Tayasui Sketches Pro',\n  'Tekla Structures',\n  'Toon Boom Harmony',\n  'Tracktion',\n  'TrueSpace',\n  'Unigraphics',\n  'Unity',\n  'Unreal Engine 4',\n  'Vectorworks',\n  'Wings 3D',\n  'Wolfram Mathematica',\n  'ZBrush',\n  'Zmodeler',\n  'ZW3D',\n  'ZWCAD',\n];\n\nexport const PicartoSoftware: SelectOption[] = rawSoftware.map((v) => ({\n  label: v,\n  value: v,\n}));\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/picarto/picarto.website.ts",
    "content": "import { Http } from '@postybirb/http';\nimport {\n  ILoginState,\n  ImageResizeProps,\n  ISubmissionFile,\n  PostData,\n  PostResponse,\n  SubmissionRating,\n} from '@postybirb/types';\nimport { BrowserWindowUtils } from '@postybirb/utils/electron';\nimport { calculateImageResize } from '@postybirb/utils/file-type';\nimport { mutation, query } from 'gql-query-builder';\nimport { CancellableToken } from '../../../post/models/cancellable-token';\nimport { PostingFile } from '../../../post/models/posting-file';\nimport FileSize from '../../../utils/filesize.util';\nimport { PostBuilder } from '../../commons/post-builder';\nimport { validatorPassthru } from '../../commons/validator-passthru';\nimport { UserLoginFlow } from '../../decorators/login-flow.decorator';\nimport { SupportsFiles } from '../../decorators/supports-files.decorator';\nimport { SupportsUsernameShortcut } from '../../decorators/supports-username-shortcut.decorator';\nimport { WebsiteMetadata } from '../../decorators/website-metadata.decorator';\nimport { DataPropertyAccessibility } from '../../models/data-property-accessibility';\nimport { FileWebsite } from '../../models/website-modifiers/file-website';\nimport { Website } from '../../website';\nimport { PicartoAccountData } from './models/picarto-account-data';\nimport { PicartoFileSubmission } from './models/picarto-file-submission';\n\n@WebsiteMetadata({\n  name: 'picarto',\n  displayName: 'Picarto',\n})\n@UserLoginFlow('https://picarto.tv')\n@SupportsFiles({\n  acceptedMimeTypes: ['image/png', 'image/jpeg', 'image/jpg', 'image/gif'],\n  acceptedFileSizes: {\n    '*': FileSize.megabytes(15),\n  },\n  fileBatchSize: 5, // 1 main + up to 4 variations\n})\n@SupportsUsernameShortcut({ id: 'picarto', url: 'https://picarto.tv/$1' })\nexport default class Picarto\n  extends Website<\n    PicartoAccountData,\n    { accessToken?: string; channelId?: string; username?: string }\n  >\n  implements FileWebsite<PicartoFileSubmission>\n{\n  protected BASE_URL = 'https://picarto.tv';\n\n  public externallyAccessibleWebsiteDataProperties: DataPropertyAccessibility<PicartoAccountData> =\n    {\n      folders: true,\n    };\n\n  public async onLogin(): Promise<ILoginState> {\n    // Load the site and read localStorage to find the auth payload\n    try {\n      const ls = await BrowserWindowUtils.getLocalStorage<{\n        auth?: string;\n      }>(this.accountId, this.BASE_URL, 3000);\n\n      if (!ls?.auth) {\n        return this.loginState.logout();\n      }\n\n      const auth = JSON.parse(ls.auth) as {\n        access_token: string;\n        user: { username: string; id: number; channel: { id: string } };\n      };\n\n      this.sessionData.accessToken = auth.access_token;\n      this.sessionData.channelId = auth.user.channel.id?.toString();\n      this.sessionData.username = auth.user.username;\n\n      // Populate folders\n      await this.retrieveAlbums();\n\n      return this.loginState.setLogin(true, auth.user.username);\n    } catch (e) {\n      this.logger.error('Picarto login check failed', e);\n      return this.loginState.logout();\n    }\n  }\n\n  createFileModel(): PicartoFileSubmission {\n    return new PicartoFileSubmission();\n  }\n\n  calculateImageResize(file: ISubmissionFile): ImageResizeProps {\n    return calculateImageResize(file, {\n      maxWidth: 3840,\n      maxHeight: 2160,\n      maxBytes: FileSize.megabytes(15),\n    });\n  }\n\n  async onPostFileSubmission(\n    postData: PostData<PicartoFileSubmission>,\n    files: PostingFile[],\n    cancellationToken: CancellableToken,\n  ): Promise<PostResponse> {\n    cancellationToken.throwIfCancelled();\n    const { accessToken, channelId } = this.sessionData;\n    if (!accessToken || !channelId) {\n      throw new Error('Not authenticated with Picarto');\n    }\n\n    // 1) Generate JWT token for file uploads\n    const genToken = query({\n      operation: 'generateJwtToken',\n      variables: {\n        channel_id: { value: Number(channelId), type: 'Int' },\n        channel_name: { value: null, type: 'String' },\n        user_id: { value: null, type: 'Int' },\n      },\n      fields: ['key', '__typename'],\n    });\n\n    const jwtResp = await Http.post<{\n      data: { generateJwtToken: { key: string } };\n    }>('https://ptvintern.picarto.tv/ptvapi', {\n      partition: this.accountId,\n      type: 'json',\n      data: { query: genToken.query, variables: genToken.variables },\n      headers: { Authorization: `Bearer ${accessToken}` },\n    });\n\n    PostResponse.validateBody(this, jwtResp);\n    const jwt = jwtResp.body?.data?.generateJwtToken?.key;\n    if (!jwt) {\n      throw new Error('Failed to retrieve upload token');\n    }\n\n    // 2) Upload primary image\n    const primaryUpload = await new PostBuilder(this, cancellationToken)\n      .asMultipart()\n      .withHeader('Authorization', `Bearer ${jwt}`)\n      .addFile('file_name', files[0])\n      .setField('channel_id', channelId)\n      .send<{\n        message: string;\n        error?: string;\n        data?: { uid: string };\n      }>('https://picarto.tv/images/_upload');\n\n    const mainUid = primaryUpload.body?.data?.uid;\n    if (!mainUid) {\n      return PostResponse.fromWebsite(this)\n        .withAdditionalInfo(primaryUpload.body)\n        .withException(new Error('Primary image upload failed'));\n    }\n\n    // 3) Upload up to 4 variations\n    const variationUids: string[] = [];\n    const additional = files.slice(1, 5);\n    for (const f of additional) {\n      const up = await new PostBuilder(this, cancellationToken)\n        .asMultipart()\n        .withHeader('Authorization', `Bearer ${jwt}`)\n        .addFile('file_name', f)\n        .setField('channel_id', channelId)\n        .send<{\n          message: string;\n          error?: string;\n          data?: { uid: string };\n        }>('https://picarto.tv/images/_upload');\n      const uid = up.body?.data?.uid;\n      if (uid) variationUids.push(uid);\n    }\n\n    // 4) Finish post via GraphQL createArtwork\n    const { options } = postData;\n\n    const rating = this.convertRating(options.rating);\n\n    const createArtworkGql = mutation({\n      operation: 'createArtwork',\n      variables: {\n        input: {\n          value: {\n            album_id: options.folder || null,\n            category: options.category || 'Creative',\n            comment_setting: options.comments || 'EVERYONE',\n            description: Buffer.from(options.description || '').toString(\n              'base64',\n            ),\n            download_original: options.downloadSource ?? true,\n            main_image: mainUid,\n            rating,\n            schedule_publishing_date: '',\n            schedule_publishing_time: '',\n            schedule_publishing_timezone: '',\n            software: (options.softwares || []).join(','),\n            tags: (options.tags || []).join(','),\n            title: options.title || '',\n            variations: variationUids.join(','),\n            visibility: options.visibility || 'PUBLIC',\n          },\n          type: 'CreateArtworkInput',\n        },\n      },\n      fields: ['status', 'message', 'data', '__typename'],\n    });\n\n    const finish = await Http.post<{\n      errors?: unknown[];\n      data?: { createArtwork?: { status: 'error' | 'ok'; message?: string } };\n    }>('https://ptvintern.picarto.tv/ptvapi', {\n      partition: this.accountId,\n      type: 'json',\n      headers: { Authorization: `Bearer ${accessToken}` },\n      data: {\n        query: createArtworkGql.query,\n        variables: createArtworkGql.variables,\n      },\n    });\n\n    PostResponse.validateBody(this, finish);\n    const status = finish.body?.data?.createArtwork?.status;\n    if (status === 'error' || finish.body?.errors?.length) {\n      return PostResponse.fromWebsite(this)\n        .withAdditionalInfo(finish.body)\n        .withException(\n          new Error(\n            finish.body?.data?.createArtwork?.message || 'Picarto post failed',\n          ),\n        );\n    }\n\n    return PostResponse.fromWebsite(this).withAdditionalInfo(finish.body);\n  }\n\n  onValidateFileSubmission = validatorPassthru;\n\n  private async retrieveAlbums() {\n    if (!this.sessionData.accessToken) return;\n    try {\n      const albumsQ = query({\n        operation: 'albums',\n        fields: ['id', 'title'],\n      });\n\n      const res = await Http.post<{\n        data: { albums: { id: string | null; title: string }[] };\n      }>('https://ptvintern.picarto.tv/ptvapi', {\n        partition: this.accountId,\n        type: 'json',\n        headers: { Authorization: `Bearer ${this.sessionData.accessToken}` },\n        data: { query: albumsQ.query, variables: albumsQ.variables },\n      });\n\n      const albums = res.body?.data?.albums ?? [];\n      const folders = albums.map((a) => ({ value: a.id, label: a.title }));\n      await this.websiteDataStore.setData({\n        ...this.websiteDataStore.getData(),\n        folders,\n      });\n    } catch (e) {\n      this.logger.warn('Failed to load Picarto albums', e);\n      await this.websiteDataStore.setData({\n        ...this.websiteDataStore.getData(),\n        folders: [],\n      });\n    }\n  }\n\n  private convertRating(rating: SubmissionRating): 'SFW' | 'ECCHI' | 'NSFW' {\n    switch (rating) {\n      case SubmissionRating.MATURE:\n        return 'ECCHI';\n      case SubmissionRating.ADULT:\n      case SubmissionRating.EXTREME:\n        return 'NSFW';\n      case SubmissionRating.GENERAL:\n      default:\n        return 'SFW';\n    }\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/piczel/models/piczel-account-data.ts",
    "content": "import { SelectOption } from '@postybirb/form-builder';\n\nexport type PiczelAccountData = {\n  folders: SelectOption[];\n};\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/piczel/models/piczel-file-submission.ts",
    "content": "import {\n  DescriptionField,\n  RatingField,\n  SelectField,\n} from '@postybirb/form-builder';\nimport {\n  DescriptionType,\n  DescriptionValue,\n  SubmissionRating,\n} from '@postybirb/types';\nimport { BaseWebsiteOptions } from '../../../models/base-website-options';\n\nexport class PiczelFileSubmission extends BaseWebsiteOptions {\n  @DescriptionField({\n    descriptionType: DescriptionType.MARKDOWN,\n  })\n  description: DescriptionValue;\n\n  @SelectField({\n    label: 'folder',\n    section: 'website',\n    span: 6,\n    derive: [\n      {\n        key: 'folders',\n        populate: 'options',\n      },\n    ],\n    options: [],\n  })\n  folder: string;\n\n  @RatingField({\n    options: [\n      { value: SubmissionRating.GENERAL, label: 'SFW' },\n      { value: SubmissionRating.ADULT, label: 'NSFW' },\n    ],\n  })\n  rating: SubmissionRating;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/piczel/piczel.website.ts",
    "content": "import { Http } from '@postybirb/http';\nimport {\n  ILoginState,\n  ImageResizeProps,\n  PostData,\n  PostResponse,\n  SubmissionRating,\n} from '@postybirb/types';\nimport parse from 'node-html-parser';\nimport { CancellableToken } from '../../../post/models/cancellable-token';\nimport { PostingFile } from '../../../post/models/posting-file';\nimport FileSize from '../../../utils/filesize.util';\nimport { PostBuilder } from '../../commons/post-builder';\nimport { validatorPassthru } from '../../commons/validator-passthru';\nimport { UserLoginFlow } from '../../decorators/login-flow.decorator';\nimport { SupportsFiles } from '../../decorators/supports-files.decorator';\nimport { SupportsUsernameShortcut } from '../../decorators/supports-username-shortcut.decorator';\nimport { WebsiteMetadata } from '../../decorators/website-metadata.decorator';\nimport { DataPropertyAccessibility } from '../../models/data-property-accessibility';\nimport { FileWebsite } from '../../models/website-modifiers/file-website';\nimport { Website } from '../../website';\nimport { PiczelAccountData } from './models/piczel-account-data';\nimport { PiczelFileSubmission } from './models/piczel-file-submission';\n\n@WebsiteMetadata({\n  name: 'piczel',\n  displayName: 'Piczel',\n})\n@UserLoginFlow('https://piczel.tv/login')\n@SupportsFiles({\n  acceptedMimeTypes: ['image/png', 'image/jpeg', 'image/jpg', 'image/gif'],\n  acceptedFileSizes: {\n    '*': FileSize.megabytes(10), // 10MB limit\n  },\n  fileBatchSize: 20,\n})\n@SupportsUsernameShortcut({\n  id: 'piczel',\n  url: 'https://piczel.tv/gallery/$1',\n})\nexport default class Piczel\n  extends Website<\n    PiczelAccountData,\n    {\n      preloadedData?: {\n        auth?: {\n          client: string;\n          uid: string;\n          'access-token': string;\n        };\n      };\n    }\n  >\n  implements FileWebsite<PiczelFileSubmission>\n{\n  protected BASE_URL = 'https://piczel.tv';\n\n  public externallyAccessibleWebsiteDataProperties: DataPropertyAccessibility<PiczelAccountData> =\n    {\n      folders: true,\n    };\n\n  public async onLogin(): Promise<ILoginState> {\n    const res = await Http.get<string>(`${this.BASE_URL}/gallery/upload`, {\n      partition: this.accountId,\n    });\n\n    if (res.body.includes('/signup')) {\n      return this.loginState.logout();\n    }\n\n    try {\n      const $ = parse(res.body);\n      const preloadedData = JSON.parse(\n        $.getElementById('_R_').textContent.split(\n          'window.__PRELOADED_STATE__ = ',\n        )[1],\n      );\n      const { username } = preloadedData.currentUser.data;\n      if (!username) {\n        return this.loginState.logout();\n      }\n      // Store the preloaded data in session data for authentication\n      this.sessionData.preloadedData = preloadedData;\n\n      // Fetch folders\n      await this.getFolders(username);\n\n      return this.loginState.setLogin(true, username);\n    } catch (error) {\n      return this.loginState.logout();\n    }\n  }\n\n  private async getFolders(username: string): Promise<void> {\n    try {\n      const res = await Http.get<{ id: number; name: string }[]>(\n        `${this.BASE_URL}/api/users/${username}/gallery/folders`,\n        {\n          partition: this.accountId,\n        },\n      );\n\n      const folders = res.body.map((f) => ({\n        value: f.id.toString(),\n        label: f.name,\n      }));\n\n      const currentData = this.websiteDataStore.getData();\n      await this.websiteDataStore.setData({ ...currentData, folders });\n    } catch (error) {\n      // If folders can't be fetched, store empty array\n      const currentData = this.websiteDataStore.getData();\n      await this.websiteDataStore.setData({ ...currentData, folders: [] });\n    }\n  }\n\n  createFileModel(): PiczelFileSubmission {\n    return new PiczelFileSubmission();\n  }\n\n  calculateImageResize(): ImageResizeProps | undefined {\n    return undefined;\n  }\n\n  async onPostFileSubmission(\n    postData: PostData<PiczelFileSubmission>,\n    files: PostingFile[],\n    cancellationToken: CancellableToken,\n  ): Promise<PostResponse> {\n    cancellationToken.throwIfCancelled();\n    const { preloadedData } = this.sessionData;\n    if (!preloadedData?.auth) {\n      throw new Error('No authentication data found');\n    }\n\n    const { auth } = preloadedData;\n    const { options } = postData;\n    const builder = new PostBuilder(this, cancellationToken)\n      .asJson()\n      .setField('nsfw', options.rating !== SubmissionRating.GENERAL)\n      .setField('description', options.description || '')\n      .setField('title', options.title || 'New Submission')\n      .setField('tags', options.tags)\n      .setField('uploadMode', 'PUBLISH')\n      .setField('queue', false)\n      .setField('publish_at', '')\n      .setField('thumbnail_id', '0')\n      .setField(\n        'files',\n        files.map((file) => ({\n          name: file.fileName,\n          size: file.buffer.length,\n          type: file.mimeType,\n          data: `data:${file.mimeType};base64,${file.buffer.toString('base64')}`,\n        })),\n      )\n      .setConditional('folder_id', !!options.folder, options.folder)\n      .withHeaders({\n        Accept: '*/*',\n        client: auth.client,\n        uid: auth.uid,\n        'access-token': auth['access-token'],\n      });\n\n    const result = await builder.send<{ id?: string }>(\n      `${this.BASE_URL}/api/gallery`,\n    );\n\n    if (result.body?.id) {\n      return PostResponse.fromWebsite(this).withSourceUrl(\n        `${this.BASE_URL}/gallery/image/${result.body.id}`,\n      );\n    }\n\n    return PostResponse.fromWebsite(this)\n      .withAdditionalInfo(result.body)\n      .withException(new Error('Failed to post submission'));\n  }\n\n  onValidateFileSubmission = validatorPassthru;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/pillowfort/models/pillowfort-account-data.ts",
    "content": "export type PillowfortAccountData = {\n  // No specific account data needed for now\n};"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/pillowfort/models/pillowfort-file-submission.ts",
    "content": "import {\n  BooleanField,\n  RadioField,\n  RatingField\n} from '@postybirb/form-builder';\nimport { SubmissionRating } from '@postybirb/types';\nimport { BaseWebsiteOptions } from '../../../models/base-website-options';\n\nexport class PillowfortFileSubmission extends BaseWebsiteOptions {\n  @RatingField({\n    options: [\n      { value: SubmissionRating.GENERAL, label: 'SFW' },\n      { value: SubmissionRating.ADULT, label: 'NSFW' },\n    ],\n  })\n  rating: SubmissionRating;\n\n  @RadioField({\n    label: 'visibility',\n    section: 'website',\n    span: 6,\n    defaultValue: 'public',\n    options: [\n      { value: 'public', label: 'Public' },\n      { value: 'private', label: 'Private' },\n    ],\n  })\n  privacy: string;\n\n  @BooleanField({\n    label: 'allowComments',\n    section: 'website',\n    span: 6,\n    defaultValue: true,\n  })\n  allowComments: boolean;\n\n  @BooleanField({\n    label: 'allowReblogging',\n    section: 'website',\n    span: 6,\n    defaultValue: true,\n  })\n  allowReblogging: boolean;\n\n  @BooleanField({\n    label: 'useTitle',\n    section: 'website',\n    span: 6,\n    defaultValue: true,\n  })\n  useTitle: boolean;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/pillowfort/models/pillowfort-message-submission.ts",
    "content": "import {\n  BooleanField,\n  RadioField,\n  RatingField\n} from '@postybirb/form-builder';\nimport { SubmissionRating } from '@postybirb/types';\nimport { BaseWebsiteOptions } from '../../../models/base-website-options';\n\nexport class PillowfortMessageSubmission extends BaseWebsiteOptions {\n  @RatingField({\n    options: [\n      { value: SubmissionRating.GENERAL, label: 'SFW' },\n      { value: SubmissionRating.ADULT, label: 'NSFW' },\n    ],\n  })\n  rating: SubmissionRating;\n\n  @RadioField({\n    label: 'visibility',\n    section: 'website',\n    span: 6,\n    defaultValue: 'public',\n    options: [\n      { value: 'public', label: 'Public' },\n      { value: 'private', label: 'Private' },\n    ],\n  })\n  privacy: string;\n\n  @BooleanField({\n    label: 'allowComments',\n    section: 'website',\n    span: 6,\n    defaultValue: true,\n  })\n  allowComments: boolean;\n\n  @BooleanField({\n    label: 'allowReblogging',\n    section: 'website',\n    span: 6,\n    defaultValue: true,\n  })\n  allowReblogging: boolean;\n\n  @BooleanField({\n    label: 'useTitle',\n    section: 'website',\n    span: 6,\n    defaultValue: true,\n  })\n  useTitle: boolean;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/pillowfort/pillowfort.website.ts",
    "content": "import { Http } from '@postybirb/http';\nimport {\n  ILoginState,\n  ImageResizeProps,\n  PostData,\n  PostResponse,\n  SubmissionRating,\n} from '@postybirb/types';\nimport { BrowserWindowUtils } from '@postybirb/utils/electron';\nimport { parse } from 'node-html-parser';\nimport { CancellableToken } from '../../../post/models/cancellable-token';\nimport { PostingFile } from '../../../post/models/posting-file';\nimport FileSize from '../../../utils/filesize.util';\nimport { PostBuilder } from '../../commons/post-builder';\nimport { validatorPassthru } from '../../commons/validator-passthru';\nimport { UserLoginFlow } from '../../decorators/login-flow.decorator';\nimport { SupportsFiles } from '../../decorators/supports-files.decorator';\nimport { SupportsUsernameShortcut } from '../../decorators/supports-username-shortcut.decorator';\nimport { WebsiteMetadata } from '../../decorators/website-metadata.decorator';\nimport { DataPropertyAccessibility } from '../../models/data-property-accessibility';\nimport { FileWebsite } from '../../models/website-modifiers/file-website';\nimport { MessageWebsite } from '../../models/website-modifiers/message-website';\nimport { Website } from '../../website';\nimport { PillowfortAccountData } from './models/pillowfort-account-data';\nimport { PillowfortFileSubmission } from './models/pillowfort-file-submission';\nimport { PillowfortMessageSubmission } from './models/pillowfort-message-submission';\n\n@WebsiteMetadata({\n  name: 'pillowfort',\n  displayName: 'PillowFort',\n})\n@UserLoginFlow('https://www.pillowfort.social/users/sign_in')\n@SupportsUsernameShortcut({\n  id: 'pillowfort',\n  url: 'https://www.pillowfort.social/$1',\n})\n@SupportsFiles({\n  acceptedMimeTypes: ['image/png', 'image/jpeg', 'image/jpg', 'image/gif'],\n  acceptedFileSizes: {\n    '*': FileSize.megabytes(2),\n  },\n})\nexport default class Pillowfort\n  extends Website<PillowfortAccountData>\n  implements\n    FileWebsite<PillowfortFileSubmission>,\n    MessageWebsite<PillowfortMessageSubmission>\n{\n  protected BASE_URL = 'https://www.pillowfort.social';\n\n  public externallyAccessibleWebsiteDataProperties: DataPropertyAccessibility<PillowfortAccountData> =\n    {};\n\n  public async onLogin(): Promise<ILoginState> {\n    try {\n      const res = await Http.get<string>(this.BASE_URL, {\n        partition: this.accountId,\n      });\n\n      await BrowserWindowUtils.getLocalStorage(this.accountId, this.BASE_URL);\n\n      if (res.body.includes('/signout')) {\n        const html = parse(res.body);\n        const username =\n          html\n            .querySelector('option[value=\"current_user\"]')\n            ?.innerText.trim() || 'Unknown';\n        return this.loginState.setLogin(true, username);\n      }\n\n      return this.loginState.logout();\n    } catch (e) {\n      this.logger.error('Failed to login', e);\n      return this.loginState.logout();\n    }\n  }\n\n  createFileModel(): PillowfortFileSubmission {\n    return new PillowfortFileSubmission();\n  }\n\n  calculateImageResize(): ImageResizeProps {\n    // PillowFort max file size is 2MB, we'll use default resizing logic\n    return undefined;\n  }\n\n  async onPostFileSubmission(\n    postData: PostData<PillowfortFileSubmission>,\n    files: PostingFile[],\n    cancellationToken: CancellableToken,\n  ): Promise<PostResponse> {\n    try {\n      // Get form page and CSRF token\n      const page = await Http.get<string>(`${this.BASE_URL}/posts/new`, {\n        partition: this.accountId,\n      });\n\n      // Extract CSRF token\n      const html = parse(page.body);\n      const authToken = html\n        .querySelector('input[name=\"authenticity_token\"]')\n        ?.getAttribute('value');\n\n      if (!authToken) {\n        return PostResponse.fromWebsite(this)\n          .withMessage('Failed to extract CSRF token')\n          .withAdditionalInfo({ authToken });\n      }\n\n      // Upload each image first\n      const uploadedImages: Array<{ full_image: string; small_image: string }> =\n        [];\n      for (const file of files) {\n        cancellationToken.throwIfCancelled();\n\n        // Upload the image\n        const upload = await Http.post<{\n          full_image: string;\n          small_image: string;\n        }>(`${this.BASE_URL}/image_upload`, {\n          partition: this.accountId,\n          type: 'multipart',\n          data: {\n            file_name: file.fileName,\n            photo: file.toPostFormat(),\n          },\n          headers: {\n            'X-CSRF-Token': authToken,\n          },\n        });\n\n        if (!upload.body?.full_image) {\n          return PostResponse.fromWebsite(this)\n            .withMessage('Failed to upload image')\n            .withAdditionalInfo(upload.body);\n        }\n\n        uploadedImages.push(upload.body);\n      }\n\n      const builder = new PostBuilder(this, cancellationToken)\n        .asMultipart()\n        .setField('authenticity_token', authToken)\n        .setField('utf8', '✓')\n        .setField('post_to', 'current_user')\n        .setField('post_type', 'picture')\n        .setField(\n          'title',\n          postData.options.useTitle ? postData.options.title : '',\n        )\n        .setField('content', `<p>${postData.options.description}</p>`)\n        .setField('privacy', postData.options.privacy)\n        .setField('tags', postData.options.tags.join(', '))\n        .setField('commit', 'Submit')\n        .setConditional('rebloggable', postData.options.allowReblogging, 'on')\n        .setConditional('commentable', postData.options.allowComments, 'on')\n        .setField(\n          'picture[][pic_url]',\n          uploadedImages.map((upload) => upload.full_image),\n        )\n        .setField(\n          'picture[][small_image_url]',\n          uploadedImages.map((upload) => upload.small_image),\n        )\n        .setField('picture[][b2_lg_url]', '')\n        .setField('picture[][b2_sm_url]', '')\n        .setField(\n          'picture[][row]',\n          uploadedImages.map((_, i) => `${i + 1}`),\n        )\n        .setField('picture[][col]', '0');\n\n      // Submit the post\n      const post = await builder.send<string>(`${this.BASE_URL}/posts/create`);\n\n      if (post.statusCode === 200) {\n        return PostResponse.fromWebsite(this);\n      }\n\n      return PostResponse.fromWebsite(this)\n        .withMessage('Failed to post submission')\n        .withAdditionalInfo(post.body);\n    } catch (e) {\n      this.logger.error('Failed to post submission', e);\n      return PostResponse.fromWebsite(this)\n        .withMessage(e.message)\n        .withException(e);\n    }\n  }\n\n  onValidateFileSubmission = validatorPassthru;\n\n  createMessageModel(): PillowfortMessageSubmission {\n    return new PillowfortMessageSubmission();\n  }\n\n  async onPostMessageSubmission(\n    postData: PostData<PillowfortMessageSubmission>,\n    cancellationToken: CancellableToken,\n  ): Promise<PostResponse> {\n    try {\n      // Get form page and CSRF token\n      const page = await Http.get<string>(`${this.BASE_URL}/posts/new`, {\n        partition: this.accountId,\n      });\n\n      // Extract CSRF token\n      const html = parse(page.body);\n      const authToken = html\n        .querySelector('input[name=\"authenticity_token\"]')\n        ?.getAttribute('value');\n\n      if (!authToken) {\n        return PostResponse.fromWebsite(this)\n          .withMessage('Failed to extract CSRF token')\n          .withAdditionalInfo({ authToken });\n      }\n\n      const builder = new PostBuilder(this, cancellationToken)\n        .asMultipart()\n        .setField('authenticity_token', authToken)\n        .setField('utf8', '✓')\n        .setField('post_to', 'current_user')\n        .setField('post_type', 'text')\n        .setField(\n          'title',\n          postData.options.useTitle ? postData.options.title : '',\n        )\n        .setField('content', `<p>${postData.options.description}</p>`)\n        .setField('privacy', postData.options.privacy)\n        .setField('tags', postData.options.tags.join(', '))\n        .setField('commit', 'Submit')\n        .setConditional('rebloggable', postData.options.allowReblogging, 'on')\n        .setConditional('commentable', postData.options.allowComments, 'on')\n        .setConditional(\n          'nsfw',\n          postData.options.rating !== SubmissionRating.GENERAL,\n          'on',\n        );\n\n      // Submit the post\n      const post = await builder.send<string>(`${this.BASE_URL}/posts/create`);\n\n      if (post.statusCode === 200) {\n        return PostResponse.fromWebsite(this);\n      }\n\n      return PostResponse.fromWebsite(this)\n        .withMessage('Failed to post submission')\n        .withAdditionalInfo(post.body);\n    } catch (e) {\n      this.logger.error('Failed to post submission', e);\n      return PostResponse.fromWebsite(this)\n        .withMessage(e.message)\n        .withException(e);\n    }\n  }\n\n  onValidateMessageSubmission = validatorPassthru;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/pixelfed/pixelfed.website.ts",
    "content": "import { FileType } from '@postybirb/types';\nimport FileSize from '../../../utils/filesize.util';\nimport { DisableAds } from '../../decorators/disable-ads.decorator';\nimport { CustomLoginFlow } from '../../decorators/login-flow.decorator';\nimport { SupportsFiles } from '../../decorators/supports-files.decorator';\nimport { WebsiteMetadata } from '../../decorators/website-metadata.decorator';\nimport { MegalodonWebsite } from '../megalodon/megalodon.website';\n\n@WebsiteMetadata({\n  name: 'pixelfed',\n  displayName: 'Pixelfed',\n})\n@CustomLoginFlow()\n@SupportsFiles({\n  acceptedMimeTypes: [\n    'image/png',\n    'image/jpeg',\n    'image/gif',\n    'application/x-shockwave-flash',\n    'video/x-flv',\n    'video/mp4',\n  ],\n  acceptedFileSizes: {\n    [FileType.IMAGE]: FileSize.megabytes(16),\n    [FileType.AUDIO]: FileSize.megabytes(100),\n    [FileType.VIDEO]: FileSize.megabytes(200),\n  },\n  fileBatchSize: 4,\n})\n@DisableAds()\nexport default class Pixelfed extends MegalodonWebsite {\n  protected getMegalodonInstanceType(): 'pixelfed' {\n    return 'pixelfed';\n  }\n\n  protected getDefaultMaxDescriptionLength(): number {\n    return 500;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/pixiv/models/pixiv-account-data.ts",
    "content": "// eslint-disable-next-line @typescript-eslint/ban-types\nexport type PixivAccountData = {};\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/pixiv/models/pixiv-file-submission.ts",
    "content": "import {\n  BooleanField,\n  DescriptionField,\n  RatingField,\n  SelectField,\n  TagField,\n  TitleField,\n} from '@postybirb/form-builder';\nimport {\n  DefaultDescriptionValue,\n  DefaultTagValue,\n  DescriptionType,\n  DescriptionValue,\n  SubmissionRating,\n  TagValue,\n} from '@postybirb/types';\nimport { BaseWebsiteOptions } from '../../../models/base-website-options';\n\nexport class PixivFileSubmission extends BaseWebsiteOptions {\n  @TitleField({\n    maxLength: 32,\n  })\n  title: string;\n\n  @DescriptionField({\n    descriptionType: DescriptionType.PLAINTEXT,\n  })\n  description: DescriptionValue = DefaultDescriptionValue();\n\n  @TagField({\n    maxTags: 10,\n  })\n  tags: TagValue = DefaultTagValue();\n\n  @RatingField({\n    options: [\n      { value: SubmissionRating.GENERAL, label: 'All ages' },\n      { value: SubmissionRating.MATURE, label: 'R18' },\n      { value: SubmissionRating.EXTREME, label: 'R-18G' },\n    ],\n  })\n  rating: SubmissionRating;\n\n  @SelectField({\n    label: 'matureContent',\n    options: [\n      { value: 'yuri', label: 'Yuri' },\n      { value: 'bl', label: 'BL' },\n      { value: 'furry', label: 'Furry' },\n      { value: 'lo', label: 'Lo' },\n    ],\n    allowMultiple: true,\n    showWhen: [\n      [\n        'rating',\n        [\n          SubmissionRating.MATURE,\n          SubmissionRating.ADULT,\n          SubmissionRating.EXTREME,\n        ],\n      ],\n    ],\n    section: 'website',\n    span: 6,\n  })\n  matureContent: string[];\n\n  @SelectField({\n    label: 'containsContent',\n    options: [\n      { value: 'violent', label: 'Violence' },\n      { value: 'drug', label: 'References to drugs, alcohol, and smoking' },\n      { value: 'thoughts', label: 'Strong language/Sensitive themes' },\n      { value: 'antisocial', label: 'Depictions of criminal activity' },\n      { value: 'religion', label: 'Religion' },\n    ],\n    allowMultiple: true,\n    section: 'website',\n    span: 6,\n  })\n  containsContent: string[];\n\n  @BooleanField({\n    label: 'allowCommunityTags',\n    defaultValue: true,\n    section: 'website',\n    span: 6,\n  })\n  communityTags: boolean;\n\n  @BooleanField({\n    label: 'originalWork',\n    defaultValue: true,\n    section: 'website',\n    span: 6,\n  })\n  original: boolean;\n\n  @BooleanField({\n    label: 'hasSexualContent',\n    defaultValue: false,\n    showWhen: [['rating', [SubmissionRating.GENERAL]]],\n    section: 'website',\n    span: 6,\n  })\n  sexual: boolean;\n\n  @BooleanField({\n    label: 'aIGenerated',\n    required: true,\n    defaultValue: false,\n    section: 'website',\n    span: 6,\n  })\n  aiGenerated: boolean;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/pixiv/pixiv.website.ts",
    "content": "import { Http } from '@postybirb/http';\nimport {\n  FileType,\n  ILoginState,\n  ImageResizeProps,\n  PostData,\n  PostResponse,\n  SubmissionRating,\n} from '@postybirb/types';\nimport parse from 'node-html-parser';\nimport { CancellableToken } from '../../../post/models/cancellable-token';\nimport { PostingFile } from '../../../post/models/posting-file';\nimport FileSize from '../../../utils/filesize.util';\nimport { PostBuilder } from '../../commons/post-builder';\nimport { validatorPassthru } from '../../commons/validator-passthru';\nimport { UserLoginFlow } from '../../decorators/login-flow.decorator';\nimport { SupportsFiles } from '../../decorators/supports-files.decorator';\nimport { WebsiteMetadata } from '../../decorators/website-metadata.decorator';\nimport { DataPropertyAccessibility } from '../../models/data-property-accessibility';\nimport { FileWebsite } from '../../models/website-modifiers/file-website';\nimport { Website } from '../../website';\nimport { PixivAccountData } from './models/pixiv-account-data';\nimport { PixivFileSubmission } from './models/pixiv-file-submission';\n\n@WebsiteMetadata({\n  name: 'pixiv',\n  displayName: 'Pixiv',\n  minimumPostWaitInterval: 60000 * 5, // 5 minutes between posts\n})\n@UserLoginFlow('https://www.pixiv.net')\n@SupportsFiles({\n  acceptedMimeTypes: ['image/png', 'image/jpeg', 'image/jpg', 'image/gif'],\n  acceptedFileSizes: {\n    [FileType.IMAGE]: FileSize.megabytes(32), // Image limit is 32MB\n  },\n})\nexport default class Pixiv\n  extends Website<PixivAccountData>\n  implements FileWebsite<PixivFileSubmission>\n{\n  protected BASE_URL = 'https://www.pixiv.net';\n\n  public externallyAccessibleWebsiteDataProperties: DataPropertyAccessibility<PixivAccountData> =\n    {};\n\n  public async onLogin(): Promise<ILoginState> {\n    const res = await Http.get<string>(this.BASE_URL, {\n      partition: this.accountId,\n    });\n\n    const isLoggedIn = !res.body.includes('signup-form');\n    if (isLoggedIn) {\n      const $ = parse(res.body);\n      let username = '';\n      try {\n        const data = $.querySelector('#__NEXT_DATA__')?.textContent;\n        if (!data) {\n          this.logger.warn(\n            'Failed to find #__NEXT_DATA__ element during login',\n          );\n          return this.loginState.setLogin(true, 'Logged In');\n        }\n        username = JSON.parse(\n          JSON.parse(data).props.pageProps.serverSerializedPreloadedState,\n        ).userData.self.pixivId;\n      } catch (error) {\n        this.logger.warn('Failed to parse username from login response');\n      }\n      return this.loginState.setLogin(true, username || 'Logged In');\n    }\n\n    return this.loginState.logout();\n  }\n\n  createFileModel(): PixivFileSubmission {\n    return new PixivFileSubmission();\n  }\n\n  calculateImageResize(): ImageResizeProps {\n    return undefined;\n  }\n\n  async onPostFileSubmission(\n    postData: PostData<PixivFileSubmission>,\n    files: PostingFile[],\n    cancellationToken: CancellableToken,\n  ): Promise<PostResponse> {\n    cancellationToken.throwIfCancelled();\n\n    // Get the create page to check for version and get tokens\n    const page = await Http.get<string>(\n      `${this.BASE_URL}/illustration/create`,\n      {\n        partition: this.accountId,\n      },\n    );\n\n    const $ = parse(page.body);\n    const nextData = $.querySelector('#__NEXT_DATA__')?.textContent;\n    if (!nextData) {\n      return PostResponse.fromWebsite(this)\n        .withException(new Error('Failed to find #__NEXT_DATA__ element'))\n        .withAdditionalInfo(page.body);\n    }\n    const accountInfo = JSON.parse(nextData);\n    const { token } = JSON.parse(\n      accountInfo.props.pageProps.serverSerializedPreloadedState,\n    ).api;\n\n    const { options } = postData;\n    const postFiles = files.map((file) => file.toPostFormat());\n\n    const contentRating = this.getContentRating(options.rating);\n    const builder = new PostBuilder(this, cancellationToken)\n      .asMultipart()\n      .setField('title', options.title.substring(0, 32))\n      .setField('caption', options.description)\n      .setField('tags[]', options.tags)\n      .setField('allowTagEdit', options.communityTags)\n      .setField('xRestrict', contentRating)\n      .setField('sexual', options.sexual)\n      .setConditional(\n        'aiType',\n        options.aiGenerated,\n        'aiGenerated',\n        'notAiGenerated',\n      )\n      .setField('restrict', 'public')\n      .setField('responseAutoAccept', 'false')\n      .setField('suggestedtags[]', '')\n      .setField('original', options.original)\n      .setField('ratings[violent]', 'false')\n      .setField('ratings[drug]', 'false')\n      .setField('ratings[thoughts]', 'false')\n      .setField('ratings[antisocial]', 'false')\n      .setField('ratings[religion]', 'false')\n      .setField('attributes[yuri]', 'false')\n      .setField('attributes[bl]', 'false')\n      .setField('attributes[furry]', 'false')\n      .setField('attributes[lo]', 'false')\n      .setField('tweet', 'false')\n      .setField('allowComment', 'true')\n      .setField('titleTranslations[en]', '')\n      .setField('captionTranslations[en]', '')\n      .addFiles('files[]', files)\n      .forEach(postFiles, (_, index, b) => {\n        b.setField(`imageOrder[${index}][type]`, 'newFile');\n        b.setField(`imageOrder[${index}][fileKey]`, `${index}`);\n      })\n      .forEach(options.containsContent, (content, _, b) => {\n        b.setField(`ratings[${content}]`, 'true');\n      })\n      .whenTrue(contentRating !== 'general', (b) => {\n        b.removeField('sexual');\n        b.forEach(options.matureContent, (c, _, innerBuilder) => {\n          innerBuilder.setField(`attributes[${c}]`, 'true');\n        });\n      });\n\n    try {\n      const post = await builder.withHeader('x-csrf-token', token).send<{\n        error: string;\n      }>(`${this.BASE_URL}/ajax/work/create/illustration`);\n\n      if (!post.body.error) {\n        return PostResponse.fromWebsite(this);\n      }\n\n      return PostResponse.fromWebsite(this)\n        .withAdditionalInfo(post)\n        .withException(\n          new Error(\n            post.body.error ? JSON.stringify(post.body.error) : 'Unknown error',\n          ),\n        );\n    } catch (error) {\n      return PostResponse.fromWebsite(this).withException(\n        error instanceof Error ? error : new Error(JSON.stringify(error)),\n      );\n    }\n  }\n\n  private getContentRating(rating: SubmissionRating) {\n    switch (rating) {\n      case SubmissionRating.ADULT:\n      case SubmissionRating.MATURE:\n        return 'r18';\n      case SubmissionRating.EXTREME:\n        return 'r18g';\n      case SubmissionRating.GENERAL:\n      default:\n        return 'general';\n    }\n  }\n\n  onValidateFileSubmission = validatorPassthru;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/pleroma/pleroma.website.ts",
    "content": "import { FileType } from '@postybirb/types';\nimport FileSize from '../../../utils/filesize.util';\nimport { DisableAds } from '../../decorators/disable-ads.decorator';\nimport { CustomLoginFlow } from '../../decorators/login-flow.decorator';\nimport { SupportsFiles } from '../../decorators/supports-files.decorator';\nimport { WebsiteMetadata } from '../../decorators/website-metadata.decorator';\nimport { MegalodonWebsite } from '../megalodon/megalodon.website';\n\n@WebsiteMetadata({\n  name: 'pleroma',\n  displayName: 'Pleroma',\n})\n@CustomLoginFlow()\n@SupportsFiles({\n  acceptedMimeTypes: [\n    'image/png',\n    'image/jpeg',\n    'image/gif',\n    'application/x-shockwave-flash',\n    'video/x-flv',\n    'video/mp4',\n    'application/msword',\n    'application/rtf',\n    'text/plain',\n    'audio/mpeg',\n  ],\n  acceptedFileSizes: {\n    [FileType.IMAGE]: FileSize.megabytes(16),\n    [FileType.AUDIO]: FileSize.megabytes(100),\n    [FileType.VIDEO]: FileSize.megabytes(200),\n  },\n  fileBatchSize: 4,\n})\n@DisableAds()\nexport default class Pleroma extends MegalodonWebsite {\n  protected getMegalodonInstanceType(): 'pleroma' {\n    return 'pleroma';\n  }\n\n  protected getDefaultMaxDescriptionLength(): number {\n    return 5000;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/provider.ts",
    "content": "import { Provider } from '@nestjs/common';\nimport {\n  IsDevelopmentEnvironment,\n  IsTestEnvironment,\n} from '@postybirb/utils/electron';\nimport { Class } from 'type-fest';\nimport { WEBSITE_IMPLEMENTATIONS } from '../../constants';\nimport { UnknownWebsite } from '../website';\nimport * as Websites from './index';\nimport TestWebsite from './test/test.website';\n\nconst websiteArray = Object.values(Websites);\n\nif (IsDevelopmentEnvironment() || IsTestEnvironment()) {\n  (websiteArray as unknown as unknown[]).push(TestWebsite);\n}\n\nexport const WebsiteImplProvider: Provider<Class<UnknownWebsite>[]> = {\n  provide: WEBSITE_IMPLEMENTATIONS,\n  useValue: websiteArray as unknown as Class<UnknownWebsite>[],\n};\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/sofurry/models/sofurry-account-data.ts",
    "content": "import { SelectOption } from '@postybirb/form-builder';\n\nexport type SofurryAccountData = {\n  folders: SelectOption[];\n  csrfToken?: string;\n};\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/sofurry/models/sofurry-categories.ts",
    "content": "import { SelectOption } from '@postybirb/form-builder';\nimport { FileType } from '@postybirb/types';\n\n/**\n * SoFurry category and type definitions based on API limits.\n * Each category has specific allowed types and file extensions.\n * Organized by FileType for discriminator-based filtering.\n */\n\n/**\n * Categories organized by file type for form discriminator.\n */\nexport const SofurryCategoriesByFileType: Record<FileType, SelectOption[]> = {\n  [FileType.IMAGE]: [\n    { value: '10', label: 'Artwork' },\n    { value: '30', label: 'Photography' },\n  ],\n  [FileType.TEXT]: [{ value: '20', label: 'Writing' }],\n  [FileType.VIDEO]: [\n    { value: '10', label: 'Artwork' }, // For animations\n    { value: '50', label: 'Video' },\n  ],\n  [FileType.AUDIO]: [{ value: '40', label: 'Music' }],\n  [FileType.UNKNOWN]: [\n    { value: '10', label: 'Artwork' },\n    { value: '60', label: '3D' },\n  ],\n};\n\n/**\n * Types organized by file type for form discriminator.\n */\nexport const SofurryTypesByFileType: Record<FileType, SelectOption[]> = {\n  [FileType.IMAGE]: [\n    {\n      label: 'Artwork',\n      items: [\n        { value: '11', label: 'Drawing' },\n        { value: '12', label: 'Comic' },\n        { value: '19', label: 'Other' },\n      ],\n    },\n    {\n      label: 'Photography',\n      items: [\n        { value: '31', label: 'Photograph' },\n        { value: '32', label: 'Album' },\n        { value: '39', label: 'Other' },\n      ],\n    },\n  ],\n  [FileType.TEXT]: [\n    {\n      label: 'Writing',\n      items: [\n        { value: '21', label: 'Short Story' },\n        { value: '22', label: 'Book' },\n        { value: '29', label: 'Other' },\n      ],\n    },\n  ],\n  [FileType.VIDEO]: [\n    {\n      label: 'Artwork',\n      items: [\n        { value: '13', label: 'Animation' },\n        { value: '19', label: 'Other' },\n      ],\n    },\n    { label: 'Video', items: [{ value: '59', label: 'Other' }] },\n  ],\n  [FileType.AUDIO]: [\n    {\n      label: 'Music',\n      items: [\n        { value: '41', label: 'Track' },\n        { value: '42', label: 'Album' },\n        { value: '49', label: 'Other' },\n      ],\n    },\n  ],\n  [FileType.UNKNOWN]: [\n    {\n      label: 'Artwork',\n      items: [\n        { value: '11', label: 'Drawing' },\n        { value: '12', label: 'Comic' },\n        { value: '13', label: 'Animation' },\n        { value: '19', label: 'Other' },\n      ],\n    },\n    { label: '3D', items: [{ value: '61', label: '3D Model' }] },\n  ],\n};\n\n/**\n * Privacy options for submissions.\n */\nexport const SofurryPrivacyOptions: SelectOption[] = [\n  { value: '3', label: 'Public' },\n  { value: '2', label: 'Unlisted' },\n  { value: '1', label: 'Private' },\n];\n\n/**\n * Get the category ID from a type ID.\n */\nexport function getCategoryFromType(typeId: string): string {\n  const typeNum = parseInt(typeId, 10);\n  return String(Math.floor(typeNum / 10) * 10);\n}\n\n/**\n * Get the default type for a category.\n */\nexport function getDefaultTypeForCategory(categoryId: string): string {\n  const categoryNum = parseInt(categoryId, 10);\n  // Default to first type in category (e.g., 10 -> 11, 20 -> 21)\n  return String(categoryNum + 1);\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/sofurry/models/sofurry-file-submission.ts",
    "content": "import {\n  BooleanField,\n  DescriptionField,\n  RatingField,\n  SelectField,\n  TagField,\n} from '@postybirb/form-builder';\nimport {\n  DescriptionType,\n  DescriptionValue,\n  SubmissionRating,\n  TagValue,\n} from '@postybirb/types';\nimport { BaseWebsiteOptions } from '../../../models/base-website-options';\nimport { SofurryAccountData } from './sofurry-account-data';\nimport {\n  SofurryCategoriesByFileType,\n  SofurryPrivacyOptions,\n  SofurryTypesByFileType,\n} from './sofurry-categories';\n\nexport class SofurryFileSubmission extends BaseWebsiteOptions {\n  @DescriptionField({\n    descriptionType: DescriptionType.PLAINTEXT,\n  })\n  description: DescriptionValue;\n\n  @TagField({\n    minTags: 2,\n  })\n  tags: TagValue;\n\n  @RatingField({\n    options: [\n      { value: SubmissionRating.GENERAL, label: 'Clean' },\n      { value: SubmissionRating.ADULT, label: 'Mature' },\n      { value: SubmissionRating.EXTREME, label: 'Adult' },\n    ],\n  })\n  rating: SubmissionRating;\n\n  @SelectField({\n    label: 'category',\n    section: 'website',\n    span: 6,\n    options: {\n      options: SofurryCategoriesByFileType,\n      discriminator: 'overallFileType',\n    },\n  })\n  category: string;\n\n  @SelectField({\n    label: { untranslated: 'Type' },\n    section: 'website',\n    span: 6,\n    options: {\n      options: SofurryTypesByFileType,\n      discriminator: 'overallFileType',\n    },\n  })\n  type: string;\n\n  @SelectField({\n    label: { untranslated: 'Privacy' },\n    defaultValue: '3',\n    options: SofurryPrivacyOptions,\n    section: 'website',\n    span: 6,\n  })\n  privacy: string;\n\n  @SelectField<SofurryAccountData>({\n    label: 'folder',\n    defaultValue: '0',\n    options: [],\n    derive: [\n      {\n        key: 'folders',\n        populate: 'options',\n      },\n    ],\n    section: 'website',\n    span: 6,\n  })\n  folder: string;\n\n  @BooleanField({\n    label: 'allowComments',\n    defaultValue: true,\n    section: 'website',\n    span: 6,\n  })\n  allowComments: boolean;\n\n  @BooleanField({\n    label: 'allowFreeDownload',\n    defaultValue: true,\n    section: 'website',\n    span: 6,\n  })\n  allowDownloads: boolean;\n\n  @BooleanField({\n    label: 'intendedAsAdvertisement',\n    defaultValue: false,\n    section: 'website',\n    span: 6,\n  })\n  intendedAsAdvertisement: boolean;\n\n  @BooleanField({\n    label: 'markAsWorkInProgress',\n    defaultValue: false,\n    section: 'website',\n    span: 6,\n  })\n  markAsWorkInProgress: boolean;\n\n  @BooleanField({\n    label: 'pixelPerfectDisplay',\n    defaultValue: false,\n    section: 'website',\n    span: 6,\n  })\n  pixelPerfectDisplay: boolean;\n\n  processTag(tag: string): string {\n    return tag.replace(/_/g, ' ');\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/sofurry/sofurry.website.ts",
    "content": "import { SelectOption } from '@postybirb/form-builder';\nimport { Http } from '@postybirb/http';\nimport {\n  FileType,\n  ILoginState,\n  ImageResizeProps,\n  IPostResponse,\n  PostData,\n  PostResponse,\n  SubmissionRating,\n} from '@postybirb/types';\nimport { CancellableToken } from '../../../post/models/cancellable-token';\nimport { PostingFile } from '../../../post/models/posting-file';\nimport { PostBuilder } from '../../commons/post-builder';\nimport { validatorPassthru } from '../../commons/validator-passthru';\nimport { UserLoginFlow } from '../../decorators/login-flow.decorator';\nimport { SupportsFiles } from '../../decorators/supports-files.decorator';\nimport { SupportsUsernameShortcut } from '../../decorators/supports-username-shortcut.decorator';\nimport { WebsiteMetadata } from '../../decorators/website-metadata.decorator';\nimport { DataPropertyAccessibility } from '../../models/data-property-accessibility';\nimport { FileWebsite } from '../../models/website-modifiers/file-website';\nimport { Website } from '../../website';\nimport { SofurryAccountData } from './models/sofurry-account-data';\nimport { SofurryFileSubmission } from './models/sofurry-file-submission';\n\ninterface SofurrySubmissionResponse {\n  id: string;\n  title: string;\n  description: string | null;\n  author: string;\n  category: number;\n  type: number;\n  rating: number | null;\n  status: number;\n  privacy: string | null;\n  content: string[];\n  allowComments: boolean | null;\n  allowDownloads: boolean | null;\n  isWorkInProgress: boolean | null;\n  isAdvert: boolean | null;\n  optimize: boolean | null;\n  pixelPerfect: boolean | null;\n  onHold: boolean;\n  onHoldReason: string | null;\n  inReview: boolean | null;\n  thumbUrl: string;\n  coverUrl: string | null;\n  artistTags: string[];\n  publishedAt: string | null;\n  importedFrom: string | null;\n  buyAtVendor: string | null;\n  buyAtUrl: string | null;\n  customDownloadUrl: string | null;\n  folders: string[];\n}\n\ninterface SoFurryFileUploadResponse {\n  contentId: string;\n  title: string;\n  description: string;\n  body: {\n    extension: string;\n    displayUrl: string;\n  };\n  position: number;\n  type: string;\n}\n\ninterface SoFurryThumbnailUploadResponse {\n  url: string;\n}\n\n@WebsiteMetadata({\n  name: 'sofurry',\n  displayName: 'SoFurry',\n})\n@UserLoginFlow('https://sofurry.com/login')\n@SupportsUsernameShortcut({\n  id: 'sofurry',\n  url: 'https://sofurry.com/u/$1',\n})\n@SupportsFiles({\n  fileBatchSize: 10, // A guess\n  acceptedMimeTypes: [\n    'image/png',\n    'image/jpeg',\n    'image/jpg',\n    'image/gif',\n    'text/plain',\n    'text/html',\n    'audio/mp3',\n    'audio/mpeg',\n    'audio/ogg',\n    'video/mp4',\n    'video/webm',\n    'model/gltf-binary',\n    'model/gltf+json',\n  ],\n  acceptedFileSizes: {\n    '*': 512000,\n  },\n})\nexport default class Sofurry\n  extends Website<SofurryAccountData>\n  implements FileWebsite<SofurryFileSubmission>\n{\n  protected BASE_URL = 'https://sofurry.com';\n\n  public externallyAccessibleWebsiteDataProperties: DataPropertyAccessibility<SofurryAccountData> =\n    {\n      folders: true,\n    };\n\n  public async onLogin(): Promise<ILoginState> {\n    try {\n      const res = await Http.get<string>(this.BASE_URL, {\n        partition: this.accountId,\n      });\n\n      // Check for logged in user via window.handle pattern\n      const handleMatch = res.body.match(/window\\.handle = \"(.*)\"/);\n      if (handleMatch && handleMatch[1] !== 'null' && handleMatch[1]) {\n        const username = handleMatch[1];\n\n        // Extract CSRF token from meta tag\n        const csrfMatch = res.body.match(\n          /<meta name=\"csrf-token\" content=\"([^\"]+)\"/,\n        );\n        const csrfToken = csrfMatch ? csrfMatch[1] : undefined;\n        if (csrfToken) {\n          // Fetch folders\n          await this.getFolders(csrfToken);\n          return this.loginState.setLogin(true, username);\n        }\n      }\n\n      return this.loginState.setLogin(false, null);\n    } catch (e) {\n      this.logger.error('Failed to login', e);\n      return this.loginState.setLogin(false, null);\n    }\n  }\n\n  /**\n   * Fetch a fresh CSRF token from SoFurry.\n   */\n  private async fetchCsrfToken(): Promise<string | undefined> {\n    const res = await Http.get<string>(this.BASE_URL, {\n      partition: this.accountId,\n    });\n\n    const csrfMatch = res.body.match(\n      /<meta name=\"csrf-token\" content=\"([^\"]+)\"/,\n    );\n    return csrfMatch ? csrfMatch[1] : undefined;\n  }\n\n  /**\n   * Fetch user folders from SoFurry.\n   */\n  private async getFolders(csrfToken: string): Promise<void> {\n    const res = await Http.get<[{ id: string; name: string }]>(\n      `${this.BASE_URL}/ui/folders`,\n      {\n        partition: this.accountId,\n        headers: {\n          'x-csrf-token': csrfToken,\n        },\n      },\n    );\n\n    const { body } = res;\n    const folders: SelectOption[] = [];\n    if (Array.isArray(body)) {\n      body.forEach((folder) => {\n        folders.push({\n          label: folder.name,\n          value: folder.id,\n        });\n      });\n    }\n\n    this.setWebsiteData({\n      folders,\n    });\n  }\n\n  createFileModel(): SofurryFileSubmission {\n    return new SofurryFileSubmission();\n  }\n\n  calculateImageResize(): ImageResizeProps {\n    return undefined;\n  }\n\n  private getRating(rating: SubmissionRating): number {\n    switch (rating) {\n      case SubmissionRating.EXTREME:\n      case SubmissionRating.ADULT:\n        return 20; // Adult\n      case SubmissionRating.MATURE:\n        return 10; // Mature\n      case SubmissionRating.GENERAL:\n      default:\n        return 0; // Clean\n    }\n  }\n\n  private getDefaultCategoryAndType(fileType: FileType): {\n    category: number;\n    type: number;\n  } {\n    switch (fileType) {\n      case FileType.AUDIO:\n        return { category: 40, type: 41 }; // Music -> Track\n      case FileType.TEXT:\n        return { category: 20, type: 21 }; // Writing -> Short Story\n      case FileType.VIDEO:\n        return { category: 50, type: 59 }; // Video -> Other\n      case FileType.IMAGE:\n      default:\n        return { category: 10, type: 11 }; // Artwork -> Drawing\n    }\n  }\n\n  async onPostFileSubmission(\n    postData: PostData<SofurryFileSubmission>,\n    files: PostingFile[],\n    cancellationToken: CancellableToken,\n  ): Promise<IPostResponse> {\n    // Fetch fresh CSRF token before posting\n    const csrfToken = await this.fetchCsrfToken();\n    if (!csrfToken) {\n      return PostResponse.fromWebsite(this).withException(\n        new Error('Failed to fetch CSRF token. Please re-login.'),\n      );\n    }\n\n    // Step 1: Create submission (PUT request - PostBuilder doesn't support PUT)\n    cancellationToken.throwIfCancelled();\n    const createRes = await Http.put<SofurrySubmissionResponse>(\n      `${this.BASE_URL}/ui/submission`,\n      {\n        partition: this.accountId,\n        type: 'json',\n        data: {},\n        headers: {\n          'x-csrf-token': csrfToken,\n        },\n      },\n    );\n\n    if (!createRes.body?.id) {\n      return PostResponse.fromWebsite(this)\n        .withException(new Error('Failed to create submission'))\n        .withAdditionalInfo(JSON.stringify(createRes.body));\n    }\n\n    const submissionId = createRes.body.id;\n\n    // Step 2: Upload files one at a time to maintain ID order\n    const contentIds: string[] = [];\n    for (const file of files) {\n      cancellationToken.throwIfCancelled();\n      const uploadRes = await new PostBuilder(this, cancellationToken)\n        .asMultipart()\n        .withHeader('X-Csrf-Token', csrfToken)\n        .withHeader('origin', this.BASE_URL)\n        .withHeader('referer', `${this.BASE_URL}/s/${submissionId}/edit`)\n        .setField('name', file.fileName)\n        .addFile('file', file)\n        .send<SoFurryFileUploadResponse>(\n          `${this.BASE_URL}/ui/submission/${submissionId}/content`,\n        );\n\n      if (uploadRes.statusCode >= 400 || !uploadRes.body?.contentId) {\n        return PostResponse.fromWebsite(this)\n          .withException(\n            new Error(\n              `Failed to upload file \"${file.fileName}\" (${contentIds.length + 1}/${files.length})`,\n            ),\n          )\n          .withAdditionalInfo(JSON.stringify(uploadRes.body));\n      }\n\n      contentIds.push(uploadRes.body.contentId);\n    }\n\n    // Step 2b: Upload thumbnail if available\n    let thumbUrl: string | null = null;\n    const thumbnailFile = files[0].thumbnail ? files[0] : null;\n    if (thumbnailFile) {\n      cancellationToken.throwIfCancelled();\n      const thumbRes = await new PostBuilder(this, cancellationToken)\n        .asMultipart()\n        .withHeader('X-Csrf-Token', csrfToken)\n        .withHeader('origin', this.BASE_URL)\n        .withHeader('referer', `${this.BASE_URL}/s/${submissionId}/edit`)\n        .setField('name', thumbnailFile.thumbnail.fileName)\n        .addThumbnail('file', thumbnailFile)\n        .send<SoFurryThumbnailUploadResponse>(\n          `${this.BASE_URL}/ui/submission/${submissionId}/thumbnail`,\n        );\n\n      if (thumbRes.statusCode < 400 && thumbRes.body?.url) {\n        thumbUrl = thumbRes.body.url;\n      }\n    }\n\n    // Get category and type from options, with fallback to first file's type defaults\n    const defaults = this.getDefaultCategoryAndType(files[0].fileType);\n    const category = postData.options.category\n      ? parseInt(postData.options.category, 10)\n      : defaults.category;\n    const type = postData.options.type\n      ? parseInt(postData.options.type, 10)\n      : defaults.type;\n\n    // Step 3: Finalize submission with metadata using PostBuilder\n    const finalizeRes = await new PostBuilder(this, cancellationToken)\n      .asJson()\n      .withHeader('x-csrf-token', csrfToken)\n      .setField('title', postData.options.title)\n      .setField('category', category)\n      .setField('type', type)\n      .setField('rating', this.getRating(postData.options.rating))\n      .setField('privacy', postData.options.privacy || '3')\n      .setField('allowComments', postData.options.allowComments ? 1 : 0)\n      .setField('allowDownloads', postData.options.allowDownloads ? 1 : 0)\n      .setField('isWip', postData.options.markAsWorkInProgress ? 1 : 0)\n      .setField('optimize', 1)\n      .setField('pixelPerfect', postData.options.pixelPerfectDisplay ? 1 : 0)\n      .setField('isAdvert', postData.options.intendedAsAdvertisement ? 1 : 0)\n      .setField('description', postData.options.description)\n      .setField('artistTags', postData.options.tags)\n      .setField('canPurchase', false)\n      .setField('purchaseAtVendor', null)\n      .setField('purchaseAtUrl', null)\n      .setField('contentOrder', contentIds)\n      .setField('thumbUrl', thumbUrl)\n      .send<unknown>(`${this.BASE_URL}/ui/submission/${submissionId}`);\n\n    return PostResponse.fromWebsite(this)\n      .withSourceUrl(`${this.BASE_URL}/s/${submissionId}`)\n      .withMessage('File posted successfully');\n  }\n\n  onValidateFileSubmission = validatorPassthru;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/subscribe-star/base-subscribe-star.website.ts",
    "content": "import { SelectOption } from '@postybirb/form-builder';\nimport { Http } from '@postybirb/http';\nimport {\n  ILoginState,\n  ImageResizeProps,\n  ISubmissionFile,\n  PostData,\n  PostResponse,\n  SimpleValidationResult,\n} from '@postybirb/types';\nimport { BrowserWindowUtils } from '@postybirb/utils/electron';\nimport parse, { HTMLElement } from 'node-html-parser';\nimport { CancellableToken } from '../../../post/models/cancellable-token';\nimport { PostingFile } from '../../../post/models/posting-file';\nimport { PostBuilder } from '../../commons/post-builder';\nimport { DataPropertyAccessibility } from '../../models/data-property-accessibility';\nimport { FileWebsite } from '../../models/website-modifiers/file-website';\nimport { MessageWebsite } from '../../models/website-modifiers/message-website';\nimport { Website } from '../../website';\nimport { SubscribeStarAccountData } from './models/subscribe-star-account-data';\nimport { SubscribeStarFileSubmission } from './models/subscribe-star-file-submission';\nimport { SubscribeStarMessageSubmission } from './models/subscribe-star-message-submission';\n\ntype SubscribeStarSession = {\n  userId: string;\n  csrfToken?: string;\n};\n\ntype SubscribeStarUploadData = {\n  s3Url: string;\n  s3UploadPath: string;\n  authenticityToken: string;\n};\n\ntype SubscribeStarPostResponse = {\n  error: unknown;\n  html: string;\n  notice: string;\n  page_title: unknown;\n  push_state: boolean;\n  return: boolean;\n};\n\ntype SubscribeStarUploadItem = {\n  id: number;\n  original_filename: string;\n  remove_path: string;\n  pinned: boolean;\n  group: string;\n  created_at: string;\n  gallery_preview_url?: string;\n  preview_url?: string;\n  url: string;\n  width?: string;\n  height?: string;\n  type: string;\n};\n\ntype SubscribeStarProcessFileResponse = {\n  imgs_and_videos: SubscribeStarUploadItem[];\n  audios: SubscribeStarUploadItem[];\n  docs: SubscribeStarUploadItem[];\n  processed: boolean;\n};\n\nexport default abstract class BaseSubscribeStar\n  extends Website<SubscribeStarAccountData, SubscribeStarSession>\n  implements\n    FileWebsite<SubscribeStarFileSubmission>,\n    MessageWebsite<SubscribeStarMessageSubmission>\n{\n  protected abstract BASE_URL: string;\n\n  public externallyAccessibleWebsiteDataProperties: DataPropertyAccessibility<SubscribeStarAccountData> =\n    {\n      tiers: true,\n    };\n\n  public async onLogin(): Promise<ILoginState> {\n    const { body: profilePage } = await Http.get<string>(\n      `${this.BASE_URL}/profile/settings`,\n      {\n        partition: this.accountId,\n      },\n    );\n\n    const $ = parse(profilePage);\n    const topBar = $.querySelector('.top_bar-user_info');\n    if (topBar) {\n      const username = $.querySelector('.top_bar-user_name').innerText.trim();\n      const csrfToken = $.querySelector(\n        'meta[name=\"csrf-token\"]',\n      )?.getAttribute('content');\n      if (!csrfToken) {\n        this.logger.warn('Failed to find csrf-token meta element during login');\n        return this.loginState.setLogin(false, null);\n      }\n      this.sessionData.csrfToken = csrfToken;\n      const userId = topBar.querySelector('img')?.getAttribute('data-user-id');\n      if (!userId) {\n        this.logger.warn('Failed to find user-id img element during login');\n        return this.loginState.setLogin(false, null);\n      }\n      this.sessionData.userId = userId;\n      this.loadTiers($);\n      return this.loginState.setLogin(true, username || 'unknown');\n    }\n\n    return this.loginState.setLogin(false, null);\n  }\n\n  private loadTiers($: HTMLElement) {\n    const tiers: SelectOption[] = [\n      {\n        label: 'Public',\n        value: 'free',\n        mutuallyExclusive: true,\n      },\n    ];\n\n    const tierSection = $.querySelector('.for-tier_settings');\n    if (tierSection) {\n      const tierItems = tierSection.querySelectorAll('.tiers-settings_item');\n\n      tierItems.forEach((tierItem) => {\n        const tierId = tierItem.getAttribute('data-id');\n        const titleElement = tierItem.querySelector(\n          '.tiers-settings_item-title',\n        );\n        const costElement = tierItem.querySelector('.tiers-settings_item-cost');\n\n        if (tierId && titleElement && costElement) {\n          const title = titleElement.textContent.trim();\n          const costText = costElement.textContent.trim();\n\n          tiers.push({\n            label: `${title} (${costText})`,\n            value: tierId,\n          });\n        }\n      });\n    }\n\n    this.setWebsiteData({\n      tiers,\n    });\n  }\n\n  createFileModel(): SubscribeStarFileSubmission {\n    return new SubscribeStarFileSubmission();\n  }\n\n  calculateImageResize(file: ISubmissionFile): ImageResizeProps {\n    return undefined;\n  }\n\n  private async getPostData(): Promise<SubscribeStarUploadData | undefined> {\n    const url = `${this.BASE_URL}/${this.loginState.username}`;\n    try {\n      const { body } = await Http.get<string>(url, {\n        partition: this.accountId,\n      });\n      const $ = parse(body);\n      const newPost = $.querySelector('.new_post')\n        ?.querySelector('.new_post-inner')\n        ?.getAttribute('data-form-template');\n      if (newPost) {\n        // Parse the JSON string first\n        let decoded = JSON.parse(newPost);\n\n        // Then decode unicode and HTML entities\n        decoded = decoded.replace(/\\\\u([0-9a-fA-F]{4})/g, (match, hex) =>\n          String.fromCharCode(parseInt(hex, 16)),\n        );\n\n        const innerDoc = parse(decoded);\n        return {\n          s3UploadPath: innerDoc\n            .querySelector('.post_xodal')\n            .getAttribute('data-s3-upload-path'),\n          s3Url: innerDoc\n            .querySelector('.post_xodal')\n            .getAttribute('data-s3-url'),\n          authenticityToken: innerDoc\n            .querySelectorAll('form input')\n            .find((input) => input.rawAttrs.includes('authenticity_token'))\n            .getAttribute('value'),\n        };\n      }\n      this.logger.warn(\n        'Falling back to BrowserWindow method for acquiring S3 token due to missing data-form-template element',\n      );\n      return await this.fallbackS3TokenLoader(url);\n    } catch (error) {\n      this.logger.error(error, 'Failed to parse post data');\n    }\n    return undefined;\n  }\n\n  private async fallbackS3TokenLoader(\n    url: string,\n  ): Promise<SubscribeStarUploadData | undefined> {\n    const { authenticityToken, s3UploadPath, s3Url } =\n      await BrowserWindowUtils.runScriptOnPage<{\n        authenticityToken: string;\n        s3UploadPath: string;\n        s3Url: string;\n      }>(\n        this.accountId,\n        url,\n        `\n      async function getInfo() {\n        const parser = new DOMParser();\n        const doc = parser.parseFromString(JSON.parse(document.querySelector('.new_post')\n                ?.querySelector('.new_post-inner')\n                ?.getAttribute('data-form-template')).replace(/\\\\u([0-9a-fA-F]{4})/g, (match, hex) =>\n                  String.fromCharCode(parseInt(hex, 16)),\n                ) , 'text/html');\n        \n        const s3UploadPath = doc.querySelector('.post_xodal').getAttribute('data-s3-upload-path');\n        const s3Url = doc.querySelector('.post_xodal').getAttribute('data-s3-url');\n        const authenticityToken = [...doc.querySelectorAll('form input')].find((input) => input.getAttribute('name') === 'authenticity_token')\n          .getAttribute('value') ;\n\n\n        const out = { authenticityToken, s3UploadPath, s3Url };\n\n        return out;\n      }\n      \n      return getInfo();\n    `,\n        1000,\n      );\n\n    if (authenticityToken && s3UploadPath && s3Url) {\n      return {\n        authenticityToken,\n        s3UploadPath,\n        s3Url,\n      };\n    }\n\n    return undefined;\n  }\n\n  private async uploadFile(\n    file: PostingFile,\n    uploadData: SubscribeStarUploadData,\n  ): Promise<string | undefined> {\n    const bucket = uploadData.s3Url.split('//')[1].split('.')[0];\n\n    // Build the S3 key using the existing GUID filename\n    const key = `${uploadData.s3UploadPath}/${file.fileName}`;\n\n    // Get presigned URL for upload\n    const presignUrl = `${\n      this.BASE_URL\n    }/presigned_url/upload?_=${Date.now()}&key=${encodeURIComponent(\n      key,\n    )}&file_name=${encodeURIComponent(file.fileName)}&content_type=${encodeURIComponent(\n      file.mimeType,\n    )}&bucket=${bucket}`;\n\n    const presign = await Http.get<{ url: string; fields?: object }>(\n      presignUrl,\n      {\n        partition: this.accountId,\n      },\n    );\n\n    // Upload file to S3\n    const postFile = await Http.post<string>(presign.body.url, {\n      partition: this.accountId,\n      type: 'multipart',\n      data: {\n        ...presign.body.fields,\n        file: file.toPostFormat(),\n        authenticity_token: uploadData.authenticityToken,\n      },\n      headers: {\n        Referer: `${this.BASE_URL}/`,\n        Origin: this.BASE_URL,\n      },\n    });\n\n    if (postFile.statusCode !== 204 && postFile.statusCode !== 200) {\n      throw new Error(`Failed to upload file: ${postFile.statusCode}`);\n    }\n\n    // Build the record for processing\n    const record: Record<string, unknown> = {\n      path: key,\n      url: `${presign.body.url}/${key}`,\n      original_filename: file.fileName,\n      content_type: file.mimeType,\n      bucket,\n      authenticity_token: uploadData.authenticityToken,\n    };\n\n    // Add dimensions for images using pre-acquired width/height from PostingFile\n    if (\n      record.content_type.toString().includes('image') &&\n      file.width &&\n      file.height\n    ) {\n      record.width = file.width;\n      record.height = file.height;\n    }\n\n    // Process the S3 attachment\n    const processFile = await Http.post<SubscribeStarProcessFileResponse>(\n      `${this.BASE_URL}/post_uploads/process_s3_attachments.json`,\n      {\n        partition: this.accountId,\n        type: 'multipart',\n        data: record,\n        headers: {\n          'X-CSRF-Token': this.sessionData.csrfToken,\n        },\n      },\n    );\n\n    if (processFile.statusCode !== 200) {\n      throw new Error(`Failed to process file: ${processFile.statusCode}`);\n    }\n\n    // Extract the ID by matching the original filename\n    const allUploads = [\n      ...processFile.body.imgs_and_videos,\n      ...processFile.body.audios,\n      ...processFile.body.docs,\n    ];\n\n    // Find the uploaded item by matching the original filename\n    const uploadedItem = allUploads.find(\n      (item) => item.original_filename === file.fileName,\n    );\n\n    return uploadedItem ? String(uploadedItem.id) : undefined;\n  }\n\n  async onPostFileSubmission(\n    postData: PostData<SubscribeStarFileSubmission>,\n    files: PostingFile[],\n    cancellationToken: CancellableToken,\n  ): Promise<PostResponse> {\n    const uploadData = await this.getPostData();\n\n    const uploadedFileIds: string[] = [];\n    for (const file of files) {\n      const fileId = await this.uploadFile(file, uploadData);\n      if (fileId) {\n        uploadedFileIds.push(fileId);\n      }\n    }\n\n    // Reorder files if there are multiple uploads\n    if (uploadedFileIds.length > 1) {\n      await Http.post(`${this.BASE_URL}/post_uploads/reorder`, {\n        partition: this.accountId,\n        type: 'multipart',\n        data: {\n          'upload_ids[]': uploadedFileIds,\n        },\n        headers: {\n          'X-CSRF-Token': this.sessionData.csrfToken,\n        },\n      });\n    }\n\n    const builder = new PostBuilder(this, cancellationToken)\n      .asUrlEncoded(true)\n      .withHeader('X-Csrf-Token', this.sessionData.csrfToken)\n      .withHeader('Referrer', `${this.BASE_URL}/${this.loginState.username}`)\n      .setField('authenticity_token', uploadData.authenticityToken)\n      .setField('html_content', `<div>${postData.options.description}</div>`)\n      .setField('pinned_uploads', '[]')\n      .setField('new_editor', true)\n      .setField('is_draft', '')\n      .setField('tags', postData.options.tags)\n      .setField('has_poll', false)\n      .setField('poll_options', [])\n      .setField('finish_date', '')\n      .setField('finish_time', '')\n      .setField(\n        'tier_ids',\n        postData.options.tiers.filter((tier) => tier !== 'free'),\n      )\n      .setField('posting_option', 'Publish Now');\n\n    const post = await builder.send<SubscribeStarPostResponse>(\n      `${this.BASE_URL}/posts.json`,\n    );\n\n    if (post.body.error) {\n      return PostResponse.fromWebsite(this)\n        .withAdditionalInfo({\n          body: post.body,\n          statusCode: post.statusCode,\n        })\n        .withException(new Error('Failed to post'));\n    }\n\n    const $ = parse(post.body.html);\n    const postId = $.querySelector('.post')?.getAttribute('data-id');\n    if (!postId) {\n      this.logger.warn('Failed to find post ID in file submission response');\n    }\n    return PostResponse.fromWebsite(this)\n      .withAdditionalInfo(post.body)\n      .withSourceUrl(postId ? `${this.BASE_URL}/posts/${postId}` : undefined);\n  }\n\n  async onValidateFileSubmission(\n    postData: PostData<SubscribeStarFileSubmission>,\n  ): Promise<SimpleValidationResult> {\n    const validator = this.createValidator<SubscribeStarFileSubmission>();\n\n    return validator.result;\n  }\n\n  createMessageModel(): SubscribeStarMessageSubmission {\n    return new SubscribeStarMessageSubmission();\n  }\n\n  async onPostMessageSubmission(\n    postData: PostData<SubscribeStarMessageSubmission>,\n    cancellationToken: CancellableToken,\n  ): Promise<PostResponse> {\n    const uploadData = await this.getPostData();\n    const builder = new PostBuilder(this, cancellationToken)\n      .asUrlEncoded(true)\n      .withHeader('X-Csrf-Token', this.sessionData.csrfToken)\n      .withHeader('Referrer', `${this.BASE_URL}/${this.loginState.username}`)\n      .setField('authenticity_token', uploadData.authenticityToken)\n      .setField('html_content', `<div>${postData.options.description}</div>`)\n      .setField('pinned_uploads', '[]')\n      .setField('new_editor', true)\n      .setField('is_draft', '')\n      .setField('tags', postData.options.tags)\n      .setField('has_poll', false)\n      .setField('poll_options', [])\n      .setField('finish_date', '')\n      .setField('finish_time', '')\n      .setField(\n        'tier_ids',\n        postData.options.tiers.filter((tier) => tier !== 'free'),\n      )\n      .setField('posting_option', 'Publish Now');\n\n    const post = await builder.send<SubscribeStarPostResponse>(\n      `${this.BASE_URL}/posts.json`,\n    );\n\n    if (post.body.error) {\n      return PostResponse.fromWebsite(this)\n        .withAdditionalInfo({\n          body: post.body,\n          statusCode: post.statusCode,\n        })\n        .withException(new Error('Failed to post'));\n    }\n\n    const $ = parse(post.body.html);\n    const postId = $.querySelector('.post')?.getAttribute('data-id');\n    if (!postId) {\n      this.logger.warn('Failed to find post ID in message submission response');\n    }\n    return PostResponse.fromWebsite(this)\n      .withAdditionalInfo(post.body)\n      .withSourceUrl(postId ? `${this.BASE_URL}/posts/${postId}` : undefined);\n  }\n\n  async onValidateMessageSubmission(\n    postData: PostData<SubscribeStarMessageSubmission>,\n  ): Promise<SimpleValidationResult> {\n    const validator = this.createValidator<SubscribeStarMessageSubmission>();\n\n    return validator.result;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/subscribe-star/models/subscribe-star-account-data.ts",
    "content": "import { SelectOption } from '@postybirb/form-builder';\n\nexport type SubscribeStarAccountData = { \n  tiers: SelectOption[];\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/subscribe-star/models/subscribe-star-file-submission.ts",
    "content": "import { DescriptionField, SelectField } from '@postybirb/form-builder';\nimport { DescriptionType, DescriptionValue } from '@postybirb/types';\nimport { BaseWebsiteOptions } from '../../../models/base-website-options';\nimport { SubscribeStarAccountData } from './subscribe-star-account-data';\n\nexport class SubscribeStarFileSubmission extends BaseWebsiteOptions {\n  @DescriptionField({\n    descriptionType: DescriptionType.HTML,\n  })\n  description: DescriptionValue;\n\n  @SelectField<SubscribeStarAccountData>({\n    required: true,\n    label: 'accessTiers',\n    minSelected: 1,\n    allowMultiple: true,\n    options: [], // Populated dynamically\n    derive: [\n      {\n        key: 'tiers',\n        populate: 'options',\n      },\n    ],\n    span: 12,\n  })\n  tiers: string[] = [];\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/subscribe-star/models/subscribe-star-message-submission.ts",
    "content": "import { DescriptionField, SelectField } from '@postybirb/form-builder';\nimport { DescriptionType, DescriptionValue } from '@postybirb/types';\nimport { BaseWebsiteOptions } from '../../../models/base-website-options';\nimport { SubscribeStarAccountData } from './subscribe-star-account-data';\n\nexport class SubscribeStarMessageSubmission extends BaseWebsiteOptions {\n  @DescriptionField({\n    descriptionType: DescriptionType.HTML,\n  })\n  description: DescriptionValue;\n\n  @SelectField<SubscribeStarAccountData>({\n    required: true,\n    label: 'accessTiers',\n    minSelected: 1,\n    allowMultiple: true,\n    options: [], // Populated dynamically\n    derive: [\n      {\n        key: 'tiers',\n        populate: 'options',\n      },\n    ],\n    span: 12,\n  })\n  tiers: string[] = [];\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/subscribe-star/subscribe-star-adult.website.ts",
    "content": "import { FileType } from '@postybirb/types';\nimport { DisableAds } from '../../decorators/disable-ads.decorator';\nimport { UserLoginFlow } from '../../decorators/login-flow.decorator';\nimport { SupportsFiles } from '../../decorators/supports-files.decorator';\nimport { SupportsUsernameShortcut } from '../../decorators/supports-username-shortcut.decorator';\nimport { WebsiteMetadata } from '../../decorators/website-metadata.decorator';\nimport BaseSubscribeStar from './base-subscribe-star.website';\n\n@WebsiteMetadata({\n  name: 'subscribe-star-adult',\n  displayName: 'SubscribeStar (Adult)',\n})\n@UserLoginFlow('https://www.subscribestar.adult/login')\n@SupportsUsernameShortcut({\n  id: 'subscribe-star-adult',\n  url: 'https://www.subscribestar.adult/$1',\n})\n@SupportsFiles({\n  fileBatchSize: 20,\n  acceptedMimeTypes: [\n    'audio/aac',\n    'audio/x-aac',\n    'audio/mp3',\n    'audio/mpeg',\n    'audio/ogg',\n    'audio/wav',\n    'audio/wave',\n    'audio/x-wav',\n    'audio/x-pn-wav',\n    'audio/webm',\n    'video/mp4',\n    'video/webm',\n    'video/3gpp',\n    'video/x-flv',\n    'video/avi',\n    'video/ogg',\n    'video/x-ms-wmv',\n    'video/wmv',\n    'video/x-matroska',\n    'video/quicktime',\n    'image/jpeg',\n    'image/gif',\n    'image/tiff',\n    'image/png',\n    'image/x-png',\n    'image/webp',\n    'application/octet-stream',\n    'application/x-rar-compressed',\n    'application/x-compressed',\n    'application/x-rar',\n    'application/vnd.rar',\n    'application/x-7z-compressed',\n    'application/zip',\n    'application/x-zip-compressed',\n    'multipart/x-zip',\n    'application/x-mobipocket-ebook',\n    'application/epub+zip',\n    'application/epub',\n    'application/vnd.ms-fontobjec',\n    'text/plain',\n    'text/csv',\n    'application/csv',\n    'application/msword',\n    'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n    'application/vnd.openxmlformats-officedocument.wordprocessingml.template',\n    'application/vnd.ms-excel',\n    'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n    'text/html',\n    'image/vnd.adobe.photoshop',\n    'application/x-photoshop',\n    'application/photoshop',\n    'application/psd',\n    'image/psd',\n    'application/json',\n    'application/pdf',\n    'application/vnd.oasis.opendocument.text',\n    'text/rtf',\n  ],\n  acceptedFileSizes: {\n    [FileType.AUDIO]: 52428800,\n    [FileType.VIDEO]: 262144000,\n    [FileType.IMAGE]: 8388608,\n    [FileType.TEXT]: 314572800,\n  },\n})\n@DisableAds()\nexport default class SubscribeStarAdult extends BaseSubscribeStar {\n  protected BASE_URL = 'https://www.subscribestar.adult';\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/subscribe-star/subscribe-star.website.ts",
    "content": "import { FileType } from '@postybirb/types';\nimport { DisableAds } from '../../decorators/disable-ads.decorator';\nimport { UserLoginFlow } from '../../decorators/login-flow.decorator';\nimport { SupportsFiles } from '../../decorators/supports-files.decorator';\nimport { SupportsUsernameShortcut } from '../../decorators/supports-username-shortcut.decorator';\nimport { WebsiteMetadata } from '../../decorators/website-metadata.decorator';\nimport BaseSubscribeStar from './base-subscribe-star.website';\n\n@WebsiteMetadata({\n  name: 'subscribe-star',\n  displayName: 'SubscribeStar',\n})\n@UserLoginFlow('https://www.subscribestar.com/login')\n@SupportsUsernameShortcut({\n  id: 'subscribe-star',\n  url: 'https://www.subscribestar.com/$1',\n})\n@SupportsFiles({\n  fileBatchSize: 20,\n  acceptedMimeTypes: [\n    'audio/aac',\n    'audio/x-aac',\n    'audio/mp3',\n    'audio/mpeg',\n    'audio/ogg',\n    'audio/wav',\n    'audio/wave',\n    'audio/x-wav',\n    'audio/x-pn-wav',\n    'audio/webm',\n    'video/mp4',\n    'video/webm',\n    'video/3gpp',\n    'video/x-flv',\n    'video/avi',\n    'video/ogg',\n    'video/x-ms-wmv',\n    'video/wmv',\n    'video/x-matroska',\n    'video/quicktime',\n    'image/jpeg',\n    'image/gif',\n    'image/tiff',\n    'image/png',\n    'image/x-png',\n    'image/webp',\n    'application/octet-stream',\n    'application/x-rar-compressed',\n    'application/x-compressed',\n    'application/x-rar',\n    'application/vnd.rar',\n    'application/x-7z-compressed',\n    'application/zip',\n    'application/x-zip-compressed',\n    'multipart/x-zip',\n    'application/x-mobipocket-ebook',\n    'application/epub+zip',\n    'application/epub',\n    'application/vnd.ms-fontobjec',\n    'text/plain',\n    'text/csv',\n    'application/csv',\n    'application/msword',\n    'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n    'application/vnd.openxmlformats-officedocument.wordprocessingml.template',\n    'application/vnd.ms-excel',\n    'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n    'text/html',\n    'image/vnd.adobe.photoshop',\n    'application/x-photoshop',\n    'application/photoshop',\n    'application/psd',\n    'image/psd',\n    'application/json',\n    'application/pdf',\n    'application/vnd.oasis.opendocument.text',\n    'text/rtf',\n  ],\n  acceptedFileSizes: {\n    [FileType.AUDIO]: 52428800,\n    [FileType.VIDEO]: 262144000,\n    [FileType.IMAGE]: 8388608,\n    [FileType.TEXT]: 314572800,\n  },\n})\n@DisableAds()\nexport default class SubscribeStar extends BaseSubscribeStar {\n  protected BASE_URL = 'https://www.subscribestar.com';\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/telegram/models/telegram-file-submission.ts",
    "content": "import {\n  BooleanField,\n  DescriptionField,\n  SelectField,\n  TagField,\n} from '@postybirb/form-builder';\nimport {\n  DescriptionType,\n  DescriptionValue,\n  TagValue,\n  TelegramAccountData,\n} from '@postybirb/types';\nimport { BaseWebsiteOptions } from '../../../models/base-website-options';\n\nexport class TelegramFileSubmission extends BaseWebsiteOptions {\n  @DescriptionField({\n    descriptionType: DescriptionType.CUSTOM,\n    expectsInlineTitle: true,\n  })\n  description: DescriptionValue;\n\n  @TagField({})\n  tags: TagValue;\n\n  @SelectField<TelegramAccountData>({\n    label: 'channel',\n    derive: [{ key: 'channels', populate: 'options' }],\n    options: [],\n    allowMultiple: true,\n    minSelected: 1,\n    required: true,\n    section: 'website',\n    span: 6,\n  })\n  channels: string[];\n\n  @BooleanField({\n    label: 'silent',\n    section: 'website',\n    span: 12,\n  })\n  silent = false;\n\n  @BooleanField({\n    label: 'spoiler',\n    section: 'website',\n    span: 12,\n  })\n  spoiler = false;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/telegram/models/telegram-message-submission.ts",
    "content": "import { TelegramFileSubmission } from './telegram-file-submission';\n\nexport class TelegramMessageSubmission extends TelegramFileSubmission {}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/telegram/telegram.website.ts",
    "content": "// eslint-disable-next-line max-classes-per-file\nimport { SelectOption } from '@postybirb/form-builder';\nimport { getParsedProxiesFor } from '@postybirb/http';\nimport {\n  FileType,\n  ILoginState,\n  ImageResizeProps,\n  ISubmissionFile,\n  OAuthRouteHandlers,\n  PostData,\n  PostResponse,\n  SimpleValidationResult,\n  TelegramAccountData,\n  TelegramOAuthRoutes,\n  TipTapNode,\n} from '@postybirb/types';\nimport { calculateImageResize, supportsImage } from '@postybirb/utils/file-type';\nimport { Api, TelegramClient } from 'telegram';\nimport { CustomFile } from 'telegram/client/uploads';\nimport { Entity } from 'telegram/define';\nimport { HTMLParser as HTMLToTelegram } from 'telegram/extensions/html';\nimport { LogLevel } from 'telegram/extensions/Logger';\nimport { returnBigInt } from 'telegram/Helpers';\nimport { ProxyInterface } from 'telegram/network/connection/TCPMTProxy';\nimport { StringSession } from 'telegram/sessions';\nimport { BaseConverter } from '../../../post-parsers/models/description-node/converters/base-converter';\nimport { HtmlConverter } from '../../../post-parsers/models/description-node/converters/html-converter';\nimport { ConversionContext } from '../../../post-parsers/models/description-node/description-node.base';\nimport { CancellableToken } from '../../../post/models/cancellable-token';\nimport { PostingFile } from '../../../post/models/posting-file';\nimport FileSize from '../../../utils/filesize.util';\nimport { SubmissionValidator } from '../../commons/validator';\nimport { CustomLoginFlow } from '../../decorators/login-flow.decorator';\nimport { SupportsFiles } from '../../decorators/supports-files.decorator';\nimport { WebsiteMetadata } from '../../decorators/website-metadata.decorator';\nimport { DataPropertyAccessibility } from '../../models/data-property-accessibility';\nimport {\n  FileWebsite,\n  PostBatchData,\n} from '../../models/website-modifiers/file-website';\nimport { MessageWebsite } from '../../models/website-modifiers/message-website';\nimport { OAuthWebsite } from '../../models/website-modifiers/oauth-website';\nimport { WithCustomDescriptionParser } from '../../models/website-modifiers/with-custom-description-parser';\nimport { Website } from '../../website';\nimport { TelegramFileSubmission } from './models/telegram-file-submission';\nimport { TelegramMessageSubmission } from './models/telegram-message-submission';\n\n@WebsiteMetadata({\n  name: 'telegram',\n  displayName: 'Telegram',\n})\n@CustomLoginFlow()\n@SupportsFiles({\n  acceptedMimeTypes: [\n    'image/jpeg',\n    'image/png',\n    'image/gif',\n    'video/mp4',\n    'audio/mp3',\n  ],\n  fileBatchSize: 10,\n  acceptedFileSizes: {\n    '*': FileSize.megabytes(1000 * 2),\n    [FileType.IMAGE]: FileSize.megabytes(10),\n  },\n})\nexport default class Telegram\n  extends Website<TelegramAccountData>\n  implements\n    FileWebsite<TelegramFileSubmission>,\n    MessageWebsite<TelegramMessageSubmission>,\n    OAuthWebsite<TelegramOAuthRoutes>,\n    WithCustomDescriptionParser\n{\n  protected BASE_URL = 'https://t.me/';\n\n  public externallyAccessibleWebsiteDataProperties: DataPropertyAccessibility<TelegramAccountData> =\n    {\n      appHash: true,\n      appId: true,\n      phoneNumber: true,\n      channels: true,\n      session: false,\n    };\n\n  private clients = new Map<number, TelegramClient>();\n\n  private async getTelegramClient(\n    account: TelegramAccountData = this.websiteDataStore.getData(),\n  ) {\n    let client = this.clients.get(account.appId);\n    if (!client) {\n      this.logger.info(\n        `Creating client for ${account.appId} with session present ${!!account.session}`,\n      );\n\n      const telegramProxySettings = await this.resolveProxySettings();\n\n      client = new TelegramClient(\n        new StringSession(account.session ?? ''),\n        account.appId,\n        account.appHash,\n        {\n          proxy: telegramProxySettings,\n        },\n      );\n      client.setLogLevel(LogLevel.ERROR);\n      this.clients.set(account.appId, client);\n    }\n\n    if (!client.connected) await client.connect();\n    return client;\n  }\n\n  public onAuthRoute: OAuthRouteHandlers<TelegramOAuthRoutes> = {\n    startAuthentication: async (request) => {\n      this.logger.info('Starting Authentication');\n      const account: TelegramAccountData = {\n        ...this.getWebsiteData(),\n        ...request,\n      };\n      const telegram = await this.getTelegramClient(account);\n      await this.setWebsiteData(account);\n      await telegram.sendCode(\n        { apiId: request.appId, apiHash: request.appHash },\n        request.phoneNumber,\n      );\n      this.logger.info('Code sent successfully');\n    },\n    authenticate: async (request) => {\n      this.logger.info(`Authenticating`);\n      const account: TelegramAccountData = {\n        ...this.getWebsiteData(),\n        ...request,\n      };\n      const telegram = await this.getTelegramClient(account);\n\n      try {\n        await telegram.start({\n          phoneNumber: request.phoneNumber,\n          password: async () => request.password,\n          phoneCode: async () => request.code,\n          onError: (error) => {\n            throw error;\n          },\n        });\n        this.logger.info('Login successfull');\n        this.onLogin();\n        return { success: true };\n      } catch (e) {\n        this.logger.withError(e).error('Failed to ');\n        const passwordRequired = String(e).includes('Password is empty');\n        const passwordInvalid = String(e).includes('PASSWORD_HASH_INVALID');\n        const codeInvalid = String(e).includes('CODE_INVALID');\n        return {\n          message: String(e),\n          passwordRequired,\n          passwordInvalid,\n          codeInvalid,\n          success: false,\n        };\n      }\n    },\n  };\n\n  private async resolveProxySettings() {\n    let telegramProxySettings: ProxyInterface | undefined;\n\n    // Example:\n    // tg://proxy?server=127.0.0.1&port=1080&secret=dda7e716f615266d980bf8e2e41acc36b0\n    const env = process.env.POSTYBIRB_TELEGRAM_MTPROXY;\n    if (env) {\n      try {\n        const parsed = new URL(env);\n        if (\n          parsed.protocol === 'tg:' &&\n          parsed.host === 'proxy' &&\n          parsed.search\n        ) {\n          telegramProxySettings = {\n            MTProxy: true,\n            ip: parsed.searchParams.get('ip') ?? '',\n            secret: parsed.searchParams.get('secret') ?? '',\n            port: parseInt(parsed.searchParams.get('port') ?? '', 10),\n          };\n          this.logger.info('Using', env);\n        }\n      } catch (e) {\n        this.logger\n          .withError(e)\n          .error(\n            'Failed to parse env POSTYBIRB_TELEGRAM_MTPROXY, falling back to other proxy settings...',\n          );\n      }\n    }\n\n    if (!telegramProxySettings) {\n      const proxies = [\n        ...(await getParsedProxiesFor('https://telegram.org')),\n        ...(await getParsedProxiesFor('https://t.me/')),\n      ];\n      const proxy =\n        proxies.find((e) => e?.type === 'SOCKS') ??\n        proxies.find((e) => e?.type === 'PROXY') ??\n        proxies[0];\n\n      if (proxy && proxy.type !== 'DIRECT') {\n        telegramProxySettings = {\n          ip: proxy.hostname,\n          port: parseInt(proxy.port, 10),\n          socksType: 5,\n        };\n        this.logger\n          .withMetadata({ proxy: telegramProxySettings, proxies })\n          .info(\n            'Using SOCKS5 proxy resolved for hostname t.me or telegram.org',\n          );\n      }\n    }\n    return telegramProxySettings;\n  }\n\n  private async loadChannels(telegram: TelegramClient) {\n    this.logger.info('Loading folders...');\n    const channels: SelectOption[] = [];\n    let total = 0;\n\n    for await (const dialog of telegram.iterDialogs()) {\n      total++;\n      if (!dialog.id) continue;\n      if (!this.canSendMediaInChat(dialog.entity)) continue;\n\n      const id = dialog.entity.id.toString();\n      const hash =\n        dialog.entity.className === 'Channel'\n          ? `|${dialog.entity.accessHash.toString()}`\n          : '';\n\n      channels.push({\n        label: dialog.title ?? dialog.name,\n        value: `${id}${hash}`,\n      });\n      this.setWebsiteData({ ...this.websiteDataStore.getData(), channels });\n    }\n\n    this.logger.info(\n      `Loaded total ${total} folders, can send media in ${channels.length} folders.`,\n    );\n  }\n\n  private canSendMediaInChat(chat: Entity): chat is Api.Channel | Api.Chat {\n    if (chat.className !== 'Channel' && chat.className !== 'Chat') return false;\n    if (chat.left) return false;\n\n    if (\n      chat.creator ||\n      chat.adminRights?.postMessages ||\n      // Right is not banned -> user can send media\n      chat.defaultBannedRights?.sendMedia === false\n    )\n      return true;\n\n    return false;\n  }\n\n  public async onLogin(): Promise<ILoginState> {\n    const account = this.websiteDataStore.getData();\n    if (!account.appHash || !account.appId || !account.phoneNumber) {\n      return this.loginState.setLogin(false, null);\n    }\n\n    const client = await this.getTelegramClient(account);\n    if (await client.isUserAuthorized()) {\n      const me = await client.getMe();\n      const telegramSession = (client.session as StringSession).save();\n      const username = me.username ?? me.firstName ?? me.id.toString();\n      this.setWebsiteData({ ...account, session: telegramSession });\n      await this.loadChannels(client);\n      return this.loginState.setLogin(true, username);\n    }\n\n    this.logger.info(\n      `Not logged in with session presence ${!!account.session}`,\n    );\n    return this.loginState.setLogin(false, null);\n  }\n\n  createFileModel(): TelegramFileSubmission {\n    return new TelegramFileSubmission();\n  }\n\n  calculateImageResize(file: ISubmissionFile): ImageResizeProps {\n    return calculateImageResize(file, {\n      maxWidth: 2560,\n      maxHeight: 2560,\n    });\n  }\n\n  async onPostFileSubmission(\n    postData: PostData<TelegramFileSubmission>,\n    files: PostingFile[],\n    cancellationToken: CancellableToken,\n    batch: PostBatchData,\n  ): Promise<PostResponse> {\n    cancellationToken.throwIfCancelled();\n    const telegram = await this.getTelegramClient();\n\n    const medias: (\n      | Api.InputMediaUploadedPhoto\n      | Api.InputMediaUploadedDocument\n    )[] = [];\n\n    for (const file of files) {\n      cancellationToken.throwIfCancelled();\n\n      const customFile = new CustomFile(\n        file.fileName,\n        file.buffer.length,\n        '',\n        file.buffer,\n      );\n      const uploadedFile = await telegram.uploadFile({\n        file: customFile,\n        workers: 1,\n      });\n\n      const UploadedMedia = supportsImage(file.mimeType)\n        ? Api.InputMediaUploadedPhoto\n        : Api.InputMediaUploadedDocument;\n\n      const media = new UploadedMedia({\n        spoiler: postData.options.spoiler,\n        file: uploadedFile,\n        mimeType: file.mimeType,\n        attributes: [],\n        nosoundVideo: file.mimeType === 'image/gif',\n      });\n\n      medias.push(media);\n    }\n\n    const lastBatch = batch.index === batch.totalBatches - 1;\n\n    const [description, entities] = TelegramConverter.fromJson(\n      postData.options.description,\n    );\n\n    let mediaDescription = '';\n    let mediaEntities = [];\n    let messageDescription = '';\n\n    if (description.length < 1024) {\n      // Description fit image/album limit, no separate message\n      mediaDescription = description;\n      mediaEntities = entities;\n    } else if (messageDescription && lastBatch) {\n      // Send separate message with description\n      messageDescription = description;\n    }\n\n    let response: Api.TypeUpdates | undefined;\n\n    for (const channel of postData.options.channels) {\n      cancellationToken.throwIfCancelled();\n\n      // Only add description to the media in first batch\n      const firstInBatch = batch.index === 0;\n      const peer = this.getPeer(channel);\n\n      if (medias.length === 1) {\n        await telegram.invoke(\n          new Api.messages.SendMedia({\n            media: medias[0],\n            message: firstInBatch ? mediaDescription : '',\n            entities: firstInBatch ? mediaEntities : [],\n            silent: postData.options.silent,\n            peer,\n          }),\n        );\n      } else {\n        const multiMedia: Api.InputSingleMedia[] = [];\n        for (const [mediaIndex, media] of medias.entries()) {\n          const messageMedia = await telegram.invoke(\n            new Api.messages.UploadMedia({ media, peer }),\n          );\n\n          let inputMedia: Api.InputMediaPhoto | Api.InputMediaDocument;\n\n          if (\n            messageMedia.className === 'MessageMediaPhoto' &&\n            messageMedia.photo?.className === 'Photo'\n          ) {\n            inputMedia = new Api.InputMediaPhoto({\n              id: new Api.InputPhoto({\n                id: messageMedia.photo.id,\n                accessHash: messageMedia.photo.accessHash,\n                fileReference: messageMedia.photo.fileReference,\n              }),\n              spoiler: postData.options.spoiler,\n            });\n          } else if (\n            messageMedia.className === 'MessageMediaDocument' &&\n            messageMedia.document?.className === 'Document'\n          ) {\n            inputMedia = new Api.InputMediaDocument({\n              id: new Api.InputDocument({\n                id: messageMedia.document.id,\n                accessHash: messageMedia.document.accessHash,\n                fileReference: messageMedia.document.fileReference,\n              }),\n              spoiler: postData.options.spoiler,\n            });\n          } else {\n            throw new Error(`Unknown media type: ${messageMedia.className}`);\n          }\n\n          // Only add description to the first media in first batch\n          const useDescription = mediaIndex === 0 && firstInBatch;\n\n          multiMedia.push(\n            new Api.InputSingleMedia({\n              media: inputMedia,\n              message: useDescription ? mediaDescription : '',\n              entities: useDescription ? mediaEntities : [],\n            }),\n          );\n        }\n\n        response = await telegram.invoke(\n          new Api.messages.SendMultiMedia({\n            silent: postData.options.silent,\n            multiMedia,\n            peer,\n          }),\n        );\n      }\n\n      if (messageDescription) {\n        await telegram.sendMessage(peer, {\n          message: messageDescription,\n          silent: postData.options.silent,\n          formattingEntities: entities,\n        });\n      }\n    }\n\n    const postResponse = PostResponse.fromWebsite(this);\n    const sourceUrl = this.getSourceFromResponse(response);\n    if (sourceUrl) postResponse.withSourceUrl(sourceUrl);\n    return postResponse;\n  }\n\n  private getSourceFromResponse(response?: Api.TypeUpdates) {\n    if (!response || response.className !== 'Updates') return '';\n\n    const channelUpdate = response.updates.find(\n      (e) => e.className === 'UpdateNewChannelMessage',\n    ) as undefined | Api.UpdateNewChannelMessage;\n\n    const peerId = channelUpdate?.message?.peerId;\n    if (peerId?.className !== 'PeerChannel') return '';\n\n    const chat = response.chats.find((e) => e.id === peerId.channelId);\n    if (!chat || chat.className !== 'Channel' || !chat.username) return '';\n\n    return `https://t.me/${chat.username}/${channelUpdate.message.id}`;\n  }\n\n  private getPeer(channel: string) {\n    const [idRaw, accessHash] = channel.split('|');\n    const id = returnBigInt(idRaw);\n    const peer = accessHash\n      ? new Api.InputPeerChannel({\n          channelId: id,\n          accessHash: returnBigInt(accessHash),\n        })\n      : new Api.InputPeerChat({ chatId: id });\n\n    return peer;\n  }\n\n  private readonly MAX_CHARS = 4096;\n\n  private async validateDescription(\n    postData: PostData<TelegramMessageSubmission | TelegramFileSubmission>,\n    validator: SubmissionValidator<\n      TelegramMessageSubmission | TelegramFileSubmission\n    >,\n  ): Promise<void> {\n    const { description } = postData.options;\n\n    const [text] = TelegramConverter.fromJson(description);\n\n    if (text.length > this.MAX_CHARS) {\n      validator.error(\n        'validation.description.max-length',\n        { maxLength: this.MAX_CHARS, currentLength: text.length },\n        'description',\n      );\n    }\n  }\n\n  async onValidateFileSubmission(\n    postData: PostData<TelegramFileSubmission>,\n  ): Promise<SimpleValidationResult> {\n    const validator = this.createValidator<TelegramFileSubmission>();\n\n    this.validateDescription(postData, validator);\n\n    return validator.result;\n  }\n\n  createMessageModel(): TelegramMessageSubmission {\n    return new TelegramMessageSubmission();\n  }\n\n  async onPostMessageSubmission(\n    postData: PostData<TelegramMessageSubmission>,\n    cancellationToken: CancellableToken,\n  ): Promise<PostResponse> {\n    cancellationToken.throwIfCancelled();\n    let response: Api.TypeUpdates | undefined;\n    const [description, entities] = TelegramConverter.fromJson(\n      postData.options.description,\n    );\n    const telegram = await this.getTelegramClient();\n\n    for (const channel of postData.options.channels) {\n      cancellationToken.throwIfCancelled();\n\n      response = await telegram.invoke(\n        new Api.messages.SendMessage({\n          message: description,\n          entities,\n          silent: postData.options.silent,\n          peer: this.getPeer(channel),\n        }),\n      );\n    }\n\n    const postResponse = PostResponse.fromWebsite(this);\n    const sourceUrl = this.getSourceFromResponse(response);\n    if (sourceUrl) postResponse.withSourceUrl(sourceUrl);\n    return postResponse;\n  }\n\n  async onValidateMessageSubmission(\n    postData: PostData<TelegramMessageSubmission>,\n  ): Promise<SimpleValidationResult> {\n    const validator = this.createValidator<TelegramMessageSubmission>();\n\n    this.validateDescription(postData, validator);\n\n    return validator.result;\n  }\n\n  getDescriptionConverter(): BaseConverter {\n    return new TelegramConverter();\n  }\n}\n\nclass TelegramConverter extends HtmlConverter {\n  protected getBlockSeparator(): string {\n    return '<br>';\n  }\n\n  convertBlocks(nodes: TipTapNode[], context: ConversionContext): string {\n    // When html encouters the default description it uses convertRawBlocks which calls convertBlock\n    // which returns json that ends up in user posts\n    if (nodes === context.defaultDescription)\n      return super.convertBlocks(nodes, context);\n\n    let html = super.convertBlocks(nodes, context);\n\n    html = html.replaceAll('<hr>', '<span>————————</span>');\n\n    // Used for description preview\n    const rendered = HTMLToTelegram.unparse(\n      ...TelegramConverter.fromHtml(html),\n    ).replaceAll('\\n', '<br>');\n\n    return JSON.stringify({\n      html,\n      rendered,\n    });\n  }\n\n  static fromJson(json: string) {\n    const { html } = JSON.parse(json) as { html: string };\n\n    return TelegramConverter.fromHtml(html);\n  }\n\n  private static fromHtml(html: string) {\n    return HTMLToTelegram.parse(html.replaceAll('<br>', '\\n'));\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/test/models/test-file-submission.ts",
    "content": "import { TagField } from '@postybirb/form-builder';\nimport { TagValue } from '@postybirb/types';\nimport { BaseWebsiteOptions } from '../../../models/base-website-options';\n\nexport class TestFileSubmission extends BaseWebsiteOptions {\n  @TagField({ maxTags: 10 })\n  tags: TagValue;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/test/models/test-message-submission.ts",
    "content": "import { BaseWebsiteOptions } from '../../../models/base-website-options';\n\nexport class TestMessageSubmission extends BaseWebsiteOptions {}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/test/test.website.ts",
    "content": "import {\n  ILoginState,\n  ImageResizeProps,\n  IPostResponse,\n  IWebsiteFormFields,\n  IWebsiteMetadata,\n  OAuthRouteHandlers,\n  OAuthRoutes,\n  PostData,\n  PostResponse,\n  SimpleValidationResult,\n} from '@postybirb/types';\nimport { CancellableToken } from '../../../post/models/cancellable-token';\nimport { PostingFile } from '../../../post/models/posting-file';\nimport { wait } from '../../../utils/wait.util';\nimport { UserLoginFlow } from '../../decorators/login-flow.decorator';\nimport { SupportsFiles } from '../../decorators/supports-files.decorator';\nimport { WebsiteMetadata } from '../../decorators/website-metadata.decorator';\nimport { FileWebsite } from '../../models/website-modifiers/file-website';\nimport { MessageWebsite } from '../../models/website-modifiers/message-website';\nimport { OAuthWebsite } from '../../models/website-modifiers/oauth-website';\nimport { Website } from '../../website';\nimport { TestFileSubmission } from './models/test-file-submission';\nimport { TestMessageSubmission } from './models/test-message-submission';\n\nexport const TestMetadata: IWebsiteMetadata = {\n  name: 'test',\n  displayName: 'Test',\n};\n\n@WebsiteMetadata(TestMetadata)\n@UserLoginFlow('https://patreon.com')\n@SupportsFiles(['image/png', 'image/jpeg', 'text/plain'])\nexport default class TestWebsite\n  extends Website<{ test: string }>\n  implements\n    FileWebsite<TestFileSubmission>,\n    MessageWebsite<TestMessageSubmission>,\n    OAuthWebsite<OAuthRoutes>\n{\n  public externallyAccessibleWebsiteDataProperties: { test: boolean } = {\n    test: true,\n  };\n\n  protected BASE_URL = 'http://localhost:3000';\n\n  public async onLogin(): Promise<ILoginState> {\n    if (this.account.id === 'FAIL') {\n      this.loginState.logout();\n    }\n\n    // await wait(5_000);\n    await this.websiteDataStore.setData({ test: 'test-mode' });\n    return this.loginState.setLogin(true, 'TestUser');\n  }\n\n  createFileModel(): TestFileSubmission {\n    return new TestFileSubmission();\n  }\n\n  createMessageModel(): TestMessageSubmission {\n    return new TestMessageSubmission();\n  }\n\n  calculateImageResize(): ImageResizeProps {\n    return undefined;\n  }\n\n  async onPostFileSubmission(\n    postData: PostData<IWebsiteFormFields>,\n    files: PostingFile[],\n    cancellationToken: CancellableToken,\n  ): Promise<IPostResponse> {\n    cancellationToken.throwIfCancelled();\n    return PostResponse.fromWebsite(this)\n      .atStage('test')\n      .withMessage('test message');\n  }\n\n  async onValidateFileSubmission(\n    postData: PostData<TestFileSubmission>,\n  ): Promise<SimpleValidationResult> {\n    return {\n      warnings: [],\n      errors: [],\n    };\n  }\n\n  async onPostMessageSubmission(\n    postData: PostData<TestMessageSubmission>,\n    cancellationToken: CancellableToken,\n  ): Promise<IPostResponse> {\n    cancellationToken.throwIfCancelled();\n    if (this.account.name === 'FAIL') {\n      return PostResponse.fromWebsite(this)\n        .atStage('validation')\n        .withMessage('Forced failure for testing purposes.')\n        .withException(new Error('Forced failure'));\n    }\n\n    if (this.account.name === 'SUCCESS') {\n      await wait(15_000);\n      return PostResponse.fromWebsite(this)\n        .atStage('validation')\n        .withMessage('Forced success for testing purposes.')\n        .withSourceUrl('http://example.com/success');\n    }\n\n    return PostResponse.fromWebsite(this)\n      .atStage('test')\n      .withMessage('test message');\n  }\n\n  async onValidateMessageSubmission(\n    postData: PostData<TestMessageSubmission>,\n  ): Promise<SimpleValidationResult> {\n    const results: SimpleValidationResult = {\n      warnings: [],\n      errors: [],\n    };\n    return results;\n  }\n\n  onAuthRoute: OAuthRouteHandlers<OAuthRoutes> = {\n    authorize: (request) => ({ result: true }),\n  };\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/toyhouse/models/toyhouse-account-data.ts",
    "content": "import { SelectOption } from '@postybirb/form-builder';\n\nexport type ToyhouseAccountData = {\n  characters: SelectOption[];\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/toyhouse/models/toyhouse-file-submission.ts",
    "content": "import { BooleanField, DescriptionField, RatingField, SelectField, TagField, TextField, TitleField } from '@postybirb/form-builder';\nimport { DescriptionType, DescriptionValue, SubmissionRating } from '@postybirb/types';\nimport { BaseWebsiteOptions } from '../../../models/base-website-options';\nimport { ToyhouseAccountData } from './toyhouse-account-data';\n\nconst ToyhouseMaturityOptions = [\n  { value: SubmissionRating.GENERAL, label: 'No Sexual Content' },\n  { value: SubmissionRating.MATURE, label: 'Mild Sexual Content' },\n  { value: SubmissionRating.ADULT, label: 'Explicit Sexual Content' },\n];\n\nconst ToyhousePrivacyOptions = [\n  { value: '0', label: 'Full-Size' },\n  { value: '1', label: 'Watermarked' },\n  { value: '2', label: 'Thumbnail' },\n  { value: '3', label: 'Hidden' },\n];\n\nconst ToyhouseWatermarkOptions = [\n  { value: '1', label: 'Default (Center)' },\n  { value: '2', label: 'Default (Stretch)' },\n  { value: '3', label: 'Default (Tile)' },\n  // Custom watermarks are not supported at this time.\n];\n\nexport class ToyhouseFileSubmission extends BaseWebsiteOptions {\n  @TitleField({\n    required: false,\n    hidden: true,\n  })\n  title: never;\n\n  @TagField({\n    hidden: true,\n  })\n  tags: never;\n\n\n  @RatingField({\n    options: ToyhouseMaturityOptions,\n    required: true,\n    section: 'common', order: 1\n  })\n  rating: SubmissionRating;\n\n  @BooleanField({\n    label: 'nudity',\n    span: 4,\n    section: 'common', order: 2\n  })\n  nudity: boolean;\n\n  @BooleanField({\n    label: 'gore',\n    span: 4,\n    section: 'common', order: 2\n  })\n  gore: boolean;\n\n  @BooleanField({\n    label: 'sensitiveContent',\n    span: 4,\n    section: 'common', order: 2\n  })\n  sensitiveContent: boolean;\n\n  @TextField<ToyhouseFileSubmission>({\n    label: 'contentWarning',\n    span: 12,\n    maxLength: 200,\n    hidden: false,\n    showWhen: [['sensitiveContent', [true]]],\n    section: 'common', order: 3\n  })\n  contentWarning: string;\n\n  @DescriptionField({\n    label: 'description',\n    descriptionType: DescriptionType.PLAINTEXT,\n    required: false,\n    maxDescriptionLength: 255,\n    section: 'common', order: 4\n  })\n  description: DescriptionValue;\n\n\n  @SelectField<ToyhouseAccountData>({\n    label: 'characters',\n    allowMultiple: true,\n    required: true,\n    options: [],\n    derive: [\n      {\n        key: 'characters',\n        populate: 'options',\n      },\n    ],\n  })\n  characters: string[];\n\n  @TextField({\n    label: 'artistName',\n    required: true,\n    span: 6,\n  })\n  artistName: string;\n\n  @TextField<ToyhouseFileSubmission>({\n    label: 'offSiteArtistUrl',\n    required: false,\n    span: 6,\n  })\n  offSiteArtistUrl?: string;\n\n  @SelectField({ \n    label: 'authorizedViewers',\n    options: ToyhousePrivacyOptions,\n    defaultValue: '0',\n    span: 4,\n  })\n  authorizedViewers: string;\n\n  @SelectField({ \n    label: 'publicViewers',\n    options: ToyhousePrivacyOptions,\n    defaultValue: '0',\n    span: 4,\n  })\n  publicViewers: string;\n\n  @SelectField({ \n    label: 'watermark',\n    options: ToyhouseWatermarkOptions,\n    defaultValue: '1',\n    span: 4,\n  })\n  watermark: string;\n}\n\n\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/toyhouse/toyhouse.website.ts",
    "content": "import { FormFile, Http, HttpResponse } from '@postybirb/http';\nimport {\n  ILoginState,\n  ImageResizeProps,\n  ISubmissionFile,\n  PostData,\n  PostFields,\n  PostResponse,\n  SimpleValidationResult,\n  SubmissionRating,\n} from '@postybirb/types';\nimport { HTMLElement, parse } from 'node-html-parser';\nimport { CancellableToken } from '../../../post/models/cancellable-token';\nimport { PostingFile } from '../../../post/models/posting-file';\nimport FileSize from '../../../utils/filesize.util';\nimport { UserLoginFlow } from '../../decorators/login-flow.decorator';\nimport { SupportsFiles } from '../../decorators/supports-files.decorator';\nimport { WebsiteMetadata } from '../../decorators/website-metadata.decorator';\nimport { DataPropertyAccessibility } from '../../models/data-property-accessibility';\nimport { FileWebsite } from '../../models/website-modifiers/file-website';\nimport { Website } from '../../website';\nimport { ToyhouseAccountData } from './models/toyhouse-account-data';\nimport { ToyhouseFileSubmission } from './models/toyhouse-file-submission';\n\n@WebsiteMetadata({\n  name: 'toyhouse',\n  displayName: 'Toyhouse',\n})\n@UserLoginFlow('https://toyhou.se/~account/login')\n@SupportsFiles({\n  acceptedMimeTypes: ['image/png', 'image/jpeg', 'image/gif'],\n  acceptedFileSizes: {\n    '*': FileSize.megabytes(4),\n  },\n})\nexport default class Toyhouse\n  extends Website<ToyhouseAccountData>\n  implements FileWebsite<ToyhouseFileSubmission>\n{\n  protected BASE_URL = 'https://toyhou.se';\n\n  public externallyAccessibleWebsiteDataProperties: DataPropertyAccessibility<ToyhouseAccountData> =\n    {\n      characters: true,\n    };\n\n  public async onLogin(): Promise<ILoginState> {\n    try {\n      const res = await Http.get<string>(\n        `${this.BASE_URL}/~characters/manage/folder:all`,\n        { partition: this.accountId },\n      );\n\n      if (!isLoggedIn(res)) {\n        // Not logged in\n        return this.loginState.setLogin(false, null);\n      }\n\n      const $ = parse(res.body);\n      const usernameEl = $.querySelector(\n        '.navbar .display-user-tiny > span.display-user-username',\n      );\n      if (!usernameEl) {\n        this.logger.warn('Failed to find username element during login');\n        return this.loginState.setLogin(false, null);\n      }\n      const username = usernameEl.text.trim();\n\n      const characters = await this.loadAllCharacters($);\n\n      this.setWebsiteData({ characters });\n\n      return this.loginState.setLogin(true, username);\n    } catch (e) {\n      this.logger.error('Failed to login', e);\n      return this.loginState.setLogin(false, null);\n    }\n  }\n\n  private async loadAllCharacters(firstPage: HTMLElement) {\n    const allCharacters = this.getCharacters(firstPage);\n\n    // Check if there are more pages\n    let hasNextPage = !!firstPage.querySelector(\n      '.pagination-wrapper a[rel=\"next\"]',\n    );\n    let pageNumber = 2;\n\n    while (hasNextPage) {\n      const url = `${this.BASE_URL}/~characters/manage/folder:all?page=${pageNumber}`;\n\n      const res = await Http.get<string>(url, {\n        partition: this.accountId,\n      });\n\n      const $ = parse(res.body);\n      const pageCharacters = this.getCharacters($);\n      allCharacters.push(...pageCharacters);\n\n      // Check if there's a next page\n      hasNextPage = !!$.querySelector('.pagination-wrapper a[rel=\"next\"]');\n      pageNumber++;\n    }\n\n    return allCharacters;\n  }\n\n  createFileModel(): ToyhouseFileSubmission {\n    return new ToyhouseFileSubmission();\n  }\n\n  calculateImageResize(file: ISubmissionFile): ImageResizeProps {\n    return undefined;\n  }\n\n  async onPostFileSubmission(\n    postData: PostData<ToyhouseFileSubmission>,\n    files: PostingFile[],\n    cancellationToken: CancellableToken,\n  ): Promise<PostResponse> {\n    cancellationToken.throwIfCancelled();\n\n    const page = await Http.get<string>(`${this.BASE_URL}/~images/upload`, {\n      partition: this.accountId,\n    });\n\n    if (!isLoggedIn(page)) {\n      return PostResponse.fromWebsite(this).withException(\n        new Error('Not logged in'),\n      );\n    }\n\n    const $ = parse(page.body);\n    const token = $.querySelector(\n      'head > meta[name=\"csrf-token\"]',\n    )?.getAttribute('content');\n    if (!token) {\n      return PostResponse.fromWebsite(this).withException(\n        new Error('Failed to find csrf-token meta element'),\n      );\n    }\n\n    // This form data is very finnicky on what does and does not work.\n    // Having some fields as empty strings is actually required. Be wary about changing anything about this payload.\n    const formData = {\n      // Tech stuff\n      _token: token,\n      referer_url: `${this.BASE_URL}/~images/upload`,\n\n      // Image\n      image: files[0].toPostFormat(),\n      image_zoom: '',\n      image_x: '',\n      image_y: '',\n\n      // Thumbnail (custom thumbnails are not supported at this time)\n      thumbnail: new FormFile(Buffer.alloc(0), {\n        filename: '',\n        contentType: 'application/octet-stream',\n      }),\n      thumbnail_options: 'onsite',\n      thumbnail_custom: 'offsite',\n\n      // Caption\n      caption: postData.options.description,\n\n      // Privacies\n      authorized_privacy: postData.options.authorizedViewers || '0',\n      public_privacy: postData.options.publicViewers || '0',\n      watermark_id: postData.options.watermark || '1',\n\n      // NSFW Settings\n      is_sexual: parseIsSexual(postData.options.rating),\n      is_nudity: postData.options.nudity ? '1' : undefined,\n      is_gore: postData.options.gore ? '1' : undefined,\n      is_sensitive:\n        postData.options.sensitiveContent || postData.options.contentWarning\n          ? '1'\n          : undefined,\n      warning: postData.options.contentWarning || '',\n\n      // Artist Credits\n      ...parseArtistInfo(postData.options),\n\n      // Characters\n      'character_ids[]': [...postData.options.characters, ''],\n    };\n\n    const result = await Http.post<string>(`${this.BASE_URL}/~images/upload`, {\n      partition: this.accountId,\n      data: formData,\n      type: 'multipart',\n    });\n\n    if (result.statusCode === 200 && !result.body.includes('alert-danger')) {\n      return PostResponse.fromWebsite(this)\n        .withAdditionalInfo(result.body)\n        .withSourceUrl(result.responseUrl);\n    }\n\n    const err = parse(result.body)\n      .querySelector('.alert-danger')\n      .textContent.trim();\n\n    return PostResponse.fromWebsite(this)\n      .withAdditionalInfo(result.body)\n      .withException(new Error(`Failed to post: ${err}`));\n  }\n\n  async onValidateFileSubmission(\n    postData: PostData<ToyhouseFileSubmission>,\n  ): Promise<SimpleValidationResult> {\n    const validator = this.createValidator<ToyhouseFileSubmission>();\n\n    return validator.result;\n  }\n\n  private getCharacters($: HTMLElement) {\n    const getCharId = (href: string) => {\n      // Extract the rightmost ID from paths like /6669686.name or /6669686.name/6669743.other-name\n      const parts = href.split('/').filter(Boolean);\n      const match = parts[parts.length - 1]?.match(/^(\\d+)\\./);\n      return match ? match[1] : null;\n    };\n\n    return Array.from(\n      $.querySelectorAll(\n        '.characters-gallery .gallery-thumb .character-name-badge',\n      ),\n    ).map((e) => ({\n      label: e.textContent.trim(),\n      value: getCharId(e.getAttribute('href')),\n    }));\n  }\n}\n\nfunction isLoggedIn(res: HttpResponse<string>) {\n  return (\n    res.statusCode === 200 &&\n    (!res.responseUrl ||\n      new URL(res.responseUrl).pathname !== '/~account/login')\n  );\n}\n\nfunction parseIsSexual(rating: SubmissionRating) {\n  switch (rating) {\n    case SubmissionRating.GENERAL:\n      return '0'; // No Sexual Content\n    case SubmissionRating.MATURE:\n      return '1'; // Mild Sexual Content\n    case SubmissionRating.ADULT:\n    case SubmissionRating.EXTREME:\n      return '2'; // Explicit Sexual Content\n    default:\n      return '0';\n  }\n}\n\nfunction parseArtistInfo(submission: PostFields<ToyhouseFileSubmission>) {\n  // If offSiteArtistUrl is provided, use Off-Site artist info.\n  // We only support one artist for simplicity. Toyhouse expects n+1 artists, so we add an extra.\n  if (submission.offSiteArtistUrl) {\n    return {\n      'artist[]': ['offsite', 'onsite'],\n      'artist_username[]': ['', ''],\n      'artist_url[]': [submission.offSiteArtistUrl, ''],\n      'artist_name[]': [submission.artistName, ''],\n      'artist_credit[]': ['', ''],\n    };\n  }\n\n  // On-Site artist.\n  return {\n    'artist[]': ['onsite', 'onsite'],\n    'artist_username[]': [submission.artistName, ''],\n    'artist_url[]': ['', ''],\n    'artist_name[]': ['', ''],\n    'artist_credit[]': ['', ''],\n  };\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/tumblr/models/tumblr-account-data.ts",
    "content": "import { SelectOption } from '@postybirb/form-builder';\n\nexport type TumblrAccountData = { \n  blogs: SelectOption[];\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/tumblr/models/tumblr-file-submission.ts",
    "content": "import {\n  BooleanField,\n  DescriptionField,\n  SelectField,\n  TagField,\n} from '@postybirb/form-builder';\nimport {\n  DefaultTagValue,\n  DescriptionType,\n  DescriptionValue,\n  SubmissionRating,\n  TagValue,\n} from '@postybirb/types';\nimport { BaseWebsiteOptions } from '../../../models/base-website-options';\nimport { TumblrAccountData } from './tumblr-account-data';\n\nexport class TumblrFileSubmission extends BaseWebsiteOptions {\n  @DescriptionField({\n    descriptionType: DescriptionType.CUSTOM,\n    expectsInlineTitle: true,\n  })\n  description: DescriptionValue;\n\n  @TagField({\n    section: 'common',\n    order: 3,\n    span: 12,\n    spaceReplacer: ' ',\n  })\n  tags: TagValue = DefaultTagValue();\n\n  @BooleanField({\n    label: 'drugUse',\n    span: 4,\n    showWhen: [\n      [\n        'rating',\n        [\n          SubmissionRating.MATURE,\n          SubmissionRating.ADULT,\n          SubmissionRating.EXTREME,\n        ],\n      ],\n    ],\n  })\n  drugUse = false;\n\n  @BooleanField({\n    label: 'violence',\n    span: 4,\n    showWhen: [\n      [\n        'rating',\n        [\n          SubmissionRating.MATURE,\n          SubmissionRating.ADULT,\n          SubmissionRating.EXTREME,\n        ],\n      ],\n    ],\n  })\n  violence = false;\n\n  @BooleanField({\n    label: 'sexualContent',\n    span: 4,\n    showWhen: [\n      [\n        'rating',\n        [\n          SubmissionRating.MATURE,\n          SubmissionRating.ADULT,\n          SubmissionRating.EXTREME,\n        ],\n      ],\n    ],\n  })\n  sexualContent = false;\n\n  @SelectField<TumblrAccountData>({\n    label: 'blog',\n    options: [],\n    required: true,\n    derive: [\n      {\n        key: 'blogs',\n        populate: 'options',\n      },\n    ],\n    span: 6,\n  })\n  blog: string;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/tumblr/models/tumblr-message-submission.ts",
    "content": "import { TumblrFileSubmission } from './tumblr-file-submission';\n\nexport class TumblrMessageSubmission extends TumblrFileSubmission {}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/tumblr/tumblr.website.ts",
    "content": "// BlockNote types are used in the UI, but conversion happens via DescriptionNode\n// No need to import BlockNote in the server-side website implementation\nimport { Http } from '@postybirb/http';\nimport {\n    DynamicObject,\n    FileType,\n    ILoginState,\n    ImageResizeProps,\n    ISubmissionFile,\n    PostData,\n    PostResponse,\n    SimpleValidationResult,\n    SubmissionRating,\n} from '@postybirb/types';\nimport parse from 'node-html-parser';\nimport { BaseConverter } from '../../../post-parsers/models/description-node/converters/base-converter';\nimport { NpfConverter } from '../../../post-parsers/models/description-node/converters/npf-converter';\nimport { CancellableToken } from '../../../post/models/cancellable-token';\nimport { PostingFile } from '../../../post/models/posting-file';\nimport FileSize from '../../../utils/filesize.util';\nimport { PostBuilder } from '../../commons/post-builder';\nimport { UserLoginFlow } from '../../decorators/login-flow.decorator';\nimport { SupportsFiles } from '../../decorators/supports-files.decorator';\nimport { SupportsUsernameShortcut } from '../../decorators/supports-username-shortcut.decorator';\nimport { WebsiteMetadata } from '../../decorators/website-metadata.decorator';\nimport { DataPropertyAccessibility } from '../../models/data-property-accessibility';\nimport { FileWebsite } from '../../models/website-modifiers/file-website';\nimport { MessageWebsite } from '../../models/website-modifiers/message-website';\nimport { WithCustomDescriptionParser } from '../../models/website-modifiers/with-custom-description-parser';\nimport { Website } from '../../website';\nimport { TumblrAccountData } from './models/tumblr-account-data';\nimport { TumblrFileSubmission } from './models/tumblr-file-submission';\nimport { TumblrMessageSubmission } from './models/tumblr-message-submission';\n\ntype TumblrSessionData = {\n  apiToken?: string;\n  state: DynamicObject;\n  csrf: string;\n};\n\ntype TumblrPostResponse = {\n  meta: {\n    msg: string;\n    status: number;\n  };\n  response: {\n    displayText: string;\n    id: string;\n    state: string;\n  };\n};\n\n@WebsiteMetadata({\n  name: 'tumblr',\n  displayName: 'Tumblr',\n})\n@UserLoginFlow('https://www.tumblr.com')\n@SupportsUsernameShortcut({\n  id: 'tumblr',\n  url: 'https://www.tumblr.com/blog/$1',\n})\n@SupportsFiles({\n  acceptedMimeTypes: [\n    'image/jpeg',\n    'image/png',\n    'image/gif',\n    'image/jfif',\n    'image/pjpeg',\n    'image/webp',\n    'audio/mp3',\n    'audio/mpeg',\n    'video/quicktime',\n    'video/x-m4v',\n    'video/mp4',\n  ],\n  fileBatchSize: 30,\n  acceptedFileSizes: {\n    [FileType.AUDIO]: FileSize.megabytes(10),\n    [FileType.VIDEO]: FileSize.megabytes(500),\n    [FileType.IMAGE]: FileSize.megabytes(20),\n    'image/gif': FileSize.megabytes(3),\n  },\n})\nexport default class Tumblr\n  extends Website<TumblrAccountData, TumblrSessionData>\n  implements\n    FileWebsite<TumblrFileSubmission>,\n    MessageWebsite<TumblrMessageSubmission>,\n    WithCustomDescriptionParser\n{\n  protected BASE_URL = 'https://www.tumblr.com';\n\n  public externallyAccessibleWebsiteDataProperties: DataPropertyAccessibility<TumblrAccountData> =\n    {\n      blogs: true,\n    };\n\n  public async onLogin(): Promise<ILoginState> {\n    const page = await Http.get<string>(`${this.BASE_URL}`, {\n      partition: this.accountId,\n    });\n\n    const root = parse(page.body);\n    const initialStateEl = root.querySelector('#___INITIAL_STATE___');\n    if (!initialStateEl) {\n      this.logger.warn('Failed to find #___INITIAL_STATE___ element during login');\n      this.loginState.logout();\n      return this.loginState;\n    }\n    const initialState = initialStateEl.innerText;\n    const cleanedState = initialState.trim().replace(/\\\\\\\\\"/g, '\\\\\"');\n    const data = JSON.parse(cleanedState);\n    const apiToken = data?.apiFetchStore?.API_TOKEN;\n\n    if (!apiToken) {\n      this.loginState.logout();\n      return this.loginState;\n    }\n\n    this.sessionData.apiToken = apiToken;\n    this.sessionData.state = data;\n    this.sessionData.csrf = data.csrfToken;\n    const userInfo = data.queries.queries.find((query) =>\n      query.queryHash.includes('user-info'),\n    );\n\n    if (!userInfo || userInfo?.state?.data?.isLoggedIn === false) {\n      this.loginState.logout();\n      return this.loginState;\n    }\n\n    const userName = userInfo.state.data.user.name;\n\n    await this.setWebsiteData({\n      blogs: userInfo.state.data.user.blogs.map((blog) => ({\n        label: blog.name,\n        value: blog.uuid,\n        data: blog,\n      })),\n    });\n    return this.loginState.setLogin(true, userName);\n  }\n\n  createFileModel(): TumblrFileSubmission {\n    return new TumblrFileSubmission();\n  }\n\n  calculateImageResize(file: ISubmissionFile): ImageResizeProps {\n    return undefined;\n  }\n\n  async onPostFileSubmission(\n    postData: PostData<TumblrFileSubmission>,\n    files: PostingFile[],\n    cancellationToken: CancellableToken,\n  ): Promise<PostResponse> {\n    // Update csrf token\n    await this.onLogin();\n\n    // Description is a JSON string of NPF blocks from the NpfConverter\n    const npfBlocks = JSON.parse(postData.options.description);\n\n    const blogId = postData.options.blog;\n\n    // Upload files and add them as NPF media blocks\n    const mediaBlocks = await this.uploadFiles(\n      files,\n      blogId,\n      cancellationToken,\n    );\n\n    // Update csrf token\n    await this.onLogin();\n\n    // Combine description blocks with media blocks\n    const allBlocks = [...mediaBlocks, ...npfBlocks];\n\n    const builder = new PostBuilder(this, cancellationToken)\n      .asJson()\n      .withHeader('Authorization', `Bearer ${this.sessionData.apiToken}`)\n      .withHeader('Referer', 'https://www.tumblr.com/new/text')\n      .withHeader('Origin', 'https://www.tumblr.com')\n      .withHeader('X-Csrf', this.sessionData.csrf)\n      .setField(\n        'community_label_categories',\n        this.getCommunityLabelCategories(postData),\n      )\n      .setField('content', allBlocks)\n      .setField(\n        'has_community_label',\n        postData.options.rating !== SubmissionRating.GENERAL,\n      )\n      .setField('hide_trail', false)\n      .setField('layout', [\n        {\n          type: 'rows',\n          display: allBlocks.map((block, index) => ({ blocks: [index] })),\n        },\n      ])\n      .setField('tags', postData.options.tags?.join(', '));\n\n    const result = await builder.send<TumblrPostResponse>(\n      `${this.BASE_URL}/api/v2/blog/${blogId}/posts`,\n    );\n\n    if (\n      result.body.response.state === 'published' ||\n      result.body.response.state === 'transcoding' // publishing video\n    ) {\n      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion\n      const blogData = this.getWebsiteData().blogs.find(\n        (b) => b.value === blogId,\n      )!;\n      const postUrl = `${blogData.data.url}${result.body.response.id}`;\n      return PostResponse.fromWebsite(this)\n        .withAdditionalInfo(result.body)\n        .withSourceUrl(postUrl);\n    }\n\n    return PostResponse.fromWebsite(this)\n      .withAdditionalInfo({\n        body: result.body,\n        statusCode: result.statusCode,\n      })\n      .withException(new Error('Failed to post'));\n  }\n\n  async onValidateFileSubmission(\n    postData: PostData<TumblrFileSubmission>,\n  ): Promise<SimpleValidationResult> {\n    const validator = this.createValidator<TumblrFileSubmission>();\n\n    return validator.result;\n  }\n\n  createMessageModel(): TumblrMessageSubmission {\n    return new TumblrMessageSubmission();\n  }\n\n  getDescriptionConverter(): BaseConverter {\n    return new NpfConverter();\n  }\n\n  async onPostMessageSubmission(\n    postData: PostData<TumblrMessageSubmission>,\n    cancellationToken: CancellableToken,\n  ): Promise<PostResponse> {\n    // Update csrf token\n    await this.onLogin();\n\n    // Description is a JSON string of NPF blocks from the NpfConverter\n    const npfBlocks = JSON.parse(postData.options.description);\n\n    const builder = new PostBuilder(this, cancellationToken)\n      .asJson()\n      .withHeader('Authorization', `Bearer ${this.sessionData.apiToken}`)\n      .withHeader('Referer', 'https://www.tumblr.com')\n      .withHeader('Origin', 'https://www.tumblr.com')\n      .withHeader('X-Csrf', this.sessionData.csrf)\n      .setField(\n        'community_label_categories',\n        this.getCommunityLabelCategories(postData),\n      )\n      .setField('content', npfBlocks)\n      .setField(\n        'has_community_label',\n        postData.options.rating !== SubmissionRating.GENERAL,\n      )\n      .setField('hide_trail', false)\n      .setField('layout', [\n        {\n          type: 'rows',\n          display: npfBlocks.map((block, index) => ({ blocks: [index] })),\n        },\n      ])\n      .setField('tags', postData.options.tags?.join(', '));\n\n    const blogId = postData.options.blog;\n    const result = await builder.send<TumblrPostResponse>(\n      `${this.BASE_URL}/api/v2/blog/${blogId}/posts`,\n    );\n\n    if (result.body.response.state === 'published') {\n      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion\n      const blogData = this.getWebsiteData().blogs.find(\n        (b) => b.value === blogId,\n      )!;\n      const postUrl = `${blogData.data.url}${result.body.response.id}`;\n      return PostResponse.fromWebsite(this)\n        .withAdditionalInfo(result.body)\n        .withSourceUrl(postUrl);\n    }\n\n    return PostResponse.fromWebsite(this)\n      .withAdditionalInfo({\n        body: result.body,\n        statusCode: result.statusCode,\n      })\n      .withException(new Error('Failed to post'));\n  }\n\n  async onValidateMessageSubmission(\n    postData: PostData<TumblrMessageSubmission>,\n  ): Promise<SimpleValidationResult> {\n    const validator = this.createValidator<TumblrMessageSubmission>();\n\n    return validator.result;\n  }\n\n  private getCommunityLabelCategories(\n    postData: PostData<TumblrMessageSubmission | TumblrFileSubmission>,\n  ): string[] {\n    const labels = [];\n    const { options } = postData;\n    if (options.rating === SubmissionRating.GENERAL) {\n      return [];\n    }\n\n    if (options.drugUse) {\n      labels.push('drug_use');\n    }\n\n    if (options.violence) {\n      labels.push('violence');\n    }\n\n    if (options.sexualContent) {\n      labels.push('sexual_content');\n    }\n\n    return labels;\n  }\n\n  /**\n   * Uploads files to Tumblr and returns NPF media blocks\n   */\n  private async uploadFiles(\n    files: PostingFile[],\n    blogId: string,\n    cancellationToken: CancellableToken,\n  ): Promise<DynamicObject[]> {\n    const mediaBlocks: DynamicObject[] = [];\n\n    for (const file of files) {\n      cancellationToken.throwIfCancelled();\n\n      try {\n        const uploadedMedia = await this.uploadSingleFile(file, blogId);\n\n        // Create NPF media block based on file type\n        if (file.fileType === FileType.IMAGE) {\n          mediaBlocks.push({\n            type: 'image',\n            media: uploadedMedia,\n            alt_text: file.metadata?.altText || '',\n            provider: 'tumblr',\n          });\n        } else if (file.fileType === FileType.VIDEO) {\n          mediaBlocks.push({\n            type: 'video',\n            provider: 'tumblr',\n            media: uploadedMedia[0],\n          });\n        } else if (file.fileType === FileType.AUDIO) {\n          mediaBlocks.push({\n            type: 'audio',\n            provider: 'tumblr',\n            media: uploadedMedia[0],\n          });\n        }\n      } catch (error) {\n        this.logger.error(`Failed to upload file ${file.fileName}`, error);\n        throw error;\n      }\n    }\n\n    return mediaBlocks;\n  }\n\n  private getMediaType(file: PostingFile): string {\n    switch (file.fileType) {\n      case FileType.AUDIO:\n        return 'audio';\n      case FileType.IMAGE:\n        return 'image';\n      case FileType.VIDEO:\n        return 'video';\n      default:\n        throw new Error('Unsupported file type');\n    }\n  }\n\n  /**\n   * Uploads a single file to Tumblr and returns media information\n   * @param file - The file to upload\n   * @param blogId - The blog ID to upload to (optional, uses first blog if not provided)\n   */\n  private async uploadSingleFile(\n    file: PostingFile,\n    blogId: string,\n  ): Promise<DynamicObject[]> {\n    // Upload file using multipart form data\n    const uploadBuilder = new PostBuilder(this, new CancellableToken())\n      .asMultipart()\n      .withHeader('Authorization', `Bearer ${this.sessionData.apiToken}`)\n      .withHeader('Referer', 'https://www.tumblr.com')\n      .withHeader('Origin', 'https://www.tumblr.com')\n      .withHeader('X-Csrf', this.sessionData.csrf)\n      .addFile('file', file);\n\n    const uploadResult = await uploadBuilder.send<{\n      meta: { status: number; msg: string };\n      response: {\n        id?: string;\n        url?: string;\n        width?: number;\n        height?: number;\n      };\n    }>(`${this.BASE_URL}/api/v2/media/${this.getMediaType(file)}`);\n\n    if (!uploadResult.body.response?.url) {\n      throw new Error('Failed to upload file - no URL returned');\n    }\n\n    // Return media object(s) in Tumblr NPF format\n    return [uploadResult.body.response];\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/twitter/models/twitter-file-submission.ts",
    "content": "import { RadioField } from '@postybirb/form-builder';\nimport { ContentBlurValue } from '../twitter-api-service/twitter-api-service';\nimport { TwitterMessageSubmission } from './twitter-message-submission';\n\nexport class TwitterFileSubmission extends TwitterMessageSubmission {\n  @RadioField({\n    label: 'contentBlur',\n    options: [\n      {\n        label: 'None',\n        value: '',\n      },\n      {\n        label: 'Other',\n        value: 'other',\n      },\n      {\n        label: 'Adult Content',\n        value: 'adult_content',\n      },\n      {\n        label: 'Graphic Violence',\n        value: 'graphic_violence',\n      },\n    ],\n  })\n  contentBlur: ContentBlurValue;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/twitter/models/twitter-message-submission.ts",
    "content": "import { DescriptionField, RatingField } from '@postybirb/form-builder';\nimport {\n  DescriptionType,\n  DescriptionValue,\n  SubmissionRating,\n} from '@postybirb/types';\nimport { BaseWebsiteOptions } from '../../../models/base-website-options';\n\nexport class TwitterMessageSubmission extends BaseWebsiteOptions {\n  @DescriptionField({\n    descriptionType: DescriptionType.PLAINTEXT,\n    expectsInlineTags: true,\n    expectsInlineTitle: true,\n  })\n  description: DescriptionValue;\n\n  @RatingField({\n    options: [\n      {\n        label: 'Safe',\n        value: SubmissionRating.GENERAL,\n      },\n      {\n        label: 'Sensitive',\n        value: SubmissionRating.ADULT,\n      },\n    ],\n  })\n  rating: SubmissionRating;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/twitter/twitter-api-service/twitter-api-service.ts",
    "content": "/* eslint-disable @typescript-eslint/naming-convention */\nimport { Logger, PostyBirbLogger } from '@postybirb/logger';\nimport { PostData } from '@postybirb/types';\nimport {\n  InlineErrorV2,\n  TweetV2PostTweetResult,\n  TwitterApi,\n} from 'twitter-api-v2';\nimport { PostingFile } from '../../../../post/models/posting-file';\nimport { TwitterFileSubmission } from '../models/twitter-file-submission';\nimport { TwitterMessageSubmission } from '../models/twitter-message-submission';\n\nexport interface TwitterAuthLinkResult {\n  url: string;\n  oauthToken: string;\n  oauthTokenSecret: string;\n}\n\nexport interface TwitterAccessTokenResult {\n  accessToken: string;\n  accessTokenSecret: string;\n  screenName?: string;\n  userId?: string;\n}\n\nexport interface TwitterAccessKeys {\n  apiKey: string;\n  apiSecret: string;\n  accessToken: string;\n  accessTokenSecret: string;\n}\n\nexport interface TweetResultMeta {\n  id?: string;\n  text?: string;\n  mediaIds: string[];\n  url?: string;\n  replyTo?: string;\n  success: boolean;\n  error?: string;\n  raw?: unknown;\n}\n\nexport type ContentBlurValue =\n  | undefined\n  | 'other'\n  | 'adult_content'\n  | 'graphic_violence'\n  | 'graphical_violence';\n\n/**\n *\n * Wrapper for Twitter API calls\n * Primarily uses twitter-api-v2\n *\n * @class TwitterApiServiceV2\n */\nexport class TwitterApiServiceV2 {\n  private static logger: PostyBirbLogger;\n\n  private static get Logger() {\n    if (TwitterApiServiceV2.logger) {\n      return TwitterApiServiceV2.logger;\n    }\n\n    TwitterApiServiceV2.logger = Logger(TwitterApiServiceV2.name);\n    return TwitterApiServiceV2.logger;\n  }\n\n  /**\n   * Create an authenticated Twitter client\n   */\n  private static createClient(auth: TwitterAccessKeys): TwitterApi {\n    return new TwitterApi({\n      appKey: auth.apiKey,\n      appSecret: auth.apiSecret,\n      accessToken: auth.accessToken,\n      accessSecret: auth.accessTokenSecret,\n    });\n  }\n\n  /**\n   * Upload media files and apply metadata (alt text, content warnings)\n   */\n  static async uploadMediaFiles(\n    client: TwitterApi,\n    files: PostingFile[],\n    contentBlur?: ContentBlurValue,\n  ): Promise<{ mediaIds: string[]; errors: TweetResultMeta[] }> {\n    const mediaIds: string[] = [];\n    const errors: TweetResultMeta[] = [];\n\n    for (const file of files) {\n      try {\n        const mediaId = await client.v1.uploadMedia(file.buffer, {\n          mimeType: file.mimeType,\n        });\n        mediaIds.push(mediaId);\n\n        // Apply alt text or sensitive media metadata if provided\n        const altText = file.metadata?.altText?.trim();\n\n        if (altText || contentBlur) {\n          try {\n            const body: Record<string, unknown> = { media_id: mediaId };\n\n            if (altText) {\n              body.alt_text = { text: altText.substring(0, 1000) };\n            }\n\n            if (contentBlur) {\n              body.sensitive_media_warning = [\n                contentBlur === 'graphical_violence'\n                  ? 'graphic_violence'\n                  : contentBlur,\n              ];\n            }\n\n            await client.v1.post('media/metadata/create.json', body);\n          } catch (metaErr) {\n            // Non-fatal – collect metadata failure info\n            errors.push({\n              mediaIds: [mediaId],\n              success: false,\n              error: `Failed to set metadata for media ${mediaId}: ${\n                metaErr instanceof Error ? metaErr.message : String(metaErr)\n              }`,\n            });\n          }\n        }\n      } catch (uploadErr) {\n        throw new Error(\n          `Upload failed: ${uploadErr instanceof Error ? uploadErr.message : String(uploadErr)}`,\n        );\n      }\n    }\n\n    return { mediaIds, errors };\n  }\n\n  /**\n   * Get authenticated user information for URL construction\n   */\n  static async getUserInfo(client: TwitterApi): Promise<string | undefined> {\n    try {\n      const me = await client.v2.me();\n      return (me as unknown as { data?: { username?: string } })?.data\n        ?.username;\n    } catch {\n      // Ignore errors - will fallback to generic URL format\n      return undefined;\n    }\n  }\n\n  /**\n   * Convert media IDs array to Twitter API v2 tuple format\n   */\n  private static toMediaIdsTuple(\n    ids: string[],\n  ):\n    | [string]\n    | [string, string]\n    | [string, string, string]\n    | [string, string, string, string]\n    | undefined {\n    switch (ids.length) {\n      case 0:\n        return undefined;\n      case 1:\n        return [ids[0]];\n      case 2:\n        return [ids[0], ids[1]];\n      case 3:\n        return [ids[0], ids[1], ids[2]];\n      default:\n        return [ids[0], ids[1], ids[2], ids[3]];\n    }\n  }\n\n  /**\n   * Post a tweet with optional media\n   */\n  static async postTweet(\n    client: TwitterApi,\n    text: string,\n    mediaIds: string[] = [],\n    replyToId?: string,\n  ): Promise<{ tweetId?: string; tweetResponse: TweetV2PostTweetResult }> {\n    const tweetPayload = {\n      text: text || undefined,\n      media: mediaIds.length\n        ? { media_ids: this.toMediaIdsTuple(mediaIds) }\n        : undefined,\n      reply: replyToId ? { in_reply_to_tweet_id: replyToId } : undefined,\n    };\n\n    const tweetResponse = await client.v2.tweet(tweetPayload);\n\n    if (tweetResponse.errors?.length) {\n      throw new Error(\n        `Failed to post tweet: ${tweetResponse.errors\n          .map(\n            (e: InlineErrorV2) =>\n              `${e.title}: ${e.detail}${e.type ? ` (${e.type})` : ''}${e.reason ? ` - ${e.reason}` : ''}`,\n          )\n          .join(', ')}`,\n      );\n    }\n\n    const tweetId = tweetResponse.data.id;\n\n    return { tweetId, tweetResponse };\n  }\n\n  /**\n   * Generate a PIN-based (oob) auth link.\n   */\n  static async generateAuthLink(\n    apiKey: string,\n    apiSecret: string,\n  ): Promise<TwitterAuthLinkResult> {\n    const client = new TwitterApi({ appKey: apiKey, appSecret: apiSecret });\n    const { url, oauth_token, oauth_token_secret } =\n      await client.generateAuthLink('oob');\n    return {\n      url,\n      oauthToken: oauth_token,\n      oauthTokenSecret: oauth_token_secret,\n    };\n  }\n\n  /**\n   * Exchange verifier (PIN) for access tokens.\n   */\n  static async login(\n    apiKey: string,\n    apiSecret: string,\n    oauthToken: string,\n    oauthTokenSecret: string,\n    verifier: string,\n  ): Promise<TwitterAccessTokenResult> {\n    const client = new TwitterApi({\n      appKey: apiKey,\n      appSecret: apiSecret,\n      accessToken: oauthToken,\n      accessSecret: oauthTokenSecret,\n    });\n\n    const { accessToken, accessSecret, screenName, userId } =\n      await client.login(verifier);\n    return {\n      accessToken,\n      accessTokenSecret: accessSecret,\n      screenName: screenName ?? undefined,\n      userId: userId?.toString(),\n    };\n  }\n\n  static async postStatus(\n    auth: TwitterAccessKeys,\n    data: PostData<TwitterMessageSubmission>,\n    replyTo?: string,\n  ): Promise<TweetResultMeta> {\n    try {\n      const client = this.createClient(auth);\n      const postResult = await this.postTweet(\n        client,\n        data.options.description ?? '',\n        [],\n        replyTo,\n      );\n      const username = await this.getUserInfo(client);\n\n      const url = postResult.tweetId\n        ? username\n          ? `https://x.com/${username}/status/${postResult.tweetId}`\n          : `https://x.com/i/web/status/${postResult.tweetId}`\n        : undefined;\n\n      return {\n        id: postResult.tweetId,\n        success: true,\n        mediaIds: [],\n        url,\n        text: data.options.description ?? '',\n        raw: postResult.tweetResponse,\n      };\n    } catch (err) {\n      return {\n        success: false,\n        error: err instanceof Error ? err.message : String(err),\n        mediaIds: [],\n      };\n    }\n  }\n\n  static async postMedia(\n    auth: TwitterAccessKeys,\n    files: PostingFile[],\n    data: PostData<TwitterFileSubmission>,\n    replyTo?: string,\n  ): Promise<TweetResultMeta> {\n    try {\n      // Validate credentials\n      if (\n        !auth?.apiKey ||\n        !auth?.apiSecret ||\n        !auth?.accessToken ||\n        !auth?.accessTokenSecret\n      ) {\n        return {\n          success: false,\n          error: 'Missing API credentials',\n          mediaIds: [],\n        };\n      }\n\n      const client = this.createClient(auth);\n\n      const { contentBlur } = data.options;\n      const text = data.options.description ?? '';\n\n      // Upload media files with metadata\n      const { mediaIds, errors } = await this.uploadMediaFiles(\n        client,\n        files,\n        contentBlur,\n      );\n\n      if (errors.length) {\n        errors.forEach((err) => {\n          TwitterApiServiceV2.Logger.withMetadata(err).warn(\n            'There was a non-fatal issue posting media',\n          );\n        });\n      }\n\n      // Get user info for URL construction\n      const username = await this.getUserInfo(client);\n\n      // Post the tweet\n      try {\n        const { tweetId, tweetResponse } = await this.postTweet(\n          client,\n          replyTo ? '' : text, // Omit text if this is a reply to avoid duplication\n          mediaIds,\n          replyTo,\n        );\n\n        const url = tweetId\n          ? username\n            ? `https://x.com/${username}/status/${tweetId}`\n            : `https://x.com/i/web/status/${tweetId}`\n          : undefined;\n\n        const result: TweetResultMeta = {\n          id: tweetId,\n          text,\n          mediaIds,\n          url,\n          success: true,\n          raw: tweetResponse,\n        };\n\n        return result;\n      } catch (postErr) {\n        const errorResult: TweetResultMeta = {\n          mediaIds,\n          success: false,\n          error: postErr instanceof Error ? postErr.message : String(postErr),\n        };\n\n        return errorResult;\n      }\n    } catch (err) {\n      return {\n        success: false,\n        error: err instanceof Error ? err.message : String(err),\n        mediaIds: [],\n      };\n    }\n  }\n\n  /**\n   * Delete all tweets in a failed reply chain\n   */\n  public static async deleteFailedReplyChain(\n    auth: TwitterAccessKeys,\n    ids: string[],\n  ): Promise<{ deletedIds: string[]; errors: string[] }> {\n    const deletedIds: string[] = [];\n    const errors: string[] = [];\n\n    try {\n      // Validate credentials\n      if (\n        !auth?.apiKey ||\n        !auth?.apiSecret ||\n        !auth?.accessToken ||\n        !auth?.accessTokenSecret\n      ) {\n        throw new Error('Missing API credentials');\n      }\n\n      const client = this.createClient(auth);\n\n      // Delete tweets in reverse order (most recent first)\n      const idsToDelete = [...ids].reverse();\n\n      for (const tweetId of idsToDelete) {\n        if (!tweetId) continue;\n\n        try {\n          await client.v2.deleteTweet(tweetId);\n          deletedIds.push(tweetId);\n          this.Logger.debug(`Successfully deleted tweet ${tweetId}`);\n        } catch (deleteErr) {\n          const errorMsg = `Failed to delete tweet ${tweetId}: ${\n            deleteErr instanceof Error ? deleteErr.message : String(deleteErr)\n          }`;\n          errors.push(errorMsg);\n          this.Logger.error(errorMsg);\n        }\n      }\n\n      return { deletedIds, errors };\n    } catch (err) {\n      const errorMsg = `Failed to delete reply chain: ${\n        err instanceof Error ? err.message : String(err)\n      }`;\n      this.Logger.error(errorMsg);\n      return { deletedIds, errors: [errorMsg] };\n    }\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/twitter/twitter.website.ts",
    "content": "import {\n    FileType,\n    ILoginState,\n    ImageResizeProps,\n    ISubmissionFile,\n    OAuthRouteHandlers,\n    PostData,\n    PostResponse,\n    SimpleValidationResult,\n    TwitterAccountData,\n    TwitterOAuthRoutes,\n} from '@postybirb/types';\nimport { chunk } from 'lodash';\nimport { parseTweet } from 'twitter-text';\nimport { CancellableToken } from '../../../post/models/cancellable-token';\nimport { PostingFile } from '../../../post/models/posting-file';\nimport FileSize from '../../../utils/filesize.util';\nimport { DisableAds } from '../../decorators/disable-ads.decorator';\nimport { CustomLoginFlow } from '../../decorators/login-flow.decorator';\nimport { SupportsFiles } from '../../decorators/supports-files.decorator';\nimport { SupportsUsernameShortcut } from '../../decorators/supports-username-shortcut.decorator';\nimport { WebsiteMetadata } from '../../decorators/website-metadata.decorator';\nimport { DataPropertyAccessibility } from '../../models/data-property-accessibility';\nimport { FileWebsite } from '../../models/website-modifiers/file-website';\nimport { MessageWebsite } from '../../models/website-modifiers/message-website';\nimport { Website } from '../../website';\nimport { TwitterFileSubmission } from './models/twitter-file-submission';\nimport { TwitterMessageSubmission } from './models/twitter-message-submission';\nimport {\n    TweetResultMeta,\n    TwitterApiServiceV2,\n} from './twitter-api-service/twitter-api-service';\n\n@WebsiteMetadata({\n  name: 'twitter',\n  displayName: 'Twitter / X',\n})\n@CustomLoginFlow()\n@SupportsUsernameShortcut({\n  id: 'twitter',\n  url: 'https://x.com/$1',\n  convert: (websiteName, shortcut) => {\n    if (websiteName === 'twitter' && shortcut === 'twitter') {\n      return '@$1';\n    }\n\n    return undefined;\n  },\n})\n@SupportsFiles({\n  fileBatchSize: 120,\n  acceptedMimeTypes: [\n    'image/png',\n    'image/jpeg',\n    'image/gif',\n    'video/mp4',\n    'video/mov',\n    'image/webp',\n  ],\n  acceptedFileSizes: {\n    'image/gif': FileSize.megabytes(15),\n    [FileType.IMAGE]: FileSize.megabytes(5),\n    [FileType.VIDEO]: FileSize.megabytes(15),\n  },\n})\n@DisableAds()\nexport default class Twitter\n  extends Website<TwitterAccountData>\n  implements\n    FileWebsite<TwitterFileSubmission>,\n    MessageWebsite<TwitterMessageSubmission>\n{\n  protected BASE_URL = '';\n\n  public externallyAccessibleWebsiteDataProperties: DataPropertyAccessibility<TwitterAccountData> =\n    {\n      apiKey: true,\n      apiSecret: true,\n    };\n\n  // OAuth step handlers invoked via websitesApi.performOAuthStep\n  public onAuthRoute: OAuthRouteHandlers<TwitterOAuthRoutes> = {\n    setApiKeys: async ({ apiKey, apiSecret }) => {\n      const current = this.websiteDataStore.getData();\n      await this.setWebsiteData({\n        ...(current as TwitterAccountData),\n        apiKey,\n        apiSecret,\n      });\n      return { success: true };\n    },\n    requestToken: async () => {\n      const { apiKey, apiSecret } = this.websiteDataStore.getData();\n      if (!apiKey || !apiSecret) {\n        return {\n          success: false,\n          message: 'API key and secret must be set before requesting token',\n        };\n      }\n\n      try {\n        const auth = await TwitterApiServiceV2.generateAuthLink(\n          apiKey,\n          apiSecret,\n        );\n        const current = this.websiteDataStore.getData();\n        await this.setWebsiteData({\n          ...(current as TwitterAccountData),\n          requestToken: auth.oauthToken,\n          requestTokenSecret: auth.oauthTokenSecret,\n        });\n        return {\n          success: true,\n          url: auth.url,\n          oauthToken: auth.oauthToken,\n        };\n      } catch (e) {\n        this.logger.error(e);\n        return { success: false, message: 'Failed to get request token' };\n      }\n    },\n    completeOAuth: async ({ verifier }) => {\n      const { requestToken, requestTokenSecret, apiKey, apiSecret } =\n        this.websiteDataStore.getData();\n      if (!requestToken || !requestTokenSecret) {\n        return { success: false, message: 'No pending request token' };\n      }\n      if (!verifier) {\n        return { success: false, message: 'Verifier required' };\n      }\n      if (!apiKey || !apiSecret) {\n        return { success: false, message: 'API credentials missing' };\n      }\n\n      try {\n        const result = await TwitterApiServiceV2.login(\n          apiKey,\n          apiSecret,\n          requestToken,\n          requestTokenSecret,\n          verifier,\n        );\n        const current = this.websiteDataStore.getData();\n        await this.setWebsiteData({\n          ...(current as TwitterAccountData),\n          accessToken: result.accessToken,\n          accessTokenSecret: result.accessTokenSecret,\n          screenName: result.screenName,\n          userId: result.userId,\n          requestToken: undefined,\n          requestTokenSecret: undefined,\n        });\n        await this.onLogin();\n        return {\n          success: true,\n          screenName: result.screenName,\n          userId: result.userId,\n        };\n      } catch (e) {\n        this.logger.error(e);\n        return { success: false, message: 'Failed to complete OAuth' };\n      }\n    },\n  };\n\n  public async onLogin(): Promise<ILoginState> {\n    const data = this.websiteDataStore.getData();\n    if (data?.accessToken && data?.accessTokenSecret && data?.screenName) {\n      return this.loginState.setLogin(true, data.screenName);\n    }\n    return this.loginState.logout();\n  }\n\n  createFileModel(): TwitterFileSubmission {\n    return new TwitterFileSubmission();\n  }\n\n  calculateImageResize(file: ISubmissionFile): ImageResizeProps {\n    return undefined;\n  }\n\n  async onPostFileSubmission(\n    postData: PostData<TwitterFileSubmission>,\n    files: PostingFile[],\n    cancellationToken: CancellableToken,\n  ): Promise<PostResponse> {\n    cancellationToken.throwIfCancelled();\n    const filePartitions = chunk(files, 4);\n    const { accessToken, accessTokenSecret, apiKey, apiSecret } =\n      this.getWebsiteData();\n    const results: TweetResultMeta[] = [];\n    for (const partition of filePartitions) {\n      const result = await TwitterApiServiceV2.postMedia(\n        { apiKey, apiSecret, accessToken, accessTokenSecret },\n        partition,\n        postData,\n        results.length > 0 ? results[results.length - 1].id : undefined,\n      );\n\n      if (!result.success || cancellationToken.isCancelled) {\n        const cleanupSuccess = await this.cleanUpFailedPost(\n          results,\n          apiKey,\n          apiSecret,\n          accessToken,\n          accessTokenSecret,\n        );\n        return PostResponse.fromWebsite(this)\n          .withMessage(\n            `Post failed${cleanupSuccess ? '' : ' and was unable to delete partially created tweets (manual cleanup needed)'}`,\n          )\n          .withAdditionalInfo(result)\n          .withException(new Error(result.error || 'Failed to post'));\n      }\n\n      results.push(result);\n    }\n\n    return PostResponse.fromWebsite(this)\n      .withAdditionalInfo(results)\n      .withSourceUrl(results[0]?.url);\n  }\n\n  private async cleanUpFailedPost(\n    results: TweetResultMeta[],\n    apiKey: string,\n    apiSecret: string,\n    accessToken: string,\n    accessTokenSecret: string,\n  ) {\n    if (results.length > 1) {\n      try {\n        const deleteResult = await TwitterApiServiceV2.deleteFailedReplyChain(\n          { apiKey, apiSecret, accessToken, accessTokenSecret },\n          results.map((r) => r.id).filter((id) => !!id) as string[],\n        );\n\n        if (deleteResult.errors.length > 0) {\n          this.logger\n            .withMetadata(deleteResult.errors)\n            .warn('Some tweets could not be deleted');\n        }\n\n        if (deleteResult.deletedIds.length > 0) {\n          this.logger.info(\n            `Cleaned up ${deleteResult.deletedIds.length} tweets from failed thread`,\n          );\n        }\n      } catch (err) {\n        this.logger.error('Failed to cleanup failed reply chain:', err);\n        return false;\n      }\n    }\n\n    return true;\n  }\n\n  async onValidateFileSubmission(\n    postData: PostData<TwitterFileSubmission>,\n  ): Promise<SimpleValidationResult> {\n    const validator = this.createValidator<TwitterFileSubmission>();\n\n    const parsed = parseTweet(postData.options.description ?? '');\n    if (parsed.weightedLength > 280) {\n      validator.warning(\n        'validation.description.max-length',\n        {\n          currentLength: parsed.weightedLength,\n          maxLength: 280,\n        },\n        'description',\n      );\n    }\n    return validator.result;\n  }\n\n  createMessageModel(): TwitterMessageSubmission {\n    return new TwitterMessageSubmission();\n  }\n\n  async onPostMessageSubmission(\n    postData: PostData<TwitterMessageSubmission>,\n    cancellationToken: CancellableToken,\n  ): Promise<PostResponse> {\n    cancellationToken.throwIfCancelled();\n    const { accessToken, accessTokenSecret, apiKey, apiSecret } =\n      this.getWebsiteData();\n\n    const result = await TwitterApiServiceV2.postStatus(\n      {\n        accessToken,\n        accessTokenSecret,\n        apiKey,\n        apiSecret,\n      },\n      postData,\n      undefined,\n    );\n\n    if (!result.success) {\n      return PostResponse.fromWebsite(this)\n        .withAdditionalInfo(result)\n        .withException(new Error(result.error || 'Failed to post'));\n    }\n\n    return PostResponse.fromWebsite(this)\n      .withAdditionalInfo(result)\n      .withSourceUrl(result.url);\n  }\n\n  async onValidateMessageSubmission(\n    postData: PostData<TwitterMessageSubmission>,\n  ): Promise<SimpleValidationResult> {\n    const validator = this.createValidator<TwitterMessageSubmission>();\n\n    const parsed = parseTweet(postData.options.description ?? '');\n    if (parsed.weightedLength > 280) {\n      validator.warning(\n        'validation.description.max-length',\n        {\n          currentLength: parsed.weightedLength,\n          maxLength: 280,\n        },\n        'description',\n      );\n    }\n    return validator.result;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/weasyl/models/weasyl-account-data.ts",
    "content": "import { SelectOption } from '@postybirb/form-builder';\n\nexport type WeasylAccountData = {\n  folders: SelectOption[];\n};\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/weasyl/models/weasyl-categories.ts",
    "content": "import { SelectOption } from '@postybirb/form-builder';\nimport { FileType } from '@postybirb/types';\n\nexport const WeasylCategories: Record<FileType, SelectOption[]> = {\n  [FileType.IMAGE]: [\n    {\n      value: '1010',\n      label: 'Sketch',\n    },\n    {\n      value: '1020',\n      label: 'Traditional',\n    },\n    {\n      value: '1030',\n      label: 'Digital',\n    },\n    {\n      value: '1040',\n      label: 'Animation',\n    },\n    {\n      value: '1050',\n      label: 'Photography',\n    },\n    {\n      value: '1060',\n      label: 'Design / Interface',\n    },\n    {\n      value: '1070',\n      label: 'Modeling / Sculpture',\n    },\n    {\n      value: '1075',\n      label: 'Crafts / Jewelry',\n    },\n    {\n      value: '1080',\n      label: 'Desktop / Wallpaper',\n    },\n    {\n      value: '1999',\n      label: 'Other',\n    },\n  ],\n  [FileType.TEXT]: [\n    {\n      value: '2010',\n      label: 'Story',\n    },\n    {\n      value: '2020',\n      label: 'Poetry / Lyrics',\n    },\n    {\n      value: '2030',\n      label: 'Script / Screenplay',\n    },\n    {\n      value: '2999',\n      label: 'Other',\n    },\n  ],\n  [FileType.VIDEO]: [\n    {\n      value: '3500',\n      label: 'Embedded Video',\n    },\n    {\n      value: '3999',\n      label: 'Other',\n    },\n  ],\n  [FileType.AUDIO]: [\n    {\n      value: '3010',\n      label: 'Original Music',\n    },\n    {\n      value: '3020',\n      label: 'Cover Version',\n    },\n    {\n      value: '3030',\n      label: 'Remix / Mashup',\n    },\n    {\n      value: '3040',\n      label: 'Speech / Reading',\n    },\n    {\n      value: '3999',\n      label: 'Other',\n    },\n  ],\n  [FileType.UNKNOWN]: [],\n};\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/weasyl/models/weasyl-file-submission.ts",
    "content": "import { BooleanField, SelectField, TagField } from '@postybirb/form-builder';\nimport { TagValue } from '@postybirb/types';\nimport { BaseWebsiteOptions } from '../../../models/base-website-options';\nimport { WeasylCategories } from './weasyl-categories';\n\nexport class WeasylFileSubmission extends BaseWebsiteOptions {\n  @TagField({\n    required: true,\n    minTags: 2,\n  })\n  tags: TagValue;\n\n  @SelectField({\n    label: 'category',\n    section: 'website',\n    order: 2,\n    span: 6,\n    options: {\n      options: WeasylCategories,\n      discriminator: 'overallFileType',\n    },\n  })\n  category: string;\n\n  @SelectField({\n    label: 'folder',\n    section: 'website',\n    order: 3,\n    span: 6,\n    derive: [\n      {\n        key: 'folders',\n        populate: 'options',\n      },\n    ],\n    options: [],\n  })\n  folder: string;\n\n  @BooleanField({\n    label: 'critique',\n    section: 'website',\n    order: 4,\n    span: 6,\n  })\n  critique = false;\n\n  @BooleanField({\n    label: 'notify',\n    section: 'website',\n    order: 5,\n    span: 6,\n  })\n  notify = true;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/weasyl/models/weasyl-message-submission.ts",
    "content": "import { BaseWebsiteOptions } from '../../../models/base-website-options';\n\nexport class WeasylMessageSubmission extends BaseWebsiteOptions {}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/implementations/weasyl/weasyl.website.ts",
    "content": "import { SelectOption } from '@postybirb/form-builder';\nimport { Http } from '@postybirb/http';\nimport {\n  FileType,\n  ILoginState,\n  ImageResizeProps,\n  PostData,\n  PostResponse,\n  SubmissionRating,\n} from '@postybirb/types';\nimport { getFileTypeFromMimeType } from '@postybirb/utils/file-type';\nimport { parse } from 'node-html-parser';\nimport { CancellableToken } from '../../../post/models/cancellable-token';\nimport { PostingFile } from '../../../post/models/posting-file';\nimport FileSize from '../../../utils/filesize.util';\nimport { PostBuilder } from '../../commons/post-builder';\nimport { validatorPassthru } from '../../commons/validator-passthru';\nimport { UserLoginFlow } from '../../decorators/login-flow.decorator';\nimport { SupportsFiles } from '../../decorators/supports-files.decorator';\nimport { SupportsUsernameShortcut } from '../../decorators/supports-username-shortcut.decorator';\nimport { WebsiteMetadata } from '../../decorators/website-metadata.decorator';\nimport { DataPropertyAccessibility } from '../../models/data-property-accessibility';\nimport { FileWebsite } from '../../models/website-modifiers/file-website';\nimport { MessageWebsite } from '../../models/website-modifiers/message-website';\nimport { Website } from '../../website';\nimport { WeasylAccountData } from './models/weasyl-account-data';\nimport { WeasylFileSubmission } from './models/weasyl-file-submission';\nimport { WeasylMessageSubmission } from './models/weasyl-message-submission';\n\n@WebsiteMetadata({\n  name: 'weasyl',\n  displayName: 'Weasyl',\n})\n@UserLoginFlow('https://weasyl.com')\n@SupportsFiles({\n  acceptedMimeTypes: [\n    'application/pdf',\n    'audio/mp3',\n    'image/gif',\n    'image/jpeg',\n    'image/jpg',\n    'image/png',\n    'swf',\n    'text/markdown',\n    'text/plain',\n    'text/pdf',\n  ],\n  acceptedFileSizes: {\n    [FileType.IMAGE]: FileSize.megabytes(50),\n    'application/pdf': FileSize.megabytes(10),\n    'text/*': FileSize.megabytes(2),\n    swf: FileSize.megabytes(50),\n    'audio/mp3': FileSize.megabytes(15),\n  },\n})\n@SupportsUsernameShortcut({\n  id: 'weasyl',\n  url: 'https://weasyl.com/~$1',\n  convert: (websiteName, shortcut) => {\n    if (websiteName === 'weasyl' && shortcut === 'weasyl') {\n      return '<!~$1>';\n    }\n    return undefined;\n  },\n})\nexport default class Weasyl\n  extends Website<WeasylAccountData>\n  implements\n    FileWebsite<WeasylFileSubmission>,\n    MessageWebsite<WeasylMessageSubmission>\n{\n  protected BASE_URL = 'https://www.weasyl.com';\n\n  public externallyAccessibleWebsiteDataProperties: DataPropertyAccessibility<WeasylAccountData> =\n    {\n      folders: true,\n    };\n\n  public async onLogin(): Promise<ILoginState> {\n    const res = await Http.get<{ login: string }>(\n      `${this.BASE_URL}/api/whoami`,\n      {\n        partition: this.accountId,\n      },\n    );\n\n    if (res.body.login) {\n      this.loginState.setLogin(true, res.body.login);\n      await this.getFolders(res.body.login);\n    } else {\n      this.loginState.setLogin(false, null);\n    }\n\n    return this.loginState.getState();\n  }\n\n  private async getFolders(username: string): Promise<void> {\n    const res = await Http.get<{\n      folders: {\n        title: string;\n        folder_id: string;\n        subfolders: {\n          title: string;\n          folder_id: string;\n        }[];\n      }[];\n    }>(`${this.BASE_URL}/api/users/${username}/view`, {\n      partition: this.accountId,\n    });\n\n    const weasylFolders = res.body.folders ?? [];\n    const folders: SelectOption[] = [];\n    weasylFolders.forEach((f) => {\n      const folder: SelectOption = {\n        label: f.title,\n        value: f.folder_id,\n      };\n\n      folders.push(folder);\n\n      if (f.subfolders) {\n        f.subfolders.forEach((sf) => {\n          const subfolder: SelectOption = {\n            label: `${folder.label} / ${sf.title}`,\n            value: sf.folder_id,\n          };\n\n          folders.push(subfolder);\n        });\n      }\n    });\n\n    this.websiteDataStore.setData({\n      ...this.websiteDataStore.getData(),\n      folders,\n    });\n  }\n\n  createFileModel(): WeasylFileSubmission {\n    return new WeasylFileSubmission();\n  }\n\n  calculateImageResize(): ImageResizeProps {\n    return undefined;\n  }\n\n  private modifyDescription(html: string) {\n    return html\n      .replace(/<p/gm, '<div')\n      .replace(/<\\/p>/gm, '</div>')\n      .replace(/style=\"text-align: center\"/g, 'class=\"align-center\"')\n      .replace(/style=\"text-align: right\"/g, 'class=\"align-right\"')\n      .replace(/<\\/div>\\n<br>/g, '</div><br>')\n      .replace(/<\\/div><br>/g, '</div><div><br></div>');\n  }\n\n  private convertRating(rating: SubmissionRating) {\n    switch (rating) {\n      case SubmissionRating.MATURE:\n        return 30;\n      case SubmissionRating.ADULT:\n      case SubmissionRating.EXTREME:\n        return 40;\n      case SubmissionRating.GENERAL:\n      default:\n        return 10;\n    }\n  }\n\n  private getContentType(type: FileType) {\n    switch (type) {\n      case FileType.TEXT:\n        return 'literary';\n      case FileType.AUDIO:\n      case FileType.VIDEO:\n        return 'multimedia';\n      case FileType.IMAGE:\n      default:\n        return 'visual';\n    }\n  }\n\n  async onPostFileSubmission(\n    postData: PostData<WeasylFileSubmission>,\n    files: PostingFile[],\n    cancellationToken: CancellableToken,\n  ): Promise<PostResponse> {\n    const fileType = getFileTypeFromMimeType(files[0].mimeType);\n    const contentType = this.getContentType(fileType);\n    const url = `${this.BASE_URL}/submit/${contentType}`;\n\n    const {\n      description,\n      title,\n      rating,\n      tags,\n      notify,\n      critique,\n      folder,\n      category,\n    } = postData.options;\n\n    const builder = new PostBuilder(this, cancellationToken)\n      .asMultipart()\n      .withHeader('Referer', url)\n      .withHeader('Origin', 'https://www.weasyl.com')\n      .setField('title', title)\n      .setField('rating', this.convertRating(rating))\n      .setField('content', this.modifyDescription(description))\n      .setField('tags', tags.join(' '))\n      .addFile('submitfile', files[0])\n      .setConditional('nonotification', !notify, 'on')\n      .setConditional('critique', critique, 'on')\n      .setField('folderid', folder || '')\n      .setField('subtype', category || '')\n      .addThumbnail('thumbfile', files[0]);\n\n    // For text, video, and audio files, add cover file\n    if (\n      fileType === FileType.TEXT ||\n      fileType === FileType.VIDEO ||\n      fileType === FileType.AUDIO\n    ) {\n      builder.addThumbnail('coverfile', files[0]);\n    }\n\n    let result = await builder.send<string>(url);\n    const { body } = result;\n\n    if (result.body.includes('manage_thumbnail')) {\n      const html = parse(result.body);\n      const submitId = html.querySelector('input[name=\"submitid\"]');\n      if (!submitId) {\n        throw new Error('Failed to find submitid');\n      }\n      const thumbnailBuilder = new PostBuilder(this, cancellationToken)\n        .asMultipart()\n        .withHeader('Referer', url)\n        .withHeader('Origin', 'https://www.weasyl.com')\n        .setField('x1', '0')\n        .setField('x2', '0')\n        .setField('y1', '0')\n        .setField('y2', '0')\n        .setField('thumbfile', '')\n        .setField('submitid', submitId.getAttribute('value') || '');\n\n      result = await thumbnailBuilder.send<string>(\n        `${this.BASE_URL}/manage/thumbnail`,\n      );\n    }\n\n    if (\n      body.includes(\n        'You have already made a submission with this submission file',\n      )\n    ) {\n      return PostResponse.fromWebsite(this).withMessage(\n        'You have already made a submission with this submission file',\n      );\n    }\n\n    if (\n      body.includes('Submission Information') ||\n      // If they set a rating of adult and didn't set nsfw when they logged in\n      body.includes(\n        'This page contains content that you cannot view according to your current allowed ratings',\n      )\n    ) {\n      // Standard return\n      return PostResponse.fromWebsite(this).withSourceUrl(result.responseUrl);\n    }\n\n    if (body.includes('Weasyl experienced a technical issue')) {\n      // Unknown issue so do a second check\n      const recheck = await Http.get<string>(result.responseUrl, {\n        partition: this.accountId,\n      });\n      if (recheck.body.includes('Submission Information')) {\n        return PostResponse.fromWebsite(this).withSourceUrl(\n          recheck.responseUrl,\n        );\n      }\n    }\n\n    return PostResponse.fromWebsite(this)\n      .withAdditionalInfo({\n        body: result.body,\n        statusCode: result.statusCode,\n      })\n      .withException(new Error('Unknown response from Weasyl'));\n  }\n\n  onValidateFileSubmission = validatorPassthru;\n\n  createMessageModel(): WeasylMessageSubmission {\n    return new WeasylMessageSubmission();\n  }\n\n  async onPostMessageSubmission(\n    postData: PostData<WeasylMessageSubmission>,\n    cancellationToken: CancellableToken,\n  ): Promise<PostResponse> {\n    const url = `${this.BASE_URL}/submit/journal`;\n    const submissionPage = await Http.get<string>(url, {\n      partition: this.accountId,\n    });\n    PostResponse.validateBody(this, submissionPage);\n\n    const { description, title, rating, tags } = postData.options;\n\n    const result = await new PostBuilder(this, cancellationToken)\n      .asMultipart()\n      .withHeader('Referer', url)\n      .withHeader('Origin', 'https://www.weasyl.com')\n      .withHeader('Host', 'www.weasyl.com')\n      .setField('title', title)\n      .setField('rating', this.convertRating(rating))\n      .setField('content', this.modifyDescription(description))\n      .setField('tags', tags.join(' '))\n      .send<string>(`${this.BASE_URL}/submit`);\n\n    return PostResponse.fromWebsite(this).withAdditionalInfo({\n      body: result,\n    });\n  }\n\n  onValidateMessageSubmission = validatorPassthru;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/models/base-website-options.spec.ts",
    "content": "// eslint-disable-next-line max-classes-per-file\nimport { TagField, TextField } from '@postybirb/form-builder';\nimport {\n    DefaultDescriptionValue,\n    DefaultTagValue,\n    Description,\n    SubmissionRating,\n    TagValue,\n} from '@postybirb/types';\nimport { BaseWebsiteOptions } from './base-website-options';\nimport { DefaultWebsiteOptions } from './default-website-options';\n\ndescribe('BaseWebsiteOptions', () => {\n  const defaultDescriptionValue: Description = {\n    type: 'doc',\n    content: [\n      {\n        type: 'paragraph',\n        content: [\n          { type: 'text', text: 'Hello, ', marks: [{ type: 'bold' }] },\n          { type: 'text', text: 'World!' },\n        ],\n      },\n    ],\n  };\n\n  it('should create an instance with default values', () => {\n    const options = new BaseWebsiteOptions();\n    expect(options.title).toBe('');\n    expect(options.tags).toEqual(DefaultTagValue());\n    expect(options.description).toEqual(DefaultDescriptionValue());\n    expect(options.rating).toBeUndefined();\n  });\n\n  it('should create form fields with correct default values', () => {\n    const options = new BaseWebsiteOptions();\n    const formFields = options.getFormFields();\n    expect(formFields).toBeDefined();\n    expect(formFields.title.defaultValue).toBe('');\n    expect(formFields.tags.defaultValue).toEqual(DefaultTagValue());\n    expect(formFields.description.defaultValue).toEqual(\n      DefaultDescriptionValue(),\n    );\n    expect(formFields.rating.defaultValue).toBeUndefined();\n  });\n\n  it('should properly generate form fields for extended classes', () => {\n    class ExtendedWebsiteOptions extends BaseWebsiteOptions {\n      @TextField({ label: 'feature' })\n      customField = '';\n    }\n\n    const options = new ExtendedWebsiteOptions();\n    const formFields = options.getFormFields();\n    expect(formFields).toBeDefined();\n    expect(formFields.customField).toBeDefined();\n  });\n\n  it('should create an instance with provided values', () => {\n    const options = new BaseWebsiteOptions({\n      title: 'Test Title',\n      tags: { overrideDefault: true, tags: ['tag1', 'tag2'] },\n      description: {\n        overrideDefault: true,\n        description: defaultDescriptionValue,\n        insertTitle: true,\n        insertTags: true,\n      },\n      rating: SubmissionRating.GENERAL,\n    });\n    expect(options.title).toBe('Test Title');\n    expect(options.tags).toEqual({\n      overrideDefault: true,\n      tags: ['tag1', 'tag2'],\n    });\n    expect(options.description).toEqual({\n      overrideDefault: true,\n      description: defaultDescriptionValue,\n      insertTitle: true,\n      insertTags: true,\n    });\n    expect(options.rating).toBe(SubmissionRating.GENERAL);\n  });\n\n  it('should merge defaults correctly', () => {\n    const defaultOptions = new DefaultWebsiteOptions({\n      title: 'Default Title',\n      tags: { overrideDefault: false, tags: ['defaultTag'] },\n      description: {\n        overrideDefault: false,\n        description: { type: 'doc', content: [] },\n        insertTitle: false,\n        insertTags: false,\n      },\n      rating: SubmissionRating.MATURE,\n    });\n\n    const options = new BaseWebsiteOptions({\n      title: 'New Title',\n      tags: { overrideDefault: true, tags: ['newTag'] },\n      description: {\n        overrideDefault: true,\n        description: defaultDescriptionValue,\n        insertTitle: true,\n        insertTags: true,\n      },\n      rating: SubmissionRating.ADULT,\n    });\n\n    const mergedOptions = options.mergeDefaults(defaultOptions);\n\n    expect(mergedOptions.title).toBe('New Title');\n    expect(mergedOptions.tags).toEqual({\n      overrideDefault: true,\n      tags: ['newTag'],\n    });\n    expect(mergedOptions.description).toEqual({\n      overrideDefault: true,\n      description: defaultDescriptionValue,\n      insertTitle: true,\n      insertTags: true,\n    });\n    expect(mergedOptions.rating).toBe(SubmissionRating.ADULT);\n  });\n\n  it('should merge defaults with disabled override default correctly', () => {\n    const defaultOptions = new DefaultWebsiteOptions({\n      description: {\n        overrideDefault: false,\n        description: defaultDescriptionValue,\n        insertTitle: false,\n        insertTags: false,\n      },\n    });\n\n    const options = new BaseWebsiteOptions({\n      description: {\n        overrideDefault: false,\n        description: { type: 'doc', content: [] },\n        insertTitle: true,\n        insertTags: true,\n      },\n    });\n\n    const mergedOptions = options.mergeDefaults(defaultOptions);\n\n    expect(mergedOptions.description).toEqual({\n      overrideDefault: false,\n      description: defaultDescriptionValue,\n      insertTitle: true,\n      insertTags: true,\n    });\n  });\n\n  it('should get form fields', () => {\n    const options = new BaseWebsiteOptions();\n    const formFields = options.getFormFields();\n    expect(formFields).toBeDefined();\n  });\n\n  it('should process tags correctly', async () => {\n    class ExtendedWebsiteOptions extends BaseWebsiteOptions {\n      @TagField({\n        maxTagLength: 5,\n        minTagLength: 2,\n        maxTags: 3,\n      })\n      tags: TagValue;\n\n      protected processTag(tag: string): string {\n        return super.processTag(tag).toUpperCase();\n      }\n    }\n    const options = new ExtendedWebsiteOptions({\n      tags: {\n        overrideDefault: true,\n        tags: ['tag1', 'tag2', 'a', 'tag3', 'tag3', 'long-tag'],\n      },\n    });\n    expect(await options.getProcessedTags()).toEqual(['TAG1', 'TAG2', 'TAG3']);\n  });\n});\n"
  },
  {
    "path": "apps/client-server/src/app/websites/models/base-website-options.ts",
    "content": "import {\n  DescriptionField,\n  DescriptionFieldType,\n  formBuilder,\n  RatingField,\n  RatingFieldType,\n  TagField,\n  TagFieldType,\n  TextField,\n  TextFieldType,\n  TitleField,\n  TitleFieldType,\n} from '@postybirb/form-builder';\nimport {\n  DefaultDescriptionValue,\n  DefaultTagValue,\n  DescriptionValue,\n  IWebsiteFormFields,\n  SubmissionRating,\n  Tag,\n  TagValue,\n} from '@postybirb/types';\nimport { uniq } from 'lodash';\nimport { Class } from 'type-fest';\nimport { DefaultWebsiteOptions } from './default-website-options';\n\nexport class BaseWebsiteOptions implements IWebsiteFormFields {\n  @RatingField({\n    required: true,\n    section: 'common',\n    order: 1,\n    span: 12,\n    layout: 'horizontal',\n  })\n  rating: SubmissionRating;\n\n  @TitleField({\n    required: true,\n    section: 'common',\n    order: 2,\n    span: 12,\n  })\n  title = '';\n\n  @TagField({\n    section: 'common',\n    order: 3,\n    span: 12,\n  })\n  tags: TagValue = DefaultTagValue();\n\n  @DescriptionField({\n    section: 'common',\n    order: 4,\n    span: 12,\n    required: true,\n  })\n  description: DescriptionValue = DefaultDescriptionValue();\n\n  @TextField({\n    label: 'contentWarning',\n    section: 'common',\n    order: 5,\n    span: 12,\n    hidden: true, // Keep hidden unless overridden\n  })\n  contentWarning = '';\n\n  constructor(options: Partial<BaseWebsiteOptions | IWebsiteFormFields> = {}) {\n    Object.assign(this, options);\n  }\n\n  /**\n   * Merges the provided options with the default options of the current class.\n   *\n   * @param options - The options to merge with the default options.\n   * @returns A new instance of the current class with the merged options.\n   */\n  public mergeDefaults(options: DefaultWebsiteOptions): this {\n    const isNullOrWhiteSpace = (value: string) => !value || !value.trim();\n    const mergedFormFields: IWebsiteFormFields = {\n      rating: this.rating || options.rating,\n      title: (!isNullOrWhiteSpace(this.title)\n        ? this.title\n        : (options.title ?? '')\n      ).trim(),\n      tags: this.tags.overrideDefault\n        ? { ...this.tags }\n        : {\n            overrideDefault: Boolean(this.tags.overrideDefault),\n            tags: [...this.tags.tags, ...options.tags.tags],\n          },\n      description: this.description.overrideDefault\n        ? { ...this.description }\n        : {\n            overrideDefault: false,\n            description: options.description.description,\n            insertTitle:\n              this.description.insertTitle || options.description.insertTitle,\n            insertTags:\n              this.description.insertTags || options.description.insertTags,\n          },\n      contentWarning: (!isNullOrWhiteSpace(this.contentWarning)\n        ? this.contentWarning\n        : (options.contentWarning ?? '')\n      ).trim(),\n    };\n    const newInstance = Object.assign(new (this.constructor as Class<this>)(), {\n      ...options,\n      ...this,\n      ...mergedFormFields,\n    });\n\n    return newInstance;\n  }\n\n  public getFormFields(params: Record<string, never> = {}) {\n    return formBuilder(this, params);\n  }\n\n  public getFormFieldFor(key: 'tags'): TagFieldType;\n  public getFormFieldFor(key: 'description'): DescriptionFieldType;\n  public getFormFieldFor(key: 'title'): TitleFieldType;\n  public getFormFieldFor(key: 'rating'): RatingFieldType;\n  public getFormFieldFor(key: 'contentWarning'): TextFieldType;\n  public getFormFieldFor(key: keyof IWebsiteFormFields) {\n    return this.getFormFields()[key];\n  }\n\n  /**\n   * Processes the tags and returns them as an array of strings.\n   * Performs configured tag properties to filter and transform the tags.\n   * Calls the `processTag` method to transform each tag.\n   */\n  public async getProcessedTags(\n    additionalProcessor?: (tag) => Promise<string>,\n  ): Promise<Tag[]> {\n    const tagsField = this.getFormFieldFor('tags');\n    if (tagsField.hidden) {\n      return [];\n    }\n\n    return uniq(\n      (\n        await Promise.all(\n          this.tags.tags\n            .map((tag) => tag.trim())\n            .filter((tag) => tag.length > 0)\n            .map((tag) => additionalProcessor?.(tag) ?? Promise.resolve(tag)), // Mostly for tag converter insert\n        )\n      )\n        .map((tag) =>\n          tagsField.spaceReplacer\n            ? tag.replace(/\\s/g, tagsField.spaceReplacer)\n            : tag,\n        )\n        .map(this.processTag)\n        .filter((tag) => tag.length >= (tagsField.minTagLength ?? 1))\n        .filter(\n          (tag) =>\n            tag.length <= (tagsField.maxTagLength ?? Number.MAX_SAFE_INTEGER),\n        ),\n    ).slice(0, tagsField.maxTags ?? Number.MAX_SAFE_INTEGER);\n  }\n\n  /**\n   * Tag transformation function that can be overridden by subclasses.\n   *\n   * @protected\n   * @param {string} tag\n   * @return {*}  {string}\n   */\n  protected processTag(tag: string): string {\n    return tag;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/models/data-property-accessibility.ts",
    "content": "export type DataPropertyAccessibility<T> = {\n  [key in keyof T]: boolean;\n};\n"
  },
  {
    "path": "apps/client-server/src/app/websites/models/default-website-options.ts",
    "content": "import { RatingField, TextField } from '@postybirb/form-builder';\nimport { SubmissionRating } from '@postybirb/types';\nimport { BaseWebsiteOptions } from './base-website-options';\n\nexport class DefaultWebsiteOptions extends BaseWebsiteOptions {\n  @TextField({\n    label: 'contentWarning',\n    section: 'common',\n    span: 12,\n    hidden: false,\n  })\n  declare contentWarning: string;\n\n  @RatingField({})\n  declare rating: SubmissionRating;\n\n  constructor(options: Partial<DefaultWebsiteOptions> = {}) {\n    super(options);\n    // Apply defaults after parent constructor to avoid field initializer overwrite\n    this.contentWarning = this.contentWarning ?? '';\n    this.rating = this.rating ?? SubmissionRating.GENERAL;\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/models/website-modifiers/file-website.ts",
    "content": "import {\n  ImageResizeProps,\n  IPostResponse,\n  ISubmissionFile,\n  IWebsiteFormFields,\n  PostData,\n  SimpleValidationResult,\n} from '@postybirb/types';\nimport { CancellableToken } from '../../../post/models/cancellable-token';\nimport { PostingFile } from '../../../post/models/posting-file';\nimport { UnknownWebsite } from '../../website';\nimport { BaseWebsiteOptions } from '../base-website-options';\n\nexport const FileWebsiteKey = 'createFileModel';\n\nexport type ImplementedFileWebsite = FileWebsite & UnknownWebsite;\n\nexport interface PostBatchData {\n  index: number;\n  totalBatches: number;\n}\n\n/**\n * Defines methods for allowing file based posting.\n * Generally this will always be used by each supported website.\n * @interface FileWebsite\n */\nexport interface FileWebsite<\n  T extends IWebsiteFormFields = IWebsiteFormFields,\n> {\n  createFileModel(): BaseWebsiteOptions;\n\n  calculateImageResize(file: ISubmissionFile): ImageResizeProps | undefined;\n\n  /**\n   * Handles the submission of a file to the website.\n   *\n   * @param {PostData<T>} postData\n   * @param {PostingFile[]} files - The files to post\n   * @param {number} batchIndex - The index of the batch (if batching is required)\n   * @param {CancellableToken} cancellationToken\n   * @return {*}  {Promise<IPostResponse>}\n   */\n  onPostFileSubmission(\n    postData: PostData<T>,\n    files: PostingFile[],\n    cancellationToken: CancellableToken,\n    batch: PostBatchData,\n  ): Promise<IPostResponse>;\n\n  onValidateFileSubmission(\n    postData: PostData<T>,\n  ): Promise<SimpleValidationResult>;\n}\n\nexport function isFileWebsite(\n  websiteInstance: UnknownWebsite,\n): websiteInstance is ImplementedFileWebsite {\n  return Boolean((websiteInstance as ImplementedFileWebsite).supportsFile);\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/models/website-modifiers/message-website.ts",
    "content": "import {\n    IPostResponse,\n    IWebsiteFormFields,\n    PostData,\n    SimpleValidationResult,\n} from '@postybirb/types';\n\nimport { CancellableToken } from '../../../post/models/cancellable-token';\nimport { UnknownWebsite } from '../../website';\nimport { BaseWebsiteOptions } from '../base-website-options';\n\nexport const MessageWebsiteKey = 'createMessageModel';\n\n/**\n * Defines methods for allowing message (notification, journal, blob, etc.) based posting.\n * @interface MessageWebsite\n */\nexport interface MessageWebsite<\n  T extends IWebsiteFormFields = IWebsiteFormFields,\n> {\n  createMessageModel(): BaseWebsiteOptions;\n\n  onPostMessageSubmission(\n    postData: PostData<T>,\n    cancellationToken: CancellableToken,\n  ): Promise<IPostResponse>;\n\n  onValidateMessageSubmission(\n    postData: PostData<T>,\n  ): Promise<SimpleValidationResult>;\n}\n\nexport function isMessageWebsite(\n  websiteInstance: UnknownWebsite,\n): websiteInstance is MessageWebsite & UnknownWebsite {\n  return Boolean(\n    (websiteInstance as MessageWebsite & UnknownWebsite).supportsMessage,\n  );\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/models/website-modifiers/oauth-website.ts",
    "content": "import { OAuthRouteHandlers, OAuthRoutes } from '@postybirb/types';\n\n/**\n * Defines a method for allowing multi-stepped authorization flow for logging in a user.\n * Common to be used with custom login flow website.\n * @interface OAuthWebsite\n */\nexport interface OAuthWebsite<T extends OAuthRoutes> {\n  /**\n   * Methods that can be called using websiteApi.performOAuthStep\n   */\n  onAuthRoute: OAuthRouteHandlers<T>;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/models/website-modifiers/with-custom-description-parser.ts",
    "content": "import { BaseConverter } from '../../../post-parsers/models/description-node/converters/base-converter';\n\nexport interface WithCustomDescriptionParser {\n  /**\n   * Returns the converter to use for parsing descriptions.\n   * The converter should extend BaseConverter and output the desired format.\n   */\n  getDescriptionConverter(): BaseConverter;\n}\n\nexport function isWithCustomDescriptionParser(\n  website: unknown,\n): website is WithCustomDescriptionParser {\n  return (\n    typeof website === 'object' &&\n    website !== null &&\n    'getDescriptionConverter' in website\n  );\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/models/website-modifiers/with-dynamic-file-size-limits.ts",
    "content": "import { WebsiteFileOptions } from '@postybirb/types';\n\nexport type DynamicFileSizeLimits = WebsiteFileOptions['acceptedFileSizes'];\n\nexport interface WithDynamicFileSizeLimits {\n  getDynamicFileSizeLimits(): DynamicFileSizeLimits;\n}\n\nexport function isWithDynamicFileSizeLimits(\n  website: unknown,\n): website is WithDynamicFileSizeLimits {\n  return (\n    typeof website === 'object' &&\n    website !== null &&\n    'getDynamicFileSizeLimits' in website\n  );\n}\n\nexport function getDynamicFileSizeLimits(website: unknown) {\n  if (isWithDynamicFileSizeLimits(website)) {\n    return website.getDynamicFileSizeLimits();\n  }\n  return undefined;\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/models/website-modifiers/with-runtime-description-parser.ts",
    "content": "import { DescriptionType } from '@postybirb/types';\n\nexport interface WithRuntimeDescriptionParser {\n  getRuntimeParser(): DescriptionType;\n}\n\nexport function isWithRuntimeDescriptionParser(\n  website: unknown,\n): website is WithRuntimeDescriptionParser {\n  return (\n    typeof website === 'object' &&\n    website !== null &&\n    'getRuntimeParser' in website\n  );\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/website-data-manager.spec.ts",
    "content": "import { Test, TestingModule } from '@nestjs/testing';\nimport { clearDatabase } from '@postybirb/database';\nimport { Account } from '../drizzle/models';\nimport { PostyBirbDatabase } from '../drizzle/postybirb-database/postybirb-database';\nimport { PostyBirbDatabaseUtil } from '../drizzle/postybirb-database/postybirb-database.util';\nimport { WebsiteImplProvider } from './implementations/provider';\nimport WebsiteDataManager from './website-data-manager';\n\ndescribe('WebsiteDataManager', () => {\n  let module: TestingModule;\n  let repository: PostyBirbDatabase<'WebsiteDataSchema'>;\n\n  beforeEach(async () => {\n    clearDatabase();\n    module = await Test.createTestingModule({\n      providers: [WebsiteImplProvider],\n    }).compile();\n    repository = new PostyBirbDatabase('WebsiteDataSchema');\n  });\n\n  afterAll(async () => {\n    await module.close();\n  });\n\n  function populateAccount(): Promise<Account> {\n    return PostyBirbDatabaseUtil.saveFromEntity(\n      new Account({\n        name: 'test',\n        website: 'test',\n        groups: [],\n        id: 'test',\n      }),\n    );\n  }\n\n  it('should be defined', () => {\n    expect(repository).toBeDefined();\n  });\n\n  it('should initialize entity', async () => {\n    const account = await populateAccount();\n    const manager = new WebsiteDataManager(account);\n    // Pre-load\n    expect(manager.isInitialized()).toBeFalsy();\n    expect(manager.getData()).toEqual({});\n\n    await manager.initialize(repository);\n    expect(manager.isInitialized()).toBeTruthy();\n    expect(await repository.findAll()).toHaveLength(1);\n  });\n\n  it('should be able to set new data', async () => {\n    const account = await populateAccount();\n    const manager = new WebsiteDataManager(account);\n    // Pre-load\n    expect(manager.isInitialized()).toBeFalsy();\n    expect(manager.getData()).toEqual({});\n\n    await manager.initialize(repository);\n    expect(manager.isInitialized()).toBeTruthy();\n\n    const obj = { test: 'value' };\n    await manager.setData(obj);\n\n    expect(manager.getData()).toEqual(obj);\n  });\n\n  it('should be able to clear data', async () => {\n    const account = await populateAccount();\n    const manager = new WebsiteDataManager(account);\n    // Pre-load\n    expect(manager.isInitialized()).toBeFalsy();\n    expect(manager.getData()).toEqual({});\n\n    await manager.initialize(repository);\n    expect(manager.isInitialized()).toBeTruthy();\n\n    const obj = { test: 'value' };\n    await manager.setData(obj);\n    expect(manager.getData()).toEqual(obj);\n\n    await manager.clearData();\n    expect(manager.getData()).toEqual({});\n  });\n});\n"
  },
  {
    "path": "apps/client-server/src/app/websites/website-data-manager.ts",
    "content": "import { Logger, PostyBirbLogger } from '@postybirb/logger';\nimport { DynamicObject, IAccount } from '@postybirb/types';\nimport { WebsiteData } from '../drizzle/models';\nimport { PostyBirbDatabase } from '../drizzle/postybirb-database/postybirb-database';\n\n/**\n * Saves website specific data associated with an account.\n *\n * @class WebsiteDataManager\n */\nexport default class WebsiteDataManager<T extends DynamicObject> {\n  private readonly logger: PostyBirbLogger;\n\n  private readonly account: IAccount;\n\n  private entity: WebsiteData<T>;\n\n  private initialized: boolean;\n\n  private repository: PostyBirbDatabase<'WebsiteDataSchema'>;\n\n  constructor(userAccount: IAccount) {\n    this.account = userAccount;\n    this.logger = Logger();\n    this.initialized = false;\n  }\n\n  private async createOrLoadWebsiteData() {\n    let entity: WebsiteData = await this.repository.findById(this.account.id);\n\n    if (!entity) {\n      entity = await this.repository.insert({\n        id: this.account.id,\n      });\n    }\n\n    this.entity = entity;\n  }\n\n  private async saveData() {\n    await this.repository.update(this.entity.id, {\n      data: this.entity.data,\n    });\n  }\n\n  /**\n   * Initializes the internal WebsiteData entity.\n   * @param {PostyBirbDatabase<'WebsiteDataSchema'>} repository\n   */\n  public async initialize(repository: PostyBirbDatabase<'WebsiteDataSchema'>) {\n    if (!this.initialized) {\n      this.repository = repository;\n      await this.createOrLoadWebsiteData();\n      this.initialized = true;\n    }\n  }\n\n  public isInitialized(): boolean {\n    return this.initialized;\n  }\n\n  /**\n   * Deletes the internal WebsiteData entity and creates a new one.\n   */\n  public async clearData(recreateEntity = true) {\n    this.logger.info('Clearing website data');\n    await this.repository.deleteById([this.entity.id]);\n\n    if (recreateEntity) {\n      // Do a reload to recreate an object that hasn't been saved.\n      await this.createOrLoadWebsiteData();\n    }\n  }\n\n  /**\n   * Returns stored WebsiteData.\n   *\n   * @return {*}  {T}\n   * @memberof WebsiteDataManager\n   */\n  public getData(): T {\n    if (!this.initialized) {\n      return {} as T;\n    }\n\n    return { ...this.entity.data };\n  }\n\n  /**\n   * Sets WebsiteData value.\n   * @param {T} data\n   */\n  public async setData(data: T) {\n    if (JSON.stringify(data) !== JSON.stringify(this.entity.data)) {\n      this.entity.data = { ...data };\n      await this.saveData();\n    }\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/website-registry.service.spec.ts",
    "content": "import { Test, TestingModule } from '@nestjs/testing';\nimport { clearDatabase } from '@postybirb/database';\nimport { Account } from '../drizzle/models';\nimport { PostyBirbDatabase } from '../drizzle/postybirb-database/postybirb-database';\nimport { WebsiteImplProvider } from './implementations/provider';\nimport TestWebsite from './implementations/test/test.website';\nimport { WebsiteRegistryService } from './website-registry.service';\n\ndescribe('WebsiteRegistryService', () => {\n  let service: WebsiteRegistryService;\n  let module: TestingModule;\n  let accountRepository: PostyBirbDatabase<'AccountSchema'>;\n\n  beforeEach(async () => {\n    clearDatabase();\n    module = await Test.createTestingModule({\n      providers: [WebsiteRegistryService, WebsiteImplProvider],\n    }).compile();\n\n    service = module.get<WebsiteRegistryService>(WebsiteRegistryService);\n    accountRepository = new PostyBirbDatabase('AccountSchema');\n  });\n\n  afterAll(async () => {\n    await module.close();\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n\n  it('should register test website', () => {\n    const available = service.getAvailableWebsites();\n    expect(available.length).toBeGreaterThanOrEqual(1);\n    expect(available.filter((w) => w === TestWebsite)).toBeDefined();\n  });\n\n  it('should successfully create website instance', async () => {\n    const account = await accountRepository.insert(\n      new Account({\n        name: 'test',\n        id: 'test',\n        website: TestWebsite.prototype.decoratedProps.metadata.name,\n      }),\n    );\n\n    const instance = await service.create(account);\n    expect(instance instanceof TestWebsite).toBe(true);\n    expect(service.findInstance(account)).toEqual(instance);\n    expect(service.getInstancesOf(TestWebsite)).toHaveLength(1);\n  });\n\n  it('should successfully remove website instance', async () => {\n    const account = await accountRepository.insert(\n      new Account({\n        name: 'test',\n        id: 'test',\n        website: TestWebsite.prototype.decoratedProps.metadata.name,\n      }),\n    );\n\n    const instance = await service.create(account);\n    await instance.login();\n    expect(instance instanceof TestWebsite).toBe(true);\n    await service.remove(account);\n    expect(service.getInstancesOf(TestWebsite)).toHaveLength(0);\n  }, 30_000);\n});\n"
  },
  {
    "path": "apps/client-server/src/app/websites/website-registry.service.ts",
    "content": "import {\n  BadRequestException,\n  Inject,\n  Injectable,\n  NotFoundException,\n  Optional,\n} from '@nestjs/common';\nimport { Logger } from '@postybirb/logger';\nimport { WEBSITE_UPDATES } from '@postybirb/socket-events';\nimport {\n  DynamicObject,\n  IAccount,\n  IWebsiteInfoDto,\n  OAuthRoutes,\n} from '@postybirb/types';\nimport { IsTestEnvironment } from '@postybirb/utils/electron';\nimport { Class } from 'type-fest';\nimport { WEBSITE_IMPLEMENTATIONS } from '../constants';\nimport { Account } from '../drizzle/models';\nimport { PostyBirbDatabase } from '../drizzle/postybirb-database/postybirb-database';\nimport { WSGateway } from '../web-socket/web-socket-gateway';\nimport { validateWebsiteDecoratorProps } from './decorators/website-decorator-props';\nimport { OAuthWebsiteRequestDto } from './dtos/oauth-website-request.dto';\nimport { FileWebsiteKey } from './models/website-modifiers/file-website';\nimport { MessageWebsiteKey } from './models/website-modifiers/message-website';\nimport { OAuthWebsite } from './models/website-modifiers/oauth-website';\nimport { UnknownWebsite } from './website';\n\ntype WebsiteInstances = Record<string, Record<string, UnknownWebsite>>;\n\n/**\n * A registry that contains reference to all Websites.\n * Creates a new instance for each user account provided.\n */\n@Injectable()\nexport class WebsiteRegistryService {\n  private readonly logger = Logger();\n\n  private readonly availableWebsites: Record<string, Class<UnknownWebsite>> =\n    {};\n\n  private readonly websiteInstances: WebsiteInstances = {};\n\n  private readonly accountRepository: PostyBirbDatabase<'AccountSchema'>;\n\n  private readonly websiteDataRepository: PostyBirbDatabase<'WebsiteDataSchema'>;\n\n  private initialized = false;\n\n  private initializedResolve: (() => void) | null = null;\n\n  private readonly initializedPromise: Promise<void>;\n\n  constructor(\n    @Inject(WEBSITE_IMPLEMENTATIONS)\n    private readonly websiteImplementations: Class<UnknownWebsite>[],\n    @Optional() private readonly webSocket?: WSGateway,\n  ) {\n    this.initializedPromise = new Promise<void>((resolve) => {\n      this.initializedResolve = resolve;\n    });\n\n    Object.values({ ...this.websiteImplementations }).forEach(\n      (website: Class<UnknownWebsite>) => {\n        if (\n          !validateWebsiteDecoratorProps(\n            this.logger,\n            website.name,\n            website.prototype.decoratedProps,\n          )\n        ) {\n          this.logger.error(`Failed to register website: ${website.name}`);\n          return;\n        }\n\n        // this.logger.debug(\n        //   `Registered website: ${website.prototype.decoratedProps.metadata.name}`,\n        // );\n        this.availableWebsites[website.prototype.decoratedProps.metadata.name] =\n          website;\n      },\n    );\n\n    this.accountRepository = new PostyBirbDatabase('AccountSchema');\n    this.websiteDataRepository = new PostyBirbDatabase('WebsiteDataSchema');\n    this.accountRepository.subscribe(\n      ['AccountSchema', 'WebsiteDataSchema'],\n      () => this.emit(),\n    );\n  }\n\n  public async emit() {\n    if (this.webSocket) {\n      this.webSocket.emit({\n        event: WEBSITE_UPDATES,\n        data: await this.getWebsiteInfo(),\n      });\n    }\n  }\n\n  /**\n   * Marks the website registry as initialized.\n   * Called after all accounts have been loaded and website instances created.\n   */\n  public markAsInitialized(): void {\n    this.initialized = true;\n    if (this.initializedResolve) {\n      this.initializedResolve();\n      this.initializedResolve = null;\n    }\n    this.logger.info('Website registry marked as initialized');\n  }\n\n  /**\n   * Returns whether the website registry has been initialized\n   * (all accounts loaded and website instances created).\n   * @returns {boolean} True if initialized\n   */\n  public isRegistryInitialized(): boolean {\n    return this.initialized;\n  }\n\n  /**\n   * Returns a promise that resolves when the website registry is initialized.\n   * If already initialized, resolves immediately.\n   * @param {number} [timeoutMs] - Optional timeout in milliseconds\n   * @returns {Promise<void>}\n   */\n  public async waitForInitialization(timeoutMs?: number): Promise<void> {\n    if (this.initialized) {\n      return;\n    }\n\n    if (timeoutMs) {\n      const timeout = new Promise<void>((_, reject) => {\n        setTimeout(\n          () =>\n            reject(new Error('Website registry initialization timed out')),\n          timeoutMs,\n        );\n      });\n      await Promise.race([this.initializedPromise, timeout]);\n    } else {\n      await this.initializedPromise;\n    }\n  }\n\n  /**\n   * Only used for unit testing.\n   */\n  getRepository() {\n    if (IsTestEnvironment()) {\n      return this.websiteDataRepository;\n    }\n\n    throw new Error('Test only method');\n  }\n\n  /**\n   * Checks if the website is registered.\n   *\n   * @param {string} websiteName\n   * @return {*}  {boolean}\n   */\n  public canCreate(websiteName: string): boolean {\n    return Boolean(this.availableWebsites[websiteName]);\n  }\n\n  /**\n   * Creates an instance of a Website associated with an Account.\n   * @param {Account} account\n   */\n  public async create(account: Account): Promise<UnknownWebsite> {\n    const { website, id } = account;\n    if (this.canCreate(account.website)) {\n      const WebsiteCtor = this.availableWebsites[website];\n      if (!this.websiteInstances[website]) {\n        this.websiteInstances[website] = {};\n      }\n\n      if (!this.websiteInstances[website][id]) {\n        // this.logger.info(`Creating instance of '${website}' with id '${id}'`);\n        this.websiteInstances[website][id] = new WebsiteCtor(account);\n        await this.websiteInstances[website][id].onInitialize(\n          this.websiteDataRepository,\n        );\n      } else {\n        this.logger.warn(\n          `An instance of \"${website}\" with id '${id}' already exists`,\n        );\n      }\n\n      return this.websiteInstances[website][id];\n    }\n\n    this.logger.error(`Unable to find website '${website}'`);\n    throw new BadRequestException(`Unable to find website '${website}'`);\n  }\n\n  /**\n   * Finds an existing Website instance.\n   * @param {Account} account\n   */\n  public findInstance(account: IAccount): UnknownWebsite | undefined {\n    const { website, id } = account;\n    if (this.websiteInstances[website] && this.websiteInstances[website][id]) {\n      return this.websiteInstances[website][id];\n    }\n\n    return undefined;\n  }\n\n  /**\n   * Returns all created instances of a website.\n   * @param {Class<UnknownWebsite>} website\n   */\n  public getInstancesOf(website: Class<UnknownWebsite>): UnknownWebsite[] {\n    if (this.websiteInstances[website.prototype.decoratedProps.metadata.name]) {\n      return Object.values(\n        this.websiteInstances[website.prototype.decoratedProps.metadata.name],\n      );\n    }\n\n    return [];\n  }\n\n  /**\n   * Returns all website instances across all accounts.\n   * @returns {UnknownWebsite[]}\n   */\n  public getAll(): UnknownWebsite[] {\n    const all: UnknownWebsite[] = [];\n    for (const instances of Object.values(this.websiteInstances)) {\n      all.push(...Object.values(instances));\n    }\n    return all;\n  }\n\n  /**\n   * Returns a list of all available websites.\n   */\n  public getAvailableWebsites(): Class<UnknownWebsite>[] {\n    return Object.values(this.availableWebsites);\n  }\n\n  /**\n   * Returns a list of all available websites for UI.\n   * @return {*}  {Promise<IWebsiteInfoDto[]>}\n   */\n  public async getWebsiteInfo(): Promise<IWebsiteInfoDto[]> {\n    const dtos: IWebsiteInfoDto[] = [];\n\n    const availableWebsites = this.getAvailableWebsites();\n    // eslint-disable-next-line no-restricted-syntax\n    for (const website of availableWebsites) {\n      const accounts = await this.accountRepository.find({\n        where: (account, { eq }) =>\n          eq(account.website, website.prototype.decoratedProps.metadata.name),\n      });\n      dtos.push({\n        loginType: website.prototype.decoratedProps.loginFlow,\n        id: website.prototype.decoratedProps.metadata.name,\n        displayName: website.prototype.decoratedProps.metadata.displayName,\n        usernameShortcut: website.prototype.decoratedProps.usernameShortcut,\n        metadata: website.prototype.decoratedProps.metadata,\n        fileOptions: website.prototype.decoratedProps.fileOptions,\n        accounts: accounts.map((account) => {\n          const instance = this.findInstance(account);\n          return account.withWebsiteInstance(instance).toDTO();\n        }),\n        supportsFile: FileWebsiteKey in website.prototype,\n        supportsMessage: MessageWebsiteKey in website.prototype,\n      });\n    }\n\n    return dtos.sort((a, b) => a.displayName.localeCompare(b.displayName));\n  }\n\n  /**\n   * Removes an instance of a Website.\n   * Cleans up login, stored, and cache data.\n   * @param {Account} account\n   */\n  public async remove(account: IAccount): Promise<void> {\n    const { name, id, website } = account;\n    const instance = this.findInstance(account);\n    if (instance) {\n      this.logger.info(`Removing and cleaning up ${website} - ${name} - ${id}`);\n      await instance.clearLoginStateAndData(true);\n      delete this.websiteInstances[website][id];\n    }\n  }\n\n  /**\n   * Runs an authorization step for a website.\n   * @param {OAuthWebsiteRequestDto<unknown>} oauthRequestDto\n   */\n  public async performOAuthStep(\n    oauthRequestDto: OAuthWebsiteRequestDto<DynamicObject>,\n  ) {\n    this.logger.info(`OAuth website route for '${oauthRequestDto.id}'`);\n\n    const account = await this.accountRepository.findById(oauthRequestDto.id, {\n      failOnMissing: true,\n    });\n    const instance = this.findInstance(account);\n\n    if (!instance) throw new NotFoundException('Website instance not found.');\n\n    if ('onAuthRoute' in (instance as unknown as OAuthWebsite<OAuthRoutes>)) {\n      const routes = (instance as unknown as OAuthWebsite<OAuthRoutes>)\n        .onAuthRoute;\n\n      return routes[oauthRequestDto.route](oauthRequestDto.data);\n    }\n\n    throw new BadRequestException('Website does not support OAuth operations.');\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/website.events.ts",
    "content": "import { WEBSITE_UPDATES } from '@postybirb/socket-events';\nimport { IWebsiteInfoDto } from '@postybirb/types';\nimport { WebsocketEvent } from '../web-socket/models/web-socket-event';\n\nexport type WebsiteEventTypes = WebsiteUpdateEvent;\n\nclass WebsiteUpdateEvent implements WebsocketEvent<IWebsiteInfoDto[]> {\n  event: string = WEBSITE_UPDATES;\n\n  data: IWebsiteInfoDto[];\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/website.spec.ts",
    "content": "import { Test, TestingModule } from '@nestjs/testing';\nimport { clearDatabase } from '@postybirb/database';\nimport { eq } from 'drizzle-orm';\nimport { Account } from '../drizzle/models';\nimport { PostyBirbDatabase } from '../drizzle/postybirb-database/postybirb-database';\nimport { PostyBirbDatabaseUtil } from '../drizzle/postybirb-database/postybirb-database.util';\nimport { WebsiteImplProvider } from './implementations/provider';\nimport TestWebsite from './implementations/test/test.website';\nimport { WebsiteRegistryService } from './website-registry.service';\n\ndescribe('Website', () => {\n  let module: TestingModule;\n\n  let repository: PostyBirbDatabase<'WebsiteDataSchema'>;\n\n  beforeEach(async () => {\n    clearDatabase();\n    module = await Test.createTestingModule({\n      providers: [WebsiteRegistryService, WebsiteImplProvider],\n    }).compile();\n    const service = module.get(WebsiteRegistryService);\n    repository = service.getRepository();\n  });\n\n  afterAll(async () => {\n    await module.close();\n  });\n\n  it('should be defined', () => {\n    expect(repository).toBeDefined();\n  });\n\n  function populateAccount(): Promise<Account> {\n    return PostyBirbDatabaseUtil.saveFromEntity(\n      new Account({\n        name: 'test',\n        website: 'test',\n        groups: [],\n        id: 'test',\n      }),\n    );\n  }\n\n  it('should store data', async () => {\n    const website = new TestWebsite(await populateAccount());\n    await website.onInitialize(repository);\n    await website.login();\n    const entity = (\n      await repository.select(eq(repository.schemaEntity.id, website.accountId))\n    )[0];\n    expect(entity.data).toEqual({ test: 'test-mode' });\n  }, 10000);\n\n  it('should set website metadata', () => {\n    expect(TestWebsite.prototype.decoratedProps).not.toBeUndefined();\n  });\n});\n"
  },
  {
    "path": "apps/client-server/src/app/websites/website.ts",
    "content": "import { Logger, PostyBirbLogger } from '@postybirb/logger';\nimport {\n  DynamicObject,\n  ILoginState,\n  IWebsiteFormFields,\n  LoginState,\n  SubmissionType,\n} from '@postybirb/types';\nimport { BrowserWindowUtils, getPartitionKey } from '@postybirb/utils/electron';\nimport { Mutex } from 'async-mutex';\nimport { CookiesSetDetails, session } from 'electron';\nimport { Account } from '../drizzle/models';\nimport { PostyBirbDatabase } from '../drizzle/postybirb-database/postybirb-database';\nimport { SubmissionValidator } from './commons/validator';\nimport { WebsiteDecoratorProps } from './decorators/website-decorator-props';\nimport { DataPropertyAccessibility } from './models/data-property-accessibility';\nimport {\n  FileWebsiteKey,\n  isFileWebsite,\n} from './models/website-modifiers/file-website';\nimport {\n  isMessageWebsite,\n  MessageWebsiteKey,\n} from './models/website-modifiers/message-website';\nimport WebsiteDataManager from './website-data-manager';\n\nconst CookiePrefix = 'postybirb:session:';\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport type UnknownWebsite = Website<any>;\n\nexport abstract class Website<\n  D extends DynamicObject,\n  SessionData extends Record<string, unknown> = Record<string, unknown>,\n> {\n  protected readonly logger: PostyBirbLogger;\n\n  /**\n   * User account info for reference primarily during posting and login.\n   */\n  public readonly account: Account;\n\n  /**\n   * Data store for website data that is persisted to dick and read on initialization.\n   */\n  protected readonly websiteDataStore: WebsiteDataManager<D>;\n\n  /**\n   * Intended location for storing dynamically retrieved\n   * information for a website instance.\n   *\n   * Commons things that go here would be folders.\n   *\n   * This is not persisted across app restarts.\n   */\n  protected readonly sessionData: SessionData = {} as SessionData;\n\n  /**\n   * Tracks the login state of a website.\n   */\n  protected readonly loginState: LoginState;\n\n  /**\n   * Mutex that serializes login attempts for this website instance.\n   * The first caller acquires the lock and runs the full login lifecycle;\n   * subsequent callers wait for the lock and return the fresh state.\n   */\n  private readonly loginMutex = new Mutex();\n\n  /**\n   * When true, a follow-up login will run after the current one completes.\n   * This handles the case where cookies/state changed during an in-flight login\n   * (e.g. user logs in on a page while a login check is already running).\n   * Only one follow-up is ever queued regardless of how many callers request it.\n   */\n  private loginDirty = false;\n\n  /**\n   * Properties set by website decorators such as {@link WebsiteMetadata}.\n   * These should only be set by a decorator.\n   * @type {WebsiteDecoratorProps}\n   */\n  public readonly decoratedProps: WebsiteDecoratorProps;\n\n  /**\n   * Base website URL user for reference during website calls.\n   */\n  protected abstract readonly BASE_URL: string;\n\n  /**\n   * An explicit map of data properties of {D} that is allowed to be sent back out of the\n   * client server to the ui.\n   *\n   * Just an extra protection to reduce unnecessary passing of sensitive keys.\n   */\n  public abstract readonly externallyAccessibleWebsiteDataProperties: DataPropertyAccessibility<D>;\n\n  /**\n   * Reference Id of a website instance.\n   *\n   * @readonly\n   * @type {string}\n   */\n  public get id(): string {\n    return `${this.account.website}:an=[${this.account.name}]:acid=[${this.account.id}]`;\n  }\n\n  /**\n   * Reference to account id.\n   *\n   * @readonly\n   * @type {string}\n   */\n  public get accountId(): string {\n    return this.account.id;\n  }\n\n  /**\n   * Whether or not this class supports {SubmissionType.FILE}.\n   *\n   * @readonly\n   * @type {boolean}\n   */\n  public get supportsFile(): boolean {\n    return FileWebsiteKey in this;\n  }\n\n  /**\n   * Whether or not this class supports {SubmissionType.MESSAGE}.\n   *\n   * @readonly\n   * @type {boolean}\n   */\n  public get supportsMessage(): boolean {\n    return MessageWebsiteKey in this;\n  }\n\n  /**\n   * Creates a model for file submissions.\n   */\n  getModelFor(type: SubmissionType) {\n    if (type === SubmissionType.FILE && isFileWebsite(this)) {\n      return this.createFileModel();\n    }\n\n    if (type === SubmissionType.MESSAGE && isMessageWebsite(this)) {\n      return this.createMessageModel();\n    }\n\n    throw new Error(`Unsupported submission type: ${type}`);\n  }\n\n  /**\n   * Creates new validator to be used in onValidateFileSubmission or onValidateMessageSubmission\n   */\n  protected createValidator<T extends IWebsiteFormFields = never>() {\n    return new SubmissionValidator<T>();\n  }\n\n  constructor(userAccount: Account) {\n    this.account = userAccount;\n    this.logger = Logger(this.decoratedProps.metadata.displayName);\n    this.websiteDataStore = new WebsiteDataManager(userAccount);\n    this.loginState = new LoginState();\n  }\n\n  // -------------- Externally Accessed Methods --------------\n  // Methods intended to be executed by consumers of a Website\n\n  public async clearLoginStateAndData(forWebsiteDeletion = false) {\n    this.logger.info('Clearing login state and data');\n    await session\n      .fromPartition(getPartitionKey(this.account.id))\n      .clearStorageData();\n    await this.websiteDataStore.clearData(!forWebsiteDeletion);\n    this.loginState.logout();\n  }\n\n  /**\n   * Returns website data.\n   * Filters out any property marked false in externallyAccessibleWebsiteDataProperties.\n   */\n  public getWebsiteData(): D {\n    const data = this.websiteDataStore.getData();\n\n    // Filter any property marked false\n    Object.entries(this.externallyAccessibleWebsiteDataProperties)\n      .filter(([, value]) => !value)\n      .forEach(([key]) => {\n        delete data[key];\n      });\n\n    return { ...data };\n  }\n\n  /**\n   * Returns properties to be used in the form generator.\n   * This should be extended by the website to provide additional properties as needed.\n   */\n  public getFormProperties(): DynamicObject {\n    const longTermData = this.getWebsiteData();\n    const shortTermData = this.sessionData;\n    return {\n      ...longTermData,\n      ...shortTermData,\n    };\n  }\n\n  /**\n   * Returns the login state of the website.\n   */\n  public getLoginState() {\n    return this.loginState.getState();\n  }\n\n  /**\n   * Returns an array of supported SubmissionType based on implemented interfaces.\n   */\n  public getSupportedTypes(): SubmissionType[] {\n    const types: SubmissionType[] = [];\n\n    if (this.supportsMessage) {\n      types.push(SubmissionType.MESSAGE);\n    }\n\n    if (this.supportsFile) {\n      types.push(SubmissionType.FILE);\n    }\n\n    return types;\n  }\n\n  /**\n   * Sets the website data provided by user or other means.\n   * @param {D} data\n   */\n  public async setWebsiteData(data: D) {\n    await this.websiteDataStore.setData(data);\n    this.onWebsiteDataChange(data);\n  }\n\n  /**\n   * Hook that runs whenever website data is updated via {@link setWebsiteData}.\n   * Subclasses can implement this to react to data changes, e.g. update internal state or trigger side effects.\n   * @param {D} newData - The new website data that was set.\n   * @returns {Promise<void>}\n   */\n  protected async onWebsiteDataChange(newData: D) {\n    // Nothing to do here yet, but this is a hook for future if we want to trigger any actions on data change\n  }\n\n  // -------------- End Externally Accessed Methods --------------\n\n  // -------------- Login Methods --------------\n\n  /**\n   * Public entry point for login. Serialized via mutex so that:\n   * - The first caller runs the full lifecycle (onBeforeLogin -> onLogin).\n   * - Concurrent callers mark the login as dirty so one follow-up runs.\n   * - At most one follow-up is queued, not one per waiter.\n   *\n   * @returns {Promise<ILoginState>} The login state after the check completes.\n   */\n  public async login(): Promise<ILoginState> {\n    // If the mutex is already locked, mark dirty so a follow-up runs,\n    // then wait for all pending work (current + follow-up) to finish.\n    if (this.loginMutex.isLocked()) {\n      this.logger.debug(\n        `Login already in progress for ${this.id}, marking dirty and waiting...`,\n      );\n      this.loginDirty = true;\n      await this.loginMutex.waitForUnlock();\n      return this.loginState.getState();\n    }\n\n    return this.loginMutex.runExclusive(async () => {\n      await this.executeLogin();\n\n      if (this.loginDirty) {\n        this.loginDirty = false;\n        this.logger.debug(\n          `Running follow-up login for ${this.id} (state may have changed during previous run)`,\n        );\n        if (!this.loginState.isLoggedIn) {\n          await this.executeLogin();\n        }\n      }\n\n      return this.loginState.getState();\n    });\n  }\n\n  /**\n   * Runs the actual login lifecycle: onBeforeLogin -> onLogin.\n   * Must only be called while holding the loginMutex.\n   */\n  private async executeLogin(): Promise<void> {\n    try {\n      this.loginState.setPending(true);\n      await this.onBeforeLogin();\n      await this.onLogin();\n    } catch (e) {\n      this.logger.withError(e).error(`Login failed for ${this.id}`);\n    } finally {\n      this.loginState.setPending(false);\n    }\n  }\n\n  // -------------- End Login Methods --------------\n\n  // -------------- Event Methods --------------\n\n  /**\n   * Method that runs once on initialization of the Website class.\n   */\n  public async onInitialize(\n    websiteDataRepository: PostyBirbDatabase<'WebsiteDataSchema'>,\n  ): Promise<void> {\n    await this.websiteDataStore.initialize(websiteDataRepository);\n  }\n\n  /**\n   * Method that attempts to refresh expired cookies for user login flows.\n   * This is a workaround to load the actual web page in the background to refresh cookies that may be expiring.\n   */\n  private async cycleCookies(): Promise<void> {\n    if (this.decoratedProps.loginFlow.type === 'user') {\n      this.logger.debug('Cycling cookies for user login flow');\n      await BrowserWindowUtils.ping(this.accountId, this.BASE_URL).catch(\n        (err) => {\n          this.logger.error('Error cycling cookies:', err);\n        },\n      );\n    }\n  }\n\n  /**\n   * Runs before onLogin to handle cookie management.\n   * For user login flows, it checks for expired cookies and attempts to refresh them.\n   * Also persists session cookies with a prefix for cross-restart persistence.\n   *\n   * @protected - called internally by {@link login}.\n   */\n  protected async onBeforeLogin() {\n    try {\n      if (this.decoratedProps.loginFlow.type === 'user') {\n        const { cookies } = session.fromPartition(\n          getPartitionKey(this.accountId),\n        );\n        const cookiesList = await cookies.get({});\n        for (const cookie of cookiesList) {\n          // Check for expired cookies\n          if (\n            cookie.expirationDate &&\n            cookie.expirationDate < Date.now() / 1000\n          ) {\n            this.logger.debug(\n              `Found expired cookie: ${cookie.name} (${cookie.domain}) - Expired at ${new Date(cookie.expirationDate * 1000).toISOString()}`,\n            );\n            await this.cycleCookies();\n          }\n\n          if (cookie.session) {\n            const setCookie: CookiesSetDetails = {\n              url: `${cookie.secure ? 'https' : 'http'}://${cookie.domain.replace(/^\\./, '')}${cookie.path}`,\n              name: `${CookiePrefix}${cookie.name}`,\n              value: cookie.value,\n              domain: cookie.domain,\n              path: cookie.path,\n              secure: cookie.secure,\n              httpOnly: cookie.httpOnly,\n              sameSite: cookie.sameSite,\n              expirationDate:\n                Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 365,\n            };\n            await cookies.set(setCookie);\n            await cookies.flushStore();\n          } else if (cookie.name.startsWith(CookiePrefix)) {\n            const sessionCookieAlreadyPopulated = cookiesList.some(\n              (c) => c.name === cookie.name.replace(CookiePrefix, ''),\n            );\n            if (!sessionCookieAlreadyPopulated) {\n              this.logger.debug(\n                `Rehydrating session cookie: ${cookie.name} (${cookie.domain})`,\n              );\n              const setCookie: CookiesSetDetails = {\n                url: `${cookie.secure ? 'https' : 'http'}://${cookie.domain.replace(/^\\./, '')}${cookie.path}`,\n                name: cookie.name.replace(CookiePrefix, ''),\n                value: cookie.value,\n                domain: cookie.domain,\n                path: cookie.path,\n                secure: cookie.secure,\n                httpOnly: cookie.httpOnly,\n                sameSite: cookie.sameSite,\n              };\n              await cookies.set(setCookie);\n              await cookies.flushStore();\n            }\n          }\n        }\n      }\n    } catch (err) {\n      this.logger.error('Error during onBeforeLogin cookie handling:', err);\n    }\n  }\n\n  /**\n   * Method that runs whenever a user closes the login page or on a scheduled interval.\n   * Subclasses implement this to perform the actual login / session-validation logic.\n   *\n   * @protected - called internally by {@link login}.\n   */\n  protected abstract onLogin(): Promise<ILoginState>;\n\n  // -------------- End Event Methods --------------\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/websites.controller.ts",
    "content": "import { Body, Controller, Get, Post, Query, Res } from '@nestjs/common';\nimport { ApiResponse, ApiTags } from '@nestjs/swagger';\nimport { DynamicObject } from '@postybirb/types';\nimport type { Response } from 'express';\nimport { OAuthWebsiteRequestDto } from './dtos/oauth-website-request.dto';\nimport { storeOAuthCode } from './implementations/instagram/instagram-api-service/instagram-api-service';\nimport { WebsiteRegistryService } from './website-registry.service';\n\n/**\n * Special operations to be run on website instances.\n * @class WebsitesController\n */\n@ApiTags('websites')\n@Controller('websites')\nexport class WebsitesController {\n  constructor(\n    private readonly websiteRegistryService: WebsiteRegistryService,\n  ) {}\n\n  @Post('oauth')\n  @ApiResponse({ status: 200, description: 'Authorization step completed.' })\n  @ApiResponse({ status: 404, description: 'Website instance not found.' })\n  @ApiResponse({\n    status: 500,\n    description: 'An error occurred while performing authorization operation.',\n  })\n  performOAuthStep(\n    @Body() oauthRequestDto: OAuthWebsiteRequestDto<DynamicObject>,\n  ) {\n    return this.websiteRegistryService.performOAuthStep(oauthRequestDto);\n  }\n\n  @Get('info')\n  @ApiResponse({ status: 200 })\n  getWebsiteLoginInfo() {\n    return this.websiteRegistryService.getWebsiteInfo();\n  }\n\n  /**\n   * OAuth callback endpoint for Instagram.\n   * Facebook redirects the browser here after user authorization.\n   * Stores the code for retrieval by the login UI via the retrieveCode OAuth step.\n   */\n  @Get('instagram/callback')\n  handleInstagramCallback(\n    @Query('code') code: string,\n    @Query('state') state: string,\n    @Query('error') error: string,\n    @Query('error_description') errorDescription: string,\n    @Res() res: Response,\n  ) {\n    if (error) {\n      res.type('html').send(\n        `<html><body style=\"font-family:system-ui;text-align:center;padding:40px\">` +\n          `<h2>Authorization Failed</h2>` +\n          `<p>${errorDescription || error}</p>` +\n          `<p>You can close this tab and try again in PostyBirb.</p>` +\n          `</body></html>`,\n      );\n      return;\n    }\n\n    if (code && state) {\n      storeOAuthCode(state, code);\n      res.type('html').send(\n        `<html><body style=\"font-family:system-ui;text-align:center;padding:40px\">` +\n          `<h2>&#10004; Authorization Successful</h2>` +\n          `<p>You can close this tab and return to PostyBirb.</p>` +\n          `<p style=\"color:#888;font-size:0.85em\">The authorization code has been captured automatically.</p>` +\n          `</body></html>`,\n      );\n      return;\n    }\n\n    res.type('html').send(\n      `<html><body style=\"font-family:system-ui;text-align:center;padding:40px\">` +\n        `<h2>Missing Parameters</h2>` +\n        `<p>No authorization code received. Please try again.</p>` +\n        `</body></html>`,\n    );\n  }\n}\n"
  },
  {
    "path": "apps/client-server/src/app/websites/websites.module.ts",
    "content": "import { Module } from '@nestjs/common';\nimport { WebsiteImplProvider } from './implementations/provider';\nimport { WebsiteRegistryService } from './website-registry.service';\nimport { WebsitesController } from './websites.controller';\n\n@Module({\n  providers: [WebsiteRegistryService, WebsiteImplProvider],\n  exports: [WebsiteRegistryService],\n  controllers: [WebsitesController],\n})\nexport class WebsitesModule {}\n"
  },
  {
    "path": "apps/client-server/src/assets/.gitkeep",
    "content": ""
  },
  {
    "path": "apps/client-server/src/assets/sharp-worker.js",
    "content": "/**\n * Sharp Worker Script\n *\n * Runs in a separate worker thread via piscina to isolate sharp/libvips\n * native code from the main process. If libvips segfaults (e.g. after\n * long idle), only this worker dies — the main process survives and\n * piscina automatically spawns a replacement.\n *\n * All sharp imports are confined to this file.\n */\n'use strict';\n\nconst sharp = require('sharp');\nconst os = require('os');\n\n// Configure sharp for worker thread\nsharp.cache({ files: 0 });\nsharp.concurrency(1); // Limit per-worker thread count to reduce native memory pressure\n\n// On glibc-based Linux, reduce memory arena count to prevent\n// the fragmentation that causes crashes after long idle.\nif (os.platform() === 'linux' && !process.env.MALLOC_ARENA_MAX) {\n  process.env.MALLOC_ARENA_MAX = '2';\n}\n\n/**\n * Create a sharp instance with the pixel limit disabled.\n * Sharp's default limit (268 megapixels) rejects large images\n * that artists commonly work with (e.g. 50MB+ high-res JPEGs).\n * @param {Buffer} buffer\n * @returns {import('sharp').Sharp}\n */\nfunction load(buffer) {\n  return sharp(buffer, { limitInputPixels: false });\n}\n\n/**\n * @typedef {Object} SharpWorkerInput\n * @property {'resize' | 'metadata' | 'thumbnail'} operation\n * @property {Buffer} buffer - The image buffer to process\n * @property {Object} [resize] - Resize parameters\n * @property {number} [resize.width]\n * @property {number} [resize.height]\n * @property {number} [resize.maxBytes]\n * @property {boolean} [resize.allowQualityLoss]\n * @property {string} [resize.outputMimeType]\n * @property {string} mimeType - Source MIME type\n * @property {string} [fileName] - Original file name\n * @property {string} [fileId] - Submission file ID\n * @property {number} [fileWidth] - Original file width\n * @property {number} [fileHeight] - Original file height\n * @property {Buffer} [thumbnailBuffer] - Optional separate thumbnail source buffer\n * @property {number} [thumbnailWidth] - Thumbnail max width (default 500)\n * @property {number} [thumbnailHeight] - Thumbnail max height (default 500)\n * @property {boolean} [generateThumbnail] - Whether to generate a thumbnail\n * @property {number} [thumbnailPreferredDimension] - Thumbnail target dimension (default 400 for upload, 500 for post)\n */\n\n/**\n * @typedef {Object} SharpWorkerResult\n * @property {Buffer} [buffer] - Processed image buffer (for resize/thumbnail ops)\n * @property {string} [mimeType] - Result MIME type\n * @property {number} [width] - Result width\n * @property {number} [height] - Result height\n * @property {string} [fileName] - Result file name\n * @property {string} [format] - Image format string from sharp\n * @property {Buffer} [thumbnailBuffer] - Thumbnail buffer if generated\n * @property {string} [thumbnailMimeType] - Thumbnail MIME type\n * @property {number} [thumbnailWidth] - Thumbnail width\n * @property {number} [thumbnailHeight] - Thumbnail height\n * @property {string} [thumbnailFileName] - Thumbnail file name\n * @property {boolean} modified - Whether the image was modified from input\n */\n\n/**\n * Apply output format conversion to a sharp instance.\n * Uses MozJPEG for JPEG encoding — produces ~20% smaller files at Q100\n * compared to standard libjpeg-turbo, preserving more pixels when\n * scaling down to meet file size limits.\n *\n * @param {import('sharp').Sharp} instance\n * @param {string} outputMimeType\n * @returns {import('sharp').Sharp}\n */\nfunction applyOutputFormat(instance, outputMimeType) {\n  switch (outputMimeType) {\n    case 'image/jpeg':\n      return instance.jpeg({ quality: 100, mozjpeg: true });\n    case 'image/png':\n      return instance.png();\n    case 'image/webp':\n      return instance.webp({ quality: 100 });\n    default:\n      return instance;\n  }\n}\n\n/**\n * Resize image to fit within width/height bounds.\n * Uses a single sharp pipeline for both the size check and the resize.\n *\n * @param {Buffer} inputBuffer\n * @param {number} width\n * @param {number} height\n * @returns {Promise<{buffer: Buffer, width: number, height: number, format: string}>}\n */\nasync function resizeImage(inputBuffer, width, height) {\n  const metadata = await load(inputBuffer).metadata();\n\n  if (metadata.width > width || metadata.height > height) {\n    // Use resolveWithObject to get output info without re-decoding\n    const { data, info } = await load(inputBuffer)\n      .resize({ width, height, fit: 'inside', withoutEnlargement: true })\n      .toBuffer({ resolveWithObject: true });\n    return {\n      buffer: data,\n      width: info.width,\n      height: info.height,\n      format: info.format,\n    };\n  }\n\n  return {\n    buffer: inputBuffer,\n    width: metadata.width,\n    height: metadata.height,\n    format: metadata.format,\n  };\n}\n\n/**\n * Scale an image down to fit within a maximum byte size.\n *\n * Uses the \"adaptive secant\" algorithm — a fast convergence method that\n * typically finds the optimal scale in 2-3 encode attempts instead of\n * the 11+ attempts required by the old linear 5% step approach.\n *\n * How it works:\n *\n *   1. ENCODE AT FULL SIZE with MozJPEG Q100. MozJPEG's optimized Huffman\n *      tables and trellis quantization often reduce filesize by 20%+ with\n *      no visible quality loss. If this alone fits, we're done in 1 encode.\n *\n *   2. ADAPTIVE FIRST GUESS based on how far over the target we are:\n *      - Barely over (<15%): try 95% dimensions — a tiny nudge.\n *      - Moderately over (<50%): use sqrt(target/current) * 0.98 — tight\n *        prediction with minimal safety margin.\n *      - Way over (>50%): use sqrt(target/current) * 0.95 — standard\n *        prediction with 5% safety margin.\n *      The sqrt ratio works because JPEG filesize is roughly proportional\n *      to pixel count (width × height), so scale² ≈ targetBytes/currentBytes.\n *\n *   3. SECANT REFINEMENT if the first guess isn't close enough.\n *      Uses two data points (full-size bytes and guess bytes) to build\n *      a local linear model of the scale² → bytes relationship, then\n *      interpolates to find the exact crossing point. This adapts to the\n *      actual compression behavior of the specific image rather than\n *      assuming a theoretical relationship.\n *\n * Performance comparison (4.25MB 3840×2160 JPEG → 1MB target):\n *   - Old linear approach: 11 encodes, 9.5 seconds\n *   - Adaptive secant:      2-3 encodes, 2.1 seconds\n *\n * @param {Buffer} inputBuffer - The current buffer (may already be resized/converted)\n * @param {Buffer} originalBuffer - The original unmodified input buffer\n * @param {number} originalWidth - Original image width\n * @param {number} originalHeight - Original image height\n * @param {number} maxBytes - Maximum allowed file size in bytes\n * @param {string} mimeType - The output MIME type (for choosing encoder)\n * @returns {Promise<{buffer: Buffer, width: number, height: number, format: string}>}\n *   Buffer that fits within maxBytes, plus its dimensions/format.\n */\nasync function scaleDownImage(\n  inputBuffer,\n  originalBuffer,\n  originalWidth,\n  originalHeight,\n  maxBytes,\n  mimeType,\n) {\n  // Step 1: Re-encode at full size.\n  // For JPEG, MozJPEG alone often reduces the file by 20%+ without any\n  // dimension change. For PNG (lossless), re-encoding doesn't help with\n  // file size, so we skip directly to dimensional reduction.\n  let result;\n  if (mimeType === 'image/png') {\n    result = {\n      buffer: inputBuffer,\n      width: originalWidth,\n      height: originalHeight,\n      format: 'png',\n    };\n  } else {\n    result = await encodeAtScale(\n      originalBuffer,\n      originalWidth,\n      originalHeight,\n      1.0,\n      mimeType,\n    );\n  }\n\n  if (result.buffer.length <= maxBytes) {\n    return result;\n  }\n\n  const fullSize = result.buffer.length;\n  const ratio = fullSize / maxBytes;\n\n  // Step 2: Adaptive first guess — choose safety margin based on\n  // how far over the target we are.\n  let scale;\n  if (ratio < 1.15) {\n    // Barely over — just try 95% dimensions\n    scale = 0.95;\n  } else if (ratio < 1.5) {\n    // Moderately over — tight prediction, less safety margin\n    scale = Math.sqrt(maxBytes / fullSize) * 0.98;\n  } else {\n    // Way over — standard prediction with 5% safety margin\n    scale = Math.sqrt(maxBytes / fullSize) * 0.95;\n  }\n  scale = Math.max(scale, 0.1);\n\n  let currentBuffer = await encodeAtScale(\n    originalBuffer,\n    originalWidth,\n    originalHeight,\n    scale,\n    mimeType,\n  );\n\n  // Track data points for secant interpolation\n  let prevScale = 1.0;\n  let prevBytes = fullSize;\n  let currScale = scale;\n  let currBytes = currentBuffer.buffer.length;\n\n  // Step 3: Secant refinement — use two-point interpolation to converge.\n  // The secant method models scale² → bytes as locally linear and\n  // extrapolates to find the scale where bytes = maxBytes.\n  // Max 6 iterations as a safety bound (typically converges in 0-1).\n  for (let i = 0; i < 6; i++) {\n    // Converged: under target and within 5% of it — good enough\n    if (currBytes <= maxBytes && currBytes > maxBytes * 0.95) break;\n\n    const ps2 = prevScale * prevScale;\n    const cs2 = currScale * currScale;\n    if (Math.abs(currBytes - prevBytes) < 1) break; // bytes converged\n\n    const slope = (currBytes - prevBytes) / (cs2 - ps2);\n    if (slope === 0) break;\n\n    // Secant formula: solve for scale² at bytes = maxBytes\n    let nextScale2 = cs2 + (maxBytes - currBytes) / slope;\n    nextScale2 = Math.max(nextScale2, 0.01);\n    let nextScale = Math.sqrt(nextScale2);\n    nextScale = Math.max(0.1, Math.min(nextScale, 1.0));\n\n    // If still over target, apply a small 2% safety nudge downward\n    if (currBytes > maxBytes) {\n      nextScale *= 0.98;\n    }\n\n    // Don't repeat a nearly identical scale\n    if (Math.abs(nextScale - currScale) < 0.005) break;\n\n    const nextResult = await encodeAtScale(\n      originalBuffer,\n      originalWidth,\n      originalHeight,\n      nextScale,\n      mimeType,\n    );\n\n    prevScale = currScale;\n    prevBytes = currBytes;\n    currScale = nextScale;\n    currBytes = nextResult.buffer.length;\n    currentBuffer = nextResult;\n  }\n\n  if (currentBuffer.buffer.length > maxBytes) {\n    throw new Error(\n      'Image is still too large after scaling down. Try scaling down the image manually.',\n    );\n  }\n\n  return currentBuffer;\n}\n\n/**\n * Resize and re-encode an image at a given scale factor.\n * Always scales against the original buffer to avoid compounding quality loss.\n * Uses MozJPEG for JPEG output.\n *\n * @param {Buffer} originalBuffer - The original unmodified image buffer\n * @param {number} originalWidth - Original width\n * @param {number} originalHeight - Original height\n * @param {number} scale - Scale factor (0.1 to 1.0)\n * @param {string} mimeType - Output MIME type\n * @returns {Promise<{buffer: Buffer, width: number, height: number, format: string}>}\n */\nasync function encodeAtScale(\n  originalBuffer,\n  originalWidth,\n  originalHeight,\n  scale,\n  mimeType,\n) {\n  let pipeline = load(originalBuffer);\n\n  if (scale < 0.999) {\n    const targetW = Math.round(originalWidth * scale);\n    const targetH = Math.round(originalHeight * scale);\n    pipeline = pipeline.resize({\n      width: targetW,\n      height: targetH,\n      fit: 'inside',\n      withoutEnlargement: true,\n    });\n  }\n\n  // Apply format-specific encoding and get output info in one call\n  if (mimeType === 'image/png') {\n    pipeline = pipeline.png();\n  } else if (mimeType === 'image/webp') {\n    pipeline = pipeline.webp({ quality: 100 });\n  } else {\n    pipeline = pipeline.jpeg({ quality: 100, mozjpeg: true });\n  }\n\n  const { data, info } = await pipeline.toBuffer({ resolveWithObject: true });\n  return {\n    buffer: data,\n    width: info.width,\n    height: info.height,\n    format: info.format,\n  };\n}\n\n/**\n * Generate a thumbnail from an image buffer.\n * @param {Buffer} sourceBuffer\n * @param {string} sourceMimeType\n * @param {string} sourceFileName\n * @param {number} [preferredDimension=400]\n * @returns {Promise<{buffer: Buffer, width: number, height: number, mimeType: string, fileName: string}>}\n */\nasync function generateThumbnail(\n  sourceBuffer,\n  sourceMimeType,\n  sourceFileName,\n  preferredDimension,\n) {\n  const dimension = preferredDimension || 400;\n\n  const isJpeg =\n    sourceMimeType === 'image/jpeg' || sourceMimeType === 'image/jpg';\n  let pipeline = load(sourceBuffer).resize(dimension, dimension, {\n    fit: 'inside',\n    withoutEnlargement: true,\n  });\n\n  pipeline = isJpeg\n    ? pipeline.jpeg({ quality: 99, force: true })\n    : pipeline.png({ quality: 99, force: true });\n\n  // Get buffer + output dimensions in one call — no re-decode needed\n  const { data, info } = await pipeline.toBuffer({ resolveWithObject: true });\n  const mimeType = isJpeg ? 'image/jpeg' : 'image/png';\n\n  const ext = isJpeg ? 'jpg' : 'png';\n  const baseName = sourceFileName\n    ? sourceFileName.replace(/\\.[^.]+$/, '')\n    : 'thumbnail';\n  const fileName = `thumbnail_${baseName}.${ext}`;\n\n  return {\n    buffer: data,\n    width: info.width,\n    height: info.height,\n    mimeType,\n    fileName,\n  };\n}\n\n/**\n * Main worker function dispatched by piscina.\n * @param {SharpWorkerInput} input\n * @returns {Promise<SharpWorkerResult>}\n */\nmodule.exports = async function processImage(input) {\n  const { operation } = input;\n\n  switch (operation) {\n    case 'metadata': {\n      const metadata = await load(input.buffer).metadata();\n      return {\n        width: metadata.width || 0,\n        height: metadata.height || 0,\n        format: metadata.format,\n        mimeType: `image/${metadata.format}`,\n        modified: false,\n      };\n    }\n\n    case 'healthcheck': {\n      // Validate sharp can load, decode, resize, and encode.\n      // Catches missing native bindings, glibc issues, sandbox\n      // restrictions, etc. at startup instead of mid-post.\n      const diagnostics = {\n        platform: os.platform(),\n        arch: os.arch(),\n        nodeVersion: process.version,\n        sharpVersions: sharp.versions || {},\n        glibcVersion: null,\n        malloc_arena_max: process.env.MALLOC_ARENA_MAX || 'unset',\n      };\n\n      // Detect glibc version on Linux\n      if (os.platform() === 'linux') {\n        try {\n          const report = process.report ? process.report.getReport() : null;\n          if (report && report.header && report.header.glibcVersionRuntime) {\n            diagnostics.glibcVersion = report.header.glibcVersionRuntime;\n          }\n        } catch {\n          // process.report may not be available\n        }\n      }\n\n      // Create a tiny 1x1 JPEG, read it back — exercises the full\n      // sharp pipeline including native bindings\n      const pixel = Buffer.from([255, 0, 0]); // 1 red pixel\n      const testImg = await sharp(pixel, { raw: { width: 1, height: 1, channels: 3 } })\n        .jpeg({ quality: 50 })\n        .toBuffer();\n      const testMeta = await sharp(testImg).metadata();\n\n      return {\n        modified: false,\n        width: testMeta.width,\n        height: testMeta.height,\n        format: testMeta.format,\n        mimeType: 'image/jpeg',\n        diagnostics,\n      };\n    }\n\n    case 'thumbnail': {\n      const result = await generateThumbnail(\n        input.buffer,\n        input.mimeType,\n        input.fileName,\n        input.thumbnailPreferredDimension,\n      );\n      return {\n        buffer: result.buffer,\n        width: result.width,\n        height: result.height,\n        mimeType: result.mimeType,\n        fileName: result.fileName,\n        modified: true,\n      };\n    }\n\n    case 'resize': {\n      const resize = input.resize || {};\n      let buffer = input.buffer;\n      let modified = false;\n      let finalWidth = input.fileWidth;\n      let finalHeight = input.fileHeight;\n      let finalFormat = input.mimeType\n        ? input.mimeType.replace('image/', '')\n        : 'jpeg';\n\n      // Step 1: Apply format conversion if requested\n      if (resize.outputMimeType && input.mimeType !== resize.outputMimeType) {\n        modified = true;\n        buffer = await applyOutputFormat(\n          load(buffer),\n          resize.outputMimeType,\n        ).toBuffer();\n      }\n\n      // Step 2: Dimensional resize if requested\n      if (resize.width || resize.height) {\n        let srcWidth = input.fileWidth;\n        let srcHeight = input.fileHeight;\n        if (!srcWidth || !srcHeight) {\n          const meta = await load(buffer).metadata();\n          srcWidth = srcWidth || meta.width;\n          srcHeight = srcHeight || meta.height;\n        }\n\n        if (\n          (resize.width && resize.width < srcWidth) ||\n          (resize.height && resize.height < srcHeight)\n        ) {\n          modified = true;\n          const result = await resizeImage(buffer, resize.width, resize.height);\n          buffer = result.buffer;\n          finalWidth = result.width;\n          finalHeight = result.height;\n          finalFormat = result.format;\n        }\n      }\n\n      // Step 3: Scale down to maxBytes if needed\n      if (resize.maxBytes && input.buffer.length > resize.maxBytes) {\n        if (buffer.length > resize.maxBytes) {\n          modified = true;\n          let origWidth = input.fileWidth;\n          let origHeight = input.fileHeight;\n          if (!origWidth || !origHeight) {\n            const origMeta = await load(input.buffer).metadata();\n            origWidth = origWidth || origMeta.width;\n            origHeight = origHeight || origMeta.height;\n          }\n          const scaleResult = await scaleDownImage(\n            buffer,\n            input.buffer,\n            origWidth,\n            origHeight,\n            resize.maxBytes,\n            resize.outputMimeType || input.mimeType,\n          );\n          buffer = scaleResult.buffer;\n          finalWidth = scaleResult.width;\n          finalHeight = scaleResult.height;\n          finalFormat = scaleResult.format;\n        }\n      }\n\n      // If modified but metadata wasn't captured (e.g. only format conversion),\n      // decode once to get dimensions.\n      if (modified && !finalWidth) {\n        const finalMeta = await load(buffer).metadata();\n        finalWidth = finalMeta.width;\n        finalHeight = finalMeta.height;\n        finalFormat = finalMeta.format;\n      }\n\n      // Step 4: Generate thumbnail if requested\n      let thumbnailResult;\n      if (input.generateThumbnail) {\n        const thumbSource = input.thumbnailBuffer || buffer;\n        const thumbDim = input.thumbnailPreferredDimension || 500;\n        thumbnailResult = await generateThumbnail(\n          thumbSource,\n          input.thumbnailMimeType || input.mimeType,\n          input.fileName,\n          thumbDim,\n        );\n      }\n\n      return {\n        buffer: modified ? buffer : undefined,\n        width: finalWidth,\n        height: finalHeight,\n        format: finalFormat,\n        mimeType: `image/${finalFormat}`,\n        fileName: input.fileId\n          ? `${input.fileId}.${finalFormat}`\n          : input.fileName,\n        modified,\n        thumbnailBuffer: thumbnailResult ? thumbnailResult.buffer : undefined,\n        thumbnailMimeType: thumbnailResult\n          ? thumbnailResult.mimeType\n          : undefined,\n        thumbnailWidth: thumbnailResult ? thumbnailResult.width : undefined,\n        thumbnailHeight: thumbnailResult ? thumbnailResult.height : undefined,\n        thumbnailFileName: thumbnailResult\n          ? thumbnailResult.fileName\n          : undefined,\n      };\n    }\n\n    default:\n      throw new Error(`Unknown operation: ${operation}`);\n  }\n};\n"
  },
  {
    "path": "apps/client-server/src/environments/environment.prod.ts",
    "content": "export const environment = {\n  production: true,\n};\n"
  },
  {
    "path": "apps/client-server/src/environments/environment.ts",
    "content": "export const environment = {\n  production: false,\n};\n"
  },
  {
    "path": "apps/client-server/src/main.ts",
    "content": "import {\n  ClassSerializerInterceptor,\n  INestApplication,\n  Logger,\n  PlainLiteralObject,\n  ValidationPipe,\n} from '@nestjs/common';\nimport { ClassTransformOptions } from '@nestjs/common/interfaces/external/class-transform-options.interface';\nimport { NestFactory, Reflector } from '@nestjs/core';\nimport { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';\nimport {\n  IsTestEnvironment,\n  PostyBirbEnvConfig,\n} from '@postybirb/utils/electron';\nimport compression from 'compression';\nimport { AppModule } from './app/app.module';\nimport { DatabaseEntity } from './app/drizzle/models';\nimport { SSL } from './app/security-and-authentication/ssl';\nimport { WebSocketAdapter } from './app/web-socket/web-socket-adapter';\n\nclass CustomClassSerializer extends ClassSerializerInterceptor {\n  serialize(\n    response: PlainLiteralObject | PlainLiteralObject[],\n    options: ClassTransformOptions,\n  ): PlainLiteralObject | PlainLiteralObject[] {\n    // Attempts to deal with recursive objects\n    return super.serialize(\n      response instanceof DatabaseEntity ? response.toDTO() : response,\n      options,\n    );\n  }\n}\n\nasync function bootstrap() {\n  let app: INestApplication;\n  if (!IsTestEnvironment()) {\n    // TLS/SSL on non-test\n    const { cert, key } = await SSL.getOrCreateSSL();\n    app = await NestFactory.create(AppModule, {\n      logger: ['error', 'warn'],\n      httpsOptions: {\n        key,\n        cert,\n      },\n    });\n  } else {\n    app = await NestFactory.create(AppModule);\n  }\n\n  const globalPrefix = 'api';\n  app.enableCors();\n  app.useWebSocketAdapter(new WebSocketAdapter(app));\n  app.setGlobalPrefix(globalPrefix);\n  app.useGlobalInterceptors(new CustomClassSerializer(app.get(Reflector)));\n  app.useGlobalPipes(\n    new ValidationPipe({\n      forbidUnknownValues: true,\n      transform: true,\n    }),\n  );\n  app.use(compression());\n\n  // Swagger\n  const config = new DocumentBuilder()\n    .setTitle('PostyBirb')\n    .setDescription('PostyBirb API')\n    .setVersion('1.0')\n    .addApiKey(\n      { type: 'apiKey', name: 'x-remote-password', in: 'header' },\n      'x-remote-password',\n    )\n    .addTag('account')\n    .addTag('custom-shortcut')\n    .addTag('directory-watchers')\n    .addTag('file')\n    .addTag('file-submission')\n    .addTag('form-generator')\n    .addTag('notifications')\n    .addTag('post')\n    .addTag('post-queue')\n    .addTag('submissions')\n    .addTag('tag-converters')\n    .addTag('tag-groups')\n    .addTag('user-converters')\n    .addTag('website-option')\n    .addTag('websites')\n    .build();\n  const document = SwaggerModule.createDocument(app, config);\n  document.security = [{ 'x-remote-password': [] }];\n  SwaggerModule.setup('api', app, document);\n\n  const { port } = PostyBirbEnvConfig;\n\n  await app.listen(port, () => {\n    Logger.log(`Listening at https://localhost:${port}/${globalPrefix}`);\n  });\n\n  return app;\n}\n\nexport { bootstrap as bootstrapClientServer };\n\n"
  },
  {
    "path": "apps/client-server/src/test-files/README.md",
    "content": "This is a directory that should be only for test related files.\n"
  },
  {
    "path": "apps/client-server/tsconfig.app.json",
    "content": "{\n  \"extends\": \"./tsconfig.json\",\n  \"compilerOptions\": {\n    \"outDir\": \"../../dist/out-tsc\",\n    \"module\": \"commonjs\",\n    \"types\": [\"node\"],\n    \"emitDecoratorMetadata\": true,\n    \"target\": \"es2021\"\n  },\n  \"exclude\": [\"jest.config.ts\", \"src/**/*.spec.ts\", \"src/**/*.test.ts\"],\n  \"include\": [\"src/**/*.ts\"]\n}\n"
  },
  {
    "path": "apps/client-server/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.base.json\",\n  \"files\": [],\n  \"include\": [],\n  \"references\": [\n    {\n      \"path\": \"./tsconfig.app.json\"\n    },\n    {\n      \"path\": \"./tsconfig.spec.json\"\n    }\n  ]\n}\n"
  },
  {
    "path": "apps/client-server/tsconfig.spec.json",
    "content": "{\n  \"extends\": \"./tsconfig.json\",\n  \"compilerOptions\": {\n    \"outDir\": \"../../dist/out-tsc\",\n    \"module\": \"commonjs\",\n    \"types\": [\"jest\", \"node\"]\n  },\n  \"include\": [\"**/*.spec.ts\", \"**/*.test.ts\", \"**/*.d.ts\", \"jest.config.ts\"]\n}\n"
  },
  {
    "path": "apps/postybirb/.eslintrc.json",
    "content": "{\n  \"extends\": \"../../.eslintrc.js\",\n  \"ignorePatterns\": [\"!**/*\"],\n  \"overrides\": [\n    {\n      \"files\": [\"*.ts\", \"*.tsx\", \"*.js\", \"*.jsx\"],\n      \"rules\": {}\n    },\n    {\n      \"files\": [\"*.ts\", \"*.tsx\"],\n      \"rules\": {}\n    },\n    {\n      \"files\": [\"*.js\", \"*.jsx\"],\n      \"rules\": {}\n    }\n  ]\n}\n"
  },
  {
    "path": "apps/postybirb/jest.config.ts",
    "content": "/* eslint-disable */\nexport default {\n  displayName: 'postybirb',\n  preset: '../../jest.preset.js',\n  globals: {},\n  moduleFileExtensions: ['ts', 'js', 'html'],\n  coverageDirectory: '../../coverage/apps/postybirb',\n};\n"
  },
  {
    "path": "apps/postybirb/project.json",
    "content": "{\n  \"name\": \"postybirb\",\n  \"$schema\": \"../../node_modules/nx/schemas/project-schema.json\",\n  \"sourceRoot\": \"apps/postybirb/src\",\n  \"projectType\": \"application\",\n  \"prefix\": \"postybirb\",\n  \"targets\": {\n    \"build\": {\n      \"executor\": \"nx-electron:build\",\n      \"options\": {\n        \"outputPath\": \"dist/apps/postybirb\",\n        \"main\": \"apps/postybirb/src/main.ts\",\n        \"tsConfig\": \"apps/postybirb/tsconfig.app.json\",\n        \"assets\": [\n          \"apps/postybirb/src/assets\", \n          \"apps/postybirb/src/migrations\",\n          \"apps/postybirb/src/app/loader\",\n          {\n            \"glob\": \"sharp-worker.js\",\n            \"input\": \"apps/client-server/src/assets\",\n            \"output\": \"assets\"\n          }\n        ]\n      },\n      \"configurations\": {\n        \"production\": {\n          \"optimization\": true,\n          \"extractLicenses\": true,\n          \"inspect\": false,\n          \"fileReplacements\": [\n            {\n              \"replace\": \"apps/postybirb/src/environments/environment.ts\",\n              \"with\": \"apps/postybirb/src/environments/environment.prod.ts\"\n            }\n          ]\n        }\n      }\n    },\n    \"serve\": {\n      \"executor\": \"nx-electron:execute\",\n      \"options\": {\n        \"buildTarget\": \"postybirb:build\",\n        \"inspect\": true,\n        \"port\": 9229\n      }\n    },\n    \"package\": {\n      \"executor\": \"nx-electron:package\",\n      \"options\": {\n        \"name\": \"postybirb\",\n        \"frontendProject\": \"postybirb-ui\",\n        \"outputPath\": \"dist/packages\",\n        \"prepackageOnly\": true\n      }\n    },\n    \"make\": {\n      \"executor\": \"nx-electron:make\",\n      \"options\": {\n        \"name\": \"postybirb\",\n        \"frontendProject\": \"postybirb-ui\",\n        \"outputPath\": \"dist/executables\"\n      }\n    },\n    \"lint\": {\n      \"executor\": \"@nx/eslint:lint\",\n      \"outputs\": [\"{options.outputFile}\"]\n    },\n    \"test\": {\n      \"executor\": \"@nx/jest:jest\",\n      \"outputs\": [\"{workspaceRoot}/coverage/apps/postybirb\"],\n      \"options\": {\n        \"jestConfig\": \"apps/postybirb/jest.config.ts\"\n      }\n    },\n    \"typecheck\": {\n      \"executor\": \"nx:run-commands\",\n      \"options\": {\n        \"command\": \"tsc -b {projectRoot}/tsconfig.json --incremental --pretty\"\n      }\n    }\n  },\n  \"tags\": []\n}\n"
  },
  {
    "path": "apps/postybirb/src/app/api/preload.ts",
    "content": "import { contextBridge, ipcRenderer } from 'electron';\n\n// Implementation at electron.events.ts, typings for ui at main.tsx\n\ncontextBridge.exposeInMainWorld('electron', {\n  getAppVersion: () => ipcRenderer.invoke('get-app-version'),\n  pickDirectory: () => ipcRenderer.invoke('pick-directory'),\n  openExternalLink: (url: string) => {\n    // Prevent app crash from trying to open undefined link\n    if (!url) {\n      throw new TypeError(`openExternalLink: url cannot be empty! Got: ${url}`);\n    }\n\n    ipcRenderer.send('open-external-link', url);\n  },\n  getLanIp: () => ipcRenderer.invoke('get-lan-ip'),\n  getRemoteConfig: () => JSON.parse(process.env.remote || '{}'),\n  getCookiesForAccount: (accountId: string) =>\n    ipcRenderer.invoke('get-cookies-for-account', accountId),\n  // Gracefully request app quit from renderer\n  quit: (code?: number) => ipcRenderer.send('quit', code),\n  platform: process.platform,\n  app_port: process.env.POSTYBIRB_PORT,\n  app_version: process.env.POSTYBIRB_VERSION,\n\n  setSpellCheckerEnabled: (value: boolean) =>\n    ipcRenderer.invoke('set-spellchecker-enabled', value),\n  setSpellcheckerLanguages: (languages: string[]) =>\n    ipcRenderer.invoke('set-spellchecker-languages', languages),\n  getSpellcheckerLanguages: () =>\n    ipcRenderer.invoke('get-spellchecker-languages') as Promise<string[]>,\n\n  getAllSpellcheckerLanguages: () =>\n    ipcRenderer.invoke('get-all-spellchecker-languages') as Promise<string[]>,\n\n  getSpellcheckerWords: () =>\n    ipcRenderer.invoke('get-spellchecker-words') as Promise<string[]>,\n  setSpellcheckerWords: (words: string[]) =>\n    ipcRenderer.invoke('set-spellchecker-words', words),\n});\n"
  },
  {
    "path": "apps/postybirb/src/app/app.ts",
    "content": "import { INestApplication } from '@nestjs/common';\nimport {\n  PostyBirbEnvConfig,\n  getStartupOptions,\n  isLinux,\n  isOSX,\n  onStartupOptionsUpdate,\n  setStartupOptions,\n} from '@postybirb/utils/electron';\nimport {\n  BrowserWindow,\n  Menu,\n  NativeImage,\n  Tray,\n  app,\n  nativeImage,\n  nativeTheme,\n  screen\n} from 'electron';\nimport { join } from 'path';\nimport { environment } from '../environments/environment';\nimport { rendererAppPort } from './constants';\n\n// eslint-disable-next-line @typescript-eslint/no-var-requires\nconst loader = require('./loader/loader');\n\nconst appIcon = join(__dirname, 'assets/app-icon.png');\n\nexport default class PostyBirb {\n  // Keep a global reference of the window object, if you don't, the window will\n  // be closed automatically when the JavaScript object is garbage collected.\n  static mainWindow: Electron.BrowserWindow;\n\n  static application: Electron.App;\n\n  static BrowserWindow;\n\n  static appTray: Tray;\n\n  static nestApp: INestApplication;\n\n  static appImage: NativeImage;\n\n  public static isDevelopmentMode() {\n    return !environment.production;\n  }\n\n  private static onWindowAllClosed() {}\n\n  private static onClose() {\n    // Dereference the window object, usually you would store windows\n    // in an array if your app supports multi windows, this is the time\n    // when you should delete the corresponding element.\n    PostyBirb.mainWindow = null;\n  }\n\n  private static onReady() {\n    // This method will be called when Electron has finished\n    // initialization and is ready to create browser windows.\n    // Some APIs can only be used after this event occurs.\n    PostyBirb.initAppTray();\n    PostyBirb.initMainWindow();\n    PostyBirb.loadMainWindow();\n  }\n\n  private static onActivate() {\n    // On macOS it's common to re-create a window in the app when the\n    // dock icon is clicked and there are no other windows open.\n    if (PostyBirb.mainWindow === null) {\n      PostyBirb.onReady();\n    }\n  }\n\n  private static async onQuit() {\n    if (PostyBirb.nestApp) {\n      await PostyBirb.nestApp.close();\n    }\n    process.exit();\n  }\n\n  private static initMainWindow() {\n    const { workAreaSize } = screen.getPrimaryDisplay();\n    const width = Math.min(1280, workAreaSize.width || 1280);\n    const height = Math.min(720, workAreaSize.height || 720);\n\n    // Create the browser window.\n    PostyBirb.mainWindow = new BrowserWindow({\n      title: 'PostyBirb',\n      darkTheme: nativeTheme.shouldUseDarkColors,\n      width,\n      height,\n      show: false,\n      icon: PostyBirb.appImage,\n      autoHideMenuBar: true,\n      webPreferences: {\n        contextIsolation: true,\n        backgroundThrottling: false,\n        preload: join(__dirname, 'preload.js'),\n        webviewTag: true,\n        spellcheck: getStartupOptions().spellchecker,\n        devTools: true,\n      },\n    });\n    PostyBirb.mainWindow.setMenu(null);\n    PostyBirb.mainWindow.center();\n    // PostyBirb.mainWindow.webContents.openDevTools({ mode: 'detach' });\n\n    // if main window is ready to show, close the splash window and show the main window\n    PostyBirb.mainWindow.once('ready-to-show', () => {\n      loader.hide();\n      PostyBirb.mainWindow.show();\n    });\n\n    // Emitted when the window is closed.\n    PostyBirb.mainWindow.on('closed', () => {\n      PostyBirb.onClose();\n    });\n  }\n\n  private static loadMainWindow() {\n    // load the index.html of the app.\n    if (this.isDevelopmentMode()) {\n      PostyBirb.mainWindow.loadURL(`http://localhost:${rendererAppPort}`);\n    } else {\n      PostyBirb.mainWindow.loadURL(\n        `https://localhost:${PostyBirbEnvConfig.port}`,\n      );\n    }\n  }\n\n  private static initAppTray() {\n    if (!PostyBirb.appTray) {\n      const trayItems: Array<\n        Electron.MenuItem | Electron.MenuItemConstructorOptions\n      > = [\n        {\n          label: 'Open',\n          click() {\n            PostyBirb.showMainWindow();\n          },\n        },\n        {\n          enabled: !isLinux(),\n          label: 'Launch on Startup',\n          type: 'checkbox',\n          checked: getStartupOptions().startAppOnSystemStartup,\n          click(event) {\n            const { checked } = event;\n            app.setLoginItemSettings({\n              openAtLogin: event.checked,\n              path: app.getPath('exe'),\n            });\n            setStartupOptions({\n              startAppOnSystemStartup: checked,\n            });\n          },\n        },\n        {\n          label: 'Quit',\n          click() {\n            PostyBirb.application.quit();\n          },\n        },\n      ];\n\n      let image = PostyBirb.appImage;\n      if (isOSX()) {\n        image = image.resize({\n          width: 16,\n          height: 16,\n        });\n      }\n\n      const tray = new Tray(image);\n      tray.setContextMenu(Menu.buildFromTemplate(trayItems));\n      tray.setToolTip('PostyBirb');\n      tray.on('click', () => PostyBirb.showMainWindow());\n      onStartupOptionsUpdate(PostyBirb.refreshAppTray);\n      PostyBirb.appTray = tray;\n    }\n  }\n\n  private static refreshAppTray() {\n    if (PostyBirb.appTray) {\n      PostyBirb.appTray.destroy();\n      PostyBirb.appTray = null;\n      PostyBirb.initAppTray();\n    }\n  }\n\n  private static showMainWindow() {\n    if (!PostyBirb.mainWindow) {\n      PostyBirb.onReady();\n    } else {\n      if (PostyBirb.mainWindow.isMinimized()) {\n        PostyBirb.mainWindow.show();\n      }\n\n      PostyBirb.mainWindow.focus();\n    }\n  }\n\n  static main(electronApp: Electron.App, browserWindow: typeof BrowserWindow) {\n    PostyBirb.BrowserWindow = browserWindow;\n    PostyBirb.application = electronApp;\n    PostyBirb.appImage = nativeImage.createFromPath(appIcon);\n    PostyBirb.application.on('window-all-closed', PostyBirb.onWindowAllClosed); // Quit when all windows are closed.\n    PostyBirb.application.on('ready', PostyBirb.onReady); // App is ready to load data\n    PostyBirb.application.on('activate', PostyBirb.onActivate); // App is activated\n    PostyBirb.application.on('second-instance', () => {\n      if (PostyBirb.application.isReady()) {\n        PostyBirb.onReady();\n      }\n    });\n    PostyBirb.application.on('quit', PostyBirb.onQuit);\n\n    if (PostyBirb.application.isReady()) {\n      PostyBirb.onReady();\n    }\n  }\n\n  static registerNestApp(nestApp: INestApplication) {\n    PostyBirb.nestApp = nestApp;\n  }\n}\n"
  },
  {
    "path": "apps/postybirb/src/app/constants.ts",
    "content": "export const rendererAppPort = 4200;\nexport const rendererAppName = 'postybirb-ui';\nexport const electronAppName = 'postybirb';\n"
  },
  {
    "path": "apps/postybirb/src/app/events/electron.events.ts",
    "content": "/**\n * This module is responsible on handling all the inter process communications\n * between the frontend to the electron backend.\n */\n\nimport { app, dialog, ipcMain, session, shell } from 'electron';\nimport { environment } from '../../environments/environment';\n\nexport default class ElectronEvents {\n  static bootstrapElectronEvents(): Electron.IpcMain {\n    return ipcMain;\n  }\n}\n\n// Retrieve app version\nipcMain.handle('get-app-version', () => {\n  // eslint-disable-next-line no-console\n  console.log(`Fetching application version... [v${environment.version}]`);\n\n  return environment.version;\n});\n\n// Return cookies for account, bundled as base64\nipcMain.handle('get-cookies-for-account', async (event, accountId: string) => {\n  const cookies = await session\n    .fromPartition(`persist:${accountId}`)\n    .cookies.get({});\n\n  return Buffer.from(JSON.stringify(cookies)).toString('base64');\n});\n\nipcMain.handle('get-lan-ip', async () => {\n  const os = await import('os');\n  const networkInterfaces = os.networkInterfaces();\n  const addresses: string[] = [];\n\n  for (const interfaceName in networkInterfaces) {\n    if (\n      Object.prototype.hasOwnProperty.call(networkInterfaces, interfaceName)\n    ) {\n      const networkInterface = networkInterfaces[interfaceName];\n      if (networkInterface) {\n        for (const address of networkInterface) {\n          if (address.family === 'IPv4' && !address.internal) {\n            addresses.push(address.address);\n          }\n        }\n      }\n    }\n  }\n\n  return addresses.length > 0 ? addresses[0] : undefined;\n});\n\nipcMain.handle('pick-directory', async (): Promise<string | undefined> => {\n  const { canceled, filePaths } = await dialog.showOpenDialog({\n    properties: ['openDirectory'],\n  });\n  if (!canceled) {\n    return filePaths[0];\n  }\n\n  return undefined;\n});\n\nipcMain.on('open-external-link', (event, url) => {\n  shell.openExternal(url);\n});\n\n// Handle App termination\nipcMain.on('quit', (event, code) => {\n  app.exit(code);\n});\n\nipcMain.handle('set-spellchecker-enabled', (event, value) =>\n  event.sender.session.setSpellCheckerEnabled(value),\n);\n\nipcMain.handle('set-spellchecker-languages', (event, languages) => {\n  event.sender.session.setSpellCheckerLanguages(languages);\n});\n\nipcMain.handle('get-spellchecker-languages', (event) =>\n  event.sender.session.getSpellCheckerLanguages(),\n);\n\nipcMain.handle(\n  'get-all-spellchecker-languages',\n  (event) => event.sender.session.availableSpellCheckerLanguages,\n);\n\nipcMain.handle('get-spellchecker-words', (event) =>\n  event.sender.session.listWordsInSpellCheckerDictionary(),\n);\n\nipcMain.handle('set-spellchecker-words', async (event, words) => {\n  if (!Array.isArray(words) || !words.every((e) => typeof e === 'string')) {\n    throw new Error('Expected words to be a string array');\n  }\n\n  const current =\n    await event.sender.session.listWordsInSpellCheckerDictionary();\n\n  for (const word of words) {\n    if (!current.includes(word)) {\n      event.sender.session.addWordToSpellCheckerDictionary(word);\n    }\n  }\n  for (const word of current) {\n    if (!words.includes(word)) {\n      event.sender.session.removeWordFromSpellCheckerDictionary(word);\n    }\n  }\n});\n"
  },
  {
    "path": "apps/postybirb/src/app/loader/css/style.css",
    "content": "@font-face {\n    font-family: Mylodon;\n    src: url('../fonts/Mylodon-Light.otf');\n}\nbody {\n  -webkit-font-smoothing: antialiased;\n  text-rendering: optimizeLegibility;\n  font-family: 'proxima-nova-soft', sans-serif;\n  -webkit-user-select: none;\n  overflow: hidden;\n}\nbody .vertical-centered-box {\n  position: absolute;\n  width: 100%;\n  height: 100%;\n  text-align: center;\n}\nbody .vertical-centered-box:after {\n  content: '';\n  display: inline-block;\n  height: 100%;\n  vertical-align: middle;\n  margin-right: -0.25em;\n}\nbody .vertical-centered-box .content {\n  -webkit-box-sizing: border-box;\n  -moz-box-sizing: border-box;\n  box-sizing: border-box;\n  display: inline-block;\n  vertical-align: middle;\n  text-align: left;\n  font-size: 0;\n}\n* {\n  -webkit-transition: all 0.3s;\n  -moz-transition: all 0.3s;\n  -o-transition: all 0.3s;\n  transition: all 0.3s;\n}\nbody {\n  background: #2c2d44;\n}\n.loader-title {\n  color: white;\n  width: 100%;\n  height: 0;\n  padding: 0;\n  margin: 0;\n  position: absolute;\n  top: 20%;\n  font-family: Mylodon;\n}\n.loader-circle {\n  position: absolute;\n  left: 50%;\n  top: 50%;\n  width: 120px;\n  height: 120px;\n  border-radius: 50%;\n  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1);\n  margin-left: -60px;\n  margin-top: -60px;\n}\n.loader-line-mask {\n  position: absolute;\n  left: 50%;\n  top: 50%;\n  width: 60px;\n  height: 120px;\n  margin-left: -60px;\n  margin-top: -60px;\n  overflow: hidden;\n  -webkit-transform-origin: 60px 60px;\n  -moz-transform-origin: 60px 60px;\n  -ms-transform-origin: 60px 60px;\n  -o-transform-origin: 60px 60px;\n  transform-origin: 60px 60px;\n  -webkit-mask-image: -webkit-linear-gradient(top, #000000, rgba(0, 0, 0, 0));\n  -webkit-animation: rotate 1.2s infinite linear;\n  -moz-animation: rotate 1.2s infinite linear;\n  -o-animation: rotate 1.2s infinite linear;\n  animation: rotate 1.2s infinite linear;\n}\n.loader-line-mask .loader-line {\n  width: 120px;\n  height: 120px;\n  border-radius: 50%;\n  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.5);\n}\n#particles-background,\n#particles-foreground {\n  left: -51%;\n  top: -51%;\n  width: 202%;\n  height: 202%;\n  -webkit-transform: scale3d(0.5, 0.5, 1);\n  -moz-transform: scale3d(0.5, 0.5, 1);\n  -ms-transform: scale3d(0.5, 0.5, 1);\n  -o-transform: scale3d(0.5, 0.5, 1);\n  transform: scale3d(0.5, 0.5, 1);\n}\n#particles-background {\n  background: #2c2d44;\n  background-image: -moz-linear-gradient(45deg, #3f3251 2%, #002025 100%);\n  background-image: -webkit-linear-gradient(45deg, #3f3251 2%, #002025 100%);\n  background-image: linear-gradient(45deg, #3f3251 2%, #002025 100%);\n}\nlesshat-selector {\n  -lh-property: 0;\n}\n@-webkit-keyframes rotate {\n  0% {\n    -webkit-transform: rotate(0deg);\n  }\n  100% {\n    -webkit-transform: rotate(360deg);\n  }\n}\n@-moz-keyframes rotate {\n  0% {\n    -moz-transform: rotate(0deg);\n  }\n  100% {\n    -moz-transform: rotate(360deg);\n  }\n}\n@-o-keyframes rotate {\n  0% {\n    -o-transform: rotate(0deg);\n  }\n  100% {\n    -o-transform: rotate(360deg);\n  }\n}\n@keyframes rotate {\n  0% {\n    -webkit-transform: rotate(0deg);\n    -moz-transform: rotate(0deg);\n    -ms-transform: rotate(0deg);\n    transform: rotate(0deg);\n  }\n  100% {\n    -webkit-transform: rotate(360deg);\n    -moz-transform: rotate(360deg);\n    -ms-transform: rotate(360deg);\n    transform: rotate(360deg);\n  }\n}\n[not-existing] {\n  zoom: 1;\n}\nlesshat-selector {\n  -lh-property: 0;\n}\n@-webkit-keyframes fade {\n  0% {\n    opacity: 1;\n  }\n  50% {\n    opacity: 0.25;\n  }\n}\n@-moz-keyframes fade {\n  0% {\n    opacity: 1;\n  }\n  50% {\n    opacity: 0.25;\n  }\n}\n@-o-keyframes fade {\n  0% {\n    opacity: 1;\n  }\n  50% {\n    opacity: 0.25;\n  }\n}\n@keyframes fade {\n  0% {\n    opacity: 1;\n  }\n  50% {\n    opacity: 0.25;\n  }\n}\n[not-existing] {\n  zoom: 1;\n}\nlesshat-selector {\n  -lh-property: 0;\n}\n@-webkit-keyframes fade-in {\n  0% {\n    opacity: 0;\n  }\n  100% {\n    opacity: 1;\n  }\n}\n@-moz-keyframes fade-in {\n  0% {\n    opacity: 0;\n  }\n  100% {\n    opacity: 1;\n  }\n}\n@-o-keyframes fade-in {\n  0% {\n    opacity: 0;\n  }\n  100% {\n    opacity: 1;\n  }\n}\n@keyframes fade-in {\n  0% {\n    opacity: 0;\n  }\n  100% {\n    opacity: 1;\n  }\n}\n[not-existing] {\n  zoom: 1;\n}\n"
  },
  {
    "path": "apps/postybirb/src/app/loader/loader.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <script src=\"vendors/particleground.min.js\"></script>\n    <link rel=\"stylesheet\" href=\"css/style.css\" />\n  </head>\n  <body>\n    <div id=\"particles-background\" class=\"vertical-centered-box\"></div>\n    <div id=\"particles-foreground\" style=\"-webkit-app-region: drag\" class=\"vertical-centered-box\"></div>\n\n    <div class=\"vertical-centered-box\">\n      <h2 class=\"loader-title\">PostyBirb</h2>\n      <div class=\"content\">\n        <div class=\"loader-circle\"></div>\n        <div class=\"loader-line-mask\">\n          <div class=\"loader-line\"></div>\n        </div>\n        <svg\n          viewBox=\"64 64 896 896\"\n          focusable=\"false\"\n          class=\"\"\n          data-icon=\"send\"\n          width=\"36px\"\n          height=\"24px\"\n          fill=\"#FFFFFF\"\n          aria-hidden=\"true\"\n        >\n          <path\n            d=\"M931.4 498.9L94.9 79.5c-3.4-1.7-7.3-2.1-11-1.2a15.99 15.99 0 00-11.7 19.3l86.2 352.2c1.3 5.3 5.2 9.6 10.4 11.3l147.7 50.7-147.6 50.7c-5.2 1.8-9.1 6-10.3 11.3L72.2 926.5c-.9 3.7-.5 7.6 1.2 10.9 3.9 7.9 13.5 11.1 21.5 7.2l836.5-417c3.1-1.5 5.6-4.1 7.2-7.1 3.9-8 .7-17.6-7.2-21.6zM170.8 826.3l50.3-205.6 295.2-101.3c2.3-.8 4.2-2.6 5-5 1.4-4.2-.8-8.7-5-10.2L221.1 403 171 198.2l628 314.9-628.2 313.2z\"\n          ></path>\n        </svg>\n      </div>\n    </div>\n  </body>\n  <script>\n    particleground(document.getElementById('particles-foreground'), {\n      dotColor: 'rgba(255, 255, 255, 1)',\n      lineColor: 'rgba(255, 255, 255, 0.05)',\n      minSpeedX: 0.3,\n      maxSpeedX: 0.6,\n      minSpeedY: 0.3,\n      maxSpeedY: 0.6,\n      density: 50000, // One particle every n pixels\n      curvedLines: false,\n      proximity: 250, // How close two dots need to be before they join\n      parallaxMultiplier: 10, // Lower the number is more extreme parallax\n      particleRadius: 4, // Dot size\n    });\n\n    particleground(document.getElementById('particles-background'), {\n      dotColor: 'rgba(255, 255, 255, 0.5)',\n      lineColor: 'rgba(255, 255, 255, 0.05)',\n      minSpeedX: 0.075,\n      maxSpeedX: 0.15,\n      minSpeedY: 0.075,\n      maxSpeedY: 0.15,\n      density: 30000, // One particle every n pixels\n      curvedLines: false,\n      proximity: 20, // How close two dots need to be before they join\n      parallaxMultiplier: 20, // Lower the number is more extreme parallax\n      particleRadius: 2, // Dot size\n    });\n  </script>\n</html>\n"
  },
  {
    "path": "apps/postybirb/src/app/loader/loader.js",
    "content": "const { BrowserWindow } = require('electron');\nconst path = require('path');\n\nconst height = 350;\nconst width = 300;\n\nlet window = null;\n\nfunction show() {\n  if (window) {\n    if (window.isMinimized()) {\n      window.restore();\n    }\n    window.show();\n    window.focus();\n  } else {\n    window = new BrowserWindow({\n      show: false,\n      alwaysOnTop: true,\n      resizable: false,\n      frame: false,\n      closable: false,\n      width,\n      height,\n      title: 'PostyBirb',\n      center: true,\n    });\n\n    window\n      .loadFile(path.join(__dirname, 'app', 'loader', 'loader.html'))\n      .then(() => (window ? window.show() : undefined));\n    window.webContents.on('new-window', (event) => event.preventDefault());\n    window.on('closed', () => {\n      if (window) {\n        window.destroy();\n        window = null;\n      }\n    });\n  }\n}\n\nfunction hide() {\n  if (window) {\n    window.destroy();\n    window = null;\n  }\n}\n\nmodule.exports.show = show;\nmodule.exports.hide = hide;\n"
  },
  {
    "path": "apps/postybirb/src/environments/environment.base.ts",
    "content": "export const environment = {\n  app_insights_instrumentation_key:\n    'InstrumentationKey=094ad1a6-a45f-4db4-88be-366d45360ef5;IngestionEndpoint=https://eastus-8.in.applicationinsights.azure.com/;LiveEndpoint=https://eastus.livediagnostics.monitor.azure.com/',\n};\n"
  },
  {
    "path": "apps/postybirb/src/environments/environment.prod.ts",
    "content": "import { environment as baseEnvironment } from './environment.base';\n\n/* eslint-disable no-underscore-dangle */\n/* eslint-disable @typescript-eslint/naming-convention */\ndeclare const __BUILD_VERSION__: string;\n\nexport const environment = {\n  ...baseEnvironment,\n  production: true,\n  version: __BUILD_VERSION__,\n};\n"
  },
  {
    "path": "apps/postybirb/src/environments/environment.ts",
    "content": "import { environment as baseEnvironment } from './environment.base';\n\n/* eslint-disable no-underscore-dangle */\n/* eslint-disable @typescript-eslint/naming-convention */\ndeclare const __BUILD_VERSION__: string;\n\nexport const environment = {\n  ...baseEnvironment,\n  production: false,\n  version: __BUILD_VERSION__,\n};\n"
  },
  {
    "path": "apps/postybirb/src/main.ts",
    "content": "// Ensure proxy is imported first to patch fetch before any request is made\nimport '@postybirb/http';\n\nimport { INestApplication } from '@nestjs/common';\nimport { PostyBirbDirectories } from '@postybirb/fs';\nimport {\n  flushAppInsights,\n  initializeAppInsights,\n  Logger,\n  trackException,\n} from '@postybirb/logger';\nimport {\n  getRemoteConfig,\n  getRemoteConfigSync,\n  PostyBirbEnvConfig,\n} from '@postybirb/utils/electron';\nimport { app, BrowserWindow, session } from 'electron';\nimport contextMenu from 'electron-context-menu';\nimport PostyBirb from './app/app';\nimport ElectronEvents from './app/events/electron.events';\nimport { environment } from './environments/environment';\n\nconst isOnlyInstance = app.requestSingleInstanceLock();\nif (!isOnlyInstance) {\n  app.quit();\n  process.exit();\n}\n\napp.commandLine.appendSwitch('disable-renderer-backgrounding');\napp.commandLine.appendSwitch('disable-background-timer-throttling');\napp.commandLine.appendSwitch('disable-features', 'CrossOriginOpenerPolicy');\n\n// Inject environment for use in preload\nprocess.env.POSTYBIRB_PORT = PostyBirbEnvConfig.port;\nprocess.env.POSTYBIRB_VERSION = environment.version;\nprocess.env.POSTYBIRB_ENV =\n  (process.env.POSTYBIRB_ENV ?? environment.production)\n    ? 'production'\n    : 'development';\n\nconst remoteConfig = getRemoteConfigSync();\nconst entries: [string, string][] = [\n  ['Version', environment.version],\n  ['Mode', process.env.POSTYBIRB_ENV ?? ''],\n  ['Port', String(PostyBirbEnvConfig.port)],\n  ['Storage', PostyBirbDirectories.POSTYBIRB_DIRECTORY],\n  ['App Data', app.getPath('userData')],\n  ['===== Remote Config =====', ''],\n  ['Remote Enabled', String(remoteConfig?.enabled)],\n  [\n    'Remote Password',\n    remoteConfig?.enabled ? (remoteConfig?.password ?? '') : '',\n  ],\n];\nconst labelWidth = Math.max(...entries.map(([k]) => k.length));\nconst valueWidth = Math.max(...entries.map(([, v]) => v.length));\n// \"║  Label : Value  ║\" → 2 + labelWidth + 3 + valueWidth + 2\nconst innerWidth = 2 + labelWidth + 3 + valueWidth + 2;\nconst title = 'PostyBirb';\nconst titlePad = Math.max(innerWidth, title.length + 4);\nconst w = Math.max(innerWidth, titlePad);\nconst titleLine = title.padStart(Math.floor((w + title.length) / 2)).padEnd(w);\n\nconst lines = entries.map(\n  ([k, v]) => `║  ${k.padEnd(labelWidth)} : ${v.padEnd(valueWidth)}  ║`,\n);\n\n// eslint-disable-next-line no-console\nconsole.log(\n  [\n    '',\n    `╔${'═'.repeat(w)}╗`,\n    `║${titleLine}║`,\n    `╠${'═'.repeat(w)}╣`,\n    ...lines,\n    `╚${'═'.repeat(w)}╝`,\n    '',\n  ].join('\\n'),\n);\n\ninitializeAppInsights({\n  enabled: true,\n  appVersion: environment.version,\n});\n\nconst logger = Logger('MainProcess');\n\n// Handle uncaught exceptions in main process\nprocess.on('uncaughtException', (error: Error) => {\n  // eslint-disable-next-line no-console\n  logger.withError(error).error('Uncaught Exception in Main Process:');\n  trackException(error, {\n    source: 'electron-main',\n    type: 'uncaughtException',\n  });\n  // Give time for telemetry to be sent before exiting\n  flushAppInsights().then(() => {\n    if (!environment.production) {\n      process.exit(1);\n    }\n  });\n});\n\n// Handle unhandled promise rejections in main process\nprocess.on('unhandledRejection', (reason: unknown) => {\n  const error = reason instanceof Error ? reason : new Error(String(reason));\n  // eslint-disable-next-line no-console\n  logger.withError(error).error('Unhandled Rejection in Main Process:');\n  trackException(error, {\n    source: 'electron-main',\n    type: 'unhandledRejection',\n  });\n  flushAppInsights();\n});\n\n// eslint-disable-next-line @typescript-eslint/no-unused-vars\n// const psbId = powerSaveBlocker.start('prevent-app-suspension');\n\napp.on(\n  'certificate-error',\n  (\n    event: Electron.Event,\n    webContents: Electron.WebContents,\n    url: string,\n    error: string,\n    certificate: Electron.Certificate,\n    callback: (allow: boolean) => void,\n  ) => {\n    if (\n      certificate.issuerName === 'postybirb.com' &&\n      certificate.subject.organizations[0] === 'PostyBirb' &&\n      certificate.issuer.country === 'US'\n    ) {\n      callback(true);\n    } else {\n      callback(false);\n    }\n  },\n);\n\nexport default class Main {\n  static async initialize() {\n    process.env.remote = JSON.stringify(await getRemoteConfig());\n  }\n\n  static async bootstrapClientServer(): Promise<INestApplication> {\n    return (\n      // eslint-disable-next-line @nrwl/nx/enforce-module-boundaries\n      (await import('apps/client-server/src/main')).bootstrapClientServer()\n    );\n  }\n\n  static bootstrapApp(nestApp: INestApplication) {\n    PostyBirb.main(app, BrowserWindow);\n    PostyBirb.registerNestApp(nestApp);\n  }\n\n  static bootstrapAppEvents() {\n    ElectronEvents.bootstrapElectronEvents();\n  }\n}\n\nasync function start() {\n  try {\n    // handle setup events as quickly as possible\n    await Main.initialize();\n\n    // bootstrap app\n    const nestApp = await Main.bootstrapClientServer();\n    if (PostyBirbEnvConfig.headless) {\n      // eslint-disable-next-line no-console\n      console.log('[PostyBirb] Running in headless mode (no UI)');\n    } else {\n      Main.bootstrapApp(nestApp);\n      Main.bootstrapAppEvents();\n    }\n  } catch (e) {\n    // eslint-disable-next-line no-console\n    console.error('Error during startup:', e);\n    app.quit();\n  }\n}\n\n// Suppress SSL error messages\napp.on('ready', () => {\n  if (!PostyBirbEnvConfig.headless) {\n    // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require\n    const loader = require('./app/loader/loader');\n    loader.show();\n  }\n\n  session.defaultSession.setCertificateVerifyProc((request, callback) => {\n    if (request.errorCode === 0) {\n      callback(0); // Allow the certificate\n    } else {\n      const { certificate } = request;\n      if (\n        certificate.issuerName === 'postybirb.com' &&\n        certificate.subject.organizations[0] === 'PostyBirb' &&\n        certificate.issuer.country === 'US'\n      ) {\n        callback(0);\n      } else {\n        callback(-2);\n      }\n    }\n  });\n\n  contextMenu();\n  start();\n});\n"
  },
  {
    "path": "apps/postybirb/src/migrations/0000_tough_ken_ellis.sql",
    "content": "CREATE TABLE `account` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`createdAt` text NOT NULL,\n\t`updatedAt` text NOT NULL,\n\t`groups` text NOT NULL,\n\t`name` text NOT NULL,\n\t`website` text NOT NULL\n);\n--> statement-breakpoint\nCREATE UNIQUE INDEX `account_id_unique` ON `account` (`id`);--> statement-breakpoint\nCREATE TABLE `directory-watcher` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`createdAt` text NOT NULL,\n\t`updatedAt` text NOT NULL,\n\t`templateId` text,\n\t`importAction` text DEFAULT 'NEW_SUBMISSION' NOT NULL,\n\t`path` text,\n\tFOREIGN KEY (`templateId`) REFERENCES `submission`(`id`) ON UPDATE no action ON DELETE set null\n);\n--> statement-breakpoint\nCREATE UNIQUE INDEX `directory-watcher_id_unique` ON `directory-watcher` (`id`);--> statement-breakpoint\nCREATE TABLE `file-buffer` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`createdAt` text NOT NULL,\n\t`updatedAt` text NOT NULL,\n\t`submissionFileId` text NOT NULL,\n\t`buffer` blob NOT NULL,\n\t`fileName` text NOT NULL,\n\t`height` integer NOT NULL,\n\t`mimeType` text NOT NULL,\n\t`size` integer NOT NULL,\n\t`width` integer NOT NULL,\n\tFOREIGN KEY (`submissionFileId`) REFERENCES `submission-file`(`id`) ON UPDATE no action ON DELETE cascade\n);\n--> statement-breakpoint\nCREATE UNIQUE INDEX `file-buffer_id_unique` ON `file-buffer` (`id`);--> statement-breakpoint\nCREATE TABLE `notification` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`createdAt` text NOT NULL,\n\t`updatedAt` text NOT NULL,\n\t`title` text NOT NULL,\n\t`message` text NOT NULL,\n\t`tags` text NOT NULL,\n\t`data` text NOT NULL,\n\t`isRead` integer DEFAULT false NOT NULL,\n\t`hasEmitted` integer DEFAULT false NOT NULL,\n\t`type` text NOT NULL\n);\n--> statement-breakpoint\nCREATE UNIQUE INDEX `notification_id_unique` ON `notification` (`id`);--> statement-breakpoint\nCREATE TABLE `post-queue` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`createdAt` text NOT NULL,\n\t`updatedAt` text NOT NULL,\n\t`postRecordId` text,\n\t`submissionId` text NOT NULL,\n\tFOREIGN KEY (`postRecordId`) REFERENCES `post-record`(`id`) ON UPDATE no action ON DELETE set null,\n\tFOREIGN KEY (`submissionId`) REFERENCES `submission`(`id`) ON UPDATE no action ON DELETE cascade\n);\n--> statement-breakpoint\nCREATE UNIQUE INDEX `post-queue_id_unique` ON `post-queue` (`id`);--> statement-breakpoint\nCREATE TABLE `post-record` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`createdAt` text NOT NULL,\n\t`updatedAt` text NOT NULL,\n\t`submissionId` text,\n\t`completedAt` text,\n\t`resumeMode` text DEFAULT 'CONTINUE' NOT NULL,\n\t`state` text DEFAULT 'PENDING' NOT NULL,\n\tFOREIGN KEY (`submissionId`) REFERENCES `submission`(`id`) ON UPDATE no action ON DELETE cascade\n);\n--> statement-breakpoint\nCREATE UNIQUE INDEX `post-record_id_unique` ON `post-record` (`id`);--> statement-breakpoint\nCREATE TABLE `settings` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`createdAt` text NOT NULL,\n\t`updatedAt` text NOT NULL,\n\t`profile` text DEFAULT 'default' NOT NULL,\n\t`settings` text DEFAULT '{\"hiddenWebsites\":[],\"language\":\"en\",\"allowAd\":true,\"queuePaused\":false,\"desktopNotifications\":{\"enabled\":true,\"showOnPostSuccess\":true,\"showOnPostError\":true,\"showOnDirectoryWatcherError\":true,\"showOnDirectoryWatcherSuccess\":true}}' NOT NULL\n);\n--> statement-breakpoint\nCREATE UNIQUE INDEX `settings_id_unique` ON `settings` (`id`);--> statement-breakpoint\nCREATE UNIQUE INDEX `settings_profile_unique` ON `settings` (`profile`);--> statement-breakpoint\nCREATE TABLE `submission-file` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`createdAt` text NOT NULL,\n\t`updatedAt` text NOT NULL,\n\t`submissionId` text NOT NULL,\n\t`primaryFileId` text,\n\t`thumbnailId` text,\n\t`altFileId` text,\n\t`fileName` text NOT NULL,\n\t`hasAltFile` integer DEFAULT false NOT NULL,\n\t`hasCustomThumbnail` integer DEFAULT false NOT NULL,\n\t`hasThumbnail` integer NOT NULL,\n\t`hash` text NOT NULL,\n\t`height` integer NOT NULL,\n\t`mimeType` text NOT NULL,\n\t`size` integer NOT NULL,\n\t`width` integer NOT NULL,\n\tFOREIGN KEY (`submissionId`) REFERENCES `submission`(`id`) ON UPDATE no action ON DELETE cascade,\n\tFOREIGN KEY (`primaryFileId`) REFERENCES `file-buffer`(`id`) ON UPDATE no action ON DELETE no action,\n\tFOREIGN KEY (`thumbnailId`) REFERENCES `file-buffer`(`id`) ON UPDATE no action ON DELETE no action,\n\tFOREIGN KEY (`altFileId`) REFERENCES `file-buffer`(`id`) ON UPDATE no action ON DELETE no action\n);\n--> statement-breakpoint\nCREATE UNIQUE INDEX `submission-file_id_unique` ON `submission-file` (`id`);--> statement-breakpoint\nCREATE TABLE `submission` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`createdAt` text NOT NULL,\n\t`updatedAt` text NOT NULL,\n\t`type` text NOT NULL,\n\t`isArchived` integer DEFAULT false,\n\t`isMultiSubmission` integer NOT NULL,\n\t`isScheduled` integer NOT NULL,\n\t`isTemplate` integer NOT NULL,\n\t`metadata` text NOT NULL,\n\t`order` integer NOT NULL,\n\t`schedule` text NOT NULL\n);\n--> statement-breakpoint\nCREATE UNIQUE INDEX `submission_id_unique` ON `submission` (`id`);--> statement-breakpoint\nCREATE TABLE `tag-converter` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`createdAt` text NOT NULL,\n\t`updatedAt` text NOT NULL,\n\t`convertTo` text NOT NULL,\n\t`tag` text NOT NULL\n);\n--> statement-breakpoint\nCREATE UNIQUE INDEX `tag-converter_id_unique` ON `tag-converter` (`id`);--> statement-breakpoint\nCREATE UNIQUE INDEX `tag-converter_tag_unique` ON `tag-converter` (`tag`);--> statement-breakpoint\nCREATE TABLE `tag-group` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`createdAt` text NOT NULL,\n\t`updatedAt` text NOT NULL,\n\t`name` text NOT NULL,\n\t`tags` text NOT NULL\n);\n--> statement-breakpoint\nCREATE UNIQUE INDEX `tag-group_id_unique` ON `tag-group` (`id`);--> statement-breakpoint\nCREATE UNIQUE INDEX `tag-group_name_unique` ON `tag-group` (`name`);--> statement-breakpoint\nCREATE TABLE `user-specified-website-options` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`createdAt` text NOT NULL,\n\t`updatedAt` text NOT NULL,\n\t`accountId` text NOT NULL,\n\t`options` text NOT NULL,\n\t`type` text NOT NULL,\n\tFOREIGN KEY (`accountId`) REFERENCES `account`(`id`) ON UPDATE no action ON DELETE cascade\n);\n--> statement-breakpoint\nCREATE UNIQUE INDEX `user-specified-website-options_id_unique` ON `user-specified-website-options` (`id`);--> statement-breakpoint\nCREATE TABLE `website-data` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`createdAt` text NOT NULL,\n\t`data` text DEFAULT '{}' NOT NULL,\n\t`updatedAt` text NOT NULL,\n\tFOREIGN KEY (`id`) REFERENCES `account`(`id`) ON UPDATE no action ON DELETE cascade\n);\n--> statement-breakpoint\nCREATE UNIQUE INDEX `website-data_id_unique` ON `website-data` (`id`);--> statement-breakpoint\nCREATE TABLE `website-options` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`createdAt` text NOT NULL,\n\t`updatedAt` text NOT NULL,\n\t`accountId` text NOT NULL,\n\t`submissionId` text NOT NULL,\n\t`data` text NOT NULL,\n\t`isDefault` integer NOT NULL,\n\tFOREIGN KEY (`accountId`) REFERENCES `account`(`id`) ON UPDATE no action ON DELETE cascade,\n\tFOREIGN KEY (`submissionId`) REFERENCES `submission`(`id`) ON UPDATE no action ON DELETE cascade\n);\n--> statement-breakpoint\nCREATE UNIQUE INDEX `website-options_id_unique` ON `website-options` (`id`);--> statement-breakpoint\nCREATE TABLE `website-post-record` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`createdAt` text NOT NULL,\n\t`updatedAt` text NOT NULL,\n\t`accountId` text NOT NULL,\n\t`postRecordId` text NOT NULL,\n\t`completedAt` text,\n\t`errors` text NOT NULL,\n\t`metadata` text NOT NULL,\n\t`postData` text NOT NULL,\n\t`postResponse` text,\n\tFOREIGN KEY (`accountId`) REFERENCES `account`(`id`) ON UPDATE no action ON DELETE set null,\n\tFOREIGN KEY (`postRecordId`) REFERENCES `post-record`(`id`) ON UPDATE no action ON DELETE cascade\n);\n--> statement-breakpoint\nCREATE UNIQUE INDEX `website-post-record_id_unique` ON `website-post-record` (`id`);"
  },
  {
    "path": "apps/postybirb/src/migrations/0001_noisy_kate_bishop.sql",
    "content": "CREATE TABLE `custom-shortcut` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`createdAt` text NOT NULL,\n\t`updatedAt` text NOT NULL,\n\t`name` text NOT NULL,\n\t`shortcut` text DEFAULT '[]' NOT NULL\n);\n--> statement-breakpoint\nCREATE UNIQUE INDEX `custom-shortcut_id_unique` ON `custom-shortcut` (`id`);--> statement-breakpoint\nCREATE UNIQUE INDEX `custom-shortcut_name_unique` ON `custom-shortcut` (`name`);--> statement-breakpoint\nPRAGMA foreign_keys=OFF;--> statement-breakpoint\nCREATE TABLE `__new_settings` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`createdAt` text NOT NULL,\n\t`updatedAt` text NOT NULL,\n\t`profile` text DEFAULT 'default' NOT NULL,\n\t`settings` text DEFAULT '{\"hiddenWebsites\":[],\"language\":\"en\",\"allowAd\":true,\"queuePaused\":false,\"desktopNotifications\":{\"enabled\":true,\"showOnPostSuccess\":true,\"showOnPostError\":true,\"showOnDirectoryWatcherError\":true,\"showOnDirectoryWatcherSuccess\":true},\"tagSearchProvider\":{\"showWikiInHelpOnHover\":false}}' NOT NULL\n);\n--> statement-breakpoint\nINSERT INTO `__new_settings`(\"id\", \"createdAt\", \"updatedAt\", \"profile\", \"settings\") SELECT \"id\", \"createdAt\", \"updatedAt\", \"profile\", \"settings\" FROM `settings`;--> statement-breakpoint\nDROP TABLE `settings`;--> statement-breakpoint\nALTER TABLE `__new_settings` RENAME TO `settings`;--> statement-breakpoint\nPRAGMA foreign_keys=ON;--> statement-breakpoint\nCREATE UNIQUE INDEX `settings_id_unique` ON `settings` (`id`);--> statement-breakpoint\nCREATE UNIQUE INDEX `settings_profile_unique` ON `settings` (`profile`);"
  },
  {
    "path": "apps/postybirb/src/migrations/0002_pretty_sunfire.sql",
    "content": "ALTER TABLE `submission-file` ADD `metadata` text DEFAULT '{}' NOT NULL;--> statement-breakpoint\nALTER TABLE `submission-file` ADD `order` integer DEFAULT 9007199254740991 NOT NULL;"
  },
  {
    "path": "apps/postybirb/src/migrations/0003_glamorous_power_pack.sql",
    "content": "PRAGMA foreign_keys=OFF;--> statement-breakpoint\nCREATE TABLE `__new_website-post-record` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`createdAt` text NOT NULL,\n\t`updatedAt` text NOT NULL,\n\t`accountId` text,\n\t`postRecordId` text NOT NULL,\n\t`completedAt` text,\n\t`errors` text NOT NULL,\n\t`metadata` text NOT NULL,\n\t`postData` text NOT NULL,\n\t`postResponse` text,\n\tFOREIGN KEY (`accountId`) REFERENCES `account`(`id`) ON UPDATE no action ON DELETE set null,\n\tFOREIGN KEY (`postRecordId`) REFERENCES `post-record`(`id`) ON UPDATE no action ON DELETE cascade\n);\n--> statement-breakpoint\nINSERT INTO `__new_website-post-record`(\"id\", \"createdAt\", \"updatedAt\", \"accountId\", \"postRecordId\", \"completedAt\", \"errors\", \"metadata\", \"postData\", \"postResponse\") SELECT \"id\", \"createdAt\", \"updatedAt\", \"accountId\", \"postRecordId\", \"completedAt\", \"errors\", \"metadata\", \"postData\", \"postResponse\" FROM `website-post-record`;--> statement-breakpoint\nDROP TABLE `website-post-record`;--> statement-breakpoint\nALTER TABLE `__new_website-post-record` RENAME TO `website-post-record`;--> statement-breakpoint\nPRAGMA foreign_keys=ON;--> statement-breakpoint\nCREATE UNIQUE INDEX `website-post-record_id_unique` ON `website-post-record` (`id`);"
  },
  {
    "path": "apps/postybirb/src/migrations/0004_fuzzy_rafael_vega.sql",
    "content": "CREATE TABLE `user-converter` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`createdAt` text NOT NULL,\n\t`updatedAt` text NOT NULL,\n\t`convertTo` text NOT NULL,\n\t`username` text NOT NULL\n);\n--> statement-breakpoint\nCREATE UNIQUE INDEX `user-converter_id_unique` ON `user-converter` (`id`);--> statement-breakpoint\nCREATE UNIQUE INDEX `user-converter_username_unique` ON `user-converter` (`username`);"
  },
  {
    "path": "apps/postybirb/src/migrations/0005_exotic_nebula.sql",
    "content": "ALTER TABLE `submission` ADD `isInitialized` integer DEFAULT false;--> statement-breakpoint\nUPDATE `submission` SET `isInitialized` = true;"
  },
  {
    "path": "apps/postybirb/src/migrations/0006_cooing_songbird.sql",
    "content": "DROP TABLE IF EXISTS `website-post-record`;\n--> statement-breakpoint\nALTER TABLE `post-record` ADD `originPostRecordId` text REFERENCES `post-record`(`id`) ON DELETE set null;\n--> statement-breakpoint\nCREATE TABLE `post-event` (\n    `id` text PRIMARY KEY NOT NULL,\n    `createdAt` text NOT NULL,\n    `postRecordId` text NOT NULL,\n    `accountId` text,\n    `eventType` text NOT NULL,\n    `fileId` text,\n    `sourceUrl` text,\n    `error` text,\n    `metadata` text,\n    FOREIGN KEY (`postRecordId`) REFERENCES `post-record`(`id`) ON UPDATE no action ON DELETE cascade,\n    FOREIGN KEY (`accountId`) REFERENCES `account`(`id`) ON UPDATE no action ON DELETE set null\n);\n--> statement-breakpoint\nCREATE UNIQUE INDEX `post-event_id_unique` ON `post-event` (`id`);--> statement-breakpoint\nCREATE INDEX `idx_post_event_type` ON `post-event` (`postRecordId`,`eventType`);--> statement-breakpoint\nCREATE INDEX `idx_post_event_account` ON `post-event` (`postRecordId`,`accountId`,`eventType`);"
  },
  {
    "path": "apps/postybirb/src/migrations/meta/0000_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"02087254-72a4-4fbb-8414-cc4a8785e5f8\",\n  \"prevId\": \"00000000-0000-0000-0000-000000000000\",\n  \"tables\": {\n    \"account\": {\n      \"name\": \"account\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"groups\": {\n          \"name\": \"groups\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"website\": {\n          \"name\": \"website\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"account_id_unique\": {\n          \"name\": \"account_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"directory-watcher\": {\n      \"name\": \"directory-watcher\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"templateId\": {\n          \"name\": \"templateId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"importAction\": {\n          \"name\": \"importAction\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'NEW_SUBMISSION'\"\n        },\n        \"path\": {\n          \"name\": \"path\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"directory-watcher_id_unique\": {\n          \"name\": \"directory-watcher_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"directory-watcher_templateId_submission_id_fk\": {\n          \"name\": \"directory-watcher_templateId_submission_id_fk\",\n          \"tableFrom\": \"directory-watcher\",\n          \"tableTo\": \"submission\",\n          \"columnsFrom\": [\n            \"templateId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"set null\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"file-buffer\": {\n      \"name\": \"file-buffer\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"submissionFileId\": {\n          \"name\": \"submissionFileId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"buffer\": {\n          \"name\": \"buffer\",\n          \"type\": \"blob\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"fileName\": {\n          \"name\": \"fileName\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"height\": {\n          \"name\": \"height\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"mimeType\": {\n          \"name\": \"mimeType\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"size\": {\n          \"name\": \"size\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"width\": {\n          \"name\": \"width\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"file-buffer_id_unique\": {\n          \"name\": \"file-buffer_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"file-buffer_submissionFileId_submission-file_id_fk\": {\n          \"name\": \"file-buffer_submissionFileId_submission-file_id_fk\",\n          \"tableFrom\": \"file-buffer\",\n          \"tableTo\": \"submission-file\",\n          \"columnsFrom\": [\n            \"submissionFileId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"notification\": {\n      \"name\": \"notification\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"message\": {\n          \"name\": \"message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"tags\": {\n          \"name\": \"tags\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"data\": {\n          \"name\": \"data\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"isRead\": {\n          \"name\": \"isRead\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": false\n        },\n        \"hasEmitted\": {\n          \"name\": \"hasEmitted\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"notification_id_unique\": {\n          \"name\": \"notification_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"post-queue\": {\n      \"name\": \"post-queue\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"postRecordId\": {\n          \"name\": \"postRecordId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"submissionId\": {\n          \"name\": \"submissionId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"post-queue_id_unique\": {\n          \"name\": \"post-queue_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"post-queue_postRecordId_post-record_id_fk\": {\n          \"name\": \"post-queue_postRecordId_post-record_id_fk\",\n          \"tableFrom\": \"post-queue\",\n          \"tableTo\": \"post-record\",\n          \"columnsFrom\": [\n            \"postRecordId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"set null\",\n          \"onUpdate\": \"no action\"\n        },\n        \"post-queue_submissionId_submission_id_fk\": {\n          \"name\": \"post-queue_submissionId_submission_id_fk\",\n          \"tableFrom\": \"post-queue\",\n          \"tableTo\": \"submission\",\n          \"columnsFrom\": [\n            \"submissionId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"post-record\": {\n      \"name\": \"post-record\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"submissionId\": {\n          \"name\": \"submissionId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"completedAt\": {\n          \"name\": \"completedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"resumeMode\": {\n          \"name\": \"resumeMode\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'CONTINUE'\"\n        },\n        \"state\": {\n          \"name\": \"state\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'PENDING'\"\n        }\n      },\n      \"indexes\": {\n        \"post-record_id_unique\": {\n          \"name\": \"post-record_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"post-record_submissionId_submission_id_fk\": {\n          \"name\": \"post-record_submissionId_submission_id_fk\",\n          \"tableFrom\": \"post-record\",\n          \"tableTo\": \"submission\",\n          \"columnsFrom\": [\n            \"submissionId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"settings\": {\n      \"name\": \"settings\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"profile\": {\n          \"name\": \"profile\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'default'\"\n        },\n        \"settings\": {\n          \"name\": \"settings\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'{\\\"hiddenWebsites\\\":[],\\\"language\\\":\\\"en\\\",\\\"allowAd\\\":true,\\\"queuePaused\\\":false,\\\"desktopNotifications\\\":{\\\"enabled\\\":true,\\\"showOnPostSuccess\\\":true,\\\"showOnPostError\\\":true,\\\"showOnDirectoryWatcherError\\\":true,\\\"showOnDirectoryWatcherSuccess\\\":true}}'\"\n        }\n      },\n      \"indexes\": {\n        \"settings_id_unique\": {\n          \"name\": \"settings_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        },\n        \"settings_profile_unique\": {\n          \"name\": \"settings_profile_unique\",\n          \"columns\": [\n            \"profile\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"submission-file\": {\n      \"name\": \"submission-file\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"submissionId\": {\n          \"name\": \"submissionId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"primaryFileId\": {\n          \"name\": \"primaryFileId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"thumbnailId\": {\n          \"name\": \"thumbnailId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"altFileId\": {\n          \"name\": \"altFileId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"fileName\": {\n          \"name\": \"fileName\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"hasAltFile\": {\n          \"name\": \"hasAltFile\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": false\n        },\n        \"hasCustomThumbnail\": {\n          \"name\": \"hasCustomThumbnail\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": false\n        },\n        \"hasThumbnail\": {\n          \"name\": \"hasThumbnail\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"hash\": {\n          \"name\": \"hash\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"height\": {\n          \"name\": \"height\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"mimeType\": {\n          \"name\": \"mimeType\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"size\": {\n          \"name\": \"size\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"width\": {\n          \"name\": \"width\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"submission-file_id_unique\": {\n          \"name\": \"submission-file_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"submission-file_submissionId_submission_id_fk\": {\n          \"name\": \"submission-file_submissionId_submission_id_fk\",\n          \"tableFrom\": \"submission-file\",\n          \"tableTo\": \"submission\",\n          \"columnsFrom\": [\n            \"submissionId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        },\n        \"submission-file_primaryFileId_file-buffer_id_fk\": {\n          \"name\": \"submission-file_primaryFileId_file-buffer_id_fk\",\n          \"tableFrom\": \"submission-file\",\n          \"tableTo\": \"file-buffer\",\n          \"columnsFrom\": [\n            \"primaryFileId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"no action\",\n          \"onUpdate\": \"no action\"\n        },\n        \"submission-file_thumbnailId_file-buffer_id_fk\": {\n          \"name\": \"submission-file_thumbnailId_file-buffer_id_fk\",\n          \"tableFrom\": \"submission-file\",\n          \"tableTo\": \"file-buffer\",\n          \"columnsFrom\": [\n            \"thumbnailId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"no action\",\n          \"onUpdate\": \"no action\"\n        },\n        \"submission-file_altFileId_file-buffer_id_fk\": {\n          \"name\": \"submission-file_altFileId_file-buffer_id_fk\",\n          \"tableFrom\": \"submission-file\",\n          \"tableTo\": \"file-buffer\",\n          \"columnsFrom\": [\n            \"altFileId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"no action\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"submission\": {\n      \"name\": \"submission\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"isArchived\": {\n          \"name\": \"isArchived\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false,\n          \"default\": false\n        },\n        \"isMultiSubmission\": {\n          \"name\": \"isMultiSubmission\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"isScheduled\": {\n          \"name\": \"isScheduled\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"isTemplate\": {\n          \"name\": \"isTemplate\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"metadata\": {\n          \"name\": \"metadata\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"order\": {\n          \"name\": \"order\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"schedule\": {\n          \"name\": \"schedule\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"submission_id_unique\": {\n          \"name\": \"submission_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"tag-converter\": {\n      \"name\": \"tag-converter\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"convertTo\": {\n          \"name\": \"convertTo\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"tag\": {\n          \"name\": \"tag\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"tag-converter_id_unique\": {\n          \"name\": \"tag-converter_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        },\n        \"tag-converter_tag_unique\": {\n          \"name\": \"tag-converter_tag_unique\",\n          \"columns\": [\n            \"tag\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"tag-group\": {\n      \"name\": \"tag-group\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"tags\": {\n          \"name\": \"tags\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"tag-group_id_unique\": {\n          \"name\": \"tag-group_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        },\n        \"tag-group_name_unique\": {\n          \"name\": \"tag-group_name_unique\",\n          \"columns\": [\n            \"name\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"user-specified-website-options\": {\n      \"name\": \"user-specified-website-options\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"accountId\": {\n          \"name\": \"accountId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"options\": {\n          \"name\": \"options\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"user-specified-website-options_id_unique\": {\n          \"name\": \"user-specified-website-options_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"user-specified-website-options_accountId_account_id_fk\": {\n          \"name\": \"user-specified-website-options_accountId_account_id_fk\",\n          \"tableFrom\": \"user-specified-website-options\",\n          \"tableTo\": \"account\",\n          \"columnsFrom\": [\n            \"accountId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"website-data\": {\n      \"name\": \"website-data\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"data\": {\n          \"name\": \"data\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'{}'\"\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"website-data_id_unique\": {\n          \"name\": \"website-data_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"website-data_id_account_id_fk\": {\n          \"name\": \"website-data_id_account_id_fk\",\n          \"tableFrom\": \"website-data\",\n          \"tableTo\": \"account\",\n          \"columnsFrom\": [\n            \"id\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"website-options\": {\n      \"name\": \"website-options\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"accountId\": {\n          \"name\": \"accountId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"submissionId\": {\n          \"name\": \"submissionId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"data\": {\n          \"name\": \"data\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"isDefault\": {\n          \"name\": \"isDefault\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"website-options_id_unique\": {\n          \"name\": \"website-options_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"website-options_accountId_account_id_fk\": {\n          \"name\": \"website-options_accountId_account_id_fk\",\n          \"tableFrom\": \"website-options\",\n          \"tableTo\": \"account\",\n          \"columnsFrom\": [\n            \"accountId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        },\n        \"website-options_submissionId_submission_id_fk\": {\n          \"name\": \"website-options_submissionId_submission_id_fk\",\n          \"tableFrom\": \"website-options\",\n          \"tableTo\": \"submission\",\n          \"columnsFrom\": [\n            \"submissionId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"website-post-record\": {\n      \"name\": \"website-post-record\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"accountId\": {\n          \"name\": \"accountId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"postRecordId\": {\n          \"name\": \"postRecordId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"completedAt\": {\n          \"name\": \"completedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"errors\": {\n          \"name\": \"errors\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"metadata\": {\n          \"name\": \"metadata\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"postData\": {\n          \"name\": \"postData\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"postResponse\": {\n          \"name\": \"postResponse\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"website-post-record_id_unique\": {\n          \"name\": \"website-post-record_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"website-post-record_accountId_account_id_fk\": {\n          \"name\": \"website-post-record_accountId_account_id_fk\",\n          \"tableFrom\": \"website-post-record\",\n          \"tableTo\": \"account\",\n          \"columnsFrom\": [\n            \"accountId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"set null\",\n          \"onUpdate\": \"no action\"\n        },\n        \"website-post-record_postRecordId_post-record_id_fk\": {\n          \"name\": \"website-post-record_postRecordId_post-record_id_fk\",\n          \"tableFrom\": \"website-post-record\",\n          \"tableTo\": \"post-record\",\n          \"columnsFrom\": [\n            \"postRecordId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {},\n    \"columns\": {}\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}"
  },
  {
    "path": "apps/postybirb/src/migrations/meta/0001_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"6267de6d-bca1-4514-a132-756536c2a4c9\",\n  \"prevId\": \"02087254-72a4-4fbb-8414-cc4a8785e5f8\",\n  \"tables\": {\n    \"account\": {\n      \"name\": \"account\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"groups\": {\n          \"name\": \"groups\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"website\": {\n          \"name\": \"website\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"account_id_unique\": {\n          \"name\": \"account_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"custom-shortcut\": {\n      \"name\": \"custom-shortcut\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"shortcut\": {\n          \"name\": \"shortcut\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'[]'\"\n        }\n      },\n      \"indexes\": {\n        \"custom-shortcut_id_unique\": {\n          \"name\": \"custom-shortcut_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        },\n        \"custom-shortcut_name_unique\": {\n          \"name\": \"custom-shortcut_name_unique\",\n          \"columns\": [\n            \"name\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"directory-watcher\": {\n      \"name\": \"directory-watcher\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"templateId\": {\n          \"name\": \"templateId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"importAction\": {\n          \"name\": \"importAction\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'NEW_SUBMISSION'\"\n        },\n        \"path\": {\n          \"name\": \"path\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"directory-watcher_id_unique\": {\n          \"name\": \"directory-watcher_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"directory-watcher_templateId_submission_id_fk\": {\n          \"name\": \"directory-watcher_templateId_submission_id_fk\",\n          \"tableFrom\": \"directory-watcher\",\n          \"tableTo\": \"submission\",\n          \"columnsFrom\": [\n            \"templateId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"set null\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"file-buffer\": {\n      \"name\": \"file-buffer\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"submissionFileId\": {\n          \"name\": \"submissionFileId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"buffer\": {\n          \"name\": \"buffer\",\n          \"type\": \"blob\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"fileName\": {\n          \"name\": \"fileName\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"height\": {\n          \"name\": \"height\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"mimeType\": {\n          \"name\": \"mimeType\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"size\": {\n          \"name\": \"size\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"width\": {\n          \"name\": \"width\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"file-buffer_id_unique\": {\n          \"name\": \"file-buffer_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"file-buffer_submissionFileId_submission-file_id_fk\": {\n          \"name\": \"file-buffer_submissionFileId_submission-file_id_fk\",\n          \"tableFrom\": \"file-buffer\",\n          \"tableTo\": \"submission-file\",\n          \"columnsFrom\": [\n            \"submissionFileId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"notification\": {\n      \"name\": \"notification\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"message\": {\n          \"name\": \"message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"tags\": {\n          \"name\": \"tags\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"data\": {\n          \"name\": \"data\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"isRead\": {\n          \"name\": \"isRead\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": false\n        },\n        \"hasEmitted\": {\n          \"name\": \"hasEmitted\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"notification_id_unique\": {\n          \"name\": \"notification_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"post-queue\": {\n      \"name\": \"post-queue\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"postRecordId\": {\n          \"name\": \"postRecordId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"submissionId\": {\n          \"name\": \"submissionId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"post-queue_id_unique\": {\n          \"name\": \"post-queue_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"post-queue_postRecordId_post-record_id_fk\": {\n          \"name\": \"post-queue_postRecordId_post-record_id_fk\",\n          \"tableFrom\": \"post-queue\",\n          \"tableTo\": \"post-record\",\n          \"columnsFrom\": [\n            \"postRecordId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"set null\",\n          \"onUpdate\": \"no action\"\n        },\n        \"post-queue_submissionId_submission_id_fk\": {\n          \"name\": \"post-queue_submissionId_submission_id_fk\",\n          \"tableFrom\": \"post-queue\",\n          \"tableTo\": \"submission\",\n          \"columnsFrom\": [\n            \"submissionId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"post-record\": {\n      \"name\": \"post-record\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"submissionId\": {\n          \"name\": \"submissionId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"completedAt\": {\n          \"name\": \"completedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"resumeMode\": {\n          \"name\": \"resumeMode\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'CONTINUE'\"\n        },\n        \"state\": {\n          \"name\": \"state\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'PENDING'\"\n        }\n      },\n      \"indexes\": {\n        \"post-record_id_unique\": {\n          \"name\": \"post-record_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"post-record_submissionId_submission_id_fk\": {\n          \"name\": \"post-record_submissionId_submission_id_fk\",\n          \"tableFrom\": \"post-record\",\n          \"tableTo\": \"submission\",\n          \"columnsFrom\": [\n            \"submissionId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"settings\": {\n      \"name\": \"settings\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"profile\": {\n          \"name\": \"profile\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'default'\"\n        },\n        \"settings\": {\n          \"name\": \"settings\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'{\\\"hiddenWebsites\\\":[],\\\"language\\\":\\\"en\\\",\\\"allowAd\\\":true,\\\"queuePaused\\\":false,\\\"desktopNotifications\\\":{\\\"enabled\\\":true,\\\"showOnPostSuccess\\\":true,\\\"showOnPostError\\\":true,\\\"showOnDirectoryWatcherError\\\":true,\\\"showOnDirectoryWatcherSuccess\\\":true},\\\"tagSearchProvider\\\":{\\\"showWikiInHelpOnHover\\\":false}}'\"\n        }\n      },\n      \"indexes\": {\n        \"settings_id_unique\": {\n          \"name\": \"settings_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        },\n        \"settings_profile_unique\": {\n          \"name\": \"settings_profile_unique\",\n          \"columns\": [\n            \"profile\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"submission-file\": {\n      \"name\": \"submission-file\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"submissionId\": {\n          \"name\": \"submissionId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"primaryFileId\": {\n          \"name\": \"primaryFileId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"thumbnailId\": {\n          \"name\": \"thumbnailId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"altFileId\": {\n          \"name\": \"altFileId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"fileName\": {\n          \"name\": \"fileName\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"hasAltFile\": {\n          \"name\": \"hasAltFile\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": false\n        },\n        \"hasCustomThumbnail\": {\n          \"name\": \"hasCustomThumbnail\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": false\n        },\n        \"hasThumbnail\": {\n          \"name\": \"hasThumbnail\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"hash\": {\n          \"name\": \"hash\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"height\": {\n          \"name\": \"height\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"mimeType\": {\n          \"name\": \"mimeType\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"size\": {\n          \"name\": \"size\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"width\": {\n          \"name\": \"width\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"submission-file_id_unique\": {\n          \"name\": \"submission-file_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"submission-file_submissionId_submission_id_fk\": {\n          \"name\": \"submission-file_submissionId_submission_id_fk\",\n          \"tableFrom\": \"submission-file\",\n          \"tableTo\": \"submission\",\n          \"columnsFrom\": [\n            \"submissionId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        },\n        \"submission-file_primaryFileId_file-buffer_id_fk\": {\n          \"name\": \"submission-file_primaryFileId_file-buffer_id_fk\",\n          \"tableFrom\": \"submission-file\",\n          \"tableTo\": \"file-buffer\",\n          \"columnsFrom\": [\n            \"primaryFileId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"no action\",\n          \"onUpdate\": \"no action\"\n        },\n        \"submission-file_thumbnailId_file-buffer_id_fk\": {\n          \"name\": \"submission-file_thumbnailId_file-buffer_id_fk\",\n          \"tableFrom\": \"submission-file\",\n          \"tableTo\": \"file-buffer\",\n          \"columnsFrom\": [\n            \"thumbnailId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"no action\",\n          \"onUpdate\": \"no action\"\n        },\n        \"submission-file_altFileId_file-buffer_id_fk\": {\n          \"name\": \"submission-file_altFileId_file-buffer_id_fk\",\n          \"tableFrom\": \"submission-file\",\n          \"tableTo\": \"file-buffer\",\n          \"columnsFrom\": [\n            \"altFileId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"no action\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"submission\": {\n      \"name\": \"submission\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"isArchived\": {\n          \"name\": \"isArchived\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false,\n          \"default\": false\n        },\n        \"isMultiSubmission\": {\n          \"name\": \"isMultiSubmission\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"isScheduled\": {\n          \"name\": \"isScheduled\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"isTemplate\": {\n          \"name\": \"isTemplate\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"metadata\": {\n          \"name\": \"metadata\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"order\": {\n          \"name\": \"order\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"schedule\": {\n          \"name\": \"schedule\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"submission_id_unique\": {\n          \"name\": \"submission_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"tag-converter\": {\n      \"name\": \"tag-converter\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"convertTo\": {\n          \"name\": \"convertTo\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"tag\": {\n          \"name\": \"tag\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"tag-converter_id_unique\": {\n          \"name\": \"tag-converter_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        },\n        \"tag-converter_tag_unique\": {\n          \"name\": \"tag-converter_tag_unique\",\n          \"columns\": [\n            \"tag\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"tag-group\": {\n      \"name\": \"tag-group\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"tags\": {\n          \"name\": \"tags\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"tag-group_id_unique\": {\n          \"name\": \"tag-group_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        },\n        \"tag-group_name_unique\": {\n          \"name\": \"tag-group_name_unique\",\n          \"columns\": [\n            \"name\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"user-specified-website-options\": {\n      \"name\": \"user-specified-website-options\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"accountId\": {\n          \"name\": \"accountId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"options\": {\n          \"name\": \"options\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"user-specified-website-options_id_unique\": {\n          \"name\": \"user-specified-website-options_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"user-specified-website-options_accountId_account_id_fk\": {\n          \"name\": \"user-specified-website-options_accountId_account_id_fk\",\n          \"tableFrom\": \"user-specified-website-options\",\n          \"tableTo\": \"account\",\n          \"columnsFrom\": [\n            \"accountId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"website-data\": {\n      \"name\": \"website-data\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"data\": {\n          \"name\": \"data\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'{}'\"\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"website-data_id_unique\": {\n          \"name\": \"website-data_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"website-data_id_account_id_fk\": {\n          \"name\": \"website-data_id_account_id_fk\",\n          \"tableFrom\": \"website-data\",\n          \"tableTo\": \"account\",\n          \"columnsFrom\": [\n            \"id\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"website-options\": {\n      \"name\": \"website-options\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"accountId\": {\n          \"name\": \"accountId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"submissionId\": {\n          \"name\": \"submissionId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"data\": {\n          \"name\": \"data\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"isDefault\": {\n          \"name\": \"isDefault\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"website-options_id_unique\": {\n          \"name\": \"website-options_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"website-options_accountId_account_id_fk\": {\n          \"name\": \"website-options_accountId_account_id_fk\",\n          \"tableFrom\": \"website-options\",\n          \"tableTo\": \"account\",\n          \"columnsFrom\": [\n            \"accountId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        },\n        \"website-options_submissionId_submission_id_fk\": {\n          \"name\": \"website-options_submissionId_submission_id_fk\",\n          \"tableFrom\": \"website-options\",\n          \"tableTo\": \"submission\",\n          \"columnsFrom\": [\n            \"submissionId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"website-post-record\": {\n      \"name\": \"website-post-record\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"accountId\": {\n          \"name\": \"accountId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"postRecordId\": {\n          \"name\": \"postRecordId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"completedAt\": {\n          \"name\": \"completedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"errors\": {\n          \"name\": \"errors\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"metadata\": {\n          \"name\": \"metadata\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"postData\": {\n          \"name\": \"postData\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"postResponse\": {\n          \"name\": \"postResponse\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"website-post-record_id_unique\": {\n          \"name\": \"website-post-record_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"website-post-record_accountId_account_id_fk\": {\n          \"name\": \"website-post-record_accountId_account_id_fk\",\n          \"tableFrom\": \"website-post-record\",\n          \"tableTo\": \"account\",\n          \"columnsFrom\": [\n            \"accountId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"set null\",\n          \"onUpdate\": \"no action\"\n        },\n        \"website-post-record_postRecordId_post-record_id_fk\": {\n          \"name\": \"website-post-record_postRecordId_post-record_id_fk\",\n          \"tableFrom\": \"website-post-record\",\n          \"tableTo\": \"post-record\",\n          \"columnsFrom\": [\n            \"postRecordId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {},\n    \"columns\": {}\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}"
  },
  {
    "path": "apps/postybirb/src/migrations/meta/0002_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"49ae36e5-baa2-4552-9b42-e1ba427c7eb4\",\n  \"prevId\": \"6267de6d-bca1-4514-a132-756536c2a4c9\",\n  \"tables\": {\n    \"account\": {\n      \"name\": \"account\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"groups\": {\n          \"name\": \"groups\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"website\": {\n          \"name\": \"website\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"account_id_unique\": {\n          \"name\": \"account_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"custom-shortcut\": {\n      \"name\": \"custom-shortcut\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"shortcut\": {\n          \"name\": \"shortcut\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'[]'\"\n        }\n      },\n      \"indexes\": {\n        \"custom-shortcut_id_unique\": {\n          \"name\": \"custom-shortcut_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        },\n        \"custom-shortcut_name_unique\": {\n          \"name\": \"custom-shortcut_name_unique\",\n          \"columns\": [\n            \"name\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"directory-watcher\": {\n      \"name\": \"directory-watcher\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"templateId\": {\n          \"name\": \"templateId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"importAction\": {\n          \"name\": \"importAction\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'NEW_SUBMISSION'\"\n        },\n        \"path\": {\n          \"name\": \"path\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"directory-watcher_id_unique\": {\n          \"name\": \"directory-watcher_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"directory-watcher_templateId_submission_id_fk\": {\n          \"name\": \"directory-watcher_templateId_submission_id_fk\",\n          \"tableFrom\": \"directory-watcher\",\n          \"tableTo\": \"submission\",\n          \"columnsFrom\": [\n            \"templateId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"set null\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"file-buffer\": {\n      \"name\": \"file-buffer\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"submissionFileId\": {\n          \"name\": \"submissionFileId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"buffer\": {\n          \"name\": \"buffer\",\n          \"type\": \"blob\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"fileName\": {\n          \"name\": \"fileName\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"height\": {\n          \"name\": \"height\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"mimeType\": {\n          \"name\": \"mimeType\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"size\": {\n          \"name\": \"size\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"width\": {\n          \"name\": \"width\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"file-buffer_id_unique\": {\n          \"name\": \"file-buffer_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"file-buffer_submissionFileId_submission-file_id_fk\": {\n          \"name\": \"file-buffer_submissionFileId_submission-file_id_fk\",\n          \"tableFrom\": \"file-buffer\",\n          \"tableTo\": \"submission-file\",\n          \"columnsFrom\": [\n            \"submissionFileId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"notification\": {\n      \"name\": \"notification\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"message\": {\n          \"name\": \"message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"tags\": {\n          \"name\": \"tags\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"data\": {\n          \"name\": \"data\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"isRead\": {\n          \"name\": \"isRead\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": false\n        },\n        \"hasEmitted\": {\n          \"name\": \"hasEmitted\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"notification_id_unique\": {\n          \"name\": \"notification_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"post-queue\": {\n      \"name\": \"post-queue\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"postRecordId\": {\n          \"name\": \"postRecordId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"submissionId\": {\n          \"name\": \"submissionId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"post-queue_id_unique\": {\n          \"name\": \"post-queue_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"post-queue_postRecordId_post-record_id_fk\": {\n          \"name\": \"post-queue_postRecordId_post-record_id_fk\",\n          \"tableFrom\": \"post-queue\",\n          \"tableTo\": \"post-record\",\n          \"columnsFrom\": [\n            \"postRecordId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"set null\",\n          \"onUpdate\": \"no action\"\n        },\n        \"post-queue_submissionId_submission_id_fk\": {\n          \"name\": \"post-queue_submissionId_submission_id_fk\",\n          \"tableFrom\": \"post-queue\",\n          \"tableTo\": \"submission\",\n          \"columnsFrom\": [\n            \"submissionId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"post-record\": {\n      \"name\": \"post-record\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"submissionId\": {\n          \"name\": \"submissionId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"completedAt\": {\n          \"name\": \"completedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"resumeMode\": {\n          \"name\": \"resumeMode\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'CONTINUE'\"\n        },\n        \"state\": {\n          \"name\": \"state\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'PENDING'\"\n        }\n      },\n      \"indexes\": {\n        \"post-record_id_unique\": {\n          \"name\": \"post-record_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"post-record_submissionId_submission_id_fk\": {\n          \"name\": \"post-record_submissionId_submission_id_fk\",\n          \"tableFrom\": \"post-record\",\n          \"tableTo\": \"submission\",\n          \"columnsFrom\": [\n            \"submissionId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"settings\": {\n      \"name\": \"settings\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"profile\": {\n          \"name\": \"profile\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'default'\"\n        },\n        \"settings\": {\n          \"name\": \"settings\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'{\\\"hiddenWebsites\\\":[],\\\"language\\\":\\\"en\\\",\\\"allowAd\\\":true,\\\"queuePaused\\\":false,\\\"desktopNotifications\\\":{\\\"enabled\\\":true,\\\"showOnPostSuccess\\\":true,\\\"showOnPostError\\\":true,\\\"showOnDirectoryWatcherError\\\":true,\\\"showOnDirectoryWatcherSuccess\\\":true},\\\"tagSearchProvider\\\":{\\\"showWikiInHelpOnHover\\\":false}}'\"\n        }\n      },\n      \"indexes\": {\n        \"settings_id_unique\": {\n          \"name\": \"settings_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        },\n        \"settings_profile_unique\": {\n          \"name\": \"settings_profile_unique\",\n          \"columns\": [\n            \"profile\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"submission-file\": {\n      \"name\": \"submission-file\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"submissionId\": {\n          \"name\": \"submissionId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"primaryFileId\": {\n          \"name\": \"primaryFileId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"thumbnailId\": {\n          \"name\": \"thumbnailId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"altFileId\": {\n          \"name\": \"altFileId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"fileName\": {\n          \"name\": \"fileName\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"hasAltFile\": {\n          \"name\": \"hasAltFile\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": false\n        },\n        \"hasCustomThumbnail\": {\n          \"name\": \"hasCustomThumbnail\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": false\n        },\n        \"hasThumbnail\": {\n          \"name\": \"hasThumbnail\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"hash\": {\n          \"name\": \"hash\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"height\": {\n          \"name\": \"height\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"mimeType\": {\n          \"name\": \"mimeType\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"size\": {\n          \"name\": \"size\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"width\": {\n          \"name\": \"width\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"metadata\": {\n          \"name\": \"metadata\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'{}'\"\n        },\n        \"order\": {\n          \"name\": \"order\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": 9007199254740991\n        }\n      },\n      \"indexes\": {\n        \"submission-file_id_unique\": {\n          \"name\": \"submission-file_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"submission-file_submissionId_submission_id_fk\": {\n          \"name\": \"submission-file_submissionId_submission_id_fk\",\n          \"tableFrom\": \"submission-file\",\n          \"tableTo\": \"submission\",\n          \"columnsFrom\": [\n            \"submissionId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        },\n        \"submission-file_primaryFileId_file-buffer_id_fk\": {\n          \"name\": \"submission-file_primaryFileId_file-buffer_id_fk\",\n          \"tableFrom\": \"submission-file\",\n          \"tableTo\": \"file-buffer\",\n          \"columnsFrom\": [\n            \"primaryFileId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"no action\",\n          \"onUpdate\": \"no action\"\n        },\n        \"submission-file_thumbnailId_file-buffer_id_fk\": {\n          \"name\": \"submission-file_thumbnailId_file-buffer_id_fk\",\n          \"tableFrom\": \"submission-file\",\n          \"tableTo\": \"file-buffer\",\n          \"columnsFrom\": [\n            \"thumbnailId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"no action\",\n          \"onUpdate\": \"no action\"\n        },\n        \"submission-file_altFileId_file-buffer_id_fk\": {\n          \"name\": \"submission-file_altFileId_file-buffer_id_fk\",\n          \"tableFrom\": \"submission-file\",\n          \"tableTo\": \"file-buffer\",\n          \"columnsFrom\": [\n            \"altFileId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"no action\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"submission\": {\n      \"name\": \"submission\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"isArchived\": {\n          \"name\": \"isArchived\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false,\n          \"default\": false\n        },\n        \"isMultiSubmission\": {\n          \"name\": \"isMultiSubmission\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"isScheduled\": {\n          \"name\": \"isScheduled\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"isTemplate\": {\n          \"name\": \"isTemplate\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"metadata\": {\n          \"name\": \"metadata\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"order\": {\n          \"name\": \"order\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"schedule\": {\n          \"name\": \"schedule\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"submission_id_unique\": {\n          \"name\": \"submission_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"tag-converter\": {\n      \"name\": \"tag-converter\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"convertTo\": {\n          \"name\": \"convertTo\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"tag\": {\n          \"name\": \"tag\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"tag-converter_id_unique\": {\n          \"name\": \"tag-converter_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        },\n        \"tag-converter_tag_unique\": {\n          \"name\": \"tag-converter_tag_unique\",\n          \"columns\": [\n            \"tag\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"tag-group\": {\n      \"name\": \"tag-group\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"tags\": {\n          \"name\": \"tags\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"tag-group_id_unique\": {\n          \"name\": \"tag-group_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        },\n        \"tag-group_name_unique\": {\n          \"name\": \"tag-group_name_unique\",\n          \"columns\": [\n            \"name\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"user-specified-website-options\": {\n      \"name\": \"user-specified-website-options\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"accountId\": {\n          \"name\": \"accountId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"options\": {\n          \"name\": \"options\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"user-specified-website-options_id_unique\": {\n          \"name\": \"user-specified-website-options_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"user-specified-website-options_accountId_account_id_fk\": {\n          \"name\": \"user-specified-website-options_accountId_account_id_fk\",\n          \"tableFrom\": \"user-specified-website-options\",\n          \"tableTo\": \"account\",\n          \"columnsFrom\": [\n            \"accountId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"website-data\": {\n      \"name\": \"website-data\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"data\": {\n          \"name\": \"data\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'{}'\"\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"website-data_id_unique\": {\n          \"name\": \"website-data_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"website-data_id_account_id_fk\": {\n          \"name\": \"website-data_id_account_id_fk\",\n          \"tableFrom\": \"website-data\",\n          \"tableTo\": \"account\",\n          \"columnsFrom\": [\n            \"id\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"website-options\": {\n      \"name\": \"website-options\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"accountId\": {\n          \"name\": \"accountId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"submissionId\": {\n          \"name\": \"submissionId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"data\": {\n          \"name\": \"data\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"isDefault\": {\n          \"name\": \"isDefault\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"website-options_id_unique\": {\n          \"name\": \"website-options_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"website-options_accountId_account_id_fk\": {\n          \"name\": \"website-options_accountId_account_id_fk\",\n          \"tableFrom\": \"website-options\",\n          \"tableTo\": \"account\",\n          \"columnsFrom\": [\n            \"accountId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        },\n        \"website-options_submissionId_submission_id_fk\": {\n          \"name\": \"website-options_submissionId_submission_id_fk\",\n          \"tableFrom\": \"website-options\",\n          \"tableTo\": \"submission\",\n          \"columnsFrom\": [\n            \"submissionId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"website-post-record\": {\n      \"name\": \"website-post-record\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"accountId\": {\n          \"name\": \"accountId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"postRecordId\": {\n          \"name\": \"postRecordId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"completedAt\": {\n          \"name\": \"completedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"errors\": {\n          \"name\": \"errors\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"metadata\": {\n          \"name\": \"metadata\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"postData\": {\n          \"name\": \"postData\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"postResponse\": {\n          \"name\": \"postResponse\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"website-post-record_id_unique\": {\n          \"name\": \"website-post-record_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"website-post-record_accountId_account_id_fk\": {\n          \"name\": \"website-post-record_accountId_account_id_fk\",\n          \"tableFrom\": \"website-post-record\",\n          \"tableTo\": \"account\",\n          \"columnsFrom\": [\n            \"accountId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"set null\",\n          \"onUpdate\": \"no action\"\n        },\n        \"website-post-record_postRecordId_post-record_id_fk\": {\n          \"name\": \"website-post-record_postRecordId_post-record_id_fk\",\n          \"tableFrom\": \"website-post-record\",\n          \"tableTo\": \"post-record\",\n          \"columnsFrom\": [\n            \"postRecordId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {},\n    \"columns\": {}\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}"
  },
  {
    "path": "apps/postybirb/src/migrations/meta/0003_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"885681bd-0162-4edd-a447-44cc798081c8\",\n  \"prevId\": \"49ae36e5-baa2-4552-9b42-e1ba427c7eb4\",\n  \"tables\": {\n    \"account\": {\n      \"name\": \"account\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"groups\": {\n          \"name\": \"groups\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"website\": {\n          \"name\": \"website\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"account_id_unique\": {\n          \"name\": \"account_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"custom-shortcut\": {\n      \"name\": \"custom-shortcut\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"shortcut\": {\n          \"name\": \"shortcut\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'[]'\"\n        }\n      },\n      \"indexes\": {\n        \"custom-shortcut_id_unique\": {\n          \"name\": \"custom-shortcut_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        },\n        \"custom-shortcut_name_unique\": {\n          \"name\": \"custom-shortcut_name_unique\",\n          \"columns\": [\n            \"name\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"directory-watcher\": {\n      \"name\": \"directory-watcher\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"templateId\": {\n          \"name\": \"templateId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"importAction\": {\n          \"name\": \"importAction\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'NEW_SUBMISSION'\"\n        },\n        \"path\": {\n          \"name\": \"path\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"directory-watcher_id_unique\": {\n          \"name\": \"directory-watcher_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"directory-watcher_templateId_submission_id_fk\": {\n          \"name\": \"directory-watcher_templateId_submission_id_fk\",\n          \"tableFrom\": \"directory-watcher\",\n          \"tableTo\": \"submission\",\n          \"columnsFrom\": [\n            \"templateId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"set null\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"file-buffer\": {\n      \"name\": \"file-buffer\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"submissionFileId\": {\n          \"name\": \"submissionFileId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"buffer\": {\n          \"name\": \"buffer\",\n          \"type\": \"blob\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"fileName\": {\n          \"name\": \"fileName\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"height\": {\n          \"name\": \"height\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"mimeType\": {\n          \"name\": \"mimeType\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"size\": {\n          \"name\": \"size\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"width\": {\n          \"name\": \"width\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"file-buffer_id_unique\": {\n          \"name\": \"file-buffer_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"file-buffer_submissionFileId_submission-file_id_fk\": {\n          \"name\": \"file-buffer_submissionFileId_submission-file_id_fk\",\n          \"tableFrom\": \"file-buffer\",\n          \"tableTo\": \"submission-file\",\n          \"columnsFrom\": [\n            \"submissionFileId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"notification\": {\n      \"name\": \"notification\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"message\": {\n          \"name\": \"message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"tags\": {\n          \"name\": \"tags\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"data\": {\n          \"name\": \"data\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"isRead\": {\n          \"name\": \"isRead\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": false\n        },\n        \"hasEmitted\": {\n          \"name\": \"hasEmitted\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"notification_id_unique\": {\n          \"name\": \"notification_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"post-queue\": {\n      \"name\": \"post-queue\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"postRecordId\": {\n          \"name\": \"postRecordId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"submissionId\": {\n          \"name\": \"submissionId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"post-queue_id_unique\": {\n          \"name\": \"post-queue_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"post-queue_postRecordId_post-record_id_fk\": {\n          \"name\": \"post-queue_postRecordId_post-record_id_fk\",\n          \"tableFrom\": \"post-queue\",\n          \"tableTo\": \"post-record\",\n          \"columnsFrom\": [\n            \"postRecordId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"set null\",\n          \"onUpdate\": \"no action\"\n        },\n        \"post-queue_submissionId_submission_id_fk\": {\n          \"name\": \"post-queue_submissionId_submission_id_fk\",\n          \"tableFrom\": \"post-queue\",\n          \"tableTo\": \"submission\",\n          \"columnsFrom\": [\n            \"submissionId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"post-record\": {\n      \"name\": \"post-record\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"submissionId\": {\n          \"name\": \"submissionId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"completedAt\": {\n          \"name\": \"completedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"resumeMode\": {\n          \"name\": \"resumeMode\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'CONTINUE'\"\n        },\n        \"state\": {\n          \"name\": \"state\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'PENDING'\"\n        }\n      },\n      \"indexes\": {\n        \"post-record_id_unique\": {\n          \"name\": \"post-record_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"post-record_submissionId_submission_id_fk\": {\n          \"name\": \"post-record_submissionId_submission_id_fk\",\n          \"tableFrom\": \"post-record\",\n          \"tableTo\": \"submission\",\n          \"columnsFrom\": [\n            \"submissionId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"settings\": {\n      \"name\": \"settings\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"profile\": {\n          \"name\": \"profile\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'default'\"\n        },\n        \"settings\": {\n          \"name\": \"settings\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'{\\\"hiddenWebsites\\\":[],\\\"language\\\":\\\"en\\\",\\\"allowAd\\\":true,\\\"queuePaused\\\":false,\\\"desktopNotifications\\\":{\\\"enabled\\\":true,\\\"showOnPostSuccess\\\":true,\\\"showOnPostError\\\":true,\\\"showOnDirectoryWatcherError\\\":true,\\\"showOnDirectoryWatcherSuccess\\\":true},\\\"tagSearchProvider\\\":{\\\"showWikiInHelpOnHover\\\":false}}'\"\n        }\n      },\n      \"indexes\": {\n        \"settings_id_unique\": {\n          \"name\": \"settings_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        },\n        \"settings_profile_unique\": {\n          \"name\": \"settings_profile_unique\",\n          \"columns\": [\n            \"profile\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"submission-file\": {\n      \"name\": \"submission-file\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"submissionId\": {\n          \"name\": \"submissionId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"primaryFileId\": {\n          \"name\": \"primaryFileId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"thumbnailId\": {\n          \"name\": \"thumbnailId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"altFileId\": {\n          \"name\": \"altFileId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"fileName\": {\n          \"name\": \"fileName\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"hasAltFile\": {\n          \"name\": \"hasAltFile\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": false\n        },\n        \"hasCustomThumbnail\": {\n          \"name\": \"hasCustomThumbnail\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": false\n        },\n        \"hasThumbnail\": {\n          \"name\": \"hasThumbnail\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"hash\": {\n          \"name\": \"hash\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"height\": {\n          \"name\": \"height\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"mimeType\": {\n          \"name\": \"mimeType\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"size\": {\n          \"name\": \"size\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"width\": {\n          \"name\": \"width\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"metadata\": {\n          \"name\": \"metadata\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'{}'\"\n        },\n        \"order\": {\n          \"name\": \"order\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": 9007199254740991\n        }\n      },\n      \"indexes\": {\n        \"submission-file_id_unique\": {\n          \"name\": \"submission-file_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"submission-file_submissionId_submission_id_fk\": {\n          \"name\": \"submission-file_submissionId_submission_id_fk\",\n          \"tableFrom\": \"submission-file\",\n          \"tableTo\": \"submission\",\n          \"columnsFrom\": [\n            \"submissionId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        },\n        \"submission-file_primaryFileId_file-buffer_id_fk\": {\n          \"name\": \"submission-file_primaryFileId_file-buffer_id_fk\",\n          \"tableFrom\": \"submission-file\",\n          \"tableTo\": \"file-buffer\",\n          \"columnsFrom\": [\n            \"primaryFileId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"no action\",\n          \"onUpdate\": \"no action\"\n        },\n        \"submission-file_thumbnailId_file-buffer_id_fk\": {\n          \"name\": \"submission-file_thumbnailId_file-buffer_id_fk\",\n          \"tableFrom\": \"submission-file\",\n          \"tableTo\": \"file-buffer\",\n          \"columnsFrom\": [\n            \"thumbnailId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"no action\",\n          \"onUpdate\": \"no action\"\n        },\n        \"submission-file_altFileId_file-buffer_id_fk\": {\n          \"name\": \"submission-file_altFileId_file-buffer_id_fk\",\n          \"tableFrom\": \"submission-file\",\n          \"tableTo\": \"file-buffer\",\n          \"columnsFrom\": [\n            \"altFileId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"no action\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"submission\": {\n      \"name\": \"submission\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"isArchived\": {\n          \"name\": \"isArchived\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false,\n          \"default\": false\n        },\n        \"isMultiSubmission\": {\n          \"name\": \"isMultiSubmission\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"isScheduled\": {\n          \"name\": \"isScheduled\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"isTemplate\": {\n          \"name\": \"isTemplate\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"metadata\": {\n          \"name\": \"metadata\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"order\": {\n          \"name\": \"order\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"schedule\": {\n          \"name\": \"schedule\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"submission_id_unique\": {\n          \"name\": \"submission_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"tag-converter\": {\n      \"name\": \"tag-converter\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"convertTo\": {\n          \"name\": \"convertTo\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"tag\": {\n          \"name\": \"tag\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"tag-converter_id_unique\": {\n          \"name\": \"tag-converter_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        },\n        \"tag-converter_tag_unique\": {\n          \"name\": \"tag-converter_tag_unique\",\n          \"columns\": [\n            \"tag\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"tag-group\": {\n      \"name\": \"tag-group\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"tags\": {\n          \"name\": \"tags\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"tag-group_id_unique\": {\n          \"name\": \"tag-group_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        },\n        \"tag-group_name_unique\": {\n          \"name\": \"tag-group_name_unique\",\n          \"columns\": [\n            \"name\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"user-specified-website-options\": {\n      \"name\": \"user-specified-website-options\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"accountId\": {\n          \"name\": \"accountId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"options\": {\n          \"name\": \"options\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"user-specified-website-options_id_unique\": {\n          \"name\": \"user-specified-website-options_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"user-specified-website-options_accountId_account_id_fk\": {\n          \"name\": \"user-specified-website-options_accountId_account_id_fk\",\n          \"tableFrom\": \"user-specified-website-options\",\n          \"tableTo\": \"account\",\n          \"columnsFrom\": [\n            \"accountId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"website-data\": {\n      \"name\": \"website-data\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"data\": {\n          \"name\": \"data\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'{}'\"\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"website-data_id_unique\": {\n          \"name\": \"website-data_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"website-data_id_account_id_fk\": {\n          \"name\": \"website-data_id_account_id_fk\",\n          \"tableFrom\": \"website-data\",\n          \"tableTo\": \"account\",\n          \"columnsFrom\": [\n            \"id\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"website-options\": {\n      \"name\": \"website-options\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"accountId\": {\n          \"name\": \"accountId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"submissionId\": {\n          \"name\": \"submissionId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"data\": {\n          \"name\": \"data\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"isDefault\": {\n          \"name\": \"isDefault\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"website-options_id_unique\": {\n          \"name\": \"website-options_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"website-options_accountId_account_id_fk\": {\n          \"name\": \"website-options_accountId_account_id_fk\",\n          \"tableFrom\": \"website-options\",\n          \"tableTo\": \"account\",\n          \"columnsFrom\": [\n            \"accountId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        },\n        \"website-options_submissionId_submission_id_fk\": {\n          \"name\": \"website-options_submissionId_submission_id_fk\",\n          \"tableFrom\": \"website-options\",\n          \"tableTo\": \"submission\",\n          \"columnsFrom\": [\n            \"submissionId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"website-post-record\": {\n      \"name\": \"website-post-record\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"accountId\": {\n          \"name\": \"accountId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"postRecordId\": {\n          \"name\": \"postRecordId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"completedAt\": {\n          \"name\": \"completedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"errors\": {\n          \"name\": \"errors\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"metadata\": {\n          \"name\": \"metadata\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"postData\": {\n          \"name\": \"postData\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"postResponse\": {\n          \"name\": \"postResponse\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"website-post-record_id_unique\": {\n          \"name\": \"website-post-record_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"website-post-record_accountId_account_id_fk\": {\n          \"name\": \"website-post-record_accountId_account_id_fk\",\n          \"tableFrom\": \"website-post-record\",\n          \"tableTo\": \"account\",\n          \"columnsFrom\": [\n            \"accountId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"set null\",\n          \"onUpdate\": \"no action\"\n        },\n        \"website-post-record_postRecordId_post-record_id_fk\": {\n          \"name\": \"website-post-record_postRecordId_post-record_id_fk\",\n          \"tableFrom\": \"website-post-record\",\n          \"tableTo\": \"post-record\",\n          \"columnsFrom\": [\n            \"postRecordId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {},\n    \"columns\": {}\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}"
  },
  {
    "path": "apps/postybirb/src/migrations/meta/0004_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"63f0fd58-d6d0-4cb4-a098-73f8f2e8ee76\",\n  \"prevId\": \"885681bd-0162-4edd-a447-44cc798081c8\",\n  \"tables\": {\n    \"account\": {\n      \"name\": \"account\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"groups\": {\n          \"name\": \"groups\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"website\": {\n          \"name\": \"website\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"account_id_unique\": {\n          \"name\": \"account_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"custom-shortcut\": {\n      \"name\": \"custom-shortcut\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"shortcut\": {\n          \"name\": \"shortcut\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'[]'\"\n        }\n      },\n      \"indexes\": {\n        \"custom-shortcut_id_unique\": {\n          \"name\": \"custom-shortcut_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        },\n        \"custom-shortcut_name_unique\": {\n          \"name\": \"custom-shortcut_name_unique\",\n          \"columns\": [\n            \"name\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"directory-watcher\": {\n      \"name\": \"directory-watcher\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"templateId\": {\n          \"name\": \"templateId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"importAction\": {\n          \"name\": \"importAction\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'NEW_SUBMISSION'\"\n        },\n        \"path\": {\n          \"name\": \"path\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"directory-watcher_id_unique\": {\n          \"name\": \"directory-watcher_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"directory-watcher_templateId_submission_id_fk\": {\n          \"name\": \"directory-watcher_templateId_submission_id_fk\",\n          \"tableFrom\": \"directory-watcher\",\n          \"tableTo\": \"submission\",\n          \"columnsFrom\": [\n            \"templateId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"set null\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"file-buffer\": {\n      \"name\": \"file-buffer\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"submissionFileId\": {\n          \"name\": \"submissionFileId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"buffer\": {\n          \"name\": \"buffer\",\n          \"type\": \"blob\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"fileName\": {\n          \"name\": \"fileName\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"height\": {\n          \"name\": \"height\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"mimeType\": {\n          \"name\": \"mimeType\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"size\": {\n          \"name\": \"size\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"width\": {\n          \"name\": \"width\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"file-buffer_id_unique\": {\n          \"name\": \"file-buffer_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"file-buffer_submissionFileId_submission-file_id_fk\": {\n          \"name\": \"file-buffer_submissionFileId_submission-file_id_fk\",\n          \"tableFrom\": \"file-buffer\",\n          \"tableTo\": \"submission-file\",\n          \"columnsFrom\": [\n            \"submissionFileId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"notification\": {\n      \"name\": \"notification\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"message\": {\n          \"name\": \"message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"tags\": {\n          \"name\": \"tags\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"data\": {\n          \"name\": \"data\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"isRead\": {\n          \"name\": \"isRead\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": false\n        },\n        \"hasEmitted\": {\n          \"name\": \"hasEmitted\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"notification_id_unique\": {\n          \"name\": \"notification_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"post-queue\": {\n      \"name\": \"post-queue\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"postRecordId\": {\n          \"name\": \"postRecordId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"submissionId\": {\n          \"name\": \"submissionId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"post-queue_id_unique\": {\n          \"name\": \"post-queue_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"post-queue_postRecordId_post-record_id_fk\": {\n          \"name\": \"post-queue_postRecordId_post-record_id_fk\",\n          \"tableFrom\": \"post-queue\",\n          \"tableTo\": \"post-record\",\n          \"columnsFrom\": [\n            \"postRecordId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"set null\",\n          \"onUpdate\": \"no action\"\n        },\n        \"post-queue_submissionId_submission_id_fk\": {\n          \"name\": \"post-queue_submissionId_submission_id_fk\",\n          \"tableFrom\": \"post-queue\",\n          \"tableTo\": \"submission\",\n          \"columnsFrom\": [\n            \"submissionId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"post-record\": {\n      \"name\": \"post-record\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"submissionId\": {\n          \"name\": \"submissionId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"completedAt\": {\n          \"name\": \"completedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"resumeMode\": {\n          \"name\": \"resumeMode\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'CONTINUE'\"\n        },\n        \"state\": {\n          \"name\": \"state\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'PENDING'\"\n        }\n      },\n      \"indexes\": {\n        \"post-record_id_unique\": {\n          \"name\": \"post-record_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"post-record_submissionId_submission_id_fk\": {\n          \"name\": \"post-record_submissionId_submission_id_fk\",\n          \"tableFrom\": \"post-record\",\n          \"tableTo\": \"submission\",\n          \"columnsFrom\": [\n            \"submissionId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"settings\": {\n      \"name\": \"settings\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"profile\": {\n          \"name\": \"profile\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'default'\"\n        },\n        \"settings\": {\n          \"name\": \"settings\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'{\\\"hiddenWebsites\\\":[],\\\"language\\\":\\\"en\\\",\\\"allowAd\\\":true,\\\"queuePaused\\\":false,\\\"desktopNotifications\\\":{\\\"enabled\\\":true,\\\"showOnPostSuccess\\\":true,\\\"showOnPostError\\\":true,\\\"showOnDirectoryWatcherError\\\":true,\\\"showOnDirectoryWatcherSuccess\\\":true},\\\"tagSearchProvider\\\":{\\\"showWikiInHelpOnHover\\\":false}}'\"\n        }\n      },\n      \"indexes\": {\n        \"settings_id_unique\": {\n          \"name\": \"settings_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        },\n        \"settings_profile_unique\": {\n          \"name\": \"settings_profile_unique\",\n          \"columns\": [\n            \"profile\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"submission-file\": {\n      \"name\": \"submission-file\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"submissionId\": {\n          \"name\": \"submissionId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"primaryFileId\": {\n          \"name\": \"primaryFileId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"thumbnailId\": {\n          \"name\": \"thumbnailId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"altFileId\": {\n          \"name\": \"altFileId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"fileName\": {\n          \"name\": \"fileName\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"hasAltFile\": {\n          \"name\": \"hasAltFile\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": false\n        },\n        \"hasCustomThumbnail\": {\n          \"name\": \"hasCustomThumbnail\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": false\n        },\n        \"hasThumbnail\": {\n          \"name\": \"hasThumbnail\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"hash\": {\n          \"name\": \"hash\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"height\": {\n          \"name\": \"height\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"mimeType\": {\n          \"name\": \"mimeType\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"size\": {\n          \"name\": \"size\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"width\": {\n          \"name\": \"width\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"metadata\": {\n          \"name\": \"metadata\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'{}'\"\n        },\n        \"order\": {\n          \"name\": \"order\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": 9007199254740991\n        }\n      },\n      \"indexes\": {\n        \"submission-file_id_unique\": {\n          \"name\": \"submission-file_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"submission-file_submissionId_submission_id_fk\": {\n          \"name\": \"submission-file_submissionId_submission_id_fk\",\n          \"tableFrom\": \"submission-file\",\n          \"tableTo\": \"submission\",\n          \"columnsFrom\": [\n            \"submissionId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        },\n        \"submission-file_primaryFileId_file-buffer_id_fk\": {\n          \"name\": \"submission-file_primaryFileId_file-buffer_id_fk\",\n          \"tableFrom\": \"submission-file\",\n          \"tableTo\": \"file-buffer\",\n          \"columnsFrom\": [\n            \"primaryFileId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"no action\",\n          \"onUpdate\": \"no action\"\n        },\n        \"submission-file_thumbnailId_file-buffer_id_fk\": {\n          \"name\": \"submission-file_thumbnailId_file-buffer_id_fk\",\n          \"tableFrom\": \"submission-file\",\n          \"tableTo\": \"file-buffer\",\n          \"columnsFrom\": [\n            \"thumbnailId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"no action\",\n          \"onUpdate\": \"no action\"\n        },\n        \"submission-file_altFileId_file-buffer_id_fk\": {\n          \"name\": \"submission-file_altFileId_file-buffer_id_fk\",\n          \"tableFrom\": \"submission-file\",\n          \"tableTo\": \"file-buffer\",\n          \"columnsFrom\": [\n            \"altFileId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"no action\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"submission\": {\n      \"name\": \"submission\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"isArchived\": {\n          \"name\": \"isArchived\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false,\n          \"default\": false\n        },\n        \"isMultiSubmission\": {\n          \"name\": \"isMultiSubmission\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"isScheduled\": {\n          \"name\": \"isScheduled\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"isTemplate\": {\n          \"name\": \"isTemplate\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"metadata\": {\n          \"name\": \"metadata\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"order\": {\n          \"name\": \"order\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"schedule\": {\n          \"name\": \"schedule\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"submission_id_unique\": {\n          \"name\": \"submission_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"tag-converter\": {\n      \"name\": \"tag-converter\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"convertTo\": {\n          \"name\": \"convertTo\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"tag\": {\n          \"name\": \"tag\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"tag-converter_id_unique\": {\n          \"name\": \"tag-converter_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        },\n        \"tag-converter_tag_unique\": {\n          \"name\": \"tag-converter_tag_unique\",\n          \"columns\": [\n            \"tag\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"tag-group\": {\n      \"name\": \"tag-group\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"tags\": {\n          \"name\": \"tags\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"tag-group_id_unique\": {\n          \"name\": \"tag-group_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        },\n        \"tag-group_name_unique\": {\n          \"name\": \"tag-group_name_unique\",\n          \"columns\": [\n            \"name\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"user-converter\": {\n      \"name\": \"user-converter\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"convertTo\": {\n          \"name\": \"convertTo\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"username\": {\n          \"name\": \"username\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"user-converter_id_unique\": {\n          \"name\": \"user-converter_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        },\n        \"user-converter_username_unique\": {\n          \"name\": \"user-converter_username_unique\",\n          \"columns\": [\n            \"username\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"user-specified-website-options\": {\n      \"name\": \"user-specified-website-options\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"accountId\": {\n          \"name\": \"accountId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"options\": {\n          \"name\": \"options\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"user-specified-website-options_id_unique\": {\n          \"name\": \"user-specified-website-options_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"user-specified-website-options_accountId_account_id_fk\": {\n          \"name\": \"user-specified-website-options_accountId_account_id_fk\",\n          \"tableFrom\": \"user-specified-website-options\",\n          \"tableTo\": \"account\",\n          \"columnsFrom\": [\n            \"accountId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"website-data\": {\n      \"name\": \"website-data\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"data\": {\n          \"name\": \"data\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'{}'\"\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"website-data_id_unique\": {\n          \"name\": \"website-data_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"website-data_id_account_id_fk\": {\n          \"name\": \"website-data_id_account_id_fk\",\n          \"tableFrom\": \"website-data\",\n          \"tableTo\": \"account\",\n          \"columnsFrom\": [\n            \"id\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"website-options\": {\n      \"name\": \"website-options\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"accountId\": {\n          \"name\": \"accountId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"submissionId\": {\n          \"name\": \"submissionId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"data\": {\n          \"name\": \"data\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"isDefault\": {\n          \"name\": \"isDefault\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"website-options_id_unique\": {\n          \"name\": \"website-options_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"website-options_accountId_account_id_fk\": {\n          \"name\": \"website-options_accountId_account_id_fk\",\n          \"tableFrom\": \"website-options\",\n          \"tableTo\": \"account\",\n          \"columnsFrom\": [\n            \"accountId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        },\n        \"website-options_submissionId_submission_id_fk\": {\n          \"name\": \"website-options_submissionId_submission_id_fk\",\n          \"tableFrom\": \"website-options\",\n          \"tableTo\": \"submission\",\n          \"columnsFrom\": [\n            \"submissionId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"website-post-record\": {\n      \"name\": \"website-post-record\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"accountId\": {\n          \"name\": \"accountId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"postRecordId\": {\n          \"name\": \"postRecordId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"completedAt\": {\n          \"name\": \"completedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"errors\": {\n          \"name\": \"errors\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"metadata\": {\n          \"name\": \"metadata\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"postData\": {\n          \"name\": \"postData\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"postResponse\": {\n          \"name\": \"postResponse\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"website-post-record_id_unique\": {\n          \"name\": \"website-post-record_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"website-post-record_accountId_account_id_fk\": {\n          \"name\": \"website-post-record_accountId_account_id_fk\",\n          \"tableFrom\": \"website-post-record\",\n          \"tableTo\": \"account\",\n          \"columnsFrom\": [\n            \"accountId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"set null\",\n          \"onUpdate\": \"no action\"\n        },\n        \"website-post-record_postRecordId_post-record_id_fk\": {\n          \"name\": \"website-post-record_postRecordId_post-record_id_fk\",\n          \"tableFrom\": \"website-post-record\",\n          \"tableTo\": \"post-record\",\n          \"columnsFrom\": [\n            \"postRecordId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {},\n    \"columns\": {}\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}"
  },
  {
    "path": "apps/postybirb/src/migrations/meta/0005_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"141755e7-aab5-4c52-b76c-f5aecc76273c\",\n  \"prevId\": \"63f0fd58-d6d0-4cb4-a098-73f8f2e8ee76\",\n  \"tables\": {\n    \"account\": {\n      \"name\": \"account\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"groups\": {\n          \"name\": \"groups\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"website\": {\n          \"name\": \"website\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"account_id_unique\": {\n          \"name\": \"account_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"custom-shortcut\": {\n      \"name\": \"custom-shortcut\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"shortcut\": {\n          \"name\": \"shortcut\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'[]'\"\n        }\n      },\n      \"indexes\": {\n        \"custom-shortcut_id_unique\": {\n          \"name\": \"custom-shortcut_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        },\n        \"custom-shortcut_name_unique\": {\n          \"name\": \"custom-shortcut_name_unique\",\n          \"columns\": [\n            \"name\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"directory-watcher\": {\n      \"name\": \"directory-watcher\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"templateId\": {\n          \"name\": \"templateId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"importAction\": {\n          \"name\": \"importAction\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'NEW_SUBMISSION'\"\n        },\n        \"path\": {\n          \"name\": \"path\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"directory-watcher_id_unique\": {\n          \"name\": \"directory-watcher_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"directory-watcher_templateId_submission_id_fk\": {\n          \"name\": \"directory-watcher_templateId_submission_id_fk\",\n          \"tableFrom\": \"directory-watcher\",\n          \"tableTo\": \"submission\",\n          \"columnsFrom\": [\n            \"templateId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"set null\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"file-buffer\": {\n      \"name\": \"file-buffer\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"submissionFileId\": {\n          \"name\": \"submissionFileId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"buffer\": {\n          \"name\": \"buffer\",\n          \"type\": \"blob\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"fileName\": {\n          \"name\": \"fileName\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"height\": {\n          \"name\": \"height\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"mimeType\": {\n          \"name\": \"mimeType\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"size\": {\n          \"name\": \"size\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"width\": {\n          \"name\": \"width\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"file-buffer_id_unique\": {\n          \"name\": \"file-buffer_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"file-buffer_submissionFileId_submission-file_id_fk\": {\n          \"name\": \"file-buffer_submissionFileId_submission-file_id_fk\",\n          \"tableFrom\": \"file-buffer\",\n          \"tableTo\": \"submission-file\",\n          \"columnsFrom\": [\n            \"submissionFileId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"notification\": {\n      \"name\": \"notification\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"message\": {\n          \"name\": \"message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"tags\": {\n          \"name\": \"tags\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"data\": {\n          \"name\": \"data\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"isRead\": {\n          \"name\": \"isRead\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": false\n        },\n        \"hasEmitted\": {\n          \"name\": \"hasEmitted\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"notification_id_unique\": {\n          \"name\": \"notification_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"post-queue\": {\n      \"name\": \"post-queue\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"postRecordId\": {\n          \"name\": \"postRecordId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"submissionId\": {\n          \"name\": \"submissionId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"post-queue_id_unique\": {\n          \"name\": \"post-queue_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"post-queue_postRecordId_post-record_id_fk\": {\n          \"name\": \"post-queue_postRecordId_post-record_id_fk\",\n          \"tableFrom\": \"post-queue\",\n          \"tableTo\": \"post-record\",\n          \"columnsFrom\": [\n            \"postRecordId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"set null\",\n          \"onUpdate\": \"no action\"\n        },\n        \"post-queue_submissionId_submission_id_fk\": {\n          \"name\": \"post-queue_submissionId_submission_id_fk\",\n          \"tableFrom\": \"post-queue\",\n          \"tableTo\": \"submission\",\n          \"columnsFrom\": [\n            \"submissionId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"post-record\": {\n      \"name\": \"post-record\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"submissionId\": {\n          \"name\": \"submissionId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"completedAt\": {\n          \"name\": \"completedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"resumeMode\": {\n          \"name\": \"resumeMode\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'CONTINUE'\"\n        },\n        \"state\": {\n          \"name\": \"state\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'PENDING'\"\n        }\n      },\n      \"indexes\": {\n        \"post-record_id_unique\": {\n          \"name\": \"post-record_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"post-record_submissionId_submission_id_fk\": {\n          \"name\": \"post-record_submissionId_submission_id_fk\",\n          \"tableFrom\": \"post-record\",\n          \"tableTo\": \"submission\",\n          \"columnsFrom\": [\n            \"submissionId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"settings\": {\n      \"name\": \"settings\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"profile\": {\n          \"name\": \"profile\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'default'\"\n        },\n        \"settings\": {\n          \"name\": \"settings\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'{\\\"hiddenWebsites\\\":[],\\\"language\\\":\\\"en\\\",\\\"allowAd\\\":true,\\\"queuePaused\\\":false,\\\"desktopNotifications\\\":{\\\"enabled\\\":true,\\\"showOnPostSuccess\\\":true,\\\"showOnPostError\\\":true,\\\"showOnDirectoryWatcherError\\\":true,\\\"showOnDirectoryWatcherSuccess\\\":true},\\\"tagSearchProvider\\\":{\\\"showWikiInHelpOnHover\\\":false}}'\"\n        }\n      },\n      \"indexes\": {\n        \"settings_id_unique\": {\n          \"name\": \"settings_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        },\n        \"settings_profile_unique\": {\n          \"name\": \"settings_profile_unique\",\n          \"columns\": [\n            \"profile\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"submission-file\": {\n      \"name\": \"submission-file\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"submissionId\": {\n          \"name\": \"submissionId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"primaryFileId\": {\n          \"name\": \"primaryFileId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"thumbnailId\": {\n          \"name\": \"thumbnailId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"altFileId\": {\n          \"name\": \"altFileId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"fileName\": {\n          \"name\": \"fileName\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"hasAltFile\": {\n          \"name\": \"hasAltFile\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": false\n        },\n        \"hasCustomThumbnail\": {\n          \"name\": \"hasCustomThumbnail\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": false\n        },\n        \"hasThumbnail\": {\n          \"name\": \"hasThumbnail\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"hash\": {\n          \"name\": \"hash\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"height\": {\n          \"name\": \"height\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"mimeType\": {\n          \"name\": \"mimeType\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"size\": {\n          \"name\": \"size\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"width\": {\n          \"name\": \"width\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"metadata\": {\n          \"name\": \"metadata\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'{}'\"\n        },\n        \"order\": {\n          \"name\": \"order\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": 9007199254740991\n        }\n      },\n      \"indexes\": {\n        \"submission-file_id_unique\": {\n          \"name\": \"submission-file_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"submission-file_submissionId_submission_id_fk\": {\n          \"name\": \"submission-file_submissionId_submission_id_fk\",\n          \"tableFrom\": \"submission-file\",\n          \"tableTo\": \"submission\",\n          \"columnsFrom\": [\n            \"submissionId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        },\n        \"submission-file_primaryFileId_file-buffer_id_fk\": {\n          \"name\": \"submission-file_primaryFileId_file-buffer_id_fk\",\n          \"tableFrom\": \"submission-file\",\n          \"tableTo\": \"file-buffer\",\n          \"columnsFrom\": [\n            \"primaryFileId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"no action\",\n          \"onUpdate\": \"no action\"\n        },\n        \"submission-file_thumbnailId_file-buffer_id_fk\": {\n          \"name\": \"submission-file_thumbnailId_file-buffer_id_fk\",\n          \"tableFrom\": \"submission-file\",\n          \"tableTo\": \"file-buffer\",\n          \"columnsFrom\": [\n            \"thumbnailId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"no action\",\n          \"onUpdate\": \"no action\"\n        },\n        \"submission-file_altFileId_file-buffer_id_fk\": {\n          \"name\": \"submission-file_altFileId_file-buffer_id_fk\",\n          \"tableFrom\": \"submission-file\",\n          \"tableTo\": \"file-buffer\",\n          \"columnsFrom\": [\n            \"altFileId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"no action\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"submission\": {\n      \"name\": \"submission\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"isArchived\": {\n          \"name\": \"isArchived\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false,\n          \"default\": false\n        },\n        \"isInitialized\": {\n          \"name\": \"isInitialized\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false,\n          \"default\": false\n        },\n        \"isMultiSubmission\": {\n          \"name\": \"isMultiSubmission\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"isScheduled\": {\n          \"name\": \"isScheduled\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"isTemplate\": {\n          \"name\": \"isTemplate\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"metadata\": {\n          \"name\": \"metadata\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"order\": {\n          \"name\": \"order\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"schedule\": {\n          \"name\": \"schedule\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"submission_id_unique\": {\n          \"name\": \"submission_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"tag-converter\": {\n      \"name\": \"tag-converter\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"convertTo\": {\n          \"name\": \"convertTo\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"tag\": {\n          \"name\": \"tag\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"tag-converter_id_unique\": {\n          \"name\": \"tag-converter_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        },\n        \"tag-converter_tag_unique\": {\n          \"name\": \"tag-converter_tag_unique\",\n          \"columns\": [\n            \"tag\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"tag-group\": {\n      \"name\": \"tag-group\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"tags\": {\n          \"name\": \"tags\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"tag-group_id_unique\": {\n          \"name\": \"tag-group_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        },\n        \"tag-group_name_unique\": {\n          \"name\": \"tag-group_name_unique\",\n          \"columns\": [\n            \"name\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"user-converter\": {\n      \"name\": \"user-converter\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"convertTo\": {\n          \"name\": \"convertTo\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"username\": {\n          \"name\": \"username\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"user-converter_id_unique\": {\n          \"name\": \"user-converter_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        },\n        \"user-converter_username_unique\": {\n          \"name\": \"user-converter_username_unique\",\n          \"columns\": [\n            \"username\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"user-specified-website-options\": {\n      \"name\": \"user-specified-website-options\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"accountId\": {\n          \"name\": \"accountId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"options\": {\n          \"name\": \"options\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"user-specified-website-options_id_unique\": {\n          \"name\": \"user-specified-website-options_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"user-specified-website-options_accountId_account_id_fk\": {\n          \"name\": \"user-specified-website-options_accountId_account_id_fk\",\n          \"tableFrom\": \"user-specified-website-options\",\n          \"tableTo\": \"account\",\n          \"columnsFrom\": [\n            \"accountId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"website-data\": {\n      \"name\": \"website-data\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"data\": {\n          \"name\": \"data\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'{}'\"\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"website-data_id_unique\": {\n          \"name\": \"website-data_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"website-data_id_account_id_fk\": {\n          \"name\": \"website-data_id_account_id_fk\",\n          \"tableFrom\": \"website-data\",\n          \"tableTo\": \"account\",\n          \"columnsFrom\": [\n            \"id\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"website-options\": {\n      \"name\": \"website-options\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"accountId\": {\n          \"name\": \"accountId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"submissionId\": {\n          \"name\": \"submissionId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"data\": {\n          \"name\": \"data\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"isDefault\": {\n          \"name\": \"isDefault\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"website-options_id_unique\": {\n          \"name\": \"website-options_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"website-options_accountId_account_id_fk\": {\n          \"name\": \"website-options_accountId_account_id_fk\",\n          \"tableFrom\": \"website-options\",\n          \"tableTo\": \"account\",\n          \"columnsFrom\": [\n            \"accountId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        },\n        \"website-options_submissionId_submission_id_fk\": {\n          \"name\": \"website-options_submissionId_submission_id_fk\",\n          \"tableFrom\": \"website-options\",\n          \"tableTo\": \"submission\",\n          \"columnsFrom\": [\n            \"submissionId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"website-post-record\": {\n      \"name\": \"website-post-record\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"accountId\": {\n          \"name\": \"accountId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"postRecordId\": {\n          \"name\": \"postRecordId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"completedAt\": {\n          \"name\": \"completedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"errors\": {\n          \"name\": \"errors\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"metadata\": {\n          \"name\": \"metadata\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"postData\": {\n          \"name\": \"postData\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"postResponse\": {\n          \"name\": \"postResponse\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"website-post-record_id_unique\": {\n          \"name\": \"website-post-record_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"website-post-record_accountId_account_id_fk\": {\n          \"name\": \"website-post-record_accountId_account_id_fk\",\n          \"tableFrom\": \"website-post-record\",\n          \"tableTo\": \"account\",\n          \"columnsFrom\": [\n            \"accountId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"set null\",\n          \"onUpdate\": \"no action\"\n        },\n        \"website-post-record_postRecordId_post-record_id_fk\": {\n          \"name\": \"website-post-record_postRecordId_post-record_id_fk\",\n          \"tableFrom\": \"website-post-record\",\n          \"tableTo\": \"post-record\",\n          \"columnsFrom\": [\n            \"postRecordId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {},\n    \"columns\": {}\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}"
  },
  {
    "path": "apps/postybirb/src/migrations/meta/0006_snapshot.json",
    "content": "{\n  \"version\": \"6\",\n  \"dialect\": \"sqlite\",\n  \"id\": \"e3d07928-f97f-408f-8c4c-25795f984ac3\",\n  \"prevId\": \"c4b2f2a9-3a4c-4e3f-a185-9b2023c630bd\",\n  \"tables\": {\n    \"account\": {\n      \"name\": \"account\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"groups\": {\n          \"name\": \"groups\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"website\": {\n          \"name\": \"website\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"account_id_unique\": {\n          \"name\": \"account_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"custom-shortcut\": {\n      \"name\": \"custom-shortcut\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"shortcut\": {\n          \"name\": \"shortcut\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'[]'\"\n        }\n      },\n      \"indexes\": {\n        \"custom-shortcut_id_unique\": {\n          \"name\": \"custom-shortcut_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        },\n        \"custom-shortcut_name_unique\": {\n          \"name\": \"custom-shortcut_name_unique\",\n          \"columns\": [\n            \"name\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"directory-watcher\": {\n      \"name\": \"directory-watcher\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"templateId\": {\n          \"name\": \"templateId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"importAction\": {\n          \"name\": \"importAction\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'NEW_SUBMISSION'\"\n        },\n        \"path\": {\n          \"name\": \"path\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"directory-watcher_id_unique\": {\n          \"name\": \"directory-watcher_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"directory-watcher_templateId_submission_id_fk\": {\n          \"name\": \"directory-watcher_templateId_submission_id_fk\",\n          \"tableFrom\": \"directory-watcher\",\n          \"tableTo\": \"submission\",\n          \"columnsFrom\": [\n            \"templateId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"set null\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"file-buffer\": {\n      \"name\": \"file-buffer\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"submissionFileId\": {\n          \"name\": \"submissionFileId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"buffer\": {\n          \"name\": \"buffer\",\n          \"type\": \"blob\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"fileName\": {\n          \"name\": \"fileName\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"height\": {\n          \"name\": \"height\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"mimeType\": {\n          \"name\": \"mimeType\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"size\": {\n          \"name\": \"size\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"width\": {\n          \"name\": \"width\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"file-buffer_id_unique\": {\n          \"name\": \"file-buffer_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"file-buffer_submissionFileId_submission-file_id_fk\": {\n          \"name\": \"file-buffer_submissionFileId_submission-file_id_fk\",\n          \"tableFrom\": \"file-buffer\",\n          \"tableTo\": \"submission-file\",\n          \"columnsFrom\": [\n            \"submissionFileId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"notification\": {\n      \"name\": \"notification\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"title\": {\n          \"name\": \"title\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"message\": {\n          \"name\": \"message\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"tags\": {\n          \"name\": \"tags\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"data\": {\n          \"name\": \"data\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"isRead\": {\n          \"name\": \"isRead\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": false\n        },\n        \"hasEmitted\": {\n          \"name\": \"hasEmitted\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"notification_id_unique\": {\n          \"name\": \"notification_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"post-event\": {\n      \"name\": \"post-event\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"postRecordId\": {\n          \"name\": \"postRecordId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"accountId\": {\n          \"name\": \"accountId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"eventType\": {\n          \"name\": \"eventType\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"fileId\": {\n          \"name\": \"fileId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"sourceUrl\": {\n          \"name\": \"sourceUrl\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"error\": {\n          \"name\": \"error\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"metadata\": {\n          \"name\": \"metadata\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"post-event_id_unique\": {\n          \"name\": \"post-event_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        },\n        \"idx_post_event_type\": {\n          \"name\": \"idx_post_event_type\",\n          \"columns\": [\n            \"postRecordId\",\n            \"eventType\"\n          ],\n          \"isUnique\": false\n        },\n        \"idx_post_event_account\": {\n          \"name\": \"idx_post_event_account\",\n          \"columns\": [\n            \"postRecordId\",\n            \"accountId\",\n            \"eventType\"\n          ],\n          \"isUnique\": false\n        }\n      },\n      \"foreignKeys\": {\n        \"post-event_postRecordId_post-record_id_fk\": {\n          \"name\": \"post-event_postRecordId_post-record_id_fk\",\n          \"tableFrom\": \"post-event\",\n          \"tableTo\": \"post-record\",\n          \"columnsFrom\": [\n            \"postRecordId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        },\n        \"post-event_accountId_account_id_fk\": {\n          \"name\": \"post-event_accountId_account_id_fk\",\n          \"tableFrom\": \"post-event\",\n          \"tableTo\": \"account\",\n          \"columnsFrom\": [\n            \"accountId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"set null\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"post-queue\": {\n      \"name\": \"post-queue\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"postRecordId\": {\n          \"name\": \"postRecordId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"submissionId\": {\n          \"name\": \"submissionId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"post-queue_id_unique\": {\n          \"name\": \"post-queue_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"post-queue_postRecordId_post-record_id_fk\": {\n          \"name\": \"post-queue_postRecordId_post-record_id_fk\",\n          \"tableFrom\": \"post-queue\",\n          \"tableTo\": \"post-record\",\n          \"columnsFrom\": [\n            \"postRecordId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"set null\",\n          \"onUpdate\": \"no action\"\n        },\n        \"post-queue_submissionId_submission_id_fk\": {\n          \"name\": \"post-queue_submissionId_submission_id_fk\",\n          \"tableFrom\": \"post-queue\",\n          \"tableTo\": \"submission\",\n          \"columnsFrom\": [\n            \"submissionId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"post-record\": {\n      \"name\": \"post-record\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"submissionId\": {\n          \"name\": \"submissionId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"originPostRecordId\": {\n          \"name\": \"originPostRecordId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"completedAt\": {\n          \"name\": \"completedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"resumeMode\": {\n          \"name\": \"resumeMode\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'CONTINUE'\"\n        },\n        \"state\": {\n          \"name\": \"state\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'PENDING'\"\n        }\n      },\n      \"indexes\": {\n        \"post-record_id_unique\": {\n          \"name\": \"post-record_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"post-record_submissionId_submission_id_fk\": {\n          \"name\": \"post-record_submissionId_submission_id_fk\",\n          \"tableFrom\": \"post-record\",\n          \"tableTo\": \"submission\",\n          \"columnsFrom\": [\n            \"submissionId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        },\n        \"post-record_originPostRecordId_post-record_id_fk\": {\n          \"name\": \"post-record_originPostRecordId_post-record_id_fk\",\n          \"tableFrom\": \"post-record\",\n          \"tableTo\": \"post-record\",\n          \"columnsFrom\": [\n            \"originPostRecordId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"set null\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"settings\": {\n      \"name\": \"settings\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"profile\": {\n          \"name\": \"profile\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'default'\"\n        },\n        \"settings\": {\n          \"name\": \"settings\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'{\\\"hiddenWebsites\\\":[],\\\"language\\\":\\\"en\\\",\\\"allowAd\\\":true,\\\"queuePaused\\\":false,\\\"desktopNotifications\\\":{\\\"enabled\\\":true,\\\"showOnPostSuccess\\\":true,\\\"showOnPostError\\\":true,\\\"showOnDirectoryWatcherError\\\":true,\\\"showOnDirectoryWatcherSuccess\\\":true},\\\"tagSearchProvider\\\":{\\\"showWikiInHelpOnHover\\\":false}}'\"\n        }\n      },\n      \"indexes\": {\n        \"settings_id_unique\": {\n          \"name\": \"settings_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        },\n        \"settings_profile_unique\": {\n          \"name\": \"settings_profile_unique\",\n          \"columns\": [\n            \"profile\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"submission-file\": {\n      \"name\": \"submission-file\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"submissionId\": {\n          \"name\": \"submissionId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"primaryFileId\": {\n          \"name\": \"primaryFileId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"thumbnailId\": {\n          \"name\": \"thumbnailId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"altFileId\": {\n          \"name\": \"altFileId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false\n        },\n        \"fileName\": {\n          \"name\": \"fileName\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"hasAltFile\": {\n          \"name\": \"hasAltFile\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": false\n        },\n        \"hasCustomThumbnail\": {\n          \"name\": \"hasCustomThumbnail\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": false\n        },\n        \"hasThumbnail\": {\n          \"name\": \"hasThumbnail\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"hash\": {\n          \"name\": \"hash\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"height\": {\n          \"name\": \"height\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"mimeType\": {\n          \"name\": \"mimeType\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"size\": {\n          \"name\": \"size\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"width\": {\n          \"name\": \"width\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"metadata\": {\n          \"name\": \"metadata\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'{}'\"\n        },\n        \"order\": {\n          \"name\": \"order\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": 9007199254740991\n        }\n      },\n      \"indexes\": {\n        \"submission-file_id_unique\": {\n          \"name\": \"submission-file_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"submission-file_submissionId_submission_id_fk\": {\n          \"name\": \"submission-file_submissionId_submission_id_fk\",\n          \"tableFrom\": \"submission-file\",\n          \"tableTo\": \"submission\",\n          \"columnsFrom\": [\n            \"submissionId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        },\n        \"submission-file_primaryFileId_file-buffer_id_fk\": {\n          \"name\": \"submission-file_primaryFileId_file-buffer_id_fk\",\n          \"tableFrom\": \"submission-file\",\n          \"tableTo\": \"file-buffer\",\n          \"columnsFrom\": [\n            \"primaryFileId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"no action\",\n          \"onUpdate\": \"no action\"\n        },\n        \"submission-file_thumbnailId_file-buffer_id_fk\": {\n          \"name\": \"submission-file_thumbnailId_file-buffer_id_fk\",\n          \"tableFrom\": \"submission-file\",\n          \"tableTo\": \"file-buffer\",\n          \"columnsFrom\": [\n            \"thumbnailId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"no action\",\n          \"onUpdate\": \"no action\"\n        },\n        \"submission-file_altFileId_file-buffer_id_fk\": {\n          \"name\": \"submission-file_altFileId_file-buffer_id_fk\",\n          \"tableFrom\": \"submission-file\",\n          \"tableTo\": \"file-buffer\",\n          \"columnsFrom\": [\n            \"altFileId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"no action\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"submission\": {\n      \"name\": \"submission\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"isArchived\": {\n          \"name\": \"isArchived\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false,\n          \"default\": false\n        },\n        \"isInitialized\": {\n          \"name\": \"isInitialized\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": false,\n          \"autoincrement\": false,\n          \"default\": false\n        },\n        \"isMultiSubmission\": {\n          \"name\": \"isMultiSubmission\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"isScheduled\": {\n          \"name\": \"isScheduled\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"isTemplate\": {\n          \"name\": \"isTemplate\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"metadata\": {\n          \"name\": \"metadata\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"order\": {\n          \"name\": \"order\",\n          \"type\": \"real\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"schedule\": {\n          \"name\": \"schedule\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"submission_id_unique\": {\n          \"name\": \"submission_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"tag-converter\": {\n      \"name\": \"tag-converter\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"convertTo\": {\n          \"name\": \"convertTo\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"tag\": {\n          \"name\": \"tag\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"tag-converter_id_unique\": {\n          \"name\": \"tag-converter_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        },\n        \"tag-converter_tag_unique\": {\n          \"name\": \"tag-converter_tag_unique\",\n          \"columns\": [\n            \"tag\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"tag-group\": {\n      \"name\": \"tag-group\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"name\": {\n          \"name\": \"name\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"tags\": {\n          \"name\": \"tags\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"tag-group_id_unique\": {\n          \"name\": \"tag-group_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        },\n        \"tag-group_name_unique\": {\n          \"name\": \"tag-group_name_unique\",\n          \"columns\": [\n            \"name\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"user-converter\": {\n      \"name\": \"user-converter\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"convertTo\": {\n          \"name\": \"convertTo\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"username\": {\n          \"name\": \"username\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"user-converter_id_unique\": {\n          \"name\": \"user-converter_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        },\n        \"user-converter_username_unique\": {\n          \"name\": \"user-converter_username_unique\",\n          \"columns\": [\n            \"username\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {},\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"user-specified-website-options\": {\n      \"name\": \"user-specified-website-options\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"accountId\": {\n          \"name\": \"accountId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"options\": {\n          \"name\": \"options\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"type\": {\n          \"name\": \"type\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"user-specified-website-options_id_unique\": {\n          \"name\": \"user-specified-website-options_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"user-specified-website-options_accountId_account_id_fk\": {\n          \"name\": \"user-specified-website-options_accountId_account_id_fk\",\n          \"tableFrom\": \"user-specified-website-options\",\n          \"tableTo\": \"account\",\n          \"columnsFrom\": [\n            \"accountId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"website-data\": {\n      \"name\": \"website-data\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"data\": {\n          \"name\": \"data\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false,\n          \"default\": \"'{}'\"\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"website-data_id_unique\": {\n          \"name\": \"website-data_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"website-data_id_account_id_fk\": {\n          \"name\": \"website-data_id_account_id_fk\",\n          \"tableFrom\": \"website-data\",\n          \"tableTo\": \"account\",\n          \"columnsFrom\": [\n            \"id\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    },\n    \"website-options\": {\n      \"name\": \"website-options\",\n      \"columns\": {\n        \"id\": {\n          \"name\": \"id\",\n          \"type\": \"text\",\n          \"primaryKey\": true,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"createdAt\": {\n          \"name\": \"createdAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"updatedAt\": {\n          \"name\": \"updatedAt\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"accountId\": {\n          \"name\": \"accountId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"submissionId\": {\n          \"name\": \"submissionId\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"data\": {\n          \"name\": \"data\",\n          \"type\": \"text\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        },\n        \"isDefault\": {\n          \"name\": \"isDefault\",\n          \"type\": \"integer\",\n          \"primaryKey\": false,\n          \"notNull\": true,\n          \"autoincrement\": false\n        }\n      },\n      \"indexes\": {\n        \"website-options_id_unique\": {\n          \"name\": \"website-options_id_unique\",\n          \"columns\": [\n            \"id\"\n          ],\n          \"isUnique\": true\n        }\n      },\n      \"foreignKeys\": {\n        \"website-options_accountId_account_id_fk\": {\n          \"name\": \"website-options_accountId_account_id_fk\",\n          \"tableFrom\": \"website-options\",\n          \"tableTo\": \"account\",\n          \"columnsFrom\": [\n            \"accountId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        },\n        \"website-options_submissionId_submission_id_fk\": {\n          \"name\": \"website-options_submissionId_submission_id_fk\",\n          \"tableFrom\": \"website-options\",\n          \"tableTo\": \"submission\",\n          \"columnsFrom\": [\n            \"submissionId\"\n          ],\n          \"columnsTo\": [\n            \"id\"\n          ],\n          \"onDelete\": \"cascade\",\n          \"onUpdate\": \"no action\"\n        }\n      },\n      \"compositePrimaryKeys\": {},\n      \"uniqueConstraints\": {},\n      \"checkConstraints\": {}\n    }\n  },\n  \"views\": {},\n  \"enums\": {},\n  \"_meta\": {\n    \"schemas\": {},\n    \"tables\": {},\n    \"columns\": {}\n  },\n  \"internal\": {\n    \"indexes\": {}\n  }\n}"
  },
  {
    "path": "apps/postybirb/src/migrations/meta/_journal.json",
    "content": "{\n  \"version\": \"7\",\n  \"dialect\": \"sqlite\",\n  \"entries\": [\n    {\n      \"idx\": 0,\n      \"version\": \"6\",\n      \"when\": 1749644079670,\n      \"tag\": \"0000_tough_ken_ellis\",\n      \"breakpoints\": true\n    },\n    {\n      \"idx\": 1,\n      \"version\": \"6\",\n      \"when\": 1756210703918,\n      \"tag\": \"0001_noisy_kate_bishop\",\n      \"breakpoints\": true\n    },\n    {\n      \"idx\": 2,\n      \"version\": \"6\",\n      \"when\": 1758887195072,\n      \"tag\": \"0002_pretty_sunfire\",\n      \"breakpoints\": true\n    },\n    {\n      \"idx\": 3,\n      \"version\": \"6\",\n      \"when\": 1762259248246,\n      \"tag\": \"0003_glamorous_power_pack\",\n      \"breakpoints\": true\n    },\n    {\n      \"idx\": 4,\n      \"version\": \"6\",\n      \"when\": 1762397892994,\n      \"tag\": \"0004_fuzzy_rafael_vega\",\n      \"breakpoints\": true\n    },\n    {\n      \"idx\": 5,\n      \"version\": \"6\",\n      \"when\": 1765890133538,\n      \"tag\": \"0005_exotic_nebula\",\n      \"breakpoints\": true\n    },\n    {\n      \"idx\": 6,\n      \"version\": \"6\",\n      \"when\": 1768396981329,\n      \"tag\": \"0006_cooing_songbird\",\n      \"breakpoints\": true\n    }\n  ]\n}"
  },
  {
    "path": "apps/postybirb/tsconfig.app.json",
    "content": "{\n  \"extends\": \"./tsconfig.json\",\n  \"compilerOptions\": {\n    \"outDir\": \"../../dist/out-tsc\",\n    \"module\": \"commonjs\",\n    \"types\": [\"node\"],\n    \"emitDecoratorMetadata\": true,\n    \"target\": \"es2021\"\n  },\n  \"exclude\": [\"**/*.spec.ts\", \"**/*.test.ts\", \"jest.config.ts\"],\n  \"include\": [\"**/*.ts\"]\n}\n"
  },
  {
    "path": "apps/postybirb/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.base.json\",\n  \"files\": [],\n  \"include\": [],\n  \"references\": [\n    {\n      \"path\": \"./tsconfig.app.json\"\n    },\n    {\n      \"path\": \"./tsconfig.spec.json\"\n    }\n  ]\n}\n"
  },
  {
    "path": "apps/postybirb/tsconfig.spec.json",
    "content": "{\n  \"extends\": \"./tsconfig.json\",\n  \"compilerOptions\": {\n    \"outDir\": \"../../dist/out-tsc\",\n    \"module\": \"commonjs\",\n    \"types\": [\"jest\", \"node\"]\n  },\n  \"include\": [\"**/*.spec.ts\", \"**/*.test.ts\", \"**/*.d.ts\", \"jest.config.ts\"]\n}\n"
  },
  {
    "path": "apps/postybirb-cloud-server/.gitignore",
    "content": "node_modules\ndist"
  },
  {
    "path": "apps/postybirb-cloud-server/host.json",
    "content": "{\n  \"version\": \"2.0\",\n  \"extensionBundle\": {\n    \"id\": \"Microsoft.Azure.Functions.ExtensionBundle\",\n    \"version\": \"[4.*, 5.0.0)\"\n  }\n}\n"
  },
  {
    "path": "apps/postybirb-cloud-server/local.settings.json",
    "content": "{\n  \"IsEncrypted\": false,\n  \"Values\": {\n    \"AzureWebJobsStorage\": \"UseDevelopmentStorage=true\",\n    \"FUNCTIONS_WORKER_RUNTIME\": \"node\",\n    \"BLOB_CONNECTION_STRING\": \"\"\n  }\n}\n"
  },
  {
    "path": "apps/postybirb-cloud-server/package.json",
    "content": "{\n  \"name\": \"postybirb-cloud-server\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"main\": \"dist/functions/*.js\",\n  \"scripts\": {\n    \"build\": \"tsc\",\n    \"start\": \"func start\",\n    \"deploy\": \"func azure functionapp publish postybirb\"\n  },\n  \"dependencies\": {\n    \"@azure/functions\": \"^4.0.0\",\n    \"@azure/storage-blob\": \"^12.0.0\"\n  },\n  \"devDependencies\": {\n    \"typescript\": \"^5.0.0\"\n  }\n}\n"
  },
  {
    "path": "apps/postybirb-cloud-server/src/functions/upload.ts",
    "content": "import {\n    app,\n    HttpRequest,\n    HttpResponseInit,\n    InvocationContext,\n} from '@azure/functions';\nimport { BlobServiceClient } from '@azure/storage-blob';\nimport { createHash } from 'crypto';\n\nconst CONTAINER_NAME = 'instagram';\nconst MAX_FILE_SIZE = 30 * 1024 * 1024; // 30 MB\n\n// In-memory per-IP rate limiter\nconst rateLimitMap = new Map<string, { count: number; resetTime: number }>();\nconst RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute\nconst RATE_LIMIT_MAX = 20; // 20 uploads per minute per IP\n\n// Clean up stale entries every 5 minutes to prevent memory leak\nsetInterval(\n  () => {\n    const now = Date.now();\n    for (const [ip, entry] of rateLimitMap) {\n      if (now > entry.resetTime) {\n        rateLimitMap.delete(ip);\n      }\n    }\n  },\n  5 * 60 * 1000,\n);\n\nfunction getRateLimitInfo(ip: string): {\n  limited: boolean;\n  remaining: number;\n  retryAfterMs: number;\n} {\n  const now = Date.now();\n  const entry = rateLimitMap.get(ip);\n\n  if (!entry || now > entry.resetTime) {\n    rateLimitMap.set(ip, {\n      count: 1,\n      resetTime: now + RATE_LIMIT_WINDOW_MS,\n    });\n    return { limited: false, remaining: RATE_LIMIT_MAX - 1, retryAfterMs: 0 };\n  }\n\n  entry.count++;\n  if (entry.count > RATE_LIMIT_MAX) {\n    const retryAfterMs = entry.resetTime - now;\n    return { limited: true, remaining: 0, retryAfterMs };\n  }\n\n  return {\n    limited: false,\n    remaining: RATE_LIMIT_MAX - entry.count,\n    retryAfterMs: 0,\n  };\n}\n\nfunction getContainerClient() {\n  const connectionString = process.env.BLOB_CONNECTION_STRING;\n  if (!connectionString) {\n    throw new Error('BLOB_CONNECTION_STRING not configured');\n  }\n  const blobServiceClient =\n    BlobServiceClient.fromConnectionString(connectionString);\n  return blobServiceClient.getContainerClient(CONTAINER_NAME);\n}\n\nasync function upload(\n  request: HttpRequest,\n  context: InvocationContext,\n): Promise<HttpResponseInit> {\n  try {\n    // Rate limit by IP\n    const clientIp =\n      request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||\n      request.headers.get('x-azure-clientip') ||\n      'unknown';\n    const rateLimit = getRateLimitInfo(clientIp);\n\n    if (rateLimit.limited) {\n      return {\n        status: 429,\n        headers: {\n          'Retry-After': String(Math.ceil(rateLimit.retryAfterMs / 1000)),\n          'X-RateLimit-Remaining': '0',\n        },\n        jsonBody: { error: 'Too many requests. Try again later.' },\n      };\n    }\n\n    // Read body\n    const body = await request.arrayBuffer();\n    if (!body || body.byteLength === 0) {\n      return { status: 400, jsonBody: { error: 'No file provided' } };\n    }\n    if (body.byteLength > MAX_FILE_SIZE) {\n      return { status: 400, jsonBody: { error: 'File too large' } };\n    }\n\n    const mimeType = request.headers.get('content-type') || 'image/jpeg';\n    const ext = mimeType === 'image/png' ? 'png' : 'jpg';\n    const buffer = Buffer.from(body);\n    const hash = createHash('sha256').update(buffer).digest('hex');\n    const blobName = `${hash}.${ext}`;\n\n    const containerClient = getContainerClient();\n    await containerClient.createIfNotExists({ access: 'blob' });\n\n    const blockBlobClient = containerClient.getBlockBlobClient(blobName);\n\n    // Check if blob already exists — skip upload if duplicate\n    const exists = await blockBlobClient.exists();\n    if (exists) {\n      context.log(`Blob already exists (dedup): ${blobName}`);\n      return {\n        status: 200,\n        headers: {\n          'X-RateLimit-Remaining': String(rateLimit.remaining),\n        },\n        jsonBody: {\n          url: blockBlobClient.url,\n          blobName,\n        },\n      };\n    }\n\n    await blockBlobClient.uploadData(buffer, {\n      blobHTTPHeaders: { blobContentType: mimeType },\n    });\n\n    context.log(`Uploaded blob: ${blobName}`);\n\n    return {\n      status: 200,\n      headers: {\n        'X-RateLimit-Remaining': String(rateLimit.remaining),\n      },\n      jsonBody: {\n        url: blockBlobClient.url,\n        blobName,\n      },\n    };\n  } catch (e) {\n    context.error('Upload failed', e);\n    return {\n      status: 500,\n      jsonBody: { error: 'Internal server error' },\n    };\n  }\n}\n\napp.http('upload', {\n  methods: ['POST'],\n  authLevel: 'anonymous',\n  route: 'upload',\n  handler: upload,\n});\n"
  },
  {
    "path": "apps/postybirb-cloud-server/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"module\": \"Node16\",\n    \"target\": \"ES2022\",\n    \"moduleResolution\": \"Node16\",\n    \"outDir\": \"dist\",\n    \"rootDir\": \"src\",\n    \"strict\": true,\n    \"esModuleInterop\": true,\n    \"skipLibCheck\": true,\n    \"declaration\": true,\n    \"types\": [\"node\"]\n  },\n  \"include\": [\"src/**/*.ts\"]\n}\n"
  },
  {
    "path": "apps/postybirb-ui/.eslintrc.json",
    "content": "{\n  \"extends\": [\"plugin:@nx/react\", \"../../.eslintrc.js\"],\n  \"ignorePatterns\": [\"!**/*\"],\n  \"overrides\": [\n    {\n      \"files\": [\"*.ts\", \"*.tsx\", \"*.js\", \"*.jsx\"],\n      \"plugins\": [\"lingui\"],\n      \"rules\": {\n        \"react/require-default-props\": \"off\",\n        \"no-restricted-syntax\": \"off\",\n        \"lingui/no-unlocalized-strings\": \"error\",\n        \"lingui/t-call-in-function\": \"error\",\n        \"lingui/no-single-variables-to-translate\": \"error\",\n        \"lingui/no-expression-in-message\": \"error\",\n        \"lingui/no-single-tag-to-translate\": \"error\",\n        \"lingui/no-trans-inside-trans\": \"error\",\n        \"lingui/text-restrictions\": [\n          \"error\",\n          {\n            \"rules\": [\n              {\n                \"patterns\": [\"''\", \"`\", \"“\"],\n                \"message\": \"Don't use '', ` or “ in text\"\n              }\n            ]\n          }\n        ]\n      }\n    },\n    {\n      \"files\": [\"*.ts\", \"*.tsx\"],\n      \"rules\": {}\n    },\n    {\n      \"files\": [\"*.js\", \"*.jsx\"],\n      \"rules\": {}\n    }\n  ]\n}\n"
  },
  {
    "path": "apps/postybirb-ui/index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <title>PostyBirb</title>\n    <base href=\"./\" />\n\n    <!-- <meta http-equiv=\"Content-Security-Policy\" content=\"%VITE_CSP%\"> -->\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n\n    <script type=\"application/javascript\">\n      global = globalThis;\n      window.electron = {\n        getAppVersion: () => '0',\n        platform: 'Web',\n        app_port: location.port,\n        app_version: '0.0',\n      };\n    </script>\n  </head>\n\n  <body>\n    <div id=\"root\"></div>\n    <script type=\"module\" src=\"/src/main.tsx\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "apps/postybirb-ui/jest.config.ts",
    "content": "/* eslint-disable */\nexport default {\n  displayName: 'postybirb-ui',\n  preset: '../../jest.preset.js',\n  moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],\n  coverageDirectory: '../../coverage/apps/postybirb-ui',\n};\n"
  },
  {
    "path": "apps/postybirb-ui/postcss.config.js",
    "content": "const { join } = require('path');\n\nmodule.exports = {\n  plugins: {\n    tailwindcss: { config: join(__dirname, 'tailwind.config.js') },\n    autoprefixer: {},\n    'postcss-import': {},\n    'postcss-preset-mantine': {},\n    'postcss-simple-vars': {\n      variables: {\n        'mantine-breakpoint-xs': '36em',\n        'mantine-breakpoint-sm': '48em',\n        'mantine-breakpoint-md': '62em',\n        'mantine-breakpoint-lg': '75em',\n        'mantine-breakpoint-xl': '88em',\n      },\n    },\n  },\n};\n"
  },
  {
    "path": "apps/postybirb-ui/project.json",
    "content": "{\n  \"name\": \"postybirb-ui\",\n  \"$schema\": \"../../node_modules/nx/schemas/project-schema.json\",\n  \"sourceRoot\": \"apps/postybirb-ui/src\",\n  \"projectType\": \"application\",\n  \"tags\": [],\n  \"targets\": {\n    \"build\": {\n      \"executor\": \"@nx/vite:build\",\n      \"outputs\": [\"{options.outputPath}\"],\n      \"defaultConfiguration\": \"production\",\n      \"options\": {\n        \"outputPath\": \"dist/apps/postybirb-ui\"\n      },\n      \"configurations\": {\n        \"development\": {\n          \"extractLicenses\": false,\n          \"optimization\": false,\n          \"sourceMap\": true,\n          \"vendorChunk\": true\n        },\n        \"production\": {\n          \"fileReplacements\": [\n            {\n              \"replace\": \"apps/postybirb-ui/src/environments/environment.ts\",\n              \"with\": \"apps/postybirb-ui/src/environments/environment.prod.ts\"\n            }\n          ],\n          \"optimization\": true,\n          \"outputHashing\": \"all\",\n          \"sourceMap\": false,\n          \"namedChunks\": false,\n          \"extractLicenses\": true,\n          \"vendorChunk\": false\n        }\n      }\n    },\n    \"serve\": {\n      \"executor\": \"@nx/vite:dev-server\",\n      \"defaultConfiguration\": \"development\",\n      \"options\": {\n        \"buildTarget\": \"postybirb-ui:build\",\n        \"hmr\": true\n      },\n      \"configurations\": {\n        \"development\": {\n          \"buildTarget\": \"postybirb-ui:build:development\"\n        },\n        \"production\": {\n          \"buildTarget\": \"postybirb-ui:build:production\",\n          \"hmr\": false\n        }\n      }\n    },\n    \"lint\": {\n      \"executor\": \"@nx/eslint:lint\",\n      \"outputs\": [\"{options.outputFile}\"]\n    },\n    \"test\": {\n      \"executor\": \"@nx/jest:jest\",\n      \"outputs\": [\"{workspaceRoot}/coverage/apps/postybirb-ui\"],\n      \"options\": {\n        \"jestConfig\": \"apps/postybirb-ui/jest.config.ts\"\n      }\n    },\n    \"preview\": {\n      \"executor\": \"@nx/vite:preview-server\",\n      \"defaultConfiguration\": \"development\",\n      \"options\": {\n        \"buildTarget\": \"postybirb-ui:build\"\n      },\n      \"configurations\": {\n        \"development\": {\n          \"buildTarget\": \"postybirb-ui:build:development\"\n        },\n        \"production\": {\n          \"buildTarget\": \"postybirb-ui:build:production\"\n        }\n      },\n      \"dependsOn\": [\"build\"]\n    },\n    \"typecheck\": {\n      \"executor\": \"nx:run-commands\",\n      \"options\": {\n        \"command\": \"tsc -b {projectRoot}/tsconfig.json --incremental --pretty\"\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/README.md",
    "content": "# PostyBirb UI - Remake\n\nThis folder contains the redesigned PostyBirb UI built with React, Mantine UI, and Zustand for state management.\n\n## Folder Structure\n\n```\nremake/\n├── api/                    # API client modules\n├── components/             # React components\n│   ├── dialogs/            # Modal dialogs (e.g., Settings)\n│   ├── drawers/            # Side drawers (e.g., Accounts, Notifications)\n│   ├── error-boundary/     # Error boundary components\n│   └── layout/             # Core layout components\n├── config/                 # Configuration (keybindings, nav items)\n├── hooks/                  # Custom React hooks\n├── providers/              # React context providers\n├── routes/                 # Route definitions and page components\n│   └── pages/              # Page-level components\n├── stores/                 # Zustand stores\n│   └── records/            # Record wrapper classes for DTOs\n├── styles/                 # Global CSS styles\n├── theme/                  # Mantine theme configuration\n├── transports/             # HTTP client and WebSocket utilities\n└── types/                  # TypeScript type definitions\n```\n\n## Key Concepts\n\n### State Management (Zustand)\n\nAll entity data is managed through Zustand stores located in `stores/`. The stores follow a consistent pattern using `createEntityStore()`:\n\n```typescript\n// Example usage\nconst useAccountStore = createEntityStore<AccountDto, AccountRecord>(\n  fetchAccounts,\n  (dto) => new AccountRecord(dto),\n  { storeName: 'AccountStore', websocketEvent: ACCOUNT_UPDATES }\n);\n```\n\n**Key stores:**\n\n- `account-store.ts` - Website accounts and login state\n- `submission-store.ts` - Submissions (file, message, templates)\n- `settings-store.ts` - Application settings (single record)\n- `notification-store.ts` - System notifications\n- `ui-store.ts` - UI state (drawers, sidenav, filters)\n\n### Record Classes\n\nDTOs from the API are wrapped in Record classes (`stores/records/`) that provide:\n\n- Type-safe property access\n- Computed/derived properties\n- Utility methods for common operations\n- `toDto()` for API updates\n\n```typescript\n// Example\nconst account = useAccount(id);\naccount.isLoggedIn;        // Computed property\naccount.displayName;       // Fallback logic built-in\naccount.toDto();           // Convert back to DTO\n```\n\n### Store Initialization\n\nStores are initialized at app startup via `useInitializeStores()` hook in the root component. This loads all entity data in parallel and sets up WebSocket listeners for real-time updates.\n\n```typescript\n// In App component\nconst { isLoading, error } = useInitializeStores();\n```\n\n### CSS Naming Convention\n\nGlobal CSS classes follow the `postybirb__snake_case` naming pattern:\n\n```css\n/* Base class */\n.postybirb__sidenav {\n}\n\n/* Modifier state (use -- suffix) */\n.postybirb__sidenav--collapsed {\n}\n\n/* Child elements (use _ separator) */\n.postybirb__sidenav_header {\n}\n.postybirb__sidenav_nav {\n}\n```\n\nCSS Modules are used for component-specific styles (e.g., `settings-dialog.module.css`).\n\n### Layout System\n\nThe app uses a custom flexbox layout (not Mantine AppShell) for full control:\n\n- **SideNav**: Fixed left navigation (collapsible, 280px → 60px)\n- **Main**: Content area with margin adjusted for sidenav\n- **SubNavBar**: Optional horizontal contextual navigation\n- **ContentNavbar**: Title, pagination, and action buttons\n- **ContentArea**: Scrollable content container\n\nLayout state is managed in `ui-store.ts`:\n\n```typescript\nconst collapsed = useSidenavCollapsed();\nconst { setSidenavCollapsed } = useToggleSidenav();\n```\n\n### API Layer\n\nAPI modules in `api/` extend `BaseApi` or use `HttpClient` directly:\n\n```typescript\nclass AccountApi extends BaseApi<AccountDto> {\n  constructor() {\n    super('account');\n  }\n\n  // Custom methods\n  login(id: EntityId) {\n    return this.client.post<AccountDto>(`${id}/login`);\n  }\n}\n```\n\nThe `HttpClient` (`transports/http-client.ts`) provides:\n\n- Automatic retry with exponential backoff\n- Remote mode support (client connecting to host)\n- Consistent error handling\n\n### Drawer/Dialog System\n\nDrawers and dialogs are controlled via `ui-store.ts`:\n\n```typescript\n// Open a drawer\nconst { openDrawer, closeDrawer } = useDrawerActions();\nopenDrawer('settings');\n\n// Check active drawer\nconst activeDrawer = useActiveDrawer();\n```\n\nAvailable drawer keys: `'settings'`, `'accounts'`, `'notifications'`, `'tag-groups'`, `'tag-converters'`, `'user-converters'`, `'custom-shortcuts'`\n\n### Internationalization (i18n)\n\nUses Lingui for translations:\n\n```typescript\nimport { Trans } from '@lingui/react/macro';\n\n// In JSX\n<Trans>Settings</Trans>\n\n// For strings\nconst { t } = useLingui();\nconst label = t`None`;\n```\n\nTranslation files are in `lang/` at the project root.\n\n### Keybindings\n\nGlobal keybindings are defined in `config/keybindings.ts` and activated via `useKeybindings()` hook in the layout.\n\n## Best Practices\n\n### Component Organization\n\n1. **Pages** go in `routes/pages/<feature>/`\n2. **Shared components** go in `components/<category>/`\n3. **Feature-specific components** can be co-located with their page\n\n### Store Usage\n\n1. Use selector hooks for performance (prevents unnecessary re-renders):\n\n   ```typescript\n   // Good - only re-renders when this specific value changes\n   const isLoggedIn = useAccountStore((state) => state.records.find(r => r.id === id)?.isLoggedIn);\n\n   // Avoid - re-renders on any store change\n   const store = useAccountStore();\n   ```\n\n2. Actions are accessed via dedicated hooks:\n   ```typescript\n   const { openDrawer } = useDrawerActions();\n   ```\n\n### Error Handling\n\nUse specialized error boundaries for different contexts:\n\n```typescript\nimport { SubmissionErrorBoundary } from '../error-boundary';\n\n<SubmissionErrorBoundary submissionId={id}>\n  <SubmissionComponent />\n</SubmissionErrorBoundary>\n```\n\n### Adding New Entities\n\n1. Create API module in `api/`\n2. Create Record class in `stores/records/`\n3. Create store with `createEntityStore()` in `stores/`\n4. Add to `store-init.ts` for initialization\n5. Export from `stores/index.ts`\n\n## File Patterns\n\n| Pattern        | Purpose                     |\n| -------------- | --------------------------- |\n| `*.api.ts`     | API client modules          |\n| `*-store.ts`   | Zustand stores              |\n| `*-record.ts`  | DTO wrapper classes         |\n| `*.module.css` | CSS Modules (scoped styles) |\n| `*-page.tsx`   | Page-level components       |\n| `use-*.ts`     | Custom React hooks          |\n"
  },
  {
    "path": "apps/postybirb-ui/src/api/account.api.ts",
    "content": "import {\n  AccountId,\n  IAccountDto,\n  ICreateAccountDto,\n  ISetWebsiteDataRequestDto,\n  IUpdateAccountDto,\n} from '@postybirb/types';\nimport { getRemoteConfig } from '../transports/http-client';\nimport { BaseApi } from './base.api';\nimport remoteApi from './remote.api';\n\nclass AccountApi extends BaseApi<\n  IAccountDto,\n  ICreateAccountDto,\n  IUpdateAccountDto\n> {\n  constructor() {\n    super('account');\n  }\n\n  private async updateRemoteCookies(accountId: AccountId) {\n    const remoteConfig = getRemoteConfig();\n    if (\n      remoteConfig.mode === 'client' &&\n      remoteConfig.host &&\n      remoteConfig.password\n    ) {\n      return remoteApi.setCookies(accountId);\n    }\n    return Promise.resolve();\n  }\n\n  async clear(id: AccountId) {\n    await this.updateRemoteCookies(id);\n    return this.client.post<undefined>(`clear/${id}`);\n  }\n\n  setWebsiteData<T>(request: ISetWebsiteDataRequestDto<T>) {\n    return this.client.post<undefined>('account-data', request);\n  }\n\n  async refreshLogin(id: AccountId) {\n    await this.updateRemoteCookies(id);\n    return this.client.get<undefined>(`refresh/${id}`);\n  }\n}\n\nexport default new AccountApi();\n"
  },
  {
    "path": "apps/postybirb-ui/src/api/base.api.ts",
    "content": "/* eslint-disable @typescript-eslint/ban-types */\nimport { EntityId } from '@postybirb/types';\nimport { HttpClient } from '../transports/http-client';\n\nexport class BaseApi<\n  GetType,\n  CreateType extends Object,\n  UpdateType extends Object,\n> {\n  protected readonly client: HttpClient;\n\n  constructor(basePath: string) {\n    this.client = new HttpClient(basePath);\n  }\n\n  public get(id: EntityId) {\n    return this.client.get<GetType>(id);\n  }\n\n  public getAll() {\n    return this.client.get<Array<GetType>>();\n  }\n\n  public create(createDto: CreateType) {\n    return this.client.post<GetType>('', createDto);\n  }\n\n  public update(id: EntityId, updateDto: UpdateType) {\n    return this.client.patch<GetType>(id, updateDto);\n  }\n\n  public remove(ids: EntityId[]) {\n    return this.client.delete<{ success: boolean }>('', {\n      ids,\n    });\n  }\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/api/custom-shortcut.api.ts",
    "content": "import {\n    ICreateCustomShortcutDto,\n    ICustomShortcutDto,\n    IUpdateCustomShortcutDto,\n} from '@postybirb/types';\nimport { BaseApi } from './base.api';\n\nclass CustomShortcutsApi extends BaseApi<\n  ICustomShortcutDto,\n  ICreateCustomShortcutDto,\n  IUpdateCustomShortcutDto\n> {\n  constructor() {\n    super('custom-shortcut');\n  }\n}\n\nexport default new CustomShortcutsApi();\n"
  },
  {
    "path": "apps/postybirb-ui/src/api/directory-watchers.api.ts",
    "content": "import {\n  DirectoryWatcherDto,\n  ICreateDirectoryWatcherDto,\n  IUpdateDirectoryWatcherDto,\n} from '@postybirb/types';\nimport { BaseApi } from './base.api';\n\nexport const FILE_COUNT_WARNING_THRESHOLD = 10;\n\nexport interface CheckPathResult {\n  valid: boolean;\n  count: number;\n  files: string[];\n  error?: string;\n}\n\nclass DirectoryWatchersApi extends BaseApi<\n  DirectoryWatcherDto,\n  ICreateDirectoryWatcherDto,\n  IUpdateDirectoryWatcherDto\n> {\n  constructor() {\n    super('directory-watchers');\n  }\n\n  public checkPath(path: string) {\n    return this.client.post<CheckPathResult>('check-path', { path });\n  }\n}\n\nexport default new DirectoryWatchersApi();\n"
  },
  {
    "path": "apps/postybirb-ui/src/api/file-submission.api.ts",
    "content": "import {\n    EntityId,\n    IReorderSubmissionFilesDto,\n    ISubmissionDto,\n    SubmissionFileMetadata,\n    SubmissionId,\n} from '@postybirb/types';\nimport { HttpClient } from '../transports/http-client';\n\nexport type FileUpdateTarget = 'file' | 'thumbnail';\n\nclass FileSubmissionsApi {\n  private readonly client: HttpClient = new HttpClient('file-submission');\n\n  appendFiles(id: SubmissionId, target: FileUpdateTarget, files: Blob[]) {\n    const fd = new FormData();\n    files.forEach((file) => fd.append('files', file));\n    return this.client.post<ISubmissionDto>(`add/${target}/${id}`, fd);\n  }\n\n  replaceFile(\n    id: SubmissionId,\n    fileId: EntityId,\n    target: FileUpdateTarget,\n    file: Blob,\n    filename?: string,\n  ) {\n    const fd = new FormData();\n    // Include filename if provided to preserve file extension and name\n    fd.append('file', file, filename);\n    return this.client.post(`replace/${target}/${id}/${fileId}`, fd);\n  }\n\n  removeFile(id: SubmissionId, fileId: EntityId, target: FileUpdateTarget) {\n    return this.client.delete<ISubmissionDto>(\n      `remove/${target}/${id}/${fileId}`,\n    );\n  }\n\n  getAltText(id: EntityId) {\n    return this.client.get<string>(`alt/${id}`);\n  }\n\n  updateAltText(altFileId: EntityId, text: string) {\n    return this.client.patch(`alt/${altFileId}`, { text });\n  }\n\n  updateMetadata(id: EntityId, update: SubmissionFileMetadata) {\n    return this.client.patch(`metadata/${id}`, update);\n  }\n\n  reorder(update: IReorderSubmissionFilesDto) {\n    return this.client.patch(`reorder`, update);\n  }\n}\n\nexport default new FileSubmissionsApi();\n\nexport function getRemoveFileUrl(\n  id: SubmissionId,\n  fileId: EntityId,\n  target: FileUpdateTarget,\n): string {\n  return `api/file-submission/remove/${target}/${id}/${fileId}`;\n}\n\nexport function getReplaceFileUrl(\n  id: SubmissionId,\n  fileId: EntityId,\n  target: FileUpdateTarget,\n): string {\n  return `api/file-submission/replace/${target}/${id}/${fileId}`;\n}\n\nexport function getAppendFileUrl(\n  id: SubmissionId,\n  target: FileUpdateTarget,\n): string {\n  return `api/file-submission/add/${target}/${id}`;\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/api/form-generator.api.ts",
    "content": "import { FormBuilderMetadata } from '@postybirb/form-builder';\nimport { IFormGenerationRequestDto } from '@postybirb/types';\nimport { HttpClient } from '../transports/http-client';\n\nclass FormGeneratorApi {\n  private readonly client: HttpClient = new HttpClient('form-generator');\n\n  getForm(dto: IFormGenerationRequestDto) {\n    return this.client.post<FormBuilderMetadata>('', dto);\n  }\n}\n\nexport default new FormGeneratorApi();\n"
  },
  {
    "path": "apps/postybirb-ui/src/api/legacy-database-importer.api.ts",
    "content": "import { HttpClient } from '../transports/http-client';\n\nexport interface LegacyImportDto {\n  customShortcuts: boolean;\n  tagGroups: boolean;\n  accounts: boolean;\n  tagConverters: boolean;\n  customPath?: string;\n}\n\nexport interface LegacyImportResponse {\n  errors: Error[];\n}\n\nclass LegacyDatabaseImporterApi {\n  protected readonly client: HttpClient;\n\n  constructor() {\n    this.client = new HttpClient('legacy-database-importer');\n  }\n\n  public import(importRequest: LegacyImportDto) {\n    return this.client.post<LegacyImportResponse>('import', importRequest);\n  }\n}\n\nexport default new LegacyDatabaseImporterApi();\n"
  },
  {
    "path": "apps/postybirb-ui/src/api/notification.api.ts",
    "content": "import {\n  ICreateNotificationDto,\n  INotification,\n  IUpdateNotificationDto,\n} from '@postybirb/types';\nimport { BaseApi } from './base.api';\n\nclass NotificationApi extends BaseApi<\n  INotification,\n  ICreateNotificationDto,\n  IUpdateNotificationDto\n> {\n  constructor() {\n    super('notifications');\n  }\n}\n\nexport default new NotificationApi();\n"
  },
  {
    "path": "apps/postybirb-ui/src/api/post-manager.api.ts",
    "content": "import { SubmissionId, SubmissionType } from '@postybirb/types';\nimport { HttpClient } from '../transports/http-client';\n\nclass PostManagerApi {\n  private readonly client: HttpClient;\n\n  constructor() {\n    this.client = new HttpClient('post-manager');\n  }\n\n  cancelIfRunning(submissionId: SubmissionId) {\n    return this.client.post<boolean>(`cancel/${submissionId}`, {});\n  }\n\n  isPosting(submissionType: SubmissionType) {\n    return this.client.get<{ isPosting: boolean }>(\n      `is-posting/${submissionType}`,\n    );\n  }\n}\n\nexport default new PostManagerApi();\n"
  },
  {
    "path": "apps/postybirb-ui/src/api/post-queue.api.ts",
    "content": "import { IPostQueueActionDto, PostQueueRecordDto, PostRecordResumeMode } from '@postybirb/types';\nimport { BaseApi } from './base.api';\n\nclass PostQueueApi extends BaseApi<\n  PostQueueRecordDto,\n  IPostQueueActionDto,\n  IPostQueueActionDto\n> {\n  constructor() {\n    super('post-queue');\n  }\n\n  enqueue(submissionIds: string[], resumeMode?: PostRecordResumeMode) {\n    return this.client.post('enqueue', { submissionIds, resumeMode });\n  }\n\n  dequeue(submissionIds: string[]) {\n    return this.client.post('dequeue', { submissionIds });\n  }\n  \n  getAll() {\n    return this.client.get<PostQueueRecordDto[]>();\n  }\n\n  isPaused() {\n    return this.client.get<{ paused: boolean }>('is-paused');\n  }\n\n  pause() {\n    return this.client.post<{ paused: boolean }>('pause', {});\n  }\n\n  resume() {\n    return this.client.post<{ paused: boolean }>('resume', {});\n  }\n}\n\nexport default new PostQueueApi();\n"
  },
  {
    "path": "apps/postybirb-ui/src/api/post.api.ts",
    "content": "import { IQueuePostRecordRequestDto, PostRecordDto } from '@postybirb/types';\nimport { BaseApi } from './base.api';\n\nclass PostApi extends BaseApi<\n  PostRecordDto,\n  IQueuePostRecordRequestDto,\n  IQueuePostRecordRequestDto\n> {\n  constructor() {\n    super('post');\n  }\n}\n\nexport default new PostApi();\n"
  },
  {
    "path": "apps/postybirb-ui/src/api/remote.api.ts",
    "content": "/* eslint-disable lingui/no-unlocalized-strings */\nimport { AccountId } from '@postybirb/types';\nimport {\n  HttpClient,\n  REMOTE_HOST_KEY,\n  REMOTE_PASSWORD_KEY,\n} from '../transports/http-client';\n\nclass RemoteApi {\n  private readonly client: HttpClient = new HttpClient('remote');\n\n  /**\n   * Test ping against a remote host to validate connection\n   */\n  async testPing() {\n    const host = localStorage.getItem(REMOTE_HOST_KEY);\n    if (!host) {\n      return Promise.reject(new Error('Remote host is not configured'));\n    }\n\n    const remotePassword = localStorage.getItem(REMOTE_PASSWORD_KEY);\n    if (!remotePassword) {\n      return Promise.reject(new Error('Remote password is not configured'));\n    }\n\n    let res;\n    try {\n      const url = `https://${host}/api/remote/ping/${encodeURIComponent(remotePassword)}`;\n      res = await fetch(url);\n    } catch (e) {\n      // eslint-disable-next-line @typescript-eslint/no-throw-literal\n      throw {\n        error: `Server unreachable`,\n        statusCode: e,\n        message: 'Ensure the IP is correct',\n      };\n    }\n\n    const response = await res.json();\n    if (!res.ok) {\n      return Promise.reject(response);\n    }\n    return response;\n  }\n\n  async setCookies(accountId: AccountId) {\n    return this.client.post(`set-cookies`, {\n      accountId,\n      cookies: await window.electron?.getCookiesForAccount(accountId),\n    });\n  }\n}\n\nexport default new RemoteApi();\n"
  },
  {
    "path": "apps/postybirb-ui/src/api/settings.api.ts",
    "content": "import { EntityId, IUpdateSettingsDto, SettingsDto } from '@postybirb/types';\nimport { StartupOptions } from '@postybirb/utils/electron';\nimport { HttpClient } from '../transports/http-client';\n\nclass SettingsApi {\n  // Settings should only ever update local settings\n  private readonly client: HttpClient = new HttpClient('settings');\n\n  getAll() {\n    return this.client.get<SettingsDto[]>();\n  }\n\n  getStartupOptions() {\n    return this.client.get<StartupOptions>('startup');\n  }\n\n  update(id: EntityId, dto: IUpdateSettingsDto) {\n    return this.client.patch(id, dto);\n  }\n\n  updateSystemStartupSettings(\n    startAppOnSystemStartup: Partial<StartupOptions>,\n  ) {\n    return this.client.patch(`startup/system-startup`, startAppOnSystemStartup);\n  }\n}\n\nexport default new SettingsApi();\n"
  },
  {
    "path": "apps/postybirb-ui/src/api/submission.api.ts",
    "content": "import {\n  AccountId,\n  IApplyMultiSubmissionDto,\n  ICreateSubmissionDefaultOptions,\n  ICreateSubmissionDto,\n  IFileMetadata,\n  ISubmissionDto,\n  IUpdateSubmissionDto,\n  IUpdateSubmissionTemplateNameDto,\n  IWebsiteFormFields,\n  SubmissionId,\n  SubmissionType,\n} from '@postybirb/types';\nimport { BaseApi } from './base.api';\n\n/**\n * Options for creating a file submission.\n */\nexport interface CreateFileSubmissionOptions {\n  /** Submission type (FILE or MESSAGE) */\n  type: SubmissionType;\n  /** Files to upload */\n  files: File[];\n  /** Per-file metadata (titles) */\n  fileMetadata?: IFileMetadata[];\n  /** Default options (tags, description, rating) */\n  defaultOptions?: ICreateSubmissionDefaultOptions;\n}\n\n/**\n * DTO for applying selected template options to submissions.\n */\nexport interface ApplyTemplateOptionsDto {\n  /** Submission IDs to apply template options to */\n  targetSubmissionIds: SubmissionId[];\n  /** Template options to apply */\n  options: Array<{\n    accountId: AccountId;\n    data: IWebsiteFormFields;\n  }>;\n  /** Whether to replace title with template title */\n  overrideTitle: boolean;\n  /** Whether to replace description with template description */\n  overrideDescription: boolean;\n}\n\n/**\n * Result from applying template options.\n */\nexport interface ApplyTemplateOptionsResult {\n  success: number;\n  failed: number;\n  errors: Array<{ submissionId: SubmissionId; error: string }>;\n}\n\nclass SubmissionsApi extends BaseApi<\n  ISubmissionDto,\n  ICreateSubmissionDto,\n  IUpdateSubmissionDto\n> {\n  constructor() {\n    super('submissions');\n  }\n\n  createMessageSubmission(name: string) {\n    return this.client.post('', {\n      name,\n      type: SubmissionType.MESSAGE,\n    });\n  }\n\n  duplicate(id: SubmissionId) {\n    return this.client.post(`duplicate/${id}`);\n  }\n\n  updateTemplateName(id: SubmissionId, dto: IUpdateSubmissionTemplateNameDto) {\n    return this.client.patch(`template/${id}`, dto);\n  }\n\n  /**\n   * Create file submission(s) with optional metadata and default options.\n   */\n  createFileSubmission(options: CreateFileSubmissionOptions): ReturnType<typeof this.client.post>;\n  /**\n   * @deprecated Use the options object overload instead.\n   */\n  createFileSubmission(type: SubmissionType, files: File[]): ReturnType<typeof this.client.post>;\n  createFileSubmission(\n    typeOrOptions: SubmissionType | CreateFileSubmissionOptions,\n    filesArg?: File[],\n  ) {\n    // Handle legacy call signature\n    const options: CreateFileSubmissionOptions =\n      typeof typeOrOptions === 'object'\n        ? typeOrOptions\n        : { type: typeOrOptions, files: filesArg ?? [] };\n\n    const { type, files, fileMetadata, defaultOptions } = options;\n\n    const formData = new FormData();\n    files.forEach((file) => {\n      formData.append('files', file);\n    });\n    formData.append('type', type);\n\n    // Add optional metadata\n    if (fileMetadata && fileMetadata.length > 0) {\n      formData.append('fileMetadata', JSON.stringify(fileMetadata));\n    }\n\n    // Add optional default options\n    if (defaultOptions) {\n      formData.append('defaultOptions', JSON.stringify(defaultOptions));\n    }\n\n    return this.client.post('', formData);\n  }\n\n  reorder(id: SubmissionId, targetId: SubmissionId, position: 'before' | 'after') {\n    return this.client.patch('reorder', { id, targetId, position });\n  }\n\n  applyToMultipleSubmissions(dto: IApplyMultiSubmissionDto) {\n    return this.client.patch('apply/multi', dto);\n  }\n\n  applyTemplate(id: SubmissionId, templateId: SubmissionId) {\n    return this.client.patch(`apply/template/${id}/${templateId}`);\n  }\n\n  applyTemplateOptions(dto: ApplyTemplateOptionsDto) {\n    return this.client.patch<ApplyTemplateOptionsResult>(\n      'apply/template/options',\n      dto,\n    );\n  }\n\n  unarchive(id: SubmissionId) {\n    return this.client.post(`unarchive/${id}`);\n  }\n\n  archive(id: SubmissionId) {\n    return this.client.post(`archive/${id}`);\n  }\n}\n\nexport default new SubmissionsApi();\n"
  },
  {
    "path": "apps/postybirb-ui/src/api/tag-converters.api.ts",
    "content": "import {\n  ICreateTagConverterDto,\n  IUpdateTagConverterDto,\n  TagConverterDto,\n} from '@postybirb/types';\nimport { BaseApi } from './base.api';\n\nclass TagConvertersApi extends BaseApi<\n  TagConverterDto,\n  ICreateTagConverterDto,\n  IUpdateTagConverterDto\n> {\n  constructor() {\n    super('tag-converters');\n  }\n}\n\nexport default new TagConvertersApi();\n"
  },
  {
    "path": "apps/postybirb-ui/src/api/tag-groups.api.ts",
    "content": "import {\n  ICreateTagGroupDto,\n  IUpdateTagGroupDto,\n  TagGroupDto,\n} from '@postybirb/types';\nimport { BaseApi } from './base.api';\n\nclass TagGroupsApi extends BaseApi<\n  TagGroupDto,\n  ICreateTagGroupDto,\n  IUpdateTagGroupDto\n> {\n  constructor() {\n    super('tag-groups');\n  }\n}\n\nexport default new TagGroupsApi();\n"
  },
  {
    "path": "apps/postybirb-ui/src/api/update.api.ts",
    "content": "import { UpdateState } from '@postybirb/types';\nimport { HttpClient } from '../transports/http-client';\n\nclass UpdateApi {\n  private client = new HttpClient('update');\n\n  checkForUpdates() {\n    return this.client.get<UpdateState>('');\n  }\n\n  startUpdate() {\n    return this.client.post<undefined>('start');\n  }\n\n  installUpdate() {\n    return this.client.post<undefined>('install');\n  }\n}\n\nexport default new UpdateApi();\n"
  },
  {
    "path": "apps/postybirb-ui/src/api/user-converters.api.ts",
    "content": "import {\n  ICreateUserConverterDto,\n  IUpdateUserConverterDto,\n  UserConverterDto,\n} from '@postybirb/types';\nimport { BaseApi } from './base.api';\n\nclass UserConvertersApi extends BaseApi<\n  UserConverterDto,\n  ICreateUserConverterDto,\n  IUpdateUserConverterDto\n> {\n  constructor() {\n    super('user-converters');\n  }\n}\n\nexport default new UserConvertersApi();\n"
  },
  {
    "path": "apps/postybirb-ui/src/api/user-specified-website-options.api.ts",
    "content": "import {\n  ICreateUserSpecifiedWebsiteOptionsDto,\n  IUpdateUserSpecifiedWebsiteOptionsDto,\n  TagGroupDto,\n} from '@postybirb/types';\nimport { BaseApi } from './base.api';\n\nclass UserSpecifiedWebsiteOptionsApi extends BaseApi<\n  TagGroupDto,\n  ICreateUserSpecifiedWebsiteOptionsDto,\n  IUpdateUserSpecifiedWebsiteOptionsDto\n> {\n  constructor() {\n    super('user-specified-website-options');\n  }\n}\n\nexport default new UserSpecifiedWebsiteOptionsApi();\n"
  },
  {
    "path": "apps/postybirb-ui/src/api/website-options.api.ts",
    "content": "import {\n    ICreateWebsiteOptionsDto,\n    IDescriptionPreviewResult,\n    IPreviewDescriptionDto,\n    IUpdateSubmissionWebsiteOptionsDto,\n    IUpdateWebsiteOptionsDto,\n    IValidateWebsiteOptionsDto,\n    SubmissionId,\n    ValidationResult,\n    WebsiteOptionsDto,\n} from '@postybirb/types';\nimport { BaseApi } from './base.api';\n\nclass WebsiteOptionsApi extends BaseApi<\n  WebsiteOptionsDto,\n  ICreateWebsiteOptionsDto,\n  IUpdateWebsiteOptionsDto\n> {\n  constructor() {\n    super('website-option');\n  }\n\n  validate(dto: IValidateWebsiteOptionsDto) {\n    return this.client.post<ValidationResult>('validate', dto);\n  }\n\n  validateSubmission(submissionId: SubmissionId) {\n    return this.client.get<ValidationResult[]>(`validate/${submissionId}`);\n  }\n\n  previewDescription(dto: IPreviewDescriptionDto) {\n    return this.client.post<IDescriptionPreviewResult>(\n      'preview-description',\n      dto,\n    );\n  }\n\n  updateSubmissionOptions(\n    id: SubmissionId,\n    dto: IUpdateSubmissionWebsiteOptionsDto,\n  ) {\n    return this.client.patch<WebsiteOptionsDto>(`submission/${id}`, dto);\n  }\n\n  modifySubmission(\n    submissionId: SubmissionId,\n    dto: IUpdateSubmissionWebsiteOptionsDto,\n  ) {\n    return this.client.patch<WebsiteOptionsDto>(\n      `submission/${submissionId}`,\n      dto,\n    );\n  }\n}\n\nexport default new WebsiteOptionsApi();\n"
  },
  {
    "path": "apps/postybirb-ui/src/api/websites.api.ts",
    "content": "import {\n  IOAuthWebsiteRequestDto,\n  IWebsiteInfoDto,\n  OAuthRoutes,\n  WebsiteId,\n} from '@postybirb/types';\nimport { HttpClient } from '../transports/http-client';\n\nclass WebsitesApi {\n  private readonly client: HttpClient = new HttpClient('websites');\n\n  getWebsiteInfo() {\n    return this.client.get<IWebsiteInfoDto[]>('info');\n  }\n\n  async performOAuthStep<T extends OAuthRoutes, R extends keyof T = keyof T>(\n    id: WebsiteId,\n    route: R,\n    data: T[R]['request'],\n  ): Promise<T[R]['response']> {\n    const response = await this.client.post(`oauth`, {\n      route: route as string,\n      id,\n      data,\n    } satisfies IOAuthWebsiteRequestDto<T[R]['request']>);\n\n    return response.body as T[R]['response'];\n  }\n}\n\nexport default new WebsitesApi();\n"
  },
  {
    "path": "apps/postybirb-ui/src/app-insights-ui.ts",
    "content": "/* eslint-disable lingui/no-unlocalized-strings */\nimport { ApplicationInsights } from '@microsoft/applicationinsights-web';\n\nlet appInsights: ApplicationInsights | null = null;\n\n/**\n * Initialize Application Insights for the React UI\n * Call this once during application startup\n */\nexport function initializeAppInsightsUI(): void {\n  try {\n    const appInsightsConnectionString = null;\n\n    if (!appInsightsConnectionString) {\n      // App Insights not configured - this is expected in development\n      return;\n    }\n\n    appInsights = new ApplicationInsights({\n      config: {\n        connectionString: appInsightsConnectionString,\n        enableAutoRouteTracking: true,\n        disableFetchTracking: true,\n        disableAjaxTracking: true,\n        autoTrackPageVisitTime: false,\n        enableCorsCorrelation: true,\n        enableRequestHeaderTracking: true,\n        enableResponseHeaderTracking: true,\n      },\n    });\n\n    appInsights.loadAppInsights();\n    appInsights.addTelemetryInitializer((envelope) => {\n      // eslint-disable-next-line no-param-reassign\n      envelope.tags = envelope.tags || [];\n      // eslint-disable-next-line no-param-reassign\n      envelope.tags['ai.cloud.role'] = 'postybirb-ui';\n      // eslint-disable-next-line no-param-reassign\n      envelope.tags['ai.application.ver'] =\n        window.electron?.app_version || 'unknown';\n      return true;\n    });\n    appInsights.trackPageView();\n\n    // Set up global error handler for React\n    window.addEventListener('error', (event: ErrorEvent) => {\n      trackUIException(event.error, {\n        source: 'window.onerror',\n        message: event.message,\n        filename: event.filename,\n        lineno: String(event.lineno),\n        colno: String(event.colno),\n      });\n    });\n\n    // Handle unhandled promise rejections\n    window.addEventListener(\n      'unhandledrejection',\n      (event: PromiseRejectionEvent) => {\n        const error =\n          event.reason instanceof Error\n            ? event.reason\n            : new Error(String(event.reason));\n        trackUIException(error, {\n          source: 'unhandledrejection',\n          reason: String(event.reason),\n        });\n      },\n    );\n  } catch (error) {\n    // Failed to initialize - silently fail\n  }\n}\n\n/**\n * Track an exception in the UI\n */\nexport function trackUIException(\n  error: Error,\n  properties?: { [key: string]: string },\n): void {\n  if (appInsights) {\n    appInsights.trackException({ exception: error, properties });\n  }\n}\n\n/**\n * Track a custom event in the UI\n */\nexport function trackUIEvent(\n  name: string,\n  properties?: { [key: string]: string },\n): void {\n  if (appInsights) {\n    appInsights.trackEvent({ name, properties });\n  }\n}\n\n/**\n * Track a page view in the UI\n */\nexport function trackUIPageView(name?: string, uri?: string): void {\n  if (appInsights) {\n    appInsights.trackPageView({ name, uri });\n  }\n}\n\n/**\n * Get the Application Insights instance\n */\nexport function getAppInsightsUI(): ApplicationInsights | null {\n  return appInsights;\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/blocknote-locales.d.ts",
    "content": "/**\n * Type declarations for @blocknote/core/locales.\n * The package has the locales but doesn't export them properly in package.json exports.\n * This declaration allows TypeScript to resolve the import.\n */\ndeclare module '@blocknote/core/locales' {\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  export const de: any;\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  export const en: any;\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  export const es: any;\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  export const ru: any;\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  export const fr: any;\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  export const pt: any;\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  export const ja: any;\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  export const ko: any;\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  export const zh: any;\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/confirm-action-modal/confirm-action-modal.tsx",
    "content": "/**\n * ConfirmActionModal - A generic confirmation modal for destructive or important actions.\n * Displays a title, message, and confirm/cancel buttons.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport {\n  Button,\n  Group,\n  Modal,\n  Stack,\n  Text,\n  type MantineColor,\n} from '@mantine/core';\n\nexport interface ConfirmActionModalProps {\n  /** Whether the modal is open */\n  opened: boolean;\n  /** Handler to close the modal */\n  onClose: () => void;\n  /** Handler when user confirms the action */\n  onConfirm: () => void;\n  /** Modal title */\n  title: React.ReactNode;\n  /** Message to display in the modal body */\n  message: React.ReactNode;\n  /** Label for the confirm button (default: \"Confirm\") */\n  confirmLabel?: React.ReactNode;\n  /** Label for the cancel button (default: \"Cancel\") */\n  cancelLabel?: React.ReactNode;\n  /** Color for the confirm button (default: \"red\" for destructive actions) */\n  confirmColor?: MantineColor;\n  /** Whether the confirm action is loading */\n  loading?: boolean;\n}\n\n/**\n * A generic confirmation modal component.\n * Useful for delete, cancel, or other actions that need user confirmation.\n */\nexport function ConfirmActionModal({\n  opened,\n  onClose,\n  onConfirm,\n  title,\n  message,\n  confirmLabel,\n  cancelLabel,\n  confirmColor = 'red',\n  loading = false,\n}: ConfirmActionModalProps) {\n  const handleConfirm = () => {\n    onConfirm();\n    onClose();\n  };\n\n  return (\n    <Modal\n      opened={opened}\n      onClose={onClose}\n      title={title}\n      centered\n      radius=\"md\"\n      zIndex={1000}\n    >\n      <Stack>\n        <Text>{message}</Text>\n        <Group justify=\"flex-end\">\n          <Button variant=\"default\" onClick={onClose} disabled={loading}>\n            {cancelLabel ?? <Trans>Cancel</Trans>}\n          </Button>\n          <Button\n            color={confirmColor}\n            onClick={handleConfirm}\n            loading={loading}\n          >\n            {confirmLabel ?? <Trans>Confirm</Trans>}\n          </Button>\n        </Group>\n      </Stack>\n    </Modal>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/confirm-action-modal/index.ts",
    "content": "export { ConfirmActionModal, type ConfirmActionModalProps } from './confirm-action-modal';\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/dialogs/settings-dialog/sections/app-settings-section.tsx",
    "content": "/**\n * App Settings Section - Startup and system settings.\n */\n\nimport { Trans, useLingui } from '@lingui/react/macro';\nimport {\n  Box,\n  Button,\n  Group,\n  Stack,\n  Switch,\n  Text,\n  TextInput,\n  Title,\n  useMantineColorScheme\n} from '@mantine/core';\nimport { IconFolder, IconRouter } from '@tabler/icons-react';\nimport { useQuery } from 'react-query';\nimport settingsApi from '../../../../api/settings.api';\n\nexport function AppSettingsSection() {\n  const { t } = useLingui();\n  const { colorScheme } = useMantineColorScheme();\n\n  const {\n    data: startupSettings,\n    isLoading,\n    refetch,\n  } = useQuery(\n    // eslint-disable-next-line lingui/no-unlocalized-strings\n    'startup-settings',\n    () => settingsApi.getStartupOptions().then((res) => res.body),\n    { cacheTime: 0 },\n  );\n\n  if (isLoading) return null;\n\n  return (\n    <Stack gap=\"lg\">\n      <Box>\n        <Title order={4} mb=\"md\">\n          <Trans>Startup Settings</Trans>\n        </Title>\n\n        <Stack gap=\"md\">\n          <Switch\n            label={<Trans>Open PostyBirb on computer startup</Trans>}\n            checked={startupSettings?.startAppOnSystemStartup ?? false}\n            onChange={(event) => {\n              settingsApi\n                .updateSystemStartupSettings({\n                  startAppOnSystemStartup: event.currentTarget.checked,\n                })\n                .finally(refetch);\n            }}\n          />\n\n          <TextInput\n            label={<Trans>App Server Port</Trans>}\n            leftSection={<IconRouter size={18} />}\n            value={startupSettings?.port ?? '9487'}\n            type=\"number\"\n            min={1025}\n            max={65535}\n            onChange={(event) => {\n              const newPortStr = event.currentTarget.value;\n              const newPort = parseInt(newPortStr, 10);\n\n              if (Number.isNaN(newPort) || newPort < 1025 || newPort > 65535) {\n                return;\n              }\n\n              settingsApi\n                .updateSystemStartupSettings({ port: newPort.toString() })\n                .finally(refetch);\n            }}\n          />\n\n          <Box>\n            <Text size=\"sm\" fw={500} mb=\"xs\">\n              <Trans>App Folder</Trans>\n            </Text>\n            <Group align=\"flex-end\" gap=\"xs\">\n              <TextInput\n                style={{ flex: 1 }}\n                leftSection={<IconFolder size={18} />}\n                value={startupSettings?.appDataPath ?? ''}\n                readOnly\n              />\n              <Button\n                onClick={() => {\n                  if (window?.electron?.pickDirectory) {\n                    window.electron.pickDirectory().then((appDataPath) => {\n                      if (appDataPath) {\n                        settingsApi\n                          .updateSystemStartupSettings({ appDataPath })\n                          .finally(refetch);\n                      }\n                    });\n                  }\n                }}\n              >\n                <Trans>Browse</Trans>\n              </Button>\n            </Group>\n          </Box>\n        </Stack>\n      </Box>\n    </Stack>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/dialogs/settings-dialog/sections/appearance-settings-section.tsx",
    "content": "/**\n * Appearance Settings Section - Theme and color customization.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport {\n  Box,\n  ColorSwatch,\n  Group,\n  SegmentedControl,\n  SimpleGrid,\n  Stack,\n  Text,\n  Tooltip,\n  useMantineTheme,\n} from '@mantine/core';\nimport { IconMoon, IconSun, IconSunMoon } from '@tabler/icons-react';\nimport {\n  type ColorScheme,\n  MANTINE_COLORS,\n  type MantinePrimaryColor,\n  useAppearanceActions,\n} from '../../../../stores/ui/appearance-store';\n\n/**\n * Color scheme options for the segmented control.\n */\nconst COLOR_SCHEME_OPTIONS = [\n  {\n    value: 'light' as ColorScheme,\n    label: (\n      <Group gap={6} wrap=\"nowrap\">\n        <IconSun size={16} />\n        <Text size=\"sm\">\n          <Trans>Light</Trans>\n        </Text>\n      </Group>\n    ),\n  },\n  {\n    value: 'dark' as ColorScheme,\n    label: (\n      <Group gap={6} wrap=\"nowrap\">\n        <IconMoon size={16} />\n        <Text size=\"sm\">\n          <Trans>Dark</Trans>\n        </Text>\n      </Group>\n    ),\n  },\n  {\n    value: 'auto' as ColorScheme,\n    label: (\n      <Group gap={6} wrap=\"nowrap\">\n        <IconSunMoon size={16} />\n        <Text size=\"sm\">\n          <Trans>Auto</Trans>\n        </Text>\n      </Group>\n    ),\n  },\n];\n\n/**\n * Capitalize first letter of a string.\n */\nfunction capitalize(str: string): string {\n  return str.charAt(0).toUpperCase() + str.slice(1);\n}\n\nexport function AppearanceSettingsSection() {\n  const theme = useMantineTheme();\n  const { colorScheme, primaryColor, setColorScheme, setPrimaryColor } =\n    useAppearanceActions();\n\n  return (\n    <Stack gap=\"xl\">\n      {/* Color Scheme Selection */}\n      <Box>\n        <SegmentedControl\n          value={colorScheme}\n          onChange={(value) => setColorScheme(value as ColorScheme)}\n          data={COLOR_SCHEME_OPTIONS}\n          fullWidth\n        />\n      </Box>\n\n      {/* Primary Color Selection */}\n      <Box>\n        <SimpleGrid cols={6} spacing=\"sm\">\n          {MANTINE_COLORS.map((color) => {\n            const isSelected = primaryColor === color;\n            // Get the color value from the theme (shade 6 is the default)\n            const colorValue = theme.colors[color][6];\n\n            return (\n              <Tooltip\n                key={color}\n                label={capitalize(color)}\n                withArrow\n              >\n                <Box\n                  onClick={() => setPrimaryColor(color as MantinePrimaryColor)}\n                  style={{\n                    borderRadius: 4,\n                    padding: 3,\n                    /* eslint-disable-next-line lingui/no-unlocalized-strings */\n                    border: isSelected ? `2px solid ${colorValue}` : undefined,\n                  }}\n                >\n                  <ColorSwatch\n                    color={colorValue}\n                    style={{ cursor: 'pointer' }}\n                    size={32}\n                  />\n                </Box>\n              </Tooltip>\n            );\n          })}\n        </SimpleGrid>\n      </Box>\n    </Stack>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/dialogs/settings-dialog/sections/data-settings-section.tsx",
    "content": "/**\n * Data Settings Section - Download logs and manage application data.\n */\n\n/* eslint-disable lingui/no-unlocalized-strings */\nimport { Trans } from '@lingui/react/macro';\nimport { Alert, Box, Button, Stack, Text, Title } from '@mantine/core';\nimport { IconDownload, IconInfoCircle, IconX } from '@tabler/icons-react';\nimport { useState } from 'react';\nimport {\n    defaultTargetProvider,\n    getRemotePassword,\n} from '../../../../transports/http-client';\n\nexport function DataSettingsSection() {\n  const [downloading, setDownloading] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n\n  const handleDownloadLogs = async () => {\n    setDownloading(true);\n    setError(null);\n\n    try {\n      const url = new URL('api/logs/download', defaultTargetProvider());\n      const headers: Record<string, string> = {};\n      const pw = getRemotePassword();\n      if (pw) {\n        headers['X-Remote-Password'] = pw;\n      }\n\n      const response = await fetch(url.toString(), { headers });\n\n      if (!response.ok) {\n        throw new Error(`Download failed with status ${response.status}`);\n      }\n\n      const blob = await response.blob();\n      const objectUrl = URL.createObjectURL(blob);\n\n      const anchor = document.createElement('a');\n      anchor.href = objectUrl;\n\n      // Extract filename from Content-Disposition header, or use a default\n      const disposition = response.headers.get('Content-Disposition');\n      const fileNameMatch = disposition?.match(/filename=(.+)/);\n      anchor.download = fileNameMatch\n        ? fileNameMatch[1]\n        : `postybirb-logs-${new Date().toISOString().split('T')[0]}.tar.gz`;\n\n      document.body.appendChild(anchor);\n      anchor.click();\n      anchor.remove();\n      URL.revokeObjectURL(objectUrl);\n    } catch (err) {\n      setError(\n        err instanceof Error ? err.message : 'An unknown error occurred',\n      );\n    } finally {\n      setDownloading(false);\n    }\n  };\n\n  return (\n    <Stack gap=\"lg\">\n      <Box>\n        <Title order={4} mb=\"md\">\n          <Trans>Logs</Trans>\n        </Title>\n\n        <Stack gap=\"md\">\n          <Alert icon={<IconInfoCircle size={16} />} color=\"blue\">\n            <Trans>\n              Download application logs as a compressed archive. This is useful\n              for troubleshooting issues or sharing logs with developers.\n            </Trans>\n          </Alert>\n\n          <Button\n            leftSection={<IconDownload size={16} />}\n            loading={downloading}\n            onClick={handleDownloadLogs}\n          >\n            <Trans>Download Logs</Trans>\n          </Button>\n\n          {error && (\n            <Alert color=\"red\" icon={<IconX size={16} />}>\n              <Text size=\"sm\">{error}</Text>\n            </Alert>\n          )}\n        </Stack>\n      </Box>\n    </Stack>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/dialogs/settings-dialog/sections/description-settings-section.tsx",
    "content": "/**\n * Description Settings Section - Description-related preferences.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport { Box, Stack, Switch, Title } from '@mantine/core';\nimport settingsApi from '../../../../api/settings.api';\nimport { useSettings } from '../../../../stores';\n\nexport function DescriptionSettingsSection() {\n  const settings = useSettings();\n\n  if (!settings) return null;\n\n  return (\n    <Stack gap=\"lg\">\n      <Box>\n        <Title order={4} mb=\"md\">\n          <Trans>Description Settings</Trans>\n        </Title>\n\n        <Switch\n          label={\n            <Trans>\n              Allow PostyBirb to self-advertise at the end of descriptions\n            </Trans>\n          }\n          checked={settings.allowAd}\n          onChange={(event) => {\n            settingsApi.update(settings.id, {\n              settings: {\n                ...settings.settings,\n                allowAd: event.currentTarget.checked,\n              },\n            });\n          }}\n        />\n      </Box>\n    </Stack>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/dialogs/settings-dialog/sections/import-settings-section.tsx",
    "content": "/**\n * Import Settings Section - Legacy data import from PostyBirb Plus.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport { Alert, Box, Button, Checkbox, Stack, Text, Title } from '@mantine/core';\nimport { IconCheck, IconInfoCircle, IconX } from '@tabler/icons-react';\nimport { useState } from 'react';\nimport legacyImporterApi from '../../../../api/legacy-database-importer.api';\n\nexport function ImportSettingsSection() {\n  const [importing, setImporting] = useState(false);\n  const [errors, setErrors] = useState<Error[]>([]);\n  const [success, setSuccess] = useState(false);\n\n  const [importOptions, setImportOptions] = useState({\n    customShortcuts: true,\n    tagGroups: true,\n    accounts: true,\n    tagConverters: true,\n  });\n\n  const handleImport = async () => {\n    setImporting(true);\n    setErrors([]);\n    setSuccess(false);\n\n    try {\n      const result = await legacyImporterApi.import(importOptions);\n      if (result.body.errors.length > 0) {\n        setErrors(result.body.errors);\n      } else {\n        setSuccess(true);\n      }\n    } catch (error) {\n      setErrors([error as Error]);\n    } finally {\n      setImporting(false);\n    }\n  };\n\n  return (\n    <Stack gap=\"lg\">\n      <Box>\n        <Title order={4} mb=\"md\">\n          <Trans>Import from PostyBirb Plus</Trans>\n        </Title>\n\n        <Stack gap=\"md\">\n          <Alert icon={<IconInfoCircle size={16} />} color=\"blue\">\n            <Trans>\n              Import your data from PostyBirb Plus. Select which types of data\n              you want to import.\n            </Trans>\n          </Alert>\n\n          <Stack gap=\"xs\">\n            <Checkbox\n              label={<Trans>Custom Shortcuts</Trans>}\n              checked={importOptions.customShortcuts}\n              onChange={(event) =>\n                setImportOptions({\n                  ...importOptions,\n                  customShortcuts: event.currentTarget.checked,\n                })\n              }\n            />\n\n            <Checkbox\n              label={<Trans>Tag Groups</Trans>}\n              checked={importOptions.tagGroups}\n              onChange={(event) =>\n                setImportOptions({\n                  ...importOptions,\n                  tagGroups: event.currentTarget.checked,\n                })\n              }\n            />\n\n            <Checkbox\n              label={<Trans>Accounts</Trans>}\n              checked={importOptions.accounts}\n              onChange={(event) =>\n                setImportOptions({\n                  ...importOptions,\n                  accounts: event.currentTarget.checked,\n                })\n              }\n            />\n\n            <Checkbox\n              label={<Trans>Tag Converters</Trans>}\n              checked={importOptions.tagConverters}\n              onChange={(event) =>\n                setImportOptions({\n                  ...importOptions,\n                  tagConverters: event.currentTarget.checked,\n                })\n              }\n            />\n          </Stack>\n\n          <Button loading={importing} onClick={handleImport}>\n            <Trans>Import</Trans>\n          </Button>\n\n          {success && (\n            <Alert color=\"green\" icon={<IconCheck size={16} />}>\n              <Trans>Import completed successfully!</Trans>\n            </Alert>\n          )}\n\n          {errors.length > 0 && (\n            <Alert color=\"red\" icon={<IconX size={16} />}>\n              <Stack gap=\"xs\">\n                <Text fw={500}>\n                  <Trans>Import encountered errors:</Trans>\n                </Text>\n                {errors.map((error, index) => (\n                  // eslint-disable-next-line react/no-array-index-key\n                  <Text key={index} size=\"sm\">\n                    {error.message}\n                  </Text>\n                ))}\n              </Stack>\n            </Alert>\n          )}\n        </Stack>\n      </Box>\n    </Stack>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/dialogs/settings-dialog/sections/index.ts",
    "content": "/**\n * Barrel exports for settings dialog sections.\n */\n\nexport { AppSettingsSection } from './app-settings-section';\nexport { AppearanceSettingsSection } from './appearance-settings-section';\nexport { DataSettingsSection } from './data-settings-section';\nexport { DescriptionSettingsSection } from './description-settings-section';\nexport { ImportSettingsSection } from './import-settings-section';\nexport { NotificationsSettingsSection } from './notifications-settings-section';\nexport { RemoteSettingsSection } from './remote-settings-section';\nexport { TagsSettingsSection } from './tags-settings-section';\n\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx",
    "content": "/**\n * Notifications Settings Section - Desktop notification preferences.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport { Box, Stack, Switch, Title } from '@mantine/core';\nimport type { DesktopNotificationSettings } from '@postybirb/types';\nimport settingsApi from '../../../../api/settings.api';\nimport { useSettings } from '../../../../stores';\n\nexport function NotificationsSettingsSection() {\n  const settings = useSettings();\n\n  if (!settings) return null;\n\n  const updateDesktopNotifications = (\n    key: keyof DesktopNotificationSettings,\n    value: boolean,\n  ) => {\n    const updatedDesktopNotifications = {\n      ...settings.desktopNotifications,\n      [key]: value,\n    };\n\n    settingsApi.update(settings.id, {\n      settings: {\n        ...settings.settings,\n        desktopNotifications: updatedDesktopNotifications,\n      },\n    });\n  };\n\n  return (\n    <Stack gap=\"lg\">\n      <Box>\n        <Title order={4} mb=\"md\">\n          <Trans>Desktop Notifications</Trans>\n        </Title>\n\n        <Stack gap=\"md\">\n          <Switch\n            label={<Trans>Enable desktop notifications</Trans>}\n            checked={settings.desktopNotifications?.enabled ?? false}\n            onChange={(event) => {\n              updateDesktopNotifications('enabled', event.currentTarget.checked);\n            }}\n          />\n\n          {settings.desktopNotifications?.enabled && (\n            <Stack gap=\"sm\" ml=\"md\">\n              <Switch\n                label={<Trans>Post Success</Trans>}\n                checked={settings.desktopNotifications?.showOnPostSuccess ?? false}\n                onChange={(event) => {\n                  updateDesktopNotifications(\n                    'showOnPostSuccess',\n                    event.currentTarget.checked,\n                  );\n                }}\n              />\n              <Switch\n                label={<Trans>Post Failure</Trans>}\n                checked={settings.desktopNotifications?.showOnPostError ?? true}\n                onChange={(event) => {\n                  updateDesktopNotifications(\n                    'showOnPostError',\n                    event.currentTarget.checked,\n                  );\n                }}\n              />\n              <Switch\n                label={<Trans>File Watcher Success</Trans>}\n                checked={\n                  settings.desktopNotifications?.showOnDirectoryWatcherSuccess ??\n                  false\n                }\n                onChange={(event) => {\n                  updateDesktopNotifications(\n                    'showOnDirectoryWatcherSuccess',\n                    event.currentTarget.checked,\n                  );\n                }}\n              />\n              <Switch\n                label={<Trans>File Watcher Failure</Trans>}\n                checked={\n                  settings.desktopNotifications?.showOnDirectoryWatcherError ??\n                  true\n                }\n                onChange={(event) => {\n                  updateDesktopNotifications(\n                    'showOnDirectoryWatcherError',\n                    event.currentTarget.checked,\n                  );\n                }}\n              />\n            </Stack>\n          )}\n        </Stack>\n      </Box>\n    </Stack>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/dialogs/settings-dialog/sections/remote-settings-section.tsx",
    "content": "/**\n * Remote Settings Section - Remote connection configuration.\n */\n\nimport { t } from '@lingui/core/macro';\nimport { Trans } from '@lingui/react/macro';\nimport {\n  Box,\n  Button,\n  Divider,\n  Group,\n  Stack,\n  Text,\n  TextInput,\n  Title,\n} from '@mantine/core';\nimport {\n  IconDeviceFloppy,\n  IconEye,\n  IconEyeOff,\n  IconNetwork,\n  IconPlug,\n  IconRefresh,\n  IconRouter,\n  IconServer,\n} from '@tabler/icons-react';\nimport { useEffect, useState } from 'react';\nimport {\n  REMOTE_HOST_KEY,\n  REMOTE_MODE_KEY,\n  REMOTE_PASSWORD_KEY,\n} from '../../../../transports/http-client';\nimport {\n  showConnectionErrorNotification,\n  showConnectionSuccessNotification,\n} from '../../../../utils/notifications';\nimport { ConfirmActionModal } from '../../../confirm-action-modal/confirm-action-modal';\nimport { CopyToClipboard } from '../../../shared/copy-to-clipboard';\n\nexport function RemoteSettingsSection() {\n  const [isTestingConnection, setIsTestingConnection] = useState(false);\n  const [isConnectionValid, setIsConnectionValid] = useState(false);\n  const [showResetModal, setShowResetModal] = useState(false);\n  const [showPassword, setShowPassword] = useState(false);\n  const [showLanIp, setShowLanIp] = useState(false);\n  const [lanIp, setLanIp] = useState<string>('localhost:9487');\n  const [remoteConfig, setRemoteConfig] = useState({\n    enabled: true,\n    password: '',\n  });\n\n  // Local storage state\n  const [remoteMode, setRemoteMode] = useState<'host' | 'client'>(\n    () =>\n      (localStorage.getItem(REMOTE_MODE_KEY) as 'host' | 'client') || 'host',\n  );\n  const [remotePassword, setRemotePassword] = useState<string>(\n    () => localStorage.getItem(REMOTE_PASSWORD_KEY) || '',\n  );\n  const [hostUrl, setHostUrl] = useState<string>(\n    () => localStorage.getItem(REMOTE_HOST_KEY) || '',\n  );\n\n  const isHost = remoteMode === 'host';\n\n  useEffect(() => {\n    if (window.electron?.getLanIp) {\n      window.electron\n        .getLanIp()\n        .then((ip) => {\n          setLanIp(`${ip}:${window.electron.app_port}`);\n        })\n        .catch(() => {\n          setLanIp('localhost:9487');\n        });\n    }\n\n    if (window.electron?.getRemoteConfig) {\n      setRemoteConfig(window.electron.getRemoteConfig());\n    }\n  }, []);\n\n  const testConnection = async () => {\n    if (!hostUrl?.trim()) {\n      showConnectionErrorNotification(\n        <Trans>Configuration Error</Trans>,\n        <Trans>Remote host URL is not configured</Trans>,\n      );\n      return;\n    }\n\n    if (!remotePassword?.trim()) {\n      showConnectionErrorNotification(\n        <Trans>Configuration Error</Trans>,\n        <Trans>Remote password is not configured</Trans>,\n      );\n      return;\n    }\n\n    setIsTestingConnection(true);\n    setIsConnectionValid(false);\n\n    try {\n      const url = `https://${hostUrl.trim()}/api/remote/ping/${encodeURIComponent(remotePassword.trim())}`;\n      const res = await fetch(url);\n      const response = await res.json();\n\n      if (!res.ok) {\n        const errorInfo = {\n          error: response.error || t`Connection Failed`,\n          statusCode: response.statusCode || res.status,\n          message: response.message || t`Failed to connect to remote host`,\n        };\n        showConnectionErrorNotification(\n          `${errorInfo.error} (${errorInfo.statusCode})`,\n          errorInfo.message,\n        );\n        return;\n      }\n\n      setIsConnectionValid(true);\n      showConnectionSuccessNotification(\n        <Trans>Successfully connected to the remote host</Trans>,\n      );\n    } catch (error) {\n      if (error instanceof TypeError) {\n        showConnectionErrorNotification(\n          <Trans>Server unreachable</Trans>,\n          <Trans>Ensure the IP is correct</Trans>,\n        );\n      } else {\n        const err = error as {\n          error: string;\n          statusCode: number;\n          message: string;\n        };\n        showConnectionErrorNotification(\n          `${err.error} (${err.statusCode})`,\n          err.message,\n        );\n      }\n    } finally {\n      setIsTestingConnection(false);\n    }\n  };\n\n  const handleSaveClientSettings = () => {\n    localStorage.setItem(REMOTE_HOST_KEY, hostUrl.trim());\n    localStorage.setItem(REMOTE_PASSWORD_KEY, remotePassword.trim());\n    window.location.reload();\n  };\n\n  const handleReset = () => {\n    setShowResetModal(true);\n  };\n\n  const confirmReset = () => {\n    localStorage.removeItem(REMOTE_MODE_KEY);\n    localStorage.removeItem(REMOTE_HOST_KEY);\n    localStorage.removeItem(REMOTE_PASSWORD_KEY);\n    window.location.reload();\n  };\n\n  return (\n    <Stack gap=\"lg\">\n      <Box>\n        <Group justify=\"space-between\" mb=\"md\">\n          <Title order={4}>\n            <Trans>Remote Connection</Trans>\n          </Title>\n          <Button\n            variant=\"subtle\"\n            color=\"red\"\n            size=\"compact-sm\"\n            leftSection={<IconRefresh size={14} />}\n            onClick={handleReset}\n          >\n            <Trans>Reset</Trans>\n          </Button>\n        </Group>\n\n        <Stack gap=\"md\">\n          <Group>\n            <Button\n              variant={isHost ? 'filled' : 'outline'}\n              leftSection={<IconServer size={16} />}\n              onClick={() => {\n                setRemoteMode('host');\n                localStorage.setItem(REMOTE_MODE_KEY, 'host');\n              }}\n            >\n              <Trans>Host</Trans>\n            </Button>\n            <Button\n              variant={!isHost ? 'filled' : 'outline'}\n              leftSection={<IconNetwork size={16} />}\n              onClick={() => {\n                setRemoteMode('client');\n                localStorage.setItem(REMOTE_MODE_KEY, 'client');\n              }}\n            >\n              <Trans>Client</Trans>\n            </Button>\n          </Group>\n\n          <Divider />\n\n          {isHost ? (\n            <Stack gap=\"md\">\n              <Text size=\"sm\" c=\"dimmed\">\n                <Trans>\n                  Share your LAN IP and password with clients to allow them to\n                  connect.\n                </Trans>\n              </Text>\n\n              <Group align=\"flex-end\">\n                <TextInput\n                  label={<Trans>LAN IP</Trans>}\n                  leftSection={<IconPlug size={18} />}\n                  value={showLanIp ? lanIp : '•••••••••••'}\n                  readOnly\n                  style={{ flex: 1 }}\n                  rightSection={\n                    <Group gap={4}>\n                      <Button\n                        variant=\"subtle\"\n                        size=\"compact-sm\"\n                        onClick={() => setShowLanIp(!showLanIp)}\n                      >\n                        {showLanIp ? (\n                          <IconEyeOff size={16} />\n                        ) : (\n                          <IconEye size={16} />\n                        )}\n                      </Button>\n                      <CopyToClipboard value={lanIp} size=\"xs\" />\n                    </Group>\n                  }\n                  rightSectionWidth={80}\n                />\n              </Group>\n\n              <Group align=\"flex-end\">\n                <TextInput\n                  label={<Trans>Password</Trans>}\n                  leftSection={<IconRouter size={18} />}\n                  value={showPassword ? remoteConfig.password : '•••••••••••'}\n                  readOnly\n                  style={{ flex: 1 }}\n                  rightSection={\n                    <Group gap={4}>\n                      <Button\n                        variant=\"subtle\"\n                        size=\"compact-sm\"\n                        onClick={() => setShowPassword(!showPassword)}\n                      >\n                        {showPassword ? (\n                          <IconEyeOff size={16} />\n                        ) : (\n                          <IconEye size={16} />\n                        )}\n                      </Button>\n                      <CopyToClipboard\n                        value={remoteConfig.password}\n                        size=\"xs\"\n                      />\n                    </Group>\n                  }\n                  rightSectionWidth={80}\n                />\n              </Group>\n            </Stack>\n          ) : (\n            <Stack gap=\"md\">\n              <Text size=\"sm\" c=\"dimmed\">\n                <Trans>\n                  Enter the host URL and password to connect to a remote\n                  PostyBirb instance.\n                </Trans>\n              </Text>\n\n              <TextInput\n                label={<Trans>Host URL</Trans>}\n                leftSection={<IconPlug size={18} />}\n                placeholder=\"192.168.1.100:9487\"\n                value={hostUrl}\n                onChange={(event) => {\n                  const { value } = event.currentTarget;\n                  setHostUrl(value);\n                  setIsConnectionValid(false);\n                }}\n              />\n\n              <TextInput\n                label={<Trans>Password</Trans>}\n                leftSection={<IconRouter size={18} />}\n                type={showPassword ? 'text' : 'password'}\n                value={remotePassword}\n                onChange={(event) => {\n                  const { value } = event.currentTarget;\n                  setRemotePassword(value);\n                  setIsConnectionValid(false);\n                }}\n                rightSection={\n                  <Button\n                    variant=\"subtle\"\n                    size=\"compact-sm\"\n                    onClick={() => setShowPassword(!showPassword)}\n                  >\n                    {showPassword ? (\n                      <IconEyeOff size={16} />\n                    ) : (\n                      <IconEye size={16} />\n                    )}\n                  </Button>\n                }\n              />\n\n              <Group>\n                <Button\n                  leftSection={<IconPlug size={16} />}\n                  loading={isTestingConnection}\n                  onClick={testConnection}\n                  disabled={!hostUrl?.trim() || !remotePassword?.trim()}\n                >\n                  <Trans>Test Connection</Trans>\n                </Button>\n                <Button\n                  leftSection={<IconDeviceFloppy size={16} />}\n                  color=\"green\"\n                  disabled={!isConnectionValid}\n                  onClick={handleSaveClientSettings}\n                >\n                  <Trans>Save</Trans>\n                </Button>\n              </Group>\n            </Stack>\n          )}\n        </Stack>\n      </Box>\n\n      <ConfirmActionModal\n        opened={showResetModal}\n        onClose={() => setShowResetModal(false)}\n        onConfirm={confirmReset}\n        title={<Trans>Reset Remote Settings</Trans>}\n        message={\n          <Trans>\n            Are you sure you want to reset all remote connection settings to\n            their defaults? This will clear the saved host URL and password.\n          </Trans>\n        }\n        confirmLabel={<Trans>Reset</Trans>}\n        confirmColor=\"red\"\n      />\n    </Stack>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/dialogs/settings-dialog/sections/spellchecker-settings-section.tsx",
    "content": "import { Trans, useLingui } from '@lingui/react/macro';\nimport {\n  Box,\n  MultiSelect,\n  Stack,\n  Switch,\n  TagsInput,\n  Text,\n  Title,\n} from '@mantine/core';\nimport { useQuery } from 'react-query';\nimport settingsApi from '../../../../api/settings.api';\nimport { useLocale } from '../../../../hooks/use-locale';\nimport { languages } from '../../../../i18n/languages';\n\nexport function SpellcheckerSettings() {\n  const allSpellcheckerLanguages = useQuery(\n    'allSpellcheckerLanguages',\n    () => window.electron.getAllSpellcheckerLanguages(),\n    { cacheTime: 0 },\n  );\n  const spellcheckerLanguages = useQuery('spellcheckerLanguages', () =>\n    window.electron.getSpellcheckerLanguages(),\n  );\n  const spellcheckerWords = useQuery(\n    'spellcheckerWords',\n    () => window.electron.getSpellcheckerWords(),\n    { cacheTime: 0 },\n  );\n  const startupSettings = useQuery(\n    'startup',\n    () => settingsApi.getStartupOptions().then((res) => res.body),\n    {\n      cacheTime: 0,\n    },\n  );\n  const { locale } = useLocale();\n  const { t } = useLingui();\n\n  if (\n    startupSettings.isLoading ||\n    spellcheckerLanguages.isLoading ||\n    spellcheckerWords.isLoading\n  )\n    return null;\n\n  const enabled = startupSettings.data?.spellchecker ?? true;\n  const isOSX = window.electron.platform === 'darwin';\n\n  return (\n    <Stack gap=\"lg\">\n      <Box>\n        <Title order={4}>\n          <Trans>Spellchecker Settings</Trans>\n        </Title>\n      </Box>\n\n      <Stack gap=\"xs\">\n        <Switch\n          label={<Trans>Enabled</Trans>}\n          checked={enabled}\n          onChange={(event) => {\n            window.electron.setSpellCheckerEnabled(event.currentTarget.checked);\n            settingsApi\n              .updateSystemStartupSettings({\n                spellchecker: event.currentTarget.checked,\n              })\n              .finally(startupSettings.refetch);\n          }}\n        />\n        {isOSX && (\n          <Text fw={500} size=\"xs\">\n            <Trans>\n              Spellchecker advanced configuration is controlled by system on\n              MacOS.\n            </Trans>\n          </Text>\n        )}\n        <MultiSelect\n          label={\n            <Text size=\"sm\" fw={500} mb=\"xs\">\n              <Trans>Languages to check</Trans>\n            </Text>\n          }\n          data={allSpellcheckerLanguages.data\n            ?.map((e) => {\n              const translated = languages.find(\n                (language) => language[1] === e,\n              )?.[0];\n              return { label: translated ? t(translated) : e, value: e };\n            })\n            .sort((a, b) => {\n              // Move translated language to top\n              const aTranslated = allSpellcheckerLanguages.data.includes(\n                a.label,\n              );\n              const bTranslated = allSpellcheckerLanguages.data.includes(\n                b.label,\n              );\n              return aTranslated === bTranslated ? 0 : aTranslated ? 1 : -1;\n            })}\n          value={spellcheckerLanguages.data}\n          searchable\n          clearable\n          onClear={() =>\n            window.electron\n              .setSpellcheckerLanguages(\n                allSpellcheckerLanguages.data?.includes(locale)\n                  ? ['en', locale]\n                  : ['en'],\n              )\n              .finally(() => spellcheckerLanguages.refetch())\n          }\n          onChange={(value) => {\n            window.electron\n              .setSpellcheckerLanguages(value)\n              .finally(() => spellcheckerLanguages.refetch());\n          }}\n          disabled={!enabled || isOSX}\n        />\n        <TagsInput\n          label={\n            <Text size=\"sm\" fw={500} mb=\"xs\">\n              <Trans>Custom words</Trans>\n            </Text>\n          }\n          value={spellcheckerWords.data}\n          clearable\n          onClear={() => {\n            window.electron\n              .setSpellcheckerWords([])\n              .finally(() => spellcheckerWords.refetch());\n          }}\n          onChange={(value) => {\n            window.electron\n              .setSpellcheckerWords(value)\n              .finally(() => spellcheckerWords.refetch());\n          }}\n          disabled={!enabled || isOSX}\n        />\n      </Stack>\n    </Stack>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/dialogs/settings-dialog/sections/tags-settings-section.tsx",
    "content": "/**\n * Tags Settings Section - Tag search provider settings.\n */\n\nimport { Trans, useLingui } from '@lingui/react/macro';\nimport { Box, Select, Stack, Switch, Text, Title } from '@mantine/core';\nimport settingsApi from '../../../../api/settings.api';\nimport { useSettings } from '../../../../stores';\n\nexport function TagsSettingsSection() {\n  const { t } = useLingui();\n  const settings = useSettings();\n\n  if (!settings) return null;\n\n  // Simple provider list - you may want to import this from elsewhere\n  const tagProviders = [\n    { label: t`None`, value: '' },\n    // eslint-disable-next-line lingui/no-unlocalized-strings\n    { label: 'e621', value: 'e621' },\n    // eslint-disable-next-line lingui/no-unlocalized-strings\n    { label: 'Danbooru', value: 'danbooru' },\n  ];\n\n  return (\n    <Stack gap=\"lg\">\n      <Box>\n        <Title order={4} mb=\"md\">\n          <Trans>Tags Settings</Trans>\n        </Title>\n\n        <Stack gap=\"md\">\n          <Box>\n            <Select\n              label={<Trans>Global tag search provider</Trans>}\n              data={tagProviders}\n              value={settings.tagSearchProvider?.id ?? ''}\n              onChange={(value) => {\n                settingsApi.update(settings.id, {\n                  settings: {\n                    ...settings.settings,\n                    tagSearchProvider: {\n                      ...settings.tagSearchProvider,\n                      id: value ?? '',\n                    },\n                  },\n                });\n              }}\n            />\n            <Text size=\"xs\" c=\"dimmed\" mt={5}>\n              <Trans>\n                Enable global tag autocomplete from sites like e621.\n              </Trans>\n            </Text>\n          </Box>\n\n          <Box>\n            <Switch\n              label={<Trans>Show wiki page related to tag on hover</Trans>}\n              checked={settings.tagSearchProvider?.showWikiInHelpOnHover ?? true}\n              onChange={(event) => {\n                settingsApi.update(settings.id, {\n                  settings: {\n                    ...settings.settings,\n                    tagSearchProvider: {\n                      ...settings.tagSearchProvider,\n                      showWikiInHelpOnHover: event.currentTarget.checked,\n                    },\n                  },\n                });\n              }}\n            />\n            <Text size=\"xs\" c=\"dimmed\" mt={5}>\n              <Trans>Not all tag providers support this feature.</Trans>\n            </Text>\n          </Box>\n        </Stack>\n      </Box>\n    </Stack>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/dialogs/settings-dialog/settings-dialog.module.css",
    "content": "/**\n * Settings Dialog styles - Discord/Slack-like settings layout\n */\n\n.modalContent {\n  background-color: var(--mantine-color-body);\n  overflow: hidden;\n  padding: 0 !important;\n  z-index: var(--z-modal-nested);\n}\n\n.modalBody {\n  padding: 0;\n  height: 80vh;\n  max-height: 700px;\n}\n\n.container {\n  display: flex;\n  height: 100%;\n}\n\n/* Sidebar Navigation */\n.sidebar {\n  width: 200px;\n  min-width: 200px;\n  background-color: var(--mantine-color-dark-7);\n  display: flex;\n  flex-direction: column;\n  border-right: 1px solid var(--mantine-color-dark-4);\n}\n\n:global([data-mantine-color-scheme='light']) .sidebar {\n  background-color: var(--mantine-color-gray-1);\n  border-right-color: var(--mantine-color-gray-3);\n}\n\n.sidebarHeader {\n  padding: var(--mantine-spacing-md);\n  border-bottom: 1px solid var(--mantine-color-dark-4);\n  height: var(--postybirb-header-height);\n}\n\n:global([data-mantine-color-scheme='light']) .sidebarHeader {\n  border-bottom-color: var(--mantine-color-gray-3);\n}\n\n.sidebarNav {\n  flex: 1;\n  padding: var(--mantine-spacing-xs);\n}\n\n.navLink {\n  border-radius: var(--mantine-radius-sm);\n}\n\n/* Content Area */\n.content {\n  flex: 1;\n  display: flex;\n  flex-direction: column;\n  min-width: 0;\n  background-color: var(--mantine-color-dark-6);\n}\n\n:global([data-mantine-color-scheme='light']) .content {\n  background-color: var(--mantine-color-white);\n}\n\n.contentHeader {\n  padding: var(--mantine-spacing-sm) var(--mantine-spacing-lg);\n  border-bottom: 1px solid var(--mantine-color-dark-4);\n  background-color: var(--mantine-color-dark-7);\n  height: var(--postybirb-header-height);\n}\n\n:global([data-mantine-color-scheme='light']) .contentHeader {\n  background-color: var(--mantine-color-gray-0);\n  border-bottom-color: var(--mantine-color-gray-3);\n}\n\n.contentBody {\n  flex: 1;\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/dialogs/settings-dialog/settings-dialog.tsx",
    "content": "/**\n * Settings Dialog - Modal dialog for application settings.\n * Uses a sidebar navigation pattern similar to Discord/Slack settings.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport {\n  Box,\n  CloseButton,\n  Group,\n  Modal,\n  NavLink,\n  ScrollArea,\n  Stack,\n  Text,\n  Title,\n} from '@mantine/core';\nimport {\n  IconBell,\n  IconDatabase,\n  IconDeviceDesktop,\n  IconFileDescription,\n  IconFileImport,\n  IconLanguage,\n  IconPalette,\n  IconRouter,\n  IconTags,\n} from '@tabler/icons-react';\nimport { useCallback, useState } from 'react';\nimport { useActiveDrawer, useDrawerActions } from '../../../stores';\nimport {\n  AppearanceSettingsSection,\n  AppSettingsSection,\n  DataSettingsSection,\n  DescriptionSettingsSection,\n  ImportSettingsSection,\n  NotificationsSettingsSection,\n  RemoteSettingsSection,\n  TagsSettingsSection,\n} from './sections';\nimport { SpellcheckerSettings } from './sections/spellchecker-settings-section';\nimport classes from './settings-dialog.module.css';\n\n// ============================================================================\n// Types\n// ============================================================================\n\ntype SettingsSection =\n  | 'appearance'\n  | 'app'\n  | 'data'\n  | 'description'\n  | 'notifications'\n  | 'remote'\n  | 'tags'\n  | 'spellchecker'\n  | 'import';\n\ninterface NavItem {\n  id: SettingsSection;\n  label: React.ReactNode;\n  icon: React.ReactNode;\n}\n\n// ============================================================================\n// Navigation Items\n// ============================================================================\n\nconst NAV_ITEMS: NavItem[] = [\n  {\n    id: 'appearance',\n    label: <Trans>Appearance</Trans>,\n    icon: <IconPalette size={18} />,\n  },\n  {\n    id: 'app',\n    label: <Trans>App</Trans>,\n    icon: <IconDeviceDesktop size={18} />,\n  },\n  {\n    id: 'description',\n    label: <Trans>Description</Trans>,\n    icon: <IconFileDescription size={18} />,\n  },\n  {\n    id: 'notifications',\n    label: <Trans>Notifications</Trans>,\n    icon: <IconBell size={18} />,\n  },\n  {\n    id: 'remote',\n    label: <Trans>Remote</Trans>,\n    icon: <IconRouter size={18} />,\n  },\n  {\n    id: 'tags',\n    label: <Trans>Tags</Trans>,\n    icon: <IconTags size={18} />,\n  },\n  {\n    id: 'spellchecker',\n    label: <Trans>Spellchecker</Trans>,\n    icon: <IconLanguage size={18} />,\n  },\n  {\n    id: 'import',\n    label: <Trans>Import</Trans>,\n    icon: <IconFileImport size={18} />,\n  },\n  {\n    id: 'data',\n    label: <Trans>Data</Trans>,\n    icon: <IconDatabase size={18} />,\n  },\n];\n\n// ============================================================================\n// Main Settings Dialog Component\n// ============================================================================\n\nexport function SettingsDialog() {\n  const activeDrawer = useActiveDrawer();\n  const { closeDrawer } = useDrawerActions();\n  const [activeSection, setActiveSection] =\n    useState<SettingsSection>('appearance');\n\n  const opened = activeDrawer === 'settings';\n\n  const renderSection = useCallback(() => {\n    switch (activeSection) {\n      case 'appearance':\n        return <AppearanceSettingsSection />;\n      case 'app':\n        return <AppSettingsSection />;\n      case 'description':\n        return <DescriptionSettingsSection />;\n      case 'notifications':\n        return <NotificationsSettingsSection />;\n      case 'remote':\n        return <RemoteSettingsSection />;\n      case 'tags':\n        return <TagsSettingsSection />;\n      case 'spellchecker':\n        return <SpellcheckerSettings />;\n      case 'import':\n        return <ImportSettingsSection />;\n      case 'data':\n        return <DataSettingsSection />;\n      default:\n        return null;\n    }\n  }, [activeSection]);\n\n  // Skip rendering entirely when not open\n  if (!opened) {\n    return null;\n  }\n\n  return (\n    <Modal\n      opened={opened}\n      onClose={closeDrawer}\n      size=\"xl\"\n      padding={0}\n      withCloseButton={false}\n      classNames={{\n        content: classes.modalContent,\n        body: classes.modalBody,\n      }}\n      zIndex=\"var(--z-modal-nested)\"\n    >\n      <div className={classes.container}>\n        {/* Sidebar Navigation */}\n        <nav className={classes.sidebar}>\n          <div className={classes.sidebarHeader}>\n            <Title order={4}>\n              <Trans>Settings</Trans>\n            </Title>\n          </div>\n          <ScrollArea className={classes.sidebarNav}>\n            <Stack gap={2}>\n              {NAV_ITEMS.map((item) => (\n                <NavLink\n                  key={item.id}\n                  label={item.label}\n                  leftSection={item.icon}\n                  active={activeSection === item.id}\n                  onClick={() => setActiveSection(item.id)}\n                  className={classes.navLink}\n                />\n              ))}\n            </Stack>\n          </ScrollArea>\n        </nav>\n\n        {/* Content Area */}\n        <div className={classes.content}>\n          {/* Header */}\n          <header className={classes.contentHeader}>\n            <Group justify=\"space-between\" align=\"center\">\n              <Group gap=\"xs\">\n                <Text c=\"dimmed\">\n                  <Trans>Settings</Trans>\n                </Text>\n                <Text c=\"dimmed\">&gt;</Text>\n                <Text fw={500}>\n                  {NAV_ITEMS.find((item) => item.id === activeSection)?.label}\n                </Text>\n              </Group>\n              <CloseButton onClick={closeDrawer} size=\"lg\" />\n            </Group>\n          </header>\n\n          {/* Scrollable Content */}\n          <ScrollArea className={classes.contentBody}>\n            <Box p=\"lg\">{renderSection()}</Box>\n          </ScrollArea>\n        </div>\n      </div>\n    </Modal>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/disclaimer/disclaimer.tsx",
    "content": "import { Trans } from '@lingui/react/macro';\nimport {\n  Button,\n  Checkbox,\n  Container,\n  Group,\n  Paper,\n  ScrollArea,\n  Stack,\n  Text,\n  Title,\n} from '@mantine/core';\nimport { PropsWithChildren, useEffect, useMemo, useState } from 'react';\nimport { isElectron } from '../../utils';\n\nexport type DisclaimerProps = {\n  onAccepted: () => void;\n  onDeclined: () => void;\n};\n\nconst DISCLAIMER_KEY = 'pb_disclaimer_accepted';\n\n// Best-effort attempt to close the app when user declines.\nfunction attemptAppQuit(): boolean {\n  try {\n    // Use the official preload-exposed API\n    if (window?.electron?.quit) {\n      window.electron.quit();\n      return true;\n    }\n  } catch {\n    // ignore and fallback\n  }\n\n  // Fallbacks: close the window; if blocked, navigate away\n  window.close();\n  setTimeout(() => {\n    try {\n      // As a last resort, navigate to a blank page to effectively end the session\n      // (useful during dev in a browser environment)\n      if (!document.hidden) {\n        window.location.href = 'about:blank';\n      }\n    } catch {\n      // no-op\n    }\n  }, 300);\n\n  return false;\n}\n\nexport function DisclaimerDisplay({ onAccepted, onDeclined }: DisclaimerProps) {\n  const [checked, setChecked] = useState(false);\n\n  // trap scroll focus on the content area for smaller windows\n  const viewportHeight = useMemo(\n    () => (typeof window !== 'undefined' ? window.innerHeight : 800),\n    [],\n  );\n\n  useEffect(() => {\n    // ensure focus is within the paper for accessibility\n    const el = document.getElementById('disclaimer-title');\n    el?.focus?.();\n  }, []);\n\n  const decline = () => {\n    onDeclined();\n    attemptAppQuit();\n  };\n\n  return (\n    <Container\n      size=\"lg\"\n      mih={viewportHeight}\n      style={{ display: 'flex', alignItems: 'center' }}\n    >\n      <Paper\n        shadow=\"md\"\n        p=\"xl\"\n        radius=\"md\"\n        withBorder\n        style={{ width: '100%' }}\n      >\n        <Stack gap=\"md\">\n          <Title id=\"disclaimer-title\" order={2} tabIndex={-1}>\n            <Trans>Legal Notice & Disclaimer</Trans>\n          </Title>\n          <ScrollArea.Autosize mah={320} type=\"always\">\n            <Stack gap=\"sm\">\n              <Text size=\"sm\">\n                <Trans>\n                  By using this application, you acknowledge that you are solely\n                  responsible for how you use it and for any content you create,\n                  upload, distribute, or interact with. The authors and\n                  maintainers provide this software as is without warranties of\n                  any kind and are not liable for any damages or losses\n                  resulting from its use.\n                </Trans>\n              </Text>\n              <Text size=\"sm\">\n                <Trans>\n                  You agree to comply with all applicable laws, terms of\n                  service, and policies of any third party platforms you connect\n                  to or interact with through this application. Do not use this\n                  software to infringe intellectual property rights, violate\n                  privacy, or circumvent platform rules.\n                </Trans>\n              </Text>\n              <Text size=\"sm\">\n                <Trans>\n                  If you do not agree with these terms, you must decline and\n                  exit the application.\n                </Trans>\n              </Text>\n            </Stack>\n          </ScrollArea.Autosize>\n\n          <Checkbox\n            checked={checked}\n            onChange={(e) => setChecked(e.currentTarget.checked)}\n            label={\n              <Trans>I have read, understand, and accept the disclaimer.</Trans>\n            }\n          />\n\n          <Group justify=\"space-between\" mt=\"sm\">\n            <Button variant=\"default\" color=\"gray\" onClick={decline}>\n              <Trans>Decline and Exit</Trans>\n            </Button>\n            <Button color=\"teal\" onClick={onAccepted} disabled={!checked}>\n              <Trans>Accept and Continue</Trans>\n            </Button>\n          </Group>\n        </Stack>\n      </Paper>\n    </Container>\n  );\n}\n\n// eslint-disable-next-line @typescript-eslint/ban-types\nexport function Disclaimer({ children }: PropsWithChildren<{}>) {\n  const initialAccepted = useMemo(() => {\n    try {\n      if (typeof window === 'undefined') return false;\n      return localStorage.getItem(DISCLAIMER_KEY) === 'true';\n    } catch {\n      return false;\n    }\n  }, []);\n\n  const [accepted, setAccepted] = useState(initialAccepted);\n\n  useEffect(() => {\n    try {\n      if (accepted) localStorage.setItem(DISCLAIMER_KEY, 'true');\n    } catch {\n      // ignore storage errors\n    }\n  }, [accepted]);\n\n  const handleDecline = (): void => {\n    // Best-effort quit from here too, in case Disclaimer fallback paths fail.\n    try {\n      if (isElectron() && window?.electron?.quit) {\n        window.electron.quit();\n        return;\n      }\n    } catch {\n      // fall through\n    }\n\n    // Fallbacks for browser/dev\n    window.close();\n    setTimeout(() => {\n      try {\n        if (!document.hidden) {\n          window.location.href = 'about:blank';\n        }\n      } catch {\n        // no-op\n      }\n    }, 300);\n  };\n\n  if (!accepted) {\n    return (\n      <DisclaimerDisplay\n        onAccepted={() => setAccepted(true)}\n        onDeclined={handleDecline}\n      />\n    );\n  }\n\n  return children;\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/drawers/converter-drawer/converter-drawer.tsx",
    "content": "/**\n * ConverterDrawer - Generic drawer for managing tag/user converters.\n * Features compact expandable cards, search, selectable rows with bulk delete,\n * and inline website conversion editing with dropdown to add new conversions.\n *\n * This is a shared implementation used by TagConverterDrawer and UserConverterDrawer.\n */\n\nimport { t } from '@lingui/core/macro';\nimport { Trans } from '@lingui/react/macro';\nimport {\n    ActionIcon,\n    Badge,\n    Box,\n    Card,\n    Checkbox,\n    Collapse,\n    Combobox,\n    Group,\n    ScrollArea,\n    Stack,\n    Text,\n    TextInput,\n    Tooltip,\n    useCombobox,\n} from '@mantine/core';\nimport { useDebouncedCallback, useDebouncedValue } from '@mantine/hooks';\nimport {\n    IconChevronDown,\n    IconChevronRight,\n    IconPlus,\n    IconTrash,\n    IconX,\n} from '@tabler/icons-react';\nimport { memo, useCallback, useMemo, useState } from 'react';\nimport { useWebsites } from '../../../stores/entity/website-store';\nimport type { BaseRecord } from '../../../stores/records/base-record';\nimport type { WebsiteRecord } from '../../../stores/records/website-record';\nimport {\n    showCreatedNotification,\n    showCreateErrorNotification,\n    showDeletedNotification,\n    showDeleteErrorNotification,\n    showUpdateErrorNotification,\n} from '../../../utils/notifications';\nimport { EmptyState } from '../../empty-state';\nimport { HoldToConfirmButton } from '../../hold-to-confirm';\nimport { SearchInput } from '../../shared';\nimport { SectionDrawer } from '../section-drawer';\n\n// ============================================================================\n// Types\n// ============================================================================\n\n/**\n * Interface for converter records (tag or user).\n */\nexport interface ConverterRecord extends BaseRecord {\n  readonly convertTo: Record<string, string>;\n  readonly conversionCount: number;\n}\n\n/**\n * API interface for converter operations.\n */\nexport interface ConverterApi<TCreateDto, TUpdateDto> {\n  create: (dto: TCreateDto) => Promise<unknown>;\n  update: (id: string, dto: TUpdateDto) => Promise<unknown>;\n  remove: (ids: string[]) => Promise<unknown>;\n}\n\n/**\n * Configuration for the converter drawer.\n */\nexport interface ConverterDrawerConfig<\n  TRecord extends ConverterRecord,\n  TCreateDto,\n  TUpdateDto,\n> {\n  /** Drawer title */\n  title: React.ReactNode;\n  /** The primary field name ('tag' or 'username') */\n  primaryField: 'tag' | 'username';\n  /** Get the primary value from a record */\n  getPrimaryValue: (record: TRecord) => string;\n  /** API for CRUD operations */\n  api: ConverterApi<TCreateDto, TUpdateDto>;\n  /** Create a DTO for the update API */\n  createUpdateDto: (\n    primaryValue: string,\n    convertTo: Record<string, string>\n  ) => TUpdateDto;\n  /** Create a DTO for the create API */\n  createCreateDto: (\n    primaryValue: string,\n    convertTo: Record<string, string>\n  ) => TCreateDto;\n  /** Entity name for notifications (e.g., \"tag converter\") */\n  entityName: string;\n  /** Duplicate field error message */\n  duplicateError: string;\n}\n\ninterface WebsiteOption {\n  id: string;\n  displayName: string;\n}\n\n// ============================================================================\n// Hooks\n// ============================================================================\n\n/**\n * Hook to manage converter search filtering and sorting.\n */\nfunction useConverterSearch<TRecord extends ConverterRecord>(\n  converters: TRecord[],\n  getPrimaryValue: (record: TRecord) => string,\n  searchQuery: string\n) {\n  const [debouncedSearch] = useDebouncedValue(searchQuery, 200);\n\n  const filteredAndSortedConverters = useMemo(() => {\n    let filtered = [...converters];\n\n    // Filter by search query\n    if (debouncedSearch.trim()) {\n      const lowerSearch = debouncedSearch.toLowerCase();\n      filtered = filtered.filter(\n        (converter) =>\n          getPrimaryValue(converter).toLowerCase().includes(lowerSearch) ||\n          Object.values(converter.convertTo).some((value) =>\n            value.toLowerCase().includes(lowerSearch)\n          )\n      );\n    }\n\n    // Sort alphabetically by primary field\n    filtered.sort((a, b) =>\n      getPrimaryValue(a)\n        .toLowerCase()\n        .localeCompare(getPrimaryValue(b).toLowerCase())\n    );\n\n    return filtered;\n  }, [converters, getPrimaryValue, debouncedSearch]);\n\n  return {\n    filteredConverters: filteredAndSortedConverters,\n    allConverters: converters,\n  };\n}\n\n// ============================================================================\n// Website Conversion Components\n// ============================================================================\n\n/**\n * Single website conversion row with editable value and delete button.\n */\nfunction WebsiteConversionRow({\n  websiteId,\n  websiteName,\n  value,\n  onValueChange,\n  onRemove,\n}: {\n  websiteId: string;\n  websiteName: string;\n  value: string;\n  onValueChange: (websiteId: string, value: string) => void;\n  onRemove: (websiteId: string) => void;\n}) {\n  const [localValue, setLocalValue] = useState(value);\n\n  const handleBlur = () => {\n    const trimmed = localValue.trim();\n    if (trimmed !== value) {\n      onValueChange(websiteId, trimmed);\n    }\n  };\n\n  const handleKeyDown = (e: React.KeyboardEvent) => {\n    if (e.key === 'Enter') {\n      (e.target as HTMLInputElement).blur();\n    }\n  };\n\n  return (\n    <Group gap=\"xs\" wrap=\"nowrap\">\n      <Text size=\"xs\" c=\"dimmed\" style={{ width: 100, flexShrink: 0 }} truncate>\n        {websiteName}\n      </Text>\n      <TextInput\n        flex={1}\n        size=\"xs\"\n        value={localValue}\n        onChange={(e) => setLocalValue(e.currentTarget.value)}\n        onBlur={handleBlur}\n        onKeyDown={handleKeyDown}\n      />\n      <Tooltip label={<Trans>Remove</Trans>}>\n        <ActionIcon\n          variant=\"subtle\"\n          color=\"red\"\n          size=\"sm\"\n          onClick={() => onRemove(websiteId)}\n        >\n          <IconX size={14} />\n        </ActionIcon>\n      </Tooltip>\n    </Group>\n  );\n}\n\n/**\n * Dropdown to add a new website conversion.\n */\nfunction AddWebsiteDropdown({\n  availableWebsites,\n  onAdd,\n}: {\n  availableWebsites: WebsiteOption[];\n  onAdd: (websiteId: string) => void;\n}) {\n  const combobox = useCombobox({\n    onDropdownClose: () => combobox.resetSelectedOption(),\n  });\n  const [search, setSearch] = useState('');\n\n  const filteredOptions = useMemo(() => {\n    if (!search.trim()) return availableWebsites;\n    const lowerSearch = search.toLowerCase();\n    return availableWebsites.filter((w) =>\n      w.displayName.toLowerCase().includes(lowerSearch)\n    );\n  }, [availableWebsites, search]);\n\n  if (availableWebsites.length === 0) {\n    return null;\n  }\n\n  return (\n    <Combobox\n      store={combobox}\n      onOptionSubmit={(val) => {\n        onAdd(val);\n        setSearch('');\n        combobox.closeDropdown();\n      }}\n    >\n      <Combobox.Target>\n        <TextInput\n          size=\"xs\"\n          placeholder={t`Add website`}\n          leftSection={<IconPlus size={14} />}\n          value={search}\n          onChange={(e) => {\n            setSearch(e.currentTarget.value);\n            combobox.openDropdown();\n            combobox.updateSelectedOptionIndex();\n          }}\n          onClick={() => combobox.openDropdown()}\n          onFocus={() => combobox.openDropdown()}\n          onBlur={() => {\n            combobox.closeDropdown();\n            setSearch('');\n          }}\n        />\n      </Combobox.Target>\n\n      <Combobox.Dropdown>\n        <Combobox.Options>\n          <ScrollArea.Autosize mah={200}>\n            {filteredOptions.length > 0 ? (\n              filteredOptions.map((website) => (\n                <Combobox.Option key={website.id} value={website.id}>\n                  {website.displayName}\n                </Combobox.Option>\n              ))\n            ) : (\n              <Combobox.Empty>\n                <Trans>No results found</Trans>\n              </Combobox.Empty>\n            )}\n          </ScrollArea.Autosize>\n        </Combobox.Options>\n      </Combobox.Dropdown>\n    </Combobox>\n  );\n}\n\n/**\n * Website conversions editor - shows active conversions with add/remove.\n */\nfunction WebsiteConversionsEditor({\n  converterId,\n  primaryValue,\n  convertTo,\n  onUpdate,\n  websites,\n  websiteMap,\n}: {\n  converterId: string;\n  primaryValue: string;\n  convertTo: Record<string, string>;\n  onUpdate: (id: string, convertTo: Record<string, string>) => Promise<void>;\n  websites: WebsiteRecord[];\n  websiteMap: Map<string, string>;\n}) {\n  const [localConvertTo, setLocalConvertTo] =\n    useState<Record<string, string>>(convertTo);\n\n  // Websites that already have conversions\n  const activeWebsiteIds = useMemo(\n    () => Object.keys(localConvertTo),\n    [localConvertTo]\n  );\n\n  // Websites available to add (sorted alphabetically)\n  const availableWebsites = useMemo(\n    () =>\n      websites\n        .filter((w) => !activeWebsiteIds.includes(w.id))\n        .map((w) => ({ id: w.id, displayName: w.displayName }))\n        .sort((a, b) => a.displayName.localeCompare(b.displayName)),\n    [websites, activeWebsiteIds]\n  );\n\n  // Debounced save\n  const debouncedSave = useDebouncedCallback(\n    async (newConvertTo: Record<string, string>) => {\n      try {\n        // Filter out empty values before saving\n        const filtered = Object.fromEntries(\n          Object.entries(newConvertTo).filter(([, v]) => v.trim().length > 0)\n        );\n        await onUpdate(converterId, filtered);\n      } catch {\n        showUpdateErrorNotification(primaryValue);\n      }\n    },\n    300\n  );\n\n  const handleValueChange = (websiteId: string, value: string) => {\n    const newConvertTo = { ...localConvertTo, [websiteId]: value };\n    setLocalConvertTo(newConvertTo);\n    debouncedSave(newConvertTo);\n  };\n\n  const handleRemove = (websiteId: string) => {\n    const newConvertTo = { ...localConvertTo };\n    delete newConvertTo[websiteId];\n    setLocalConvertTo(newConvertTo);\n    debouncedSave(newConvertTo);\n  };\n\n  const handleAdd = (websiteId: string) => {\n    const newConvertTo = { ...localConvertTo, [websiteId]: '' };\n    setLocalConvertTo(newConvertTo);\n    // Don't save yet - let user enter a value first\n  };\n\n  return (\n    <Stack gap=\"xs\" mt=\"xs\">\n      {activeWebsiteIds.length > 0 ? (\n        activeWebsiteIds\n          .sort((a, b) =>\n            (websiteMap.get(a) ?? '').localeCompare(websiteMap.get(b) ?? '')\n          )\n          .map((websiteId) => (\n            <WebsiteConversionRow\n              key={websiteId}\n              websiteId={websiteId}\n              websiteName={websiteMap.get(websiteId) ?? websiteId}\n              value={localConvertTo[websiteId] ?? ''}\n              onValueChange={handleValueChange}\n              onRemove={handleRemove}\n            />\n          ))\n      ) : (\n        <EmptyState preset=\"no-records\" size=\"sm\" />\n      )}\n      <AddWebsiteDropdown\n        availableWebsites={availableWebsites}\n        onAdd={handleAdd}\n      />\n    </Stack>\n  );\n}\n\n// ============================================================================\n// Converter Card Component\n// ============================================================================\n\n/**\n * Compact expandable converter card.\n * Memoized to prevent re-rendering unchanged cards when parent state changes\n * (e.g., search query, selection of other cards).\n */\nconst ConverterCard = memo(({\n  converter,\n  isSelected,\n  onSelect,\n  isExpanded,\n  onToggleExpand,\n  config,\n  websites,\n  websiteMap,\n}: {\n  converter: ConverterRecord;\n  isSelected: boolean;\n  onSelect: (id: string, selected: boolean) => void;\n  isExpanded: boolean;\n  onToggleExpand: (id: string) => void;\n  config: ConverterDrawerConfig<ConverterRecord, unknown, unknown>;\n  websites: WebsiteRecord[];\n  websiteMap: Map<string, string>;\n}) => {\n  const primaryValue = config.getPrimaryValue(converter);\n  const [localValue, setLocalValue] = useState(primaryValue);\n\n  const handleBlur = async () => {\n    const trimmed = localValue.trim();\n    if (!trimmed) {\n      setLocalValue(primaryValue);\n      return;\n    }\n    if (trimmed === primaryValue) return;\n\n    try {\n      const dto = config.createUpdateDto(trimmed, converter.convertTo);\n      await config.api.update(converter.id, dto);\n    } catch {\n      showUpdateErrorNotification(primaryValue);\n      setLocalValue(primaryValue);\n    }\n  };\n\n  const handleKeyDown = (e: React.KeyboardEvent) => {\n    if (e.key === 'Enter') {\n      (e.target as HTMLInputElement).blur();\n    } else if (e.key === 'Escape') {\n      setLocalValue(primaryValue);\n    }\n  };\n\n  const handleUpdate = useCallback(\n    async (id: string, convertTo: Record<string, string>) => {\n      const dto = config.createUpdateDto(primaryValue, convertTo);\n      await config.api.update(id, dto);\n    },\n    [config, primaryValue]\n  );\n\n  return (\n    <Card padding=\"xs\" withBorder shadow=\"0\" data-tour-id=\"converter-card\">\n      <Group gap=\"xs\" wrap=\"nowrap\">\n        <Checkbox\n          checked={isSelected}\n          onChange={(e) => onSelect(converter.id, e.currentTarget.checked)}\n          // eslint-disable-next-line lingui/no-unlocalized-strings\n          aria-label=\"Select converter\"\n        />\n        <ActionIcon\n          variant=\"subtle\"\n          size=\"sm\"\n          onClick={() => onToggleExpand(converter.id)}\n        >\n          {isExpanded ? (\n            <IconChevronDown size={16} />\n          ) : (\n            <IconChevronRight size={16} />\n          )}\n        </ActionIcon>\n        <TextInput\n          flex={1}\n          size=\"xs\"\n          value={localValue}\n          onChange={(e) => setLocalValue(e.currentTarget.value)}\n          onBlur={handleBlur}\n          onKeyDown={handleKeyDown}\n          styles={{ input: { fontWeight: 500 } }}\n        />\n        <Badge size=\"sm\" variant=\"light\" color=\"blue\">\n          {converter.conversionCount}\n        </Badge>\n      </Group>\n      <Collapse in={isExpanded}>\n        <Box pl={60}>\n          <WebsiteConversionsEditor\n            converterId={converter.id}\n            primaryValue={primaryValue}\n            convertTo={converter.convertTo}\n            onUpdate={handleUpdate}\n            websites={websites}\n            websiteMap={websiteMap}\n          />\n        </Box>\n      </Collapse>\n    </Card>\n  );\n}) as <TRecord extends ConverterRecord, TCreateDto, TUpdateDto>(\n  props: {\n    converter: TRecord;\n    isSelected: boolean;\n    onSelect: (id: string, selected: boolean) => void;\n    isExpanded: boolean;\n    onToggleExpand: (id: string) => void;\n    config: ConverterDrawerConfig<TRecord, TCreateDto, TUpdateDto>;\n    websites: WebsiteRecord[];\n    websiteMap: Map<string, string>;\n  }\n) => React.JSX.Element;\n\n// ============================================================================\n// Action Components\n// ============================================================================\n\n/**\n * Delete selected converters button.\n */\nfunction DeleteSelectedButton({\n  selectedIds,\n  onDeleted,\n  onRemove,\n  entityName,\n}: {\n  selectedIds: Set<string>;\n  onDeleted: () => void;\n  onRemove: (ids: string[]) => Promise<unknown>;\n  entityName: string;\n}) {\n  const count = selectedIds.size;\n\n  const handleDelete = useCallback(async () => {\n    try {\n      await onRemove([...selectedIds]);\n      showDeletedNotification(count);\n      onDeleted();\n    } catch {\n      showDeleteErrorNotification();\n    }\n  }, [selectedIds, count, onDeleted, onRemove]);\n\n  return (\n    <Tooltip\n      label={\n        count === 0 ? (\n          <Trans>Select items to delete</Trans>\n        ) : (\n          <Trans>Hold to delete {count} item(s)</Trans>\n        )\n      }\n    >\n      <HoldToConfirmButton\n        variant=\"subtle\"\n        color=\"red\"\n        disabled={count === 0}\n        onConfirm={handleDelete}\n      >\n        <IconTrash size={18} />\n      </HoldToConfirmButton>\n    </Tooltip>\n  );\n}\n\n/**\n * Create new converter form.\n */\nfunction CreateConverterForm({\n  existingValues,\n  onCreate,\n  entityName,\n  duplicateError,\n}: {\n  existingValues: Set<string>;\n  onCreate: (primaryValue: string) => Promise<void>;\n  entityName: string;\n  duplicateError: string;\n}) {\n  const [value, setValue] = useState('');\n  const [isCreating, setIsCreating] = useState(false);\n\n  const isDuplicate = existingValues.has(value.trim().toLowerCase());\n\n  const handleCreate = async () => {\n    const trimmedValue = value.trim();\n    if (!trimmedValue || isDuplicate) return;\n\n    setIsCreating(true);\n    try {\n      await onCreate(trimmedValue);\n      showCreatedNotification(trimmedValue);\n      setValue('');\n    } catch {\n      showCreateErrorNotification(trimmedValue);\n    } finally {\n      setIsCreating(false);\n    }\n  };\n\n  const handleKeyDown = (e: React.KeyboardEvent) => {\n    if (e.key === 'Enter') {\n      handleCreate();\n    }\n  };\n\n  return (\n    <Group gap=\"xs\">\n      <TextInput\n        flex={1}\n        size=\"sm\"\n        placeholder={t`New`}\n        leftSection={<IconPlus size={16} />}\n        value={value}\n        onChange={(e) => setValue(e.currentTarget.value)}\n        onKeyDown={handleKeyDown}\n        disabled={isCreating}\n        error={isDuplicate ? duplicateError : undefined}\n      />\n      <Tooltip label={<Trans>Create</Trans>}>\n        <ActionIcon\n          variant=\"filled\"\n          onClick={handleCreate}\n          disabled={!value.trim() || isDuplicate || isCreating}\n          loading={isCreating}\n        >\n          <IconPlus size={16} />\n        </ActionIcon>\n      </Tooltip>\n    </Group>\n  );\n}\n\n// ============================================================================\n// Main Generic Drawer Component\n// ============================================================================\n\n/**\n * Props for the generic ConverterDrawer component.\n */\nexport interface ConverterDrawerProps<\n  TRecord extends ConverterRecord,\n  TCreateDto,\n  TUpdateDto,\n> {\n  /** Whether the drawer is open */\n  opened: boolean;\n  /** Called when drawer should close */\n  onClose: () => void;\n  /** All converter records */\n  converters: TRecord[];\n  /** Configuration for the drawer */\n  config: ConverterDrawerConfig<TRecord, TCreateDto, TUpdateDto>;\n}\n\n/**\n * Generic converter drawer component.\n * Used by TagConverterDrawer and UserConverterDrawer.\n */\nexport function ConverterDrawer<\n  TRecord extends ConverterRecord,\n  TCreateDto,\n  TUpdateDto,\n>({\n  opened,\n  onClose,\n  converters,\n  config,\n}: ConverterDrawerProps<TRecord, TCreateDto, TUpdateDto>) {\n  const [searchQuery, setSearchQuery] = useState('');\n  const { filteredConverters, allConverters } = useConverterSearch(\n    converters,\n    config.getPrimaryValue,\n    searchQuery\n  );\n\n  const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());\n  const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());\n\n  // Single website subscription shared by all ConverterCards\n  const websites = useWebsites();\n  const websiteMap = useMemo(() => {\n    const map = new Map<string, string>();\n    websites.forEach((w) => map.set(w.id, w.displayName));\n    return map;\n  }, [websites]);\n\n  // Existing values for duplicate check (case-insensitive)\n  const existingValues = useMemo(\n    () =>\n      new Set(allConverters.map((c) => config.getPrimaryValue(c).toLowerCase())),\n    [allConverters, config]\n  );\n\n  const handleSelect = useCallback((id: string, selected: boolean) => {\n    setSelectedIds((prev) => {\n      const next = new Set(prev);\n      if (selected) {\n        next.add(id);\n      } else {\n        next.delete(id);\n      }\n      return next;\n    });\n  }, []);\n\n  const handleSelectAll = useCallback(\n    (selected: boolean) => {\n      if (selected) {\n        setSelectedIds(new Set(filteredConverters.map((c) => c.id)));\n      } else {\n        setSelectedIds(new Set());\n      }\n    },\n    [filteredConverters]\n  );\n\n  const handleToggleExpand = useCallback((id: string) => {\n    setExpandedIds((prev) => {\n      const next = new Set(prev);\n      if (next.has(id)) {\n        next.delete(id);\n      } else {\n        next.add(id);\n      }\n      return next;\n    });\n  }, []);\n\n  const handleClearSelection = useCallback(() => {\n    setSelectedIds(new Set());\n  }, []);\n\n  const handleCreate = useCallback(\n    async (primaryValue: string) => {\n      const dto = config.createCreateDto(primaryValue, {});\n      await config.api.create(dto);\n    },\n    [config]\n  );\n\n  const allSelected =\n    filteredConverters.length > 0 &&\n    selectedIds.size === filteredConverters.length;\n  const someSelected = selectedIds.size > 0 && !allSelected;\n\n  return (\n    <SectionDrawer\n      opened={opened}\n      onClose={onClose}\n      title={config.title}\n      width={500}\n    >\n      <Stack gap=\"md\" h=\"100%\">\n        {/* Create form */}\n        <Box data-tour-id=\"converter-create\">\n        <CreateConverterForm\n          existingValues={existingValues}\n          onCreate={handleCreate}\n          entityName={config.entityName}\n          duplicateError={config.duplicateError}\n        />\n\n        </Box>\n\n        {/* Search and actions */}\n        <Group gap=\"xs\" data-tour-id=\"converter-search\">\n          <SearchInput\n            flex={1}\n            size=\"sm\"\n            value={searchQuery}\n            onChange={setSearchQuery}\n            onClear={() => setSearchQuery('')}\n          />\n          <DeleteSelectedButton\n            selectedIds={selectedIds}\n            onDeleted={handleClearSelection}\n            onRemove={config.api.remove}\n            entityName={config.entityName}\n          />\n        </Group>\n\n        {/* Select all */}\n        {filteredConverters.length > 0 && (\n          <Checkbox\n            checked={allSelected}\n            indeterminate={someSelected}\n            onChange={(e) => handleSelectAll(e.currentTarget.checked)}\n            label={<Trans>Select all</Trans>}\n            size=\"xs\"\n          />\n        )}\n\n        {/* Converter list */}\n        <Box style={{ flex: 1, overflow: 'auto' }}>\n          {filteredConverters.length > 0 ? (\n            <Stack gap=\"xs\">\n              {filteredConverters.map((converter) => (\n                <ConverterCard\n                  key={converter.id}\n                  converter={converter}\n                  isSelected={selectedIds.has(converter.id)}\n                  onSelect={handleSelect}\n                  isExpanded={expandedIds.has(converter.id)}\n                  onToggleExpand={handleToggleExpand}\n                  config={config}\n                  websites={websites}\n                  websiteMap={websiteMap}\n                />\n              ))}\n            </Stack>\n          ) : (\n            <EmptyState\n              preset={searchQuery.trim() ? 'no-results' : 'no-records'}\n            />\n          )}\n        </Box>\n      </Stack>\n    </SectionDrawer>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/drawers/converter-drawer/index.ts",
    "content": "export {\n    ConverterDrawer,\n    type ConverterApi,\n    type ConverterDrawerConfig,\n    type ConverterDrawerProps,\n    type ConverterRecord\n} from './converter-drawer';\n\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx",
    "content": "/**\n * CustomShortcutsDrawer - Drawer for managing custom text shortcuts.\n * Features expandable cards for editing, search, and inline name editing.\n */\n\nimport { t } from '@lingui/core/macro';\nimport { Trans } from '@lingui/react/macro';\nimport {\n  ActionIcon,\n  Box,\n  Card,\n  Collapse,\n  Group,\n  ScrollArea,\n  Stack,\n  Text,\n  TextInput,\n  Tooltip,\n} from '@mantine/core';\nimport { useDebouncedCallback, useDebouncedValue } from '@mantine/hooks';\nimport type { Description } from '@postybirb/types';\nimport {\n  IconChevronDown,\n  IconChevronRight,\n  IconHelp,\n  IconPlus,\n  IconX,\n} from '@tabler/icons-react';\nimport React, { useCallback, useMemo, useState } from 'react';\nimport customShortcutApi from '../../../api/custom-shortcut.api';\nimport {\n  useCustomShortcuts,\n  useCustomShortcutsLoading,\n} from '../../../stores/entity/custom-shortcut-store';\nimport type { CustomShortcutRecord } from '../../../stores/records/custom-shortcut-record';\nimport { useTourActions } from '../../../stores/ui/tour-store';\nimport {\n  showCreatedNotification,\n  showCreateErrorNotification,\n  showDeletedNotification,\n  showDeleteErrorNotification,\n  showUpdateErrorNotification,\n} from '../../../utils/notifications';\nimport { EmptyState } from '../../empty-state';\nimport { HoldToConfirmButton } from '../../hold-to-confirm';\nimport { CUSTOM_SHORTCUTS_TOUR_ID } from '../../onboarding-tour/tours/custom-shortcuts-tour';\nimport { DescriptionEditor, SearchInput } from '../../shared';\nimport { SectionDrawer } from '../section-drawer';\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface CustomShortcutsDrawerProps {\n  /** Whether the drawer is open */\n  opened: boolean;\n  /** Callback when the drawer should close */\n  onClose: () => void;\n}\n\n// ============================================================================\n// Hooks\n// ============================================================================\n\n/**\n * Hook to filter and sort custom shortcuts.\n */\nfunction useShortcutSearch(\n  shortcuts: CustomShortcutRecord[],\n  searchQuery: string,\n) {\n  const [debouncedSearch] = useDebouncedValue(searchQuery, 200);\n\n  const filteredAndSortedShortcuts = useMemo(() => {\n    let filtered = [...shortcuts];\n\n    // Filter by search query\n    if (debouncedSearch.trim()) {\n      const lowerSearch = debouncedSearch.toLowerCase();\n      filtered = filtered.filter((shortcut) =>\n        shortcut.name.toLowerCase().includes(lowerSearch),\n      );\n    }\n\n    // Sort alphabetically by name\n    filtered.sort((a, b) =>\n      a.name.toLowerCase().localeCompare(b.name.toLowerCase()),\n    );\n\n    return filtered;\n  }, [shortcuts, debouncedSearch]);\n\n  return filteredAndSortedShortcuts;\n}\n\n// ============================================================================\n// Shortcut Card Component\n// ============================================================================\n\ninterface ShortcutCardProps {\n  shortcut: CustomShortcutRecord;\n  isExpanded: boolean;\n  onToggleExpand: (id: string) => void;\n  onDelete: (id: string) => void;\n}\n\nconst ShortcutCard = React.memo(\n  ({ shortcut, isExpanded, onToggleExpand, onDelete }: ShortcutCardProps) => {\n    const [name, setName] = useState(shortcut.name);\n    const [isEditingName, setIsEditingName] = useState(false);\n    const [localDescription, setLocalDescription] = useState<Description>(\n      shortcut.shortcut || [],\n    );\n\n    const handleNameBlur = async () => {\n      setIsEditingName(false);\n      const trimmed = name.trim();\n      if (trimmed && trimmed !== shortcut.name) {\n        try {\n          await customShortcutApi.update(shortcut.id, {\n            name: trimmed,\n            shortcut: shortcut.shortcut,\n          });\n        } catch (error) {\n          showUpdateErrorNotification(t`shortcut`);\n          setName(shortcut.name);\n        }\n      } else {\n        setName(shortcut.name);\n      }\n    };\n\n    const handleNameKeyDown = (e: React.KeyboardEvent) => {\n      if (e.key === 'Enter') {\n        (e.target as HTMLInputElement).blur();\n      } else if (e.key === 'Escape') {\n        setName(shortcut.name);\n        setIsEditingName(false);\n      }\n    };\n\n    const handleDescriptionChange = (value: Description) => {\n      setLocalDescription(value);\n      handleSave();\n    };\n\n    const handleSave = useDebouncedCallback(\n      async () => {\n        try {\n          await customShortcutApi.update(shortcut.id, {\n            name: shortcut.name,\n            shortcut: localDescription,\n          });\n        } catch (error) {\n          showUpdateErrorNotification(t`shortcut`);\n        }\n      },\n      { delay: 1000, flushOnUnmount: false },\n    );\n\n    return (\n      <Card withBorder p=\"xs\" data-tour-id=\"shortcuts-card\">\n        {/* Header row */}\n        <Group gap=\"xs\" wrap=\"nowrap\" align=\"center\">\n          {/* Expand/collapse button */}\n          <ActionIcon\n            variant=\"subtle\"\n            size=\"sm\"\n            onClick={() => onToggleExpand(shortcut.id)}\n            aria-label={isExpanded ? t`Collapse` : t`Expand`}\n          >\n            {isExpanded ? (\n              <IconChevronDown size={16} />\n            ) : (\n              <IconChevronRight size={16} />\n            )}\n          </ActionIcon>\n\n          {/* Name (editable) */}\n          {isEditingName ? (\n            <TextInput\n              size=\"xs\"\n              value={name}\n              onChange={(e) => setName(e.currentTarget.value)}\n              onBlur={handleNameBlur}\n              onKeyDown={handleNameKeyDown}\n              autoFocus\n              maxLength={64}\n              flex={1}\n            />\n          ) : (\n            <Text\n              size=\"sm\"\n              fw={500}\n              flex={1}\n              style={{ cursor: 'pointer' }}\n              onClick={() => setIsEditingName(true)}\n              truncate\n            >\n              {shortcut.name}\n            </Text>\n          )}\n\n          {/* Delete button */}\n          <Tooltip label={<Trans>Hold to delete</Trans>}>\n            <HoldToConfirmButton\n              onConfirm={() => onDelete(shortcut.id)}\n              size=\"xs\"\n              color=\"red\"\n              variant=\"subtle\"\n            >\n              <IconX size={14} />\n            </HoldToConfirmButton>\n          </Tooltip>\n        </Group>\n\n        {/* Expanded content */}\n        <Collapse in={isExpanded}>\n          <Box mt=\"xs\">\n            <DescriptionEditor\n              value={localDescription}\n              onChange={handleDescriptionChange}\n              showCustomShortcuts={false}\n              minHeight={80}\n            />\n          </Box>\n        </Collapse>\n      </Card>\n    );\n  },\n);\n\n// ============================================================================\n// Create Shortcut Form\n// ============================================================================\n\ninterface CreateShortcutFormProps {\n  onSuccess: () => void;\n  onCancel: () => void;\n  existingNames: Set<string>;\n}\n\nfunction CreateShortcutForm({\n  onSuccess,\n  onCancel,\n  existingNames,\n}: CreateShortcutFormProps) {\n  const [name, setName] = useState('');\n  const [error, setError] = useState<string | null>(null);\n\n  const handleSubmit = async () => {\n    const trimmed = name.trim();\n    if (!trimmed) {\n      setError(t`Name is required`);\n      return;\n    }\n    if (existingNames.has(trimmed.toLowerCase())) {\n      setError(t`A shortcut with this name already exists`);\n      return;\n    }\n\n    try {\n      await customShortcutApi.create({ name: trimmed });\n      showCreatedNotification(t`shortcut`);\n      onSuccess();\n    } catch (e) {\n      showCreateErrorNotification(t`shortcut`);\n    }\n  };\n\n  const handleKeyDown = (e: React.KeyboardEvent) => {\n    if (e.key === 'Enter') {\n      handleSubmit();\n    } else if (e.key === 'Escape') {\n      onCancel();\n    }\n  };\n\n  return (\n    <Card withBorder p=\"xs\">\n      <Group gap=\"xs\" wrap=\"nowrap\">\n        <TextInput\n          placeholder={t`Shortcut name`}\n          size=\"xs\"\n          value={name}\n          onChange={(e) => {\n            setName(e.currentTarget.value);\n            setError(null);\n          }}\n          onKeyDown={handleKeyDown}\n          error={error}\n          autoFocus\n          maxLength={64}\n          flex={1}\n        />\n        <ActionIcon\n          variant=\"filled\"\n          color=\"blue\"\n          size=\"sm\"\n          onClick={handleSubmit}\n          aria-label={t`Create`}\n        >\n          <IconPlus size={14} />\n        </ActionIcon>\n        <ActionIcon\n          variant=\"subtle\"\n          size=\"sm\"\n          onClick={onCancel}\n          aria-label={t`Cancel`}\n        >\n          <IconX size={14} />\n        </ActionIcon>\n      </Group>\n    </Card>\n  );\n}\n\n// ============================================================================\n// Main Component\n// ============================================================================\n\nexport function CustomShortcutsDrawer({\n  opened,\n  onClose,\n}: CustomShortcutsDrawerProps) {\n  const shortcuts = useCustomShortcuts();\n  const loading = useCustomShortcutsLoading();\n  const [searchQuery, setSearchQuery] = useState('');\n  const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());\n  const [isCreating, setIsCreating] = useState(false);\n\n  const filteredShortcuts = useShortcutSearch(shortcuts, searchQuery);\n  const { startTour } = useTourActions();\n\n  // Existing shortcut names for duplicate checking\n  const existingNames = useMemo(\n    () => new Set(shortcuts.map((s) => s.name.toLowerCase())),\n    [shortcuts],\n  );\n\n  const toggleExpanded = useCallback((id: string) => {\n    setExpandedIds((prev) => {\n      const next = new Set(prev);\n      if (next.has(id)) {\n        next.delete(id);\n      } else {\n        next.add(id);\n      }\n      return next;\n    });\n  }, []);\n\n  const handleDelete = useCallback(async (id: string) => {\n    try {\n      await customShortcutApi.remove([id]);\n      showDeletedNotification(1);\n    } catch (error) {\n      showDeleteErrorNotification();\n    }\n  }, []);\n\n  const handleCreateSuccess = useCallback(() => {\n    setIsCreating(false);\n  }, []);\n\n  const isEmpty = filteredShortcuts.length === 0 && !searchQuery.trim();\n  const noResults = filteredShortcuts.length === 0 && searchQuery.trim();\n\n  return (\n    <SectionDrawer\n      opened={opened}\n      onClose={onClose}\n      title={\n        <Group gap=\"xs\">\n          <Trans>Custom Shortcuts</Trans>\n          <Tooltip label={<Trans>Shortcuts Tour</Trans>}>\n            <ActionIcon\n              variant=\"subtle\"\n              size=\"xs\"\n              onClick={() => startTour(CUSTOM_SHORTCUTS_TOUR_ID)}\n            >\n              <IconHelp size={16} />\n            </ActionIcon>\n          </Tooltip>\n        </Group>\n      }\n      width={520}\n    >\n      <Stack gap=\"sm\" h=\"100%\">\n        {/* Search and add button */}\n        <Group gap=\"xs\" wrap=\"nowrap\" data-tour-id=\"shortcuts-search\">\n          <SearchInput\n            value={searchQuery}\n            onChange={setSearchQuery}\n            size=\"xs\"\n            style={{ flex: 1 }}\n          />\n          <Tooltip label={<Trans>Add shortcut</Trans>}>\n            <ActionIcon\n              data-tour-id=\"shortcuts-create\"\n              variant=\"light\"\n              color=\"blue\"\n              size=\"md\"\n              onClick={() => setIsCreating(true)}\n              disabled={isCreating}\n              aria-label={t`Add shortcut`}\n            >\n              <IconPlus size={16} />\n            </ActionIcon>\n          </Tooltip>\n        </Group>\n\n        {/* Create form */}\n        {isCreating && (\n          <CreateShortcutForm\n            onSuccess={handleCreateSuccess}\n            onCancel={() => setIsCreating(false)}\n            existingNames={existingNames}\n          />\n        )}\n\n        {/* Content area */}\n        <ScrollArea flex={1} offsetScrollbars>\n          {isEmpty && !isCreating && <EmptyState />}\n\n          {noResults && <EmptyState />}\n\n          <Stack gap=\"xs\">\n            {filteredShortcuts.map((shortcut) => (\n              <ShortcutCard\n                key={shortcut.id}\n                shortcut={shortcut}\n                isExpanded={expandedIds.has(shortcut.id)}\n                onToggleExpand={toggleExpanded}\n                onDelete={handleDelete}\n              />\n            ))}\n          </Stack>\n        </ScrollArea>\n      </Stack>\n    </SectionDrawer>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/drawers/custom-shortcuts-drawer/index.ts",
    "content": "export { CustomShortcutsDrawer } from './custom-shortcuts-drawer';\nexport type { CustomShortcutsDrawerProps } from './custom-shortcuts-drawer';\n\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/drawers/drawers.tsx",
    "content": "/**\n * Drawer components for the remake UI.\n * These are placeholders that will be implemented later.\n * Drawers slide out from the section panel area using custom SectionDrawer.\n */\n\nimport {\n    useActiveDrawer,\n    useDrawerActions\n} from '../../stores/ui/drawer-store';\n\n// Import the CustomShortcutsDrawer implementation for wrapping\nimport { CustomShortcutsDrawer as CustomShortcutsDrawerComponent } from './custom-shortcuts-drawer';\n\n// Re-export the SettingsDialog\nexport { SettingsDialog } from '../dialogs/settings-dialog/settings-dialog';\n\n// Re-export the TagGroupDrawer\nexport { TagGroupDrawer } from './tag-group-drawer';\n\n// Re-export the NotificationsDrawer\nexport { NotificationsDrawer } from './notifications-drawer';\n\n// Re-export the TagConverterDrawer\nexport { TagConverterDrawer } from './tag-converter-drawer';\n\n// Re-export the UserConverterDrawer\nexport { UserConverterDrawer } from './user-converter-drawer';\n\n// Re-export the FileWatcherDrawer\nexport { FileWatcherDrawer } from './file-watcher-drawer';\n\n// Re-export the ScheduleDrawer\nexport { ScheduleDrawer } from './schedule-drawer';\n\n/**\n * Custom shortcuts drawer wrapper.\n * Connects the drawer to the UI store.\n * Returns null when closed to avoid running the inner component's hooks.\n */\nexport function CustomShortcutsDrawer() {\n  const activeDrawer = useActiveDrawer();\n  const { closeDrawer } = useDrawerActions();\n\n  if (activeDrawer !== 'customShortcuts') return null;\n\n  return (\n    <CustomShortcutsDrawerComponent opened onClose={closeDrawer} />\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx",
    "content": "/**\n * FileWatcherDrawer - Drawer for managing directory/file watchers.\n * Watches folders and automatically imports new files as submissions.\n */\n\nimport { t } from '@lingui/core/macro';\nimport { Trans } from '@lingui/react/macro';\nimport {\n    ActionIcon,\n    Box,\n    Card,\n    Group,\n    Input,\n    Select,\n    Stack,\n    Text,\n    Tooltip\n} from '@mantine/core';\nimport { DirectoryWatcherImportAction, SubmissionType } from '@postybirb/types';\nimport {\n    IconDeviceFloppy,\n    IconFolder,\n    IconHelp,\n    IconPlus,\n    IconTrash,\n} from '@tabler/icons-react';\nimport { useCallback, useMemo, useState } from 'react';\nimport directoryWatchersApi, {\n    CheckPathResult,\n    FILE_COUNT_WARNING_THRESHOLD,\n} from '../../../api/directory-watchers.api';\nimport { useDirectoryWatchers } from '../../../stores';\nimport type { DirectoryWatcherRecord } from '../../../stores/records';\nimport { useActiveDrawer, useDrawerActions } from '../../../stores/ui/drawer-store';\nimport { useTourActions } from '../../../stores/ui/tour-store';\nimport {\n    showCreatedNotification,\n    showCreateErrorNotification,\n    showDeletedNotification,\n    showDeleteErrorNotification,\n    showUpdatedNotification,\n    showUpdateErrorNotification,\n} from '../../../utils/notifications';\nimport { ConfirmActionModal } from '../../confirm-action-modal';\nimport { EmptyState } from '../../empty-state';\nimport { ComponentErrorBoundary } from '../../error-boundary';\nimport { HoldToConfirmButton } from '../../hold-to-confirm';\nimport { FILE_WATCHERS_TOUR_ID } from '../../onboarding-tour/tours/file-watchers-tour';\nimport { TemplatePicker } from '../../shared/template-picker';\nimport { SectionDrawer } from '../section-drawer';\n\nconst DRAWER_KEY = 'fileWatchers' as const;\n\n// ============================================================================\n// Folder Confirm Modal Component\n// ============================================================================\n\ninterface FolderConfirmModalProps {\n  opened: boolean;\n  pendingPath: string | null;\n  pathCheckResult: CheckPathResult | null;\n  onCancel: () => void;\n  onConfirm: () => void;\n}\n\n/**\n * Extracted confirmation modal for folders with many files.\n */\nfunction FolderConfirmModal({\n  opened,\n  pendingPath,\n  pathCheckResult,\n  onCancel,\n  onConfirm,\n}: FolderConfirmModalProps) {\n  const folderName = pendingPath?.split('/').pop() ?? pendingPath ?? '';\n  const fileCount = pathCheckResult?.count ?? 0;\n  const remainingCount = pathCheckResult ? pathCheckResult.files.length - 5 : 0;\n\n  return (\n    <ConfirmActionModal\n      opened={opened}\n      onClose={onCancel}\n      onConfirm={onConfirm}\n      title={<Trans>Folder Contains Files</Trans>}\n      message={\n        <Stack gap=\"xs\">\n          <Text>\n            <Trans>\n              The folder \"{folderName}\" contains {fileCount} files.\n            </Trans>\n          </Text>\n          {pathCheckResult && pathCheckResult.files.length > 0 && (\n            <Text size=\"sm\" c=\"dimmed\">\n              {pathCheckResult.files.slice(0, 5).join(', ')}\n              {pathCheckResult.files.length > 5 && `, ... ${t`and ${remainingCount} more`}`}\n            </Text>\n          )}\n          <Text>\n            <Trans>Are you sure you want to watch this folder?</Trans>\n          </Text>\n        </Stack>\n      }\n      confirmLabel={<Trans>Confirm</Trans>}\n      confirmColor=\"blue\"\n    />\n  );\n}\n\n// ============================================================================\n// Watcher Card Component\n// ============================================================================\n\ninterface FileWatcherCardProps {\n  watcher: DirectoryWatcherRecord;\n}\n\n/**\n * Individual file watcher card with editable fields.\n */\nfunction FileWatcherCard({ watcher }: FileWatcherCardProps) {\n  const [path, setPath] = useState(watcher.path ?? '');\n  const [importAction, setImportAction] = useState(watcher.importAction);\n  const [template, setTemplate] = useState<string | null>(\n    watcher.template ?? null\n  );\n  const [isSaving, setIsSaving] = useState(false);\n  const [isDeleting, setIsDeleting] = useState(false);\n  const [confirmModalOpened, setConfirmModalOpened] = useState(false);\n  const [pendingPath, setPendingPath] = useState<string | null>(null);\n  const [pathCheckResult, setPathCheckResult] = useState<CheckPathResult | null>(null);\n\n  // Memoize import action options to avoid re-renders\n  const importActionOptions = useMemo(\n    () => [\n      {\n        label: t`Create new submission`,\n        value: DirectoryWatcherImportAction.NEW_SUBMISSION,\n      },\n    ],\n    []\n  );\n\n  // Check if there are unsaved changes\n  const hasChanges =\n    path !== (watcher.path ?? '') ||\n    importAction !== watcher.importAction ||\n    template !== (watcher.template ?? null);\n\n  const handlePickFolder = useCallback(async () => {\n    if (window?.electron?.pickDirectory) {\n      const folder = await window.electron.pickDirectory();\n      if (folder) {\n        try {\n          const result = await directoryWatchersApi.checkPath(folder);\n          if (!result.body.valid) {\n            // Show error notification for invalid path\n            showUpdateErrorNotification();\n            return;\n          }\n\n          if (result.body.count > FILE_COUNT_WARNING_THRESHOLD) {\n            setPendingPath(folder);\n            setPathCheckResult(result.body);\n            setConfirmModalOpened(true);\n          } else {\n            setPath(folder);\n          }\n        } catch {\n          // If check fails, still allow selecting the folder\n          setPath(folder);\n        }\n      }\n    }\n  }, []);\n\n  const handleConfirmPath = useCallback(() => {\n    if (pendingPath) {\n      setPath(pendingPath);\n      setPendingPath(null);\n      setPathCheckResult(null);\n      setConfirmModalOpened(false);\n    }\n  }, [pendingPath]);\n\n  const handleCancelPath = useCallback(() => {\n    setPendingPath(null);\n    setPathCheckResult(null);\n    setConfirmModalOpened(false);\n  }, []);\n\n  const handleSave = useCallback(async () => {\n    setIsSaving(true);\n    try {\n      await directoryWatchersApi.update(watcher.id, {\n        path: path || undefined,\n        importAction,\n        templateId: template ?? undefined,\n      });\n      showUpdatedNotification();\n    } catch {\n      showUpdateErrorNotification();\n    } finally {\n      setIsSaving(false);\n    }\n  }, [watcher.id, path, importAction, template]);\n\n  const handleDelete = useCallback(async () => {\n    setIsDeleting(true);\n    try {\n      await directoryWatchersApi.remove([watcher.id]);\n      showDeletedNotification(1);\n    } catch {\n      showDeleteErrorNotification();\n      setIsDeleting(false);\n    }\n  }, [watcher.id]);\n\n  return (\n    <Card padding=\"md\" withBorder data-tour-id=\"file-watchers-card\">\n      <ComponentErrorBoundary>\n      <Stack gap=\"sm\">\n        {/* Folder Path */}\n        <Input.Wrapper label={<Trans>Folder Path</Trans>} required>\n          <Input\n            leftSection={<IconFolder size={16} />}\n            value={path || t`No folder selected`}\n            readOnly\n            disabled={!window?.electron?.pickDirectory}\n            styles={{\n              input: {\n                cursor: window?.electron?.pickDirectory\n                  ? 'pointer'\n                  : 'not-allowed',\n                fontFamily: 'monospace',\n                fontSize: 'var(--mantine-font-size-xs)',\n              },\n            }}\n            onClick={handlePickFolder}\n          />\n        </Input.Wrapper>\n\n        {/* Import Action */}\n        <Select\n          size=\"sm\"\n          label={<Trans>Import Action</Trans>}\n          description={<Trans>What to do when new files are detected</Trans>}\n          data={importActionOptions}\n          value={importAction}\n          onChange={(value) =>\n            setImportAction(\n              (value as DirectoryWatcherImportAction) ??\n                DirectoryWatcherImportAction.NEW_SUBMISSION\n            )\n          }\n        />\n\n        {/* Template Picker - only show for NEW_SUBMISSION action */}\n        {importAction === DirectoryWatcherImportAction.NEW_SUBMISSION && (\n          <TemplatePicker\n            size=\"sm\"\n            type={SubmissionType.FILE}\n            value={template ?? undefined}\n            onChange={setTemplate}\n            description={\n              <Trans>Optional template to apply to imported files</Trans>\n            }\n          />\n        )}\n\n        {/* Actions */}\n        <Group justify=\"space-between\" mt=\"xs\">\n          <Tooltip label={<Trans>Hold to delete</Trans>}>\n            <HoldToConfirmButton\n              variant=\"subtle\"\n              color=\"red\"\n              size=\"sm\"\n              onConfirm={handleDelete}\n              loading={isDeleting}\n            >\n              <IconTrash size={16} />\n            </HoldToConfirmButton>\n          </Tooltip>\n\n          <Tooltip\n            label={\n              hasChanges ? <Trans>Save changes</Trans> : <Trans>No changes</Trans>\n            }\n          >\n            <ActionIcon\n              variant=\"filled\"\n              color=\"blue\"\n              size=\"md\"\n              onClick={handleSave}\n              disabled={!hasChanges}\n              loading={isSaving}\n            >\n              <IconDeviceFloppy size={16} />\n            </ActionIcon>\n          </Tooltip>\n        </Group>\n      </Stack>\n      </ComponentErrorBoundary>\n\n      {/* Confirmation modal for folders with many files */}\n      <FolderConfirmModal\n        opened={confirmModalOpened}\n        pendingPath={pendingPath}\n        pathCheckResult={pathCheckResult}\n        onCancel={handleCancelPath}\n        onConfirm={handleConfirmPath}\n      />\n    </Card>\n  );\n}\n\n// ============================================================================\n// Create Button Component\n// ============================================================================\n\n/**\n * Button to create a new file watcher.\n */\nfunction CreateWatcherButton() {\n  const [isCreating, setIsCreating] = useState(false);\n\n  const handleCreate = useCallback(async () => {\n    setIsCreating(true);\n    try {\n      await directoryWatchersApi.create({\n        importAction: DirectoryWatcherImportAction.NEW_SUBMISSION,\n      });\n      showCreatedNotification();\n    } catch {\n      showCreateErrorNotification();\n    } finally {\n      setIsCreating(false);\n    }\n  }, []);\n\n  return (\n    <Tooltip label={<Trans>Create new file watcher</Trans>}>\n      <ActionIcon\n        data-tour-id=\"file-watchers-create\"\n        variant=\"filled\"\n        size=\"lg\"\n        onClick={handleCreate}\n        loading={isCreating}\n      >\n        <IconPlus size={18} />\n      </ActionIcon>\n    </Tooltip>\n  );\n}\n\n// ============================================================================\n// Watcher List Component\n// ============================================================================\n\ninterface WatcherListProps {\n  watchers: DirectoryWatcherRecord[];\n}\n\n/**\n * List of file watcher cards.\n */\nfunction WatcherList({ watchers }: WatcherListProps) {\n  if (watchers.length === 0) {\n    return (\n      <EmptyState\n        preset=\"no-records\"\n        description={\n          <Trans>\n            Create a file watcher to automatically import new files from a\n            folder\n          </Trans>\n        }\n      />\n    );\n  }\n\n  return (\n    <Stack gap=\"sm\">\n      {watchers.map((watcher) => (\n        <FileWatcherCard key={watcher.id} watcher={watcher} />\n      ))}\n    </Stack>\n  );\n}\n\n// ============================================================================\n// Main Drawer Component\n// ============================================================================\n\n/**\n * File Watcher Drawer - manages directory watchers for auto-importing files.\n * Gate pattern: returns null when closed to avoid entity store subscriptions.\n */\nexport function FileWatcherDrawer() {\n  const activeDrawer = useActiveDrawer();\n  const { closeDrawer } = useDrawerActions();\n\n  if (activeDrawer !== DRAWER_KEY) return null;\n\n  return <FileWatcherDrawerContent onClose={closeDrawer} />;\n}\n\n/**\n * Inner content — only mounted when drawer is open.\n */\nfunction FileWatcherDrawerContent({ onClose }: { onClose: () => void }) {\n  const watchers = useDirectoryWatchers();\n  const { startTour } = useTourActions();\n\n  return (\n    <SectionDrawer\n      opened\n      onClose={onClose}\n      title={\n        <Group gap=\"xs\">\n          <Trans>File Watchers</Trans>\n          <Tooltip label={<Trans>File Watchers Tour</Trans>}>\n            <ActionIcon variant=\"subtle\" size=\"xs\" onClick={() => startTour(FILE_WATCHERS_TOUR_ID)}>\n              <IconHelp size={16} />\n            </ActionIcon>\n          </Tooltip>\n        </Group>\n      }\n      width={400}\n    >\n      <Stack gap=\"md\" h=\"100%\">\n        {/* Header with description and create button */}\n        <Group justify=\"space-between\" align=\"flex-start\">\n          <Box style={{ flex: 1 }}>\n            <Text size=\"sm\" c=\"dimmed\">\n              <Trans>\n                Automatically import new files from specified folders\n              </Trans>\n            </Text>\n          </Box>\n          <CreateWatcherButton />\n        </Group>\n\n        {/* Watcher list */}\n        <Box style={{ flex: 1, overflow: 'auto' }}>\n          <WatcherList watchers={watchers} />\n        </Box>\n      </Stack>\n    </SectionDrawer>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/drawers/file-watcher-drawer/index.ts",
    "content": "export { FileWatcherDrawer } from './file-watcher-drawer';\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/drawers/notifications-drawer/index.ts",
    "content": "export { NotificationsDrawer } from './notifications-drawer';\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/drawers/notifications-drawer/notifications-drawer.tsx",
    "content": "/**\n * NotificationsDrawer - Drawer for viewing and managing notifications.\n * Features filtering by read/unread status and severity type,\n * with actions for marking as read and deleting notifications.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport {\n    ActionIcon,\n    Badge,\n    Box,\n    Card,\n    Checkbox,\n    Group,\n    SegmentedControl,\n    Stack,\n    Text,\n    Tooltip,\n} from '@mantine/core';\nimport {\n    IconAlertTriangle,\n    IconBell,\n    IconCheck,\n    IconCircleCheck,\n    IconExclamationCircle,\n    IconHelp,\n    IconMail,\n    IconMailOpened,\n    IconTrash,\n} from '@tabler/icons-react';\nimport React, { useCallback, useMemo, useState } from 'react';\nimport notificationApi from '../../../api/notification.api';\nimport { useLocale } from '../../../hooks';\nimport {\n    useNotifications,\n    useUnreadNotificationCount,\n} from '../../../stores/entity/notification-store';\nimport type { NotificationRecord } from '../../../stores/records';\nimport { useActiveDrawer, useDrawerActions } from '../../../stores/ui/drawer-store';\nimport { useTourActions } from '../../../stores/ui/tour-store';\nimport {\n    showDeletedNotification,\n    showDeleteErrorNotification,\n    showSuccessNotification,\n    showUpdateErrorNotification,\n} from '../../../utils/notifications';\nimport { EmptyState } from '../../empty-state';\nimport { HoldToConfirmButton } from '../../hold-to-confirm';\nimport { NOTIFICATIONS_TOUR_ID } from '../../onboarding-tour/tours/notifications-tour';\nimport { SectionDrawer } from '../section-drawer';\n\n// ============================================================================\n// Types & Constants\n// ============================================================================\n\ntype ReadFilterValue = 'all' | 'unread' | 'read';\ntype TypeFilterValue = 'all' | 'error' | 'warning' | 'success' | 'info';\n\n// ============================================================================\n// Utility Functions\n// ============================================================================\n\n/**\n * Get Mantine color for notification type.\n */\nfunction getTypeColor(type: NotificationRecord['type']): string {\n  switch (type) {\n    case 'error':\n      return 'red';\n    case 'warning':\n      return 'yellow';\n    case 'success':\n      return 'green';\n    case 'info':\n    default:\n      return 'blue';\n  }\n}\n\n/**\n * Get icon for notification type.\n */\nfunction getTypeIcon(type: NotificationRecord['type']): React.ReactNode {\n  const size = 16;\n  switch (type) {\n    case 'error':\n      return <IconExclamationCircle size={size} />;\n    case 'warning':\n      return <IconAlertTriangle size={size} />;\n    case 'success':\n      return <IconCircleCheck size={size} />;\n    case 'info':\n    default:\n      return <IconBell size={size} />;\n  }\n}\n\n// ============================================================================\n// Filter Hook\n// ============================================================================\n\n/**\n * Hook to manage notification filtering.\n */\nfunction useNotificationFilters() {\n  const allNotifications = useNotifications();\n  const [readFilter, setReadFilter] = useState<ReadFilterValue>('all');\n  const [typeFilter, setTypeFilter] = useState<TypeFilterValue>('all');\n\n  const filteredNotifications = useMemo(() => {\n    let notifications = [...allNotifications];\n\n    // Filter by read status\n    if (readFilter === 'unread') {\n      notifications = notifications.filter((n) => n.isUnread);\n    } else if (readFilter === 'read') {\n      notifications = notifications.filter((n) => n.isRead);\n    }\n\n    // Filter by type\n    if (typeFilter !== 'all') {\n      notifications = notifications.filter((n) => n.type === typeFilter);\n    }\n\n    // Sort by newest first\n    notifications.sort(\n      (a, b) =>\n        new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()\n    );\n\n    return notifications;\n  }, [allNotifications, readFilter, typeFilter]);\n\n  return {\n    notifications: filteredNotifications,\n    allNotifications,\n    readFilter,\n    setReadFilter,\n    typeFilter,\n    setTypeFilter,\n  };\n}\n\n// ============================================================================\n// Filter Components\n// ============================================================================\n\n/**\n * Read status filter segmented control.\n */\nfunction ReadStatusFilter({\n  value,\n  onChange,\n  unreadCount,\n}: {\n  value: ReadFilterValue;\n  onChange: (value: ReadFilterValue) => void;\n  unreadCount: number;\n}) {\n  return (\n    <SegmentedControl\n      size=\"xs\"\n      value={value}\n      onChange={(v) => onChange(v as ReadFilterValue)}\n      data={[\n        { value: 'all', label: <Trans>All</Trans> },\n        {\n          value: 'unread',\n          label: (\n            <Group gap={4} wrap=\"nowrap\" justify=\"center\">\n              <span>\n                <Trans>Unread</Trans>\n              </span>\n              {unreadCount > 0 && (\n                <Badge size=\"xs\" variant=\"filled\" color=\"red\">\n                  {unreadCount}\n                </Badge>\n              )}\n            </Group>\n          ),\n        },\n        { value: 'read', label: <Trans>Read</Trans> },\n      ]}\n    />\n  );\n}\n\n/**\n * Type filter segmented control.\n */\nfunction TypeFilter({\n  value,\n  onChange,\n}: {\n  value: TypeFilterValue;\n  onChange: (value: TypeFilterValue) => void;\n}) {\n  return (\n    <SegmentedControl\n      size=\"xs\"\n      value={value}\n      onChange={(v) => onChange(v as TypeFilterValue)}\n      data={[\n        { value: 'all', label: <Trans>All</Trans> },\n        {\n          value: 'error',\n          label: (\n            <Tooltip label={<Trans>Errors</Trans>}>\n              <Box c=\"red\">\n                <IconExclamationCircle size={14} />\n              </Box>\n            </Tooltip>\n          ),\n        },\n        {\n          value: 'warning',\n          label: (\n            <Tooltip label={<Trans>Warnings</Trans>}>\n              <Box c=\"yellow\">\n                <IconAlertTriangle size={14} />\n              </Box>\n            </Tooltip>\n          ),\n        },\n        {\n          value: 'success',\n          label: (\n            <Tooltip label={<Trans>Success</Trans>}>\n              <Box c=\"green\">\n                <IconCircleCheck size={14} />\n              </Box>\n            </Tooltip>\n          ),\n        },\n        {\n          value: 'info',\n          label: (\n            <Tooltip label={<Trans>Info</Trans>}>\n              <Box c=\"blue\">\n                <IconBell size={14} />\n              </Box>\n            </Tooltip>\n          ),\n        },\n      ]}\n    />\n  );\n}\n\n// ============================================================================\n// Action Components\n// ============================================================================\n\n/**\n * Bulk actions for notifications.\n */\nfunction BulkActions({\n  selectedIds,\n  notifications,\n  onClearSelection,\n}: {\n  selectedIds: Set<string>;\n  notifications: NotificationRecord[];\n  onClearSelection: () => void;\n}) {\n  const selectedNotifications = notifications.filter((n) =>\n    selectedIds.has(n.id)\n  );\n  const count = selectedIds.size;\n  const hasUnread = selectedNotifications.some((n) => n.isUnread);\n\n  const handleMarkAsRead = useCallback(async () => {\n    try {\n      await Promise.all(\n        selectedNotifications\n          .filter((n) => n.isUnread)\n          .map((n) =>\n            notificationApi.update(n.id, { isRead: true, hasEmitted: true })\n          )\n      );\n      showSuccessNotification(<Trans>Marked as read</Trans>);\n      onClearSelection();\n    } catch {\n      showUpdateErrorNotification();\n    }\n  }, [selectedNotifications, onClearSelection]);\n\n  const handleDelete = useCallback(async () => {\n    try {\n      await notificationApi.remove([...selectedIds]);\n      showDeletedNotification(count);\n      onClearSelection();\n    } catch {\n      showDeleteErrorNotification();\n    }\n  }, [selectedIds, count, onClearSelection]);\n\n  if (count === 0) return null;\n\n  return (\n    <Group gap=\"xs\">\n      <Text size=\"sm\" c=\"dimmed\">\n        <Trans>{count} selected</Trans>\n      </Text>\n      {hasUnread && (\n        <Tooltip label={<Trans>Mark as read</Trans>}>\n          <ActionIcon variant=\"subtle\" color=\"blue\" onClick={handleMarkAsRead}>\n            <IconCheck size={16} />\n          </ActionIcon>\n        </Tooltip>\n      )}\n      <Tooltip label={<Trans>Hold to delete</Trans>}>\n        <HoldToConfirmButton\n          variant=\"subtle\"\n          color=\"red\"\n          onConfirm={handleDelete}\n        >\n          <IconTrash size={16} />\n        </HoldToConfirmButton>\n      </Tooltip>\n    </Group>\n  );\n}\n\n// ============================================================================\n// Notification Card Component\n// ============================================================================\n\n/**\n * Individual notification card.\n */\nconst NotificationCard = React.memo(({\n  notification,\n  isSelected,\n  onSelect,\n  formatRelativeTime,\n}: {\n  notification: NotificationRecord;\n  isSelected: boolean;\n  onSelect: (id: string, selected: boolean) => void;\n  formatRelativeTime: (date: Date | string) => string;\n}) => {\n  const color = getTypeColor(notification.type);\n  const icon = getTypeIcon(notification.type);\n\n  const handleToggleRead = useCallback(async () => {\n    try {\n      await notificationApi.update(notification.id, {\n        isRead: !notification.isRead,\n        hasEmitted: true,\n      });\n    } catch {\n      showUpdateErrorNotification();\n    }\n  }, [notification]);\n\n  const handleDelete = useCallback(async () => {\n    try {\n      await notificationApi.remove([notification.id]);\n    } catch {\n      showDeleteErrorNotification();\n    }\n  }, [notification.id]);\n\n  return (\n    <Card\n      padding=\"sm\"\n      withBorder\n      style={{\n        borderLeftWidth: 3,\n        borderLeftColor: `var(--mantine-color-${color}-6)`,\n        opacity: notification.isRead ? 0.7 : 1,\n      }}\n    >\n      <Group gap=\"sm\" wrap=\"nowrap\" align=\"flex-start\">\n        <Checkbox\n          checked={isSelected}\n          onChange={(e) => onSelect(notification.id, e.currentTarget.checked)}\n          // eslint-disable-next-line lingui/no-unlocalized-strings\n          aria-label=\"Select notification\"\n        />\n        <Box c={color}>{icon}</Box>\n        <Stack gap={4} style={{ flex: 1, minWidth: 0 }}>\n          <Group gap=\"xs\" justify=\"space-between\" wrap=\"nowrap\">\n            <Text size=\"sm\" fw={notification.isUnread ? 600 : 400} truncate>\n              {notification.title}\n            </Text>\n            <Text size=\"xs\" c=\"dimmed\" style={{ flexShrink: 0 }}>\n              {formatRelativeTime(notification.createdAt)}\n            </Text>\n          </Group>\n          <Text size=\"xs\" c=\"dimmed\" lineClamp={2}>\n            {notification.message}\n          </Text>\n          {notification.tags.length > 0 && (\n            <Group gap={4}>\n              {notification.tags.map((tag) => (\n                <Badge key={tag} size=\"xs\" variant=\"light\">\n                  {tag}\n                </Badge>\n              ))}\n            </Group>\n          )}\n        </Stack>\n        <Group gap={4}>\n          <Tooltip\n            label={\n              notification.isRead ? (\n                <Trans>Mark as unread</Trans>\n              ) : (\n                <Trans>Mark as read</Trans>\n              )\n            }\n          >\n            <ActionIcon variant=\"subtle\" size=\"sm\" onClick={handleToggleRead}>\n              {notification.isRead ? (\n                <IconMail size={14} />\n              ) : (\n                <IconMailOpened size={14} />\n              )}\n            </ActionIcon>\n          </Tooltip>\n          <Tooltip label={<Trans>Hold to delete</Trans>}>\n            <HoldToConfirmButton\n              variant=\"subtle\"\n              color=\"red\"\n              size=\"sm\"\n              onConfirm={handleDelete}\n            >\n              <IconTrash size={14} />\n            </HoldToConfirmButton>\n          </Tooltip>\n        </Group>\n      </Group>\n    </Card>\n  );\n});\n\n// ============================================================================\n// Notification List Component\n// ============================================================================\n\n/**\n * List of notification cards.\n */\nfunction NotificationList({\n  notifications,\n  selectedIds,\n  onSelect,\n  onSelectAll,\n  formatRelativeTime,\n}: {\n  notifications: NotificationRecord[];\n  selectedIds: Set<string>;\n  onSelect: (id: string, selected: boolean) => void;\n  onSelectAll: (selected: boolean) => void;\n  formatRelativeTime: (date: Date | string) => string;\n}) {\n  const allSelected =\n    notifications.length > 0 && selectedIds.size === notifications.length;\n  const someSelected = selectedIds.size > 0 && !allSelected;\n\n  if (notifications.length === 0) {\n    return <EmptyState preset=\"no-notifications\" />;\n  }\n\n  return (\n    <Stack gap=\"xs\">\n      <Group gap=\"xs\">\n        <Checkbox\n          checked={allSelected}\n          indeterminate={someSelected}\n          onChange={(e) => onSelectAll(e.currentTarget.checked)}\n          label={<Trans>Select all</Trans>}\n          size=\"xs\"\n        />\n      </Group>\n      {notifications.map((notification) => (\n        <NotificationCard\n          key={notification.id}\n          notification={notification}\n          isSelected={selectedIds.has(notification.id)}\n          onSelect={onSelect}\n          formatRelativeTime={formatRelativeTime}\n        />\n      ))}\n    </Stack>\n  );\n}\n\n// ============================================================================\n// Main Drawer Component\n// ============================================================================\n\n/**\n * Notifications drawer component.\n * Gate pattern: returns null when closed to avoid entity store subscriptions.\n */\nexport function NotificationsDrawer() {\n  const activeDrawer = useActiveDrawer();\n  const { closeDrawer } = useDrawerActions();\n\n  if (activeDrawer !== 'notifications') return null;\n\n  return <NotificationsDrawerContent onClose={closeDrawer} />;\n}\n\n/**\n * Inner content — only mounted when drawer is open.\n */\nfunction NotificationsDrawerContent({ onClose }: { onClose: () => void }) {\n  const unreadCount = useUnreadNotificationCount();\n  const { formatRelativeTime } = useLocale();\n  const { startTour } = useTourActions();\n\n  const {\n    notifications,\n    readFilter,\n    setReadFilter,\n    typeFilter,\n    setTypeFilter,\n  } = useNotificationFilters();\n\n  const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());\n\n  const handleSelect = useCallback((id: string, selected: boolean) => {\n    setSelectedIds((prev) => {\n      const next = new Set(prev);\n      if (selected) {\n        next.add(id);\n      } else {\n        next.delete(id);\n      }\n      return next;\n    });\n  }, []);\n\n  const handleSelectAll = useCallback(\n    (selected: boolean) => {\n      if (selected) {\n        setSelectedIds(new Set(notifications.map((n) => n.id)));\n      } else {\n        setSelectedIds(new Set());\n      }\n    },\n    [notifications]\n  );\n\n  const handleClearSelection = useCallback(() => {\n    setSelectedIds(new Set());\n  }, []);\n\n  return (\n    <SectionDrawer\n      opened\n      onClose={onClose}\n      title={\n        <Group gap=\"xs\">\n          <Trans>Notifications</Trans>\n          {unreadCount > 0 && (\n            <Badge size=\"sm\" variant=\"filled\" color=\"red\">\n              {unreadCount}\n            </Badge>\n          )}\n          <Tooltip label={<Trans>Notifications Tour</Trans>}>\n            <ActionIcon variant=\"subtle\" size=\"xs\" onClick={() => startTour(NOTIFICATIONS_TOUR_ID)}>\n              <IconHelp size={16} />\n            </ActionIcon>\n          </Tooltip>\n        </Group>\n      }\n      width={500}\n    >\n      <Stack gap=\"md\" h=\"100%\">\n        {/* Filters */}\n        <Stack gap=\"xs\">\n          <Box data-tour-id=\"notifications-read-filter\">\n          <ReadStatusFilter\n            value={readFilter}\n            onChange={setReadFilter}\n            unreadCount={unreadCount}\n          />\n          </Box>\n          <Box data-tour-id=\"notifications-type-filter\">\n          <TypeFilter value={typeFilter} onChange={setTypeFilter} />\n          </Box>\n        </Stack>\n\n        {/* Bulk Actions */}\n        <BulkActions\n          selectedIds={selectedIds}\n          notifications={notifications}\n          onClearSelection={handleClearSelection}\n        />\n\n        {/* Notification List */}\n        <Box data-tour-id=\"notifications-list\" style={{ flex: 1, overflow: 'auto' }}>\n          <NotificationList\n            notifications={notifications}\n            selectedIds={selectedIds}\n            onSelect={handleSelect}\n            onSelectAll={handleSelectAll}\n            formatRelativeTime={formatRelativeTime}\n          />\n        </Box>\n      </Stack>\n    </SectionDrawer>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/drawers/schedule-drawer/index.ts",
    "content": "/**\n * Barrel exports for schedule drawer.\n */\n\nexport { ScheduleDrawer } from './schedule-drawer';\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/drawers/schedule-drawer/schedule-calendar.tsx",
    "content": "/**\n * ScheduleCalendar - FullCalendar component for viewing and managing scheduled submissions.\n * Supports drag-drop from external elements, event moving, and click-to-manage.\n */\n\nimport { EventClickArg, EventDropArg } from '@fullcalendar/core';\nimport { EventImpl } from '@fullcalendar/core/internal';\nimport dayGridPlugin from '@fullcalendar/daygrid';\nimport interactionPlugin, { DropArg } from '@fullcalendar/interaction';\nimport FullCalendar from '@fullcalendar/react';\nimport timeGridPlugin from '@fullcalendar/timegrid';\nimport { Trans } from '@lingui/react/macro';\nimport {\n  Badge,\n  Button,\n  Divider,\n  Group,\n  Modal,\n  Stack,\n  Text,\n  ThemeIcon,\n} from '@mantine/core';\nimport { ScheduleType, SubmissionType } from '@postybirb/types';\nimport {\n  IconCalendarOff,\n  IconCalendarTime,\n  IconClock,\n  IconFile,\n  IconMessage,\n} from '@tabler/icons-react';\nimport Cron from 'croner';\nimport moment from 'moment';\nimport { useCallback, useMemo, useRef, useState } from 'react';\nimport submissionApi from '../../../api/submission.api';\nimport { useLocale } from '../../../hooks';\nimport { useSubmissionsWithSchedule } from '../../../stores/entity/submission-store';\nimport {\n  showInfoNotification,\n  showScheduleUpdatedNotification,\n  showUpdateErrorNotification,\n} from '../../../utils/notifications';\n\n/**\n * Calendar component for schedule drawer.\n * Shows all scheduled submissions (both FILE and MESSAGE types).\n */\nexport function ScheduleCalendar() {\n  const { calendarLocale, formatRelativeTime, formatDateTime } = useLocale();\n  const scheduledSubmissions = useSubmissionsWithSchedule();\n  const [selectedEvent, setSelectedEvent] = useState<EventImpl | null>(null);\n  const [modalOpened, setModalOpened] = useState(false);\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  const calendarRef = useRef<any>(null);\n\n  // Format events for FullCalendar\n  const events = useMemo(\n    () =>\n      scheduledSubmissions.flatMap((submission) => {\n        const result = [];\n        const { title } = submission;\n        const isMessageType = submission.type === SubmissionType.MESSAGE;\n\n        if (submission.schedule.cron) {\n          // Add main scheduled event\n          if (submission.schedule.scheduledFor) {\n            result.push({\n              id: submission.id,\n              title,\n              start: moment(submission.schedule.scheduledFor).toISOString(),\n              backgroundColor: isMessageType ? '#40c057' : '#339af0',\n              borderColor: isMessageType ? '#40c057' : '#339af0',\n              textColor: '#ffffff',\n              extendedProps: {\n                type: 'scheduled',\n                submissionType: submission.type,\n                isScheduled: true,\n              },\n            });\n          }\n\n          // Add future recurring events\n          const cron = Cron(submission.schedule.cron);\n          const nextRuns = cron.nextRuns(4);\n          nextRuns.forEach((nextRun, index) => {\n            result.push({\n              id: `${submission.id}-recurring-${index}`,\n              title,\n              start: moment(nextRun).toISOString(),\n              backgroundColor: isMessageType ? '#69db7c' : '#74c0fc',\n              borderColor: isMessageType ? '#69db7c' : '#74c0fc',\n              textColor: '#ffffff',\n              extendedProps: {\n                type: 'recurring',\n                submissionType: submission.type,\n                parentId: submission.id,\n              },\n            });\n          });\n        } else if (submission.schedule.scheduledFor) {\n          // Regular scheduled event\n          result.push({\n            id: submission.id,\n            title,\n            start: moment(submission.schedule.scheduledFor).toISOString(),\n            backgroundColor: submission.isScheduled\n              ? isMessageType\n                ? '#40c057'\n                : '#339af0'\n              : '#909296',\n            borderColor: submission.isScheduled\n              ? isMessageType\n                ? '#40c057'\n                : '#339af0'\n              : '#909296',\n            textColor: '#ffffff',\n            extendedProps: {\n              type: submission.isScheduled ? 'scheduled' : 'unscheduled',\n              submissionType: submission.type,\n              isScheduled: submission.isScheduled,\n            },\n          });\n        }\n\n        return result;\n      }),\n    [scheduledSubmissions],\n  );\n\n  // Handle event drop (for existing events)\n  const handleEventDrop = useCallback((info: EventDropArg) => {\n    const { event } = info;\n    // Skip recurring previews\n    if (event.id.includes('-recurring-')) {\n      info.revert();\n      return;\n    }\n    if (event.start) {\n      submissionApi.update(event.id, {\n        scheduledFor: event.start.toISOString(),\n        scheduleType: ScheduleType.SINGLE,\n      });\n    }\n  }, []);\n\n  // Handle external elements being dropped onto the calendar\n  const handleExternalDrop = useCallback((dropInfo: DropArg) => {\n    const submissionId = dropInfo.draggedEl.getAttribute('data-submission-id');\n\n    if (!submissionId) return;\n\n    const dropDate = dropInfo.date;\n\n    submissionApi.update(submissionId, {\n      scheduledFor: dropDate.toISOString(),\n      scheduleType: ScheduleType.SINGLE,\n      isScheduled: false,\n    });\n  }, []);\n\n  // Handle event click\n  const handleEventClick = useCallback((info: EventClickArg) => {\n    // Skip recurring previews\n    if (info.event.id.includes('-recurring-')) {\n      return;\n    }\n    setSelectedEvent(info.event);\n    setModalOpened(true);\n  }, []);\n\n  const handleUnschedule = () => {\n    if (!selectedEvent) return;\n\n    submissionApi\n      .update(selectedEvent.id, {\n        scheduledFor: undefined,\n        scheduleType: ScheduleType.NONE,\n        isScheduled: false,\n      })\n      .then(() => {\n        showInfoNotification(\n          selectedEvent.title,\n          <Trans>Submission unscheduled</Trans>\n        );\n      })\n      .catch((error) => {\n        showUpdateErrorNotification(error.message);\n      });\n\n    setModalOpened(false);\n  };\n\n  const toggleScheduledState = () => {\n    if (!selectedEvent) return;\n\n    const currentScheduledState =\n      selectedEvent.extendedProps?.isScheduled || false;\n\n    submissionApi\n      .update(selectedEvent.id, { isScheduled: !currentScheduledState })\n      .then(() => {\n        showScheduleUpdatedNotification(selectedEvent.title);\n      })\n      .catch((error) => {\n        showUpdateErrorNotification(error.message);\n      });\n\n    setModalOpened(false);\n  };\n\n  return (\n    <div style={{ overflow: 'auto', position: 'relative', height: '100%' }}>\n      <FullCalendar\n        ref={calendarRef}\n        plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}\n        headerToolbar={{\n          // eslint-disable-next-line lingui/no-unlocalized-strings\n          left: 'prev,next today',\n          center: 'title',\n          right: 'dayGridMonth,timeGridWeek,timeGridDay',\n        }}\n        initialView=\"dayGridMonth\"\n        editable\n        selectable\n        selectMirror\n        dayMaxEvents\n        allDaySlot={false}\n        weekends\n        events={events}\n        locale={calendarLocale}\n        eventDrop={handleEventDrop}\n        height=\"100%\"\n        themeSystem=\"standard\"\n        snapDuration=\"00:01:00\"\n        slotLabelInterval=\"00:60:00\"\n        droppable\n        drop={handleExternalDrop}\n        eventClick={handleEventClick}\n      />\n\n      {/* Event details modal */}\n      <Modal\n        opened={modalOpened}\n        onClose={() => setModalOpened(false)}\n        title={selectedEvent?.title}\n        centered\n        size=\"sm\"\n        zIndex=\"var(--z-popover)\"\n      >\n        <Stack gap=\"md\">\n          {/* Schedule time info */}\n          <Group gap=\"xs\" align=\"center\">\n            <ThemeIcon size=\"sm\" variant=\"light\" color=\"blue\">\n              <IconClock size={14} />\n            </ThemeIcon>\n            <Text size=\"sm\" c=\"dimmed\">\n              {selectedEvent?.start\n                ? formatDateTime(selectedEvent.start)\n                : ''}\n            </Text>\n          </Group>\n          {selectedEvent?.start && (\n            <Text size=\"xs\" c=\"dimmed\">\n              {formatRelativeTime(selectedEvent.start)}\n            </Text>\n          )}\n\n          {/* Status badges */}\n          <Group gap=\"xs\">\n            {/* Submission type badge */}\n            {selectedEvent?.extendedProps?.submissionType && (\n              <Badge\n                size=\"sm\"\n                variant=\"light\"\n                color={\n                  selectedEvent.extendedProps.submissionType ===\n                  SubmissionType.MESSAGE\n                    ? 'green'\n                    : 'blue'\n                }\n                leftSection={\n                  selectedEvent.extendedProps.submissionType ===\n                  SubmissionType.MESSAGE ? (\n                    <IconMessage size={12} />\n                  ) : (\n                    <IconFile size={12} />\n                  )\n                }\n              >\n                {selectedEvent.extendedProps.submissionType ===\n                SubmissionType.MESSAGE ? (\n                  <Trans>Message</Trans>\n                ) : (\n                  <Trans>File</Trans>\n                )}\n              </Badge>\n            )}\n            {selectedEvent?.extendedProps?.type === 'recurring' && (\n              <Badge size=\"sm\" variant=\"light\" color=\"cyan\">\n                <Trans>Recurring</Trans>\n              </Badge>\n            )}\n            {selectedEvent?.extendedProps?.isScheduled !== undefined && (\n              <Badge\n                size=\"sm\"\n                variant=\"light\"\n                color={\n                  selectedEvent.extendedProps.isScheduled ? 'green' : 'orange'\n                }\n              >\n                {selectedEvent.extendedProps.isScheduled ? (\n                  <Trans>Active</Trans>\n                ) : (\n                  <Trans>Paused</Trans>\n                )}\n              </Badge>\n            )}\n          </Group>\n\n          <Divider />\n\n          {/* Action buttons */}\n          <Group gap=\"xs\">\n            <Button\n              variant=\"light\"\n              color=\"red\"\n              size=\"sm\"\n              leftSection={<IconCalendarOff size={16} />}\n              onClick={handleUnschedule}\n              flex={1}\n            >\n              <Trans>Unschedule</Trans>\n            </Button>\n\n            <Button\n              variant={\n                selectedEvent?.extendedProps?.isScheduled ? 'light' : 'filled'\n              }\n              color={\n                selectedEvent?.extendedProps?.isScheduled ? 'orange' : 'green'\n              }\n              size=\"sm\"\n              leftSection={<IconCalendarTime size={16} />}\n              onClick={toggleScheduledState}\n              flex={1}\n            >\n              {selectedEvent?.extendedProps?.isScheduled ? (\n                <Trans>Pause</Trans>\n              ) : (\n                <Trans>Activate</Trans>\n              )}\n            </Button>\n          </Group>\n        </Stack>\n      </Modal>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/drawers/schedule-drawer/schedule-drawer.css",
    "content": "/* Schedule Drawer Styles */\n\n/* Layout for the drawer content */\n.schedule-drawer-content {\n  height: calc(100vh - 120px);\n  min-height: 500px;\n}\n\n.schedule-drawer-sidebar {\n  width: 280px;\n  min-width: 280px;\n  height: 100%;\n  padding: var(--mantine-spacing-sm);\n  border-right: 1px solid var(--mantine-color-gray-3);\n  background-color: var(--mantine-color-gray-0);\n  border-radius: var(--mantine-radius-md);\n}\n\n:root[data-mantine-color-scheme='dark'] .schedule-drawer-sidebar {\n  border-color: var(--mantine-color-dark-4);\n  background-color: var(--mantine-color-dark-6);\n}\n\n.schedule-drawer-calendar {\n  flex: 1;\n  height: 100%;\n  overflow: hidden;\n}\n\n/* FullCalendar Mantine Theme Integration */\n\n/* Base light theme styles */\n.schedule-drawer-calendar .fc {\n  --fc-border-color: var(--mantine-color-gray-3);\n  --fc-page-bg-color: var(--mantine-color-body);\n  --fc-neutral-bg-color: var(--mantine-color-gray-0);\n  --fc-neutral-text-color: var(--mantine-color-text);\n  --fc-button-text-color: var(--mantine-color-white);\n  --fc-button-bg-color: var(--mantine-color-blue-6);\n  --fc-button-border-color: var(--mantine-color-blue-6);\n  --fc-button-hover-bg-color: var(--mantine-color-blue-7);\n  --fc-button-hover-border-color: var(--mantine-color-blue-7);\n  --fc-button-active-bg-color: var(--mantine-color-blue-8);\n  --fc-button-active-border-color: var(--mantine-color-blue-8);\n  \n  /* Month/week view */\n  --fc-today-bg-color: var(--mantine-color-blue-0);\n  --fc-highlight-color: var(--mantine-color-blue-1);\n  --fc-event-text-color: var(--mantine-color-white);\n  --fc-event-border-color: var(--mantine-color-blue-6);\n  --fc-event-bg-color: var(--mantine-color-blue-6);\n  \n  /* List view */\n  --fc-list-event-hover-bg-color: var(--mantine-color-gray-0);\n  --fc-theme-standard-list-day-cushion-background: var(--mantine-color-gray-0);\n}\n\n/* Dark theme overrides */\n:root[data-mantine-color-scheme='dark'] .schedule-drawer-calendar .fc {\n  --fc-border-color: var(--mantine-color-dark-4);\n  --fc-page-bg-color: var(--mantine-color-body);\n  --fc-neutral-bg-color: var(--mantine-color-dark-6);\n  --fc-neutral-text-color: var(--mantine-color-text);\n  --fc-button-text-color: var(--mantine-color-white);\n  --fc-button-bg-color: var(--mantine-color-blue-6);\n  --fc-button-border-color: var(--mantine-color-blue-6);\n  --fc-button-hover-bg-color: var(--mantine-color-blue-7);\n  --fc-button-hover-border-color: var(--mantine-color-blue-7);\n  --fc-button-active-bg-color: var(--mantine-color-blue-8);\n  --fc-button-active-border-color: var(--mantine-color-blue-8);\n  \n  /* Month/week view */\n  --fc-today-bg-color: rgba(59, 130, 246, 0.1);\n  --fc-highlight-color: rgba(59, 130, 246, 0.2);\n  --fc-event-text-color: var(--mantine-color-white);\n  --fc-event-border-color: var(--mantine-color-blue-6);\n  --fc-event-bg-color: var(--mantine-color-blue-6);\n  \n  /* List view */\n  --fc-list-event-hover-bg-color: var(--mantine-color-dark-6);\n  --fc-theme-standard-list-day-cushion-background: var(--mantine-color-dark-6);\n}\n\n/* Text color styles for better Mantine integration */\n.schedule-drawer-calendar .fc-col-header-cell-cushion,\n.schedule-drawer-calendar .fc-daygrid-day-number,\n.schedule-drawer-calendar .fc-list-day-text,\n.schedule-drawer-calendar .fc-list-event-title,\n.schedule-drawer-calendar .fc-list-day-side-text {\n  color: var(--mantine-color-text) !important;\n}\n\n/* Table and grid elements */\n.schedule-drawer-calendar .fc th,\n.schedule-drawer-calendar .fc td,\n.schedule-drawer-calendar .fc .fc-timegrid-slot,\n.schedule-drawer-calendar .fc .fc-timegrid-slot-label,\n.schedule-drawer-calendar .fc .fc-timegrid-axis-cushion,\n.schedule-drawer-calendar .fc .fc-timegrid-axis,\n.schedule-drawer-calendar .fc .fc-col-header-cell {\n  color: var(--mantine-color-text) !important;\n  border-color: var(--fc-border-color) !important;\n}\n\n/* Calendar toolbar styling */\n.schedule-drawer-calendar .fc .fc-toolbar {\n  margin-bottom: var(--mantine-spacing-md);\n}\n\n.schedule-drawer-calendar .fc .fc-toolbar-title {\n  color: var(--mantine-color-text);\n  font-weight: 600;\n  font-size: 1.5rem;\n}\n\n.schedule-drawer-calendar .fc .fc-button-group > .fc-button {\n  border-radius: var(--mantine-radius-md);\n  font-weight: 500;\n  margin: 0 2px;\n  transition: all 0.2s ease;\n  box-shadow: var(--mantine-shadow-xs);\n}\n\n.schedule-drawer-calendar .fc .fc-button-group > .fc-button:hover {\n  transform: translateY(-1px);\n  box-shadow: var(--mantine-shadow-sm);\n}\n\n.schedule-drawer-calendar .fc .fc-button:not(:disabled):active,\n.schedule-drawer-calendar .fc .fc-button:not(:disabled).fc-button-active {\n  box-shadow: var(--mantine-shadow-xs);\n  transform: none;\n}\n\n/* Draggable element styles */\n.calendar-draggable {\n  cursor: grab;\n  width: 100%;\n  border-radius: var(--mantine-radius-md);\n  user-select: none;\n  transition: background-color 0.2s ease;\n  padding: var(--mantine-spacing-xs);\n}\n\n.calendar-draggable:hover {\n  background-color: var(--mantine-color-blue-0);\n}\n\n:root[data-mantine-color-scheme='dark'] .calendar-draggable:hover {\n  background-color: rgba(59, 130, 246, 0.1);\n}\n\n.calendar-draggable:active {\n  cursor: grabbing;\n}\n\n/* Dragging state styles */\n.calendar-draggable.fc-event-dragging {\n  opacity: 0.8;\n  background-color: var(--mantine-color-blue-1);\n  box-shadow: var(--mantine-shadow-lg);\n}\n\n:root[data-mantine-color-scheme='dark'] .calendar-draggable.fc-event-dragging {\n  background-color: rgba(59, 130, 246, 0.2);\n}\n\n/* FullCalendar event styling */\n.schedule-drawer-calendar .fc-h-event {\n  border-radius: var(--mantine-radius-sm);\n  box-shadow: var(--mantine-shadow-xs);\n  padding: 2px 4px;\n  border: 1px solid var(--fc-event-border-color);\n}\n\n.schedule-drawer-calendar .fc-daygrid-event {\n  border-radius: var(--mantine-radius-sm);\n  padding: 2px 4px;\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  box-shadow: var(--mantine-shadow-xs);\n}\n\n/* Event hover and interaction effects */\n.schedule-drawer-calendar .fc-event {\n  cursor: pointer;\n  transition: \n    transform 0.15s ease,\n    box-shadow 0.15s ease,\n    opacity 0.15s ease;\n}\n\n.schedule-drawer-calendar .fc-event:hover {\n  transform: scale(1.02);\n  box-shadow: var(--mantine-shadow-md);\n  z-index: var(--z-section-panel) !important;\n  opacity: 0.95;\n}\n\n/* Today cell highlighting */\n.schedule-drawer-calendar .fc .fc-daygrid-day.fc-day-today {\n  background-color: var(--fc-today-bg-color);\n  border-color: var(--mantine-color-blue-3);\n}\n\n:root[data-mantine-color-scheme='dark'] .schedule-drawer-calendar .fc .fc-daygrid-day.fc-day-today {\n  border-color: var(--mantine-color-blue-7);\n}\n\n/* Weekend styling */\n.schedule-drawer-calendar .fc .fc-daygrid-day.fc-day-sat,\n.schedule-drawer-calendar .fc .fc-daygrid-day.fc-day-sun {\n  background-color: var(--mantine-color-gray-0);\n}\n\n:root[data-mantine-color-scheme='dark'] .schedule-drawer-calendar .fc .fc-daygrid-day.fc-day-sat,\n:root[data-mantine-color-scheme='dark'] .schedule-drawer-calendar .fc .fc-daygrid-day.fc-day-sun {\n  background-color: var(--mantine-color-dark-7);\n}\n\n/* Month view day number styling */\n.schedule-drawer-calendar .fc .fc-daygrid-day-number {\n  padding: var(--mantine-spacing-xs);\n  font-weight: 500;\n}\n\n/* Time grid styling for week/day views */\n.schedule-drawer-calendar .fc .fc-timegrid-slot {\n  height: 3em;\n}\n\n.schedule-drawer-calendar .fc .fc-timegrid-slot-label {\n  font-weight: 500;\n  color: var(--mantine-color-dimmed);\n}\n\n/* Event resize handle styling */\n.schedule-drawer-calendar .fc .fc-event-resizer {\n  background-color: var(--mantine-color-blue-6);\n  border-radius: var(--mantine-radius-xs);\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/drawers/schedule-drawer/schedule-drawer.tsx",
    "content": "/**\n * ScheduleDrawer - Calendar-based scheduling interface for submissions.\n * Allows drag-and-drop scheduling with FullCalendar.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport { ActionIcon, Box, Group, Tooltip } from '@mantine/core';\nimport { IconHelp } from '@tabler/icons-react';\nimport { useActiveDrawer, useDrawerActions } from '../../../stores/ui/drawer-store';\nimport { useTourActions } from '../../../stores/ui/tour-store';\nimport { SCHEDULE_TOUR_ID } from '../../onboarding-tour/tours/schedule-tour';\nimport { SectionDrawer } from '../section-drawer';\nimport { ScheduleCalendar } from './schedule-calendar';\nimport './schedule-drawer.css';\nimport { SubmissionList } from './submission-list';\n\n/**\n * Schedule drawer with calendar and draggable submission list.\n */\nexport function ScheduleDrawer() {\n  const activeDrawer = useActiveDrawer();\n  const { closeDrawer } = useDrawerActions();\n  const { startTour } = useTourActions();\n  const opened = activeDrawer === 'schedule';\n\n  return (\n    <SectionDrawer\n      opened={opened}\n      onClose={closeDrawer}\n      title={\n        <Group gap=\"xs\">\n          <Trans>Schedule</Trans>\n          <Tooltip label={<Trans>Schedule Tour</Trans>}>\n            <ActionIcon variant=\"subtle\" size=\"xs\" onClick={() => startTour(SCHEDULE_TOUR_ID)}>\n              <IconHelp size={16} />\n            </ActionIcon>\n          </Tooltip>\n        </Group>\n      }\n      width=\"100vw\"\n    >\n      <Group\n        align=\"stretch\"\n        gap=\"md\"\n        wrap=\"nowrap\"\n        className=\"schedule-drawer-content\"\n      >\n        {/* Unscheduled submissions list */}\n        <Box data-tour-id=\"schedule-submissions\" className=\"schedule-drawer-sidebar\">\n          <SubmissionList />\n        </Box>\n\n        {/* Calendar view */}\n        <Box data-tour-id=\"schedule-calendar\" className=\"schedule-drawer-calendar\">\n          <ScheduleCalendar />\n        </Box>\n      </Group>\n    </SectionDrawer>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/drawers/schedule-drawer/submission-list.tsx",
    "content": "/**\n * SubmissionList - Displays unscheduled submissions that can be dragged onto the calendar.\n * Uses FullCalendar's Draggable for external drag-drop support.\n */\n\nimport { Draggable } from '@fullcalendar/interaction';\nimport { Trans, useLingui } from '@lingui/react/macro';\nimport {\n  Box,\n  Group,\n  ScrollArea,\n  Stack,\n  Text,\n  TextInput,\n  ThemeIcon,\n} from '@mantine/core';\nimport { useDebouncedValue } from '@mantine/hooks';\nimport { SubmissionType } from '@postybirb/types';\nimport { IconFile, IconMessage, IconSearch } from '@tabler/icons-react';\nimport { useEffect, useMemo, useRef, useState } from 'react';\nimport { useUnscheduledSubmissions } from '../../../stores/entity/submission-store';\nimport type { SubmissionRecord } from '../../../stores/records';\nimport { EmptyState } from '../../empty-state';\n\n/**\n * Single draggable submission item.\n */\nfunction DraggableSubmissionItem({\n  submission,\n}: {\n  submission: SubmissionRecord;\n}) {\n  const { t } = useLingui();\n  const title = submission.title || t`Untitled Submission`;\n  const isMessage = submission.type === SubmissionType.MESSAGE;\n\n  return (\n    <Box\n      className=\"calendar-draggable\"\n      data-submission-id={submission.id}\n      style={{\n        cursor: 'grab',\n        userSelect: 'none',\n      }}\n    >\n      <Group gap=\"xs\" wrap=\"nowrap\">\n        <ThemeIcon\n          size=\"sm\"\n          variant=\"light\"\n          color={isMessage ? 'green' : 'blue'}\n        >\n          {isMessage ? <IconMessage size={14} /> : <IconFile size={14} />}\n        </ThemeIcon>\n        <Text size=\"sm\" lineClamp={1} style={{ flex: 1 }}>\n          {title}\n        </Text>\n      </Group>\n    </Box>\n  );\n}\n\n/**\n * Submission list with search and draggable items.\n */\nexport function SubmissionList() {\n  const { t } = useLingui();\n  const allUnscheduled = useUnscheduledSubmissions();\n  const [searchQuery, setSearchQuery] = useState('');\n  const [debouncedSearch] = useDebouncedValue(searchQuery, 200);\n  const containerRef = useRef<HTMLDivElement>(null);\n\n  // Apply search filter and sort (base filtering done in store selector)\n  const unscheduledSubmissions = useMemo(() => {\n    let list = allUnscheduled;\n\n    // Apply search filter\n    if (debouncedSearch?.trim()) {\n      const lowerSearch = debouncedSearch.toLowerCase();\n      list = list.filter((s) => s.title.toLowerCase().includes(lowerSearch));\n    }\n\n    // Sort alphabetically\n    list = [...list].sort((a, b) =>\n      a.title.toLowerCase().localeCompare(b.title.toLowerCase()),\n    );\n\n    return list;\n  }, [allUnscheduled, debouncedSearch]);\n\n  // Initialize FullCalendar Draggable on mount\n  useEffect(() => {\n    if (!containerRef.current) return undefined;\n\n    const draggable = new Draggable(containerRef.current, {\n      itemSelector: '.calendar-draggable',\n      eventData: (eventEl) => {\n        const submissionId = eventEl.getAttribute('data-submission-id');\n        // eslint-disable-next-line lingui/no-unlocalized-strings\n        const textContent = eventEl.textContent || 'Untitled';\n        return {\n          id: submissionId,\n          title: textContent.trim(),\n          duration: { hours: 0, minutes: 30 },\n        };\n      },\n    });\n\n    return () => {\n      draggable.destroy();\n    };\n  }, []);\n\n  return (\n    <Stack gap=\"md\" h=\"100%\">\n      <Text fw={600} size=\"sm\">\n        <Trans>Unscheduled Submissions</Trans>\n      </Text>\n\n      <TextInput\n        placeholder={t`Search...`}\n        leftSection={<IconSearch size={16} />}\n        value={searchQuery}\n        onChange={(e) => setSearchQuery(e.currentTarget.value)}\n        size=\"sm\"\n      />\n\n      <ScrollArea\n        style={{ flex: 1 }}\n        type=\"auto\"\n        offsetScrollbars\n        scrollbarSize={6}\n      >\n        <Stack gap=\"xs\" ref={containerRef}>\n          {unscheduledSubmissions.length === 0 ? (\n            <EmptyState preset=\"no-results\" size=\"sm\" />\n          ) : (\n            unscheduledSubmissions.map((submission) => (\n              <DraggableSubmissionItem\n                key={submission.id}\n                submission={submission}\n              />\n            ))\n          )}\n        </Stack>\n      </ScrollArea>\n\n      <Text size=\"xs\" c=\"dimmed\">\n        <Trans>Drag submissions onto the calendar to schedule them</Trans>\n      </Text>\n    </Stack>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/drawers/section-drawer.tsx",
    "content": "/**\n * SectionDrawer - Custom drawer that slides out from the section panel area.\n * Uses Portal to render into .postybirb__content_split for proper positioning.\n * Includes overlay and keyboard accessibility.\n * Note: FocusTrap is intentionally omitted to avoid conflicts with Mantine's\n * internal focus management in components like Select/Combobox.\n */\n\nimport { Box, CloseButton, Portal, Title } from '@mantine/core';\nimport { useEffect, useRef, useState } from 'react';\nimport '../../styles/layout.css';\nimport { cn } from '../../utils/class-names';\nimport { ComponentErrorBoundary } from '../error-boundary';\n\nexport interface SectionDrawerProps {\n  /** Whether the drawer is open */\n  opened: boolean;\n  /** Callback when the drawer should close */\n  onClose: () => void;\n  /** Drawer title */\n  title: React.ReactNode;\n  /** Drawer content */\n  children: React.ReactNode;\n  /** Optional custom width (default: 360px via CSS) */\n  width?: number | string;\n  /** Whether to close on Escape key (default: true) */\n  closeOnEscape?: boolean;\n  /** Whether to close on overlay click (default: true) */\n  closeOnClickOutside?: boolean;\n}\n\n/**\n * Custom drawer component that slides from the left edge of the content area.\n * Must be rendered inside .postybirb__content_split for proper positioning.\n */\nexport function SectionDrawer({\n  opened,\n  onClose,\n  title,\n  children,\n  width,\n  closeOnEscape = true,\n  closeOnClickOutside = true,\n}: SectionDrawerProps) {\n  const drawerRef = useRef<HTMLDivElement>(null);\n  const [portalTarget, setPortalTarget] = useState<HTMLElement | null>(null);\n\n  // Get portal target on mount\n  useEffect(() => {\n    const target = document.getElementById('postybirb__primary_content_area');\n    setPortalTarget(target);\n  }, []);\n\n  // Handle Escape key to close drawer\n  useEffect(() => {\n    if (!opened || !closeOnEscape) return undefined;\n\n    const handleKeyDown = (event: KeyboardEvent) => {\n      if (event.key === 'Escape') {\n        event.preventDefault();\n        onClose();\n      }\n    };\n\n    document.addEventListener('keydown', handleKeyDown);\n    return () => {\n      document.removeEventListener('keydown', handleKeyDown);\n    };\n  }, [opened, closeOnEscape, onClose]);\n\n  // Prevent body scroll when drawer is open\n  useEffect(() => {\n    if (opened) {\n      const originalOverflow = document.body.style.overflow;\n      document.body.style.overflow = 'hidden';\n      return () => {\n        document.body.style.overflow = originalOverflow;\n      };\n    }\n    return undefined;\n  }, [opened]);\n\n  const handleOverlayClick = () => {\n    if (closeOnClickOutside) {\n      onClose();\n    }\n  };\n\n  const drawerStyle = width ? { width } : undefined;\n\n  // Don't render until portal target is available\n  if (!portalTarget) return null;\n\n  return (\n    <Portal target={portalTarget}>\n      {/* Overlay */}\n      <Box\n        className={cn(['postybirb__section_drawer_overlay'], {\n          'postybirb__section_drawer_overlay--visible': opened,\n        })}\n        onClick={handleOverlayClick}\n        aria-hidden=\"true\"\n      />\n\n      {/* Drawer Panel */}\n      <Box\n        ref={drawerRef}\n        className={cn(['postybirb__section_drawer'], {\n          'postybirb__section_drawer--open': opened,\n        })}\n        style={drawerStyle}\n        role=\"dialog\"\n        aria-modal=\"true\"\n        aria-labelledby=\"section-drawer-title\"\n      >\n        {/* Header */}\n        <Box className=\"postybirb__section_drawer_header\">\n          <Title order={4} id=\"section-drawer-title\">\n            {title}\n          </Title>\n          <CloseButton\n            onClick={onClose}\n            // eslint-disable-next-line lingui/no-unlocalized-strings\n            aria-label=\"Close drawer\"\n            size=\"md\"\n          />\n        </Box>\n\n        {/* Body - only render content when opened to avoid ref issues */}\n        <Box className=\"postybirb__section_drawer_body\">\n          <ComponentErrorBoundary>{opened && children}</ComponentErrorBoundary>\n        </Box>\n      </Box>\n    </Portal>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/drawers/tag-converter-drawer/index.ts",
    "content": "export { TagConverterDrawer } from './tag-converter-drawer';\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/drawers/tag-converter-drawer/tag-converter-drawer.tsx",
    "content": "/**\n * TagConverterDrawer - Drawer for managing tag converters.\n * Uses the generic ConverterDrawer component with tag-specific configuration.\n */\n\nimport { t } from '@lingui/core/macro';\nimport { Trans } from '@lingui/react/macro';\nimport { ActionIcon, Group, Tooltip } from '@mantine/core';\nimport type {\n    ICreateTagConverterDto,\n    IUpdateTagConverterDto,\n} from '@postybirb/types';\nimport { IconHelp } from '@tabler/icons-react';\nimport { useMemo } from 'react';\nimport tagConvertersApi from '../../../api/tag-converters.api';\nimport { useTagConverters } from '../../../stores/entity/tag-converter-store';\nimport type { TagConverterRecord } from '../../../stores/records';\nimport { useActiveDrawer, useDrawerActions } from '../../../stores/ui/drawer-store';\nimport { useTourActions } from '../../../stores/ui/tour-store';\nimport { TAG_CONVERTERS_TOUR_ID } from '../../onboarding-tour/tours/tag-converters-tour';\nimport {\n    ConverterDrawer,\n    type ConverterDrawerConfig,\n} from '../converter-drawer';\n\n// Drawer identifier\nconst DRAWER_KEY = 'tagConverters';\n\n/**\n * TagConverterDrawer component.\n * Gate pattern: returns null when closed to avoid entity store subscriptions.\n */\nexport function TagConverterDrawer() {\n  const activeDrawer = useActiveDrawer();\n  const { closeDrawer } = useDrawerActions();\n\n  if (activeDrawer !== DRAWER_KEY) return null;\n\n  return <TagConverterDrawerContent onClose={closeDrawer} />;\n}\n\n/**\n * Inner content — only mounted when drawer is open.\n */\nfunction TagConverterDrawerContent({ onClose }: { onClose: () => void }) {\n  const converters = useTagConverters();\n  const { startTour } = useTourActions();\n\n  const config = useMemo(\n    (): ConverterDrawerConfig<\n      TagConverterRecord,\n      ICreateTagConverterDto,\n      IUpdateTagConverterDto\n    > => ({\n      title: (\n        <Group gap=\"xs\">\n          <Trans>Tag Converters</Trans>\n          <Tooltip label={<Trans>Tag Converters Tour</Trans>}>\n            <ActionIcon variant=\"subtle\" size=\"xs\" onClick={() => startTour(TAG_CONVERTERS_TOUR_ID)}>\n              <IconHelp size={16} />\n            </ActionIcon>\n          </Tooltip>\n        </Group>\n      ),\n      primaryField: 'tag',\n      getPrimaryValue: (record: TagConverterRecord) => record.tag,\n      api: {\n        create: (dto: ICreateTagConverterDto) => tagConvertersApi.create(dto),\n        update: (id: string, dto: IUpdateTagConverterDto) =>\n          tagConvertersApi.update(id, dto),\n        remove: (ids: string[]) => tagConvertersApi.remove(ids),\n      },\n      createUpdateDto: (\n        tag: string,\n        convertTo: Record<string, string>\n      ): IUpdateTagConverterDto => ({ tag, convertTo }),\n      createCreateDto: (\n        tag: string,\n        convertTo: Record<string, string>\n      ): ICreateTagConverterDto => ({ tag, convertTo }),\n      entityName: t`tag converter`,\n      duplicateError: t`A tag converter with this tag already exists`,\n    }),\n    [startTour]\n  );\n\n  return (\n    <ConverterDrawer\n      opened\n      onClose={onClose}\n      converters={converters}\n      config={config}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/drawers/tag-group-drawer/index.ts",
    "content": "/**\n * Barrel exports for tag group drawer.\n */\n\nexport { TagGroupDrawer } from './tag-group-drawer';\n\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/drawers/tag-group-drawer/tag-group-drawer.tsx",
    "content": "/**\n * TagGroupDrawer - Drawer for managing tag groups.\n * Features search, editable table with name and tags columns,\n * selectable rows with bulk delete, and mini-form for creating new groups.\n */\n\nimport { t } from '@lingui/core/macro';\nimport { Trans } from '@lingui/react/macro';\nimport {\n    ActionIcon,\n    Box,\n    Checkbox,\n    Group,\n    Stack,\n    Table,\n    TagsInput,\n    TextInput,\n    Tooltip,\n} from '@mantine/core';\nimport { useDebouncedCallback, useDebouncedValue } from '@mantine/hooks';\nimport { IconHelp, IconPlus, IconTrash } from '@tabler/icons-react';\nimport React, { useCallback, useMemo, useState } from 'react';\nimport tagGroupsApi from '../../../api/tag-groups.api';\nimport { useTagGroups } from '../../../stores';\nimport type { TagGroupRecord } from '../../../stores/records';\nimport { useActiveDrawer, useDrawerActions } from '../../../stores/ui/drawer-store';\nimport { useTourActions } from '../../../stores/ui/tour-store';\nimport {\n    showCreatedNotification,\n    showCreateErrorNotification,\n    showDeletedNotification,\n    showDeleteErrorNotification,\n    showUpdateErrorNotification,\n} from '../../../utils/notifications';\nimport { EmptyState } from '../../empty-state';\nimport { HoldToConfirmButton } from '../../hold-to-confirm';\nimport { TAG_GROUPS_TOUR_ID } from '../../onboarding-tour/tours/tag-groups-tour';\nimport { SearchInput } from '../../shared';\nimport { SectionDrawer } from '../section-drawer';\n\n/**\n * Hook to manage tag group search filtering and sorting.\n */\nfunction useTagGroupSearch(searchQuery: string) {\n  const tagGroups = useTagGroups();\n  const [debouncedSearch] = useDebouncedValue(searchQuery, 200);\n\n  const filteredAndSortedGroups = useMemo(() => {\n    let groups = [...tagGroups];\n\n    // Filter by search query\n    if (debouncedSearch.trim()) {\n      const lowerSearch = debouncedSearch.toLowerCase();\n      groups = groups.filter((group) =>\n        group.name.toLowerCase().includes(lowerSearch)\n      );\n    }\n\n    // Sort alphabetically by name\n    groups.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));\n\n    return groups;\n  }, [tagGroups, debouncedSearch]);\n\n  return { filteredGroups: filteredAndSortedGroups, allGroups: tagGroups };\n}\n\n// ============================================================================\n// Editable Cell Components\n// ============================================================================\n\n/**\n * Editable name cell component.\n * Saves on blur if name is valid (non-empty) and changed.\n */\nfunction EditableNameCell({\n  groupId,\n  initialName,\n  tags,\n}: {\n  groupId: string;\n  initialName: string;\n  tags: string[];\n}) {\n  const [name, setName] = useState(initialName);\n  const [isEditing, setIsEditing] = useState(false);\n\n  const handleBlur = async () => {\n    setIsEditing(false);\n    const trimmedName = name.trim();\n\n    // Revert if empty\n    if (!trimmedName) {\n      setName(initialName);\n      return;\n    }\n\n    // Skip if unchanged\n    if (trimmedName === initialName) {\n      return;\n    }\n\n    try {\n      await tagGroupsApi.update(groupId, { name: trimmedName, tags });\n    } catch {\n      showUpdateErrorNotification(initialName);\n      setName(initialName);\n    }\n  };\n\n  const handleKeyDown = (e: React.KeyboardEvent) => {\n    if (e.key === 'Enter') {\n      (e.target as HTMLInputElement).blur();\n    } else if (e.key === 'Escape') {\n      setName(initialName);\n      setIsEditing(false);\n    }\n  };\n\n  return (\n    <TextInput\n      value={name}\n      onChange={(e) => setName(e.currentTarget.value)}\n      onFocus={() => setIsEditing(true)}\n      onBlur={handleBlur}\n      onKeyDown={handleKeyDown}\n      size=\"sm\"\n      styles={{\n        input: {\n          minWidth: 100,\n        },\n      }}\n    />\n  );\n}\n\n/**\n * Editable tags cell component.\n * Saves with 300ms debounce after tag changes.\n */\nfunction EditableTagsCell({\n  groupId,\n  name,\n  initialTags,\n}: {\n  groupId: string;\n  name: string;\n  initialTags: string[];\n}) {\n  const [tags, setTags] = useState<string[]>(initialTags);\n\n  const debouncedSave = useDebouncedCallback(async (newTags: string[]) => {\n    try {\n      await tagGroupsApi.update(groupId, { name, tags: newTags });\n    } catch {\n      showUpdateErrorNotification(name);\n      setTags(initialTags);\n    }\n  }, 300);\n\n  const handleChange = (newTags: string[]) => {\n    setTags(newTags);\n    debouncedSave(newTags);\n  };\n\n  return (\n    <TagsInput\n      value={tags}\n      onChange={handleChange}\n      size=\"sm\"\n      styles={{\n        input: {\n          minWidth: 150,\n        },\n      }}\n    />\n  );\n}\n\n// ============================================================================\n// Action Components\n// ============================================================================\n\n/**\n * Hold-to-confirm delete button.\n * User must hold mouse down or Enter key for 1 second to confirm deletion.\n */\nfunction DeleteSelectedButton({\n  selectedIds,\n  onDeleted,\n}: {\n  selectedIds: Set<string>;\n  onDeleted: () => void;\n}) {\n  const count = selectedIds.size;\n\n  const handleDelete = useCallback(async () => {\n    try {\n      await tagGroupsApi.remove([...selectedIds]);\n      showDeletedNotification(count);\n      onDeleted();\n    } catch {\n      showDeleteErrorNotification();\n    }\n  }, [selectedIds, count, onDeleted]);\n\n  return (\n    <Tooltip\n      label={\n        count === 0 ? (\n          <Trans>Select items to delete</Trans>\n        ) : (\n          <Trans>Hold to delete {count} item(s)</Trans>\n        )\n      }\n    >\n      <HoldToConfirmButton\n        variant=\"subtle\"\n        color=\"red\"\n        disabled={count === 0}\n        onConfirm={handleDelete}\n      >\n        <IconTrash size={18} />\n      </HoldToConfirmButton>\n    </Tooltip>\n  );\n}\n\n/**\n * Mini-form for creating a new tag group.\n */\nfunction CreateTagGroupForm() {\n  const [name, setName] = useState('');\n  const [isCreating, setIsCreating] = useState(false);\n\n  const handleCreate = async () => {\n    const trimmedName = name.trim();\n    if (!trimmedName) return;\n\n    setIsCreating(true);\n    try {\n      await tagGroupsApi.create({ name: trimmedName, tags: [] });\n      showCreatedNotification(trimmedName);\n      setName('');\n    } catch {\n      showCreateErrorNotification(trimmedName);\n    } finally {\n      setIsCreating(false);\n    }\n  };\n\n  const handleKeyDown = (e: React.KeyboardEvent) => {\n    if (e.key === 'Enter') {\n      handleCreate();\n    }\n  };\n\n  return (\n    <Group gap=\"xs\">\n      <TextInput\n        flex={1}\n        size=\"sm\"\n        placeholder={t`New`}\n        leftSection={<IconPlus size={16} />}\n        value={name}\n        onChange={(e) => setName(e.currentTarget.value)}\n        onKeyDown={handleKeyDown}\n        disabled={isCreating}\n      />\n      <Tooltip label={<Trans>Create</Trans>}>\n        <ActionIcon\n          variant=\"filled\"\n          onClick={handleCreate}\n          disabled={!name.trim() || isCreating}\n          loading={isCreating}\n        >\n          <IconPlus size={16} />\n        </ActionIcon>\n      </Tooltip>\n    </Group>\n  );\n}\n\n// ============================================================================\n// Table Components\n// ============================================================================\n\n/**\n * Tag group table row component.\n */\nconst TagGroupRow = React.memo(({\n  group,\n  isSelected,\n  onToggleSelect,\n}: {\n  group: TagGroupRecord;\n  isSelected: boolean;\n  onToggleSelect: (id: string) => void;\n}) => (\n    <Table.Tr>\n      <Table.Td w={40}>\n        <Checkbox\n          checked={isSelected}\n          onChange={() => onToggleSelect(group.id)}\n        />\n      </Table.Td>\n      <Table.Td>\n        <EditableNameCell\n          groupId={group.id}\n          initialName={group.name}\n          tags={group.tags}\n        />\n      </Table.Td>\n      <Table.Td>\n        <EditableTagsCell\n          groupId={group.id}\n          name={group.name}\n          initialTags={group.tags}\n        />\n      </Table.Td>\n    </Table.Tr>\n  ));\n\n/**\n * Tag groups table component.\n */\nfunction TagGroupsTable({\n  groups,\n  selectedIds,\n  onToggleSelect,\n  onToggleSelectAll,\n}: {\n  groups: TagGroupRecord[];\n  selectedIds: Set<string>;\n  onToggleSelect: (id: string) => void;\n  onToggleSelectAll: () => void;\n}) {\n  if (groups.length === 0) {\n    return <EmptyState preset=\"no-results\" />;\n  }\n\n  const allSelected = groups.length > 0 && groups.every((g) => selectedIds.has(g.id));\n  const someSelected = groups.some((g) => selectedIds.has(g.id)) && !allSelected;\n\n  return (\n    <Table striped highlightOnHover>\n      <Table.Thead>\n        <Table.Tr>\n          <Table.Th w={40}>\n            <Checkbox\n              checked={allSelected}\n              indeterminate={someSelected}\n              onChange={onToggleSelectAll}\n            />\n          </Table.Th>\n          <Table.Th>\n            <Trans>Name</Trans>\n          </Table.Th>\n          <Table.Th>\n            <Trans>Tags</Trans>\n          </Table.Th>\n        </Table.Tr>\n      </Table.Thead>\n      <Table.Tbody>\n        {groups.map((group) => (\n          <TagGroupRow\n            key={group.id}\n            group={group}\n            isSelected={selectedIds.has(group.id)}\n            onToggleSelect={onToggleSelect}\n          />\n        ))}\n      </Table.Tbody>\n    </Table>\n  );\n}\n\n// ============================================================================\n// Main Drawer Component\n// ============================================================================\n\n/**\n * Main Tag Group Drawer component.\n * Gate pattern: returns null when closed to avoid entity store subscriptions.\n */\nexport function TagGroupDrawer() {\n  const activeDrawer = useActiveDrawer();\n  const { closeDrawer } = useDrawerActions();\n\n  if (activeDrawer !== 'tagGroups') return null;\n\n  return <TagGroupDrawerContent onClose={closeDrawer} />;\n}\n\n/**\n * Inner content — only mounted when drawer is open.\n */\nfunction TagGroupDrawerContent({ onClose }: { onClose: () => void }) {\n  const [searchQuery, setSearchQuery] = useState('');\n  const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());\n  const { startTour } = useTourActions();\n\n  const { filteredGroups } = useTagGroupSearch(searchQuery);\n\n  const handleToggleSelect = useCallback((id: string) => {\n    setSelectedIds((prev) => {\n      const next = new Set(prev);\n      if (next.has(id)) {\n        next.delete(id);\n      } else {\n        next.add(id);\n      }\n      return next;\n    });\n  }, []);\n\n  const handleToggleSelectAll = useCallback(() => {\n    if (filteredGroups.every((g) => selectedIds.has(g.id))) {\n      // Deselect all visible\n      setSelectedIds((prev) => {\n        const next = new Set(prev);\n        filteredGroups.forEach((g) => next.delete(g.id));\n        return next;\n      });\n    } else {\n      // Select all visible\n      setSelectedIds((prev) => {\n        const next = new Set(prev);\n        filteredGroups.forEach((g) => next.add(g.id));\n        return next;\n      });\n    }\n  }, [filteredGroups, selectedIds]);\n\n  const handleDeleted = () => {\n    setSelectedIds(new Set());\n  };\n\n  return (\n    <SectionDrawer\n      opened\n      onClose={onClose}\n      title={\n        <Group gap=\"xs\">\n          <Trans>Tag Groups</Trans>\n          <Tooltip label={<Trans>Tag Groups Tour</Trans>}>\n            <ActionIcon variant=\"subtle\" size=\"xs\" onClick={() => startTour(TAG_GROUPS_TOUR_ID)}>\n              <IconHelp size={16} />\n            </ActionIcon>\n          </Tooltip>\n        </Group>\n      }\n      width={450}\n    >\n      <Stack gap=\"md\" h=\"100%\">\n        {/* Create new tag group form */}\n        <Box data-tour-id=\"tag-groups-create\">\n          <CreateTagGroupForm />\n        </Box>\n\n        {/* Search and delete actions */}\n        <Group gap=\"xs\" data-tour-id=\"tag-groups-search\">\n          <SearchInput\n            flex={1}\n            size=\"sm\"\n            value={searchQuery}\n            onChange={(value) => setSearchQuery(value)}\n            onClear={() => setSearchQuery('')}\n          />\n          <DeleteSelectedButton\n            selectedIds={selectedIds}\n            onDeleted={handleDeleted}\n          />\n        </Group>\n\n        {/* Table */}\n        <Box data-tour-id=\"tag-groups-table\" style={{ flex: 1, overflow: 'auto' }}>\n          <TagGroupsTable\n            groups={filteredGroups}\n            selectedIds={selectedIds}\n            onToggleSelect={handleToggleSelect}\n            onToggleSelectAll={handleToggleSelectAll}\n          />\n        </Box>\n      </Stack>\n    </SectionDrawer>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/drawers/user-converter-drawer/index.ts",
    "content": "export { UserConverterDrawer } from './user-converter-drawer';\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/drawers/user-converter-drawer/user-converter-drawer.tsx",
    "content": "/**\n * UserConverterDrawer - Drawer for managing user converters.\n * Uses the generic ConverterDrawer component with user-specific configuration.\n */\n\nimport { t } from '@lingui/core/macro';\nimport { Trans } from '@lingui/react/macro';\nimport { ActionIcon, Group, Tooltip } from '@mantine/core';\nimport type {\n    ICreateUserConverterDto,\n    IUpdateUserConverterDto,\n} from '@postybirb/types';\nimport { IconHelp } from '@tabler/icons-react';\nimport { useMemo } from 'react';\nimport userConvertersApi from '../../../api/user-converters.api';\nimport { useUserConverters } from '../../../stores/entity/user-converter-store';\nimport type { UserConverterRecord } from '../../../stores/records';\nimport { useActiveDrawer, useDrawerActions } from '../../../stores/ui/drawer-store';\nimport { useTourActions } from '../../../stores/ui/tour-store';\nimport { USER_CONVERTERS_TOUR_ID } from '../../onboarding-tour/tours/user-converters-tour';\nimport {\n    ConverterDrawer,\n    type ConverterDrawerConfig,\n} from '../converter-drawer';\n\n// Drawer identifier\nconst DRAWER_KEY = 'userConverters';\n\n/**\n * UserConverterDrawer component.\n * Gate pattern: returns null when closed to avoid entity store subscriptions.\n */\nexport function UserConverterDrawer() {\n  const activeDrawer = useActiveDrawer();\n  const { closeDrawer } = useDrawerActions();\n\n  if (activeDrawer !== DRAWER_KEY) return null;\n\n  return <UserConverterDrawerContent onClose={closeDrawer} />;\n}\n\n/**\n * Inner content — only mounted when drawer is open.\n */\nfunction UserConverterDrawerContent({ onClose }: { onClose: () => void }) {\n  const converters = useUserConverters();\n  const { startTour } = useTourActions();\n\n  const config = useMemo(\n    (): ConverterDrawerConfig<\n      UserConverterRecord,\n      ICreateUserConverterDto,\n      IUpdateUserConverterDto\n    > => ({\n      title: (\n        <Group gap=\"xs\">\n          <Trans>User Converters</Trans>\n          <Tooltip label={<Trans>User Converters Tour</Trans>}>\n            <ActionIcon variant=\"subtle\" size=\"xs\" onClick={() => startTour(USER_CONVERTERS_TOUR_ID)}>\n              <IconHelp size={16} />\n            </ActionIcon>\n          </Tooltip>\n        </Group>\n      ),\n      primaryField: 'username',\n      getPrimaryValue: (record: UserConverterRecord) => record.username,\n      api: {\n        create: (dto: ICreateUserConverterDto) => userConvertersApi.create(dto),\n        update: (id: string, dto: IUpdateUserConverterDto) =>\n          userConvertersApi.update(id, dto),\n        remove: (ids: string[]) => userConvertersApi.remove(ids),\n      },\n      createUpdateDto: (\n        username: string,\n        convertTo: Record<string, string>\n      ): IUpdateUserConverterDto => ({ username, convertTo }),\n      createCreateDto: (\n        username: string,\n        convertTo: Record<string, string>\n      ): ICreateUserConverterDto => ({ username, convertTo }),\n      entityName: t`user converter`,\n      duplicateError: t`A user converter with this username already exists`,\n    }),\n    [startTour]\n  );\n\n  return (\n    <ConverterDrawer\n      opened\n      onClose={onClose}\n      converters={converters}\n      config={config}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/empty-state/empty-state.tsx",
    "content": "/**\n * EmptyState - Reusable empty state component for lists and drawers.\n * Provides a consistent, visually pleasing \"no results\" experience.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport { Center, Stack, Text, ThemeIcon } from '@mantine/core';\nimport { IconBell, IconFolder, IconInbox, IconSearch } from '@tabler/icons-react';\nimport type { ReactNode } from 'react';\n\n/**\n * Preset types for common empty state scenarios.\n */\nexport type EmptyStatePreset =\n  | 'no-results' // Search returned no results\n  | 'no-records' // No data exists yet\n  | 'no-selection' // Nothing selected\n  | 'no-notifications'; // No notifications\n\ninterface EmptyStateProps {\n  /** The preset type determines icon and default message */\n  preset?: EmptyStatePreset;\n  /** Custom icon to override the preset icon */\n  icon?: ReactNode;\n  /** Primary message (if not using preset default) */\n  message?: ReactNode;\n  /** Optional secondary helper text */\n  description?: ReactNode;\n  /** Size variant */\n  size?: 'sm' | 'md' | 'lg';\n}\n\n/**\n * Get icon for preset type.\n */\nfunction getPresetIcon(preset: EmptyStatePreset): ReactNode {\n  const iconProps = { size: 32, stroke: 1.5 };\n  switch (preset) {\n    case 'no-results':\n      return <IconSearch {...iconProps} />;\n    case 'no-records':\n      return <IconFolder {...iconProps} />;\n    case 'no-notifications':\n      return <IconBell {...iconProps} />;\n    case 'no-selection':\n    default:\n      return <IconInbox {...iconProps} />;\n  }\n}\n\n/**\n * Get default message for preset type.\n */\nfunction getPresetMessage(preset: EmptyStatePreset): ReactNode {\n  switch (preset) {\n    case 'no-results':\n      return <Trans>No results found</Trans>;\n    case 'no-records':\n      return <Trans>No records yet</Trans>;\n    case 'no-notifications':\n      return <Trans>No notifications</Trans>;\n    case 'no-selection':\n    default:\n      return <Trans>Nothing selected</Trans>;\n  }\n}\n\n/**\n * Get sizes for variant.\n */\nfunction getSizes(size: 'sm' | 'md' | 'lg') {\n  switch (size) {\n    case 'sm':\n      return { iconSize: 40, textSize: 'sm' as const, gap: 'xs' as const, py: 'md' as const };\n    case 'lg':\n      return { iconSize: 64, textSize: 'md' as const, gap: 'md' as const, py: 'xl' as const };\n    case 'md':\n    default:\n      return { iconSize: 48, textSize: 'sm' as const, gap: 'sm' as const, py: 'lg' as const };\n  }\n}\n\n/**\n * EmptyState component.\n * Use presets for common scenarios, or customize with icon/message/description.\n *\n * @example\n * ```tsx\n * // Using preset\n * <EmptyState preset=\"no-results\" />\n *\n * // Custom message\n * <EmptyState preset=\"no-records\" message={<Trans>No tag groups yet</Trans>} />\n *\n * // Fully custom\n * <EmptyState\n *   icon={<IconTags size={32} />}\n *   message=\"Create your first tag group\"\n *   description=\"Tag groups help organize your tags\"\n * />\n * ```\n */\nexport function EmptyState({\n  preset = 'no-records',\n  icon,\n  message,\n  description,\n  size = 'md',\n}: EmptyStateProps) {\n  const sizes = getSizes(size);\n  const displayIcon = icon ?? getPresetIcon(preset);\n  const displayMessage = message ?? getPresetMessage(preset);\n\n  return (\n    <Center py={sizes.py}>\n      <Stack align=\"center\" gap={sizes.gap}>\n        <ThemeIcon\n          variant=\"light\"\n          color=\"gray\"\n          size={sizes.iconSize}\n          radius=\"xl\"\n        >\n          {displayIcon}\n        </ThemeIcon>\n        <Text size={sizes.textSize} c=\"dimmed\" ta=\"center\" fw={500}>\n          {displayMessage}\n        </Text>\n        {description && (\n          <Text size=\"xs\" c=\"dimmed\" ta=\"center\" maw={240}>\n            {description}\n          </Text>\n        )}\n      </Stack>\n    </Center>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/empty-state/index.ts",
    "content": "export { EmptyState, type EmptyStatePreset } from './empty-state';\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/error-boundary/error-boundary.tsx",
    "content": "/**\n * Error boundary component for the remake UI.\n * Adapted from src/components/error-boundary with App Insights removed.\n */\n\n/* eslint-disable lingui/no-unlocalized-strings */\nimport { Trans } from '@lingui/react/macro';\nimport {\n    Alert,\n    Box,\n    Button,\n    Code,\n    Container,\n    ScrollArea,\n    Stack,\n    Text,\n    Title,\n} from '@mantine/core';\nimport { IconAlertTriangle, IconRefresh } from '@tabler/icons-react';\nimport { Component, ReactNode } from 'react';\nimport { CopyToClipboard } from '../shared/copy-to-clipboard';\n\n/**\n * Copyable error details component\n */\nfunction CopyableErrorDetails({\n  error,\n  errorInfo,\n}: {\n  error: Error;\n  errorInfo?: { componentStack: string };\n}) {\n  // Extract the component name from the component stack\n  const getComponentName = (componentStack?: string): string | null => {\n    if (!componentStack) return null;\n\n    const lines = componentStack.trim().split('\\n');\n    const firstComponentLine = lines.find((line) =>\n      line.trim().startsWith('in '),\n    );\n\n    if (firstComponentLine) {\n      const match = firstComponentLine.trim().match(/^in (\\w+)/);\n      return match ? match[1] : null;\n    }\n\n    return null;\n  };\n\n  const componentName = getComponentName(errorInfo?.componentStack);\n\n  const errorDetails = [\n    `Error: ${error.message}`,\n    componentName ? `Component: ${componentName}` : '',\n    '',\n    'Stack Trace:',\n    error.stack || 'No stack trace available',\n    '',\n    'Component Stack:',\n    errorInfo?.componentStack || 'No component stack available',\n  ]\n    .filter(Boolean)\n    .join('\\n');\n\n  return (\n    <Box mt=\"sm\">\n      <Stack gap=\"xs\">\n        <Box style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>\n          <Text size=\"xs\" fw={500}>\n            <Trans>Error</Trans>:\n          </Text>\n          <CopyToClipboard value={errorDetails} variant=\"button\" size=\"xs\" color=\"gray\" />\n        </Box>\n\n        <ScrollArea.Autosize mah={120}>\n          <Code\n            block\n            p=\"xs\"\n            style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}\n          >\n            <Text span c=\"red\" fw={500}>\n              {error.message}\n            </Text>\n\n            {componentName && (\n              <>\n                {'\\n\\n'}\n                <Text span c=\"blue\" fw={500}>\n                  Component: {componentName}\n                </Text>\n              </>\n            )}\n\n            {error.stack && (\n              <>\n                {'\\n\\n'}\n                <Text span c=\"dimmed\" size=\"xs\">\n                  {error.stack}\n                </Text>\n              </>\n            )}\n\n            {errorInfo?.componentStack && (\n              <>\n                {'\\n\\n'}\n                <Text span c=\"grape\" fw={500} size=\"xs\">\n                  Component Stack:\n                </Text>\n                {'\\n'}\n                <Text span c=\"dimmed\" size=\"xs\">\n                  {errorInfo.componentStack}\n                </Text>\n              </>\n            )}\n          </Code>\n        </ScrollArea.Autosize>\n      </Stack>\n    </Box>\n  );\n}\n\ninterface ErrorBoundaryState {\n  hasError: boolean;\n  error?: Error;\n  errorInfo?: { componentStack: string };\n}\n\ninterface ErrorBoundaryProps {\n  children: ReactNode;\n  fallback?: (\n    error: Error,\n    errorInfo: { componentStack: string },\n    retry: () => void,\n  ) => ReactNode;\n  onError?: (error: Error, errorInfo: { componentStack: string }) => void;\n  level?: 'page' | 'section' | 'component';\n  resetKeys?: Array<string | number>;\n  resetOnPropsChange?: boolean;\n}\n\nexport class ErrorBoundary extends Component<\n  ErrorBoundaryProps,\n  ErrorBoundaryState\n> {\n  private resetTimeoutId: number | null = null;\n\n  constructor(props: ErrorBoundaryProps) {\n    super(props);\n    this.state = { hasError: false };\n  }\n\n  static getDerivedStateFromError(error: Error): ErrorBoundaryState {\n    return { hasError: true, error };\n  }\n\n  componentDidCatch(error: Error, errorInfo: { componentStack: string }) {\n    // eslint-disable-next-line no-console\n    console.error('ErrorBoundary caught an error:', error, errorInfo);\n\n    this.setState({ error, errorInfo });\n\n    // Extract component name from stack for better tracking\n    const getComponentName = (componentStack?: string): string | null => {\n      if (!componentStack) return null;\n      const lines = componentStack.trim().split('\\n');\n      const firstComponentLine = lines.find((line) =>\n        line.trim().startsWith('in '),\n      );\n      if (firstComponentLine) {\n        const match = firstComponentLine.trim().match(/^in (\\w+)/);\n        return match ? match[1] : null;\n      }\n      return null;\n    };\n\n    const componentName = getComponentName(errorInfo.componentStack);\n    const { level = 'section' } = this.props;\n\n    // Log for debugging\n    // eslint-disable-next-line no-console\n    console.error('Error details:', {\n      source: 'error-boundary',\n      level,\n      component: componentName || 'unknown',\n    });\n\n    // Call the onError callback if provided\n    // eslint-disable-next-line react/destructuring-assignment\n    this.props.onError?.(error, errorInfo);\n  }\n\n  componentDidUpdate(prevProps: ErrorBoundaryProps) {\n    const { resetKeys, resetOnPropsChange } = this.props;\n    const { hasError } = this.state;\n\n    // Reset error boundary when resetKeys change\n    if (hasError && resetKeys && prevProps.resetKeys) {\n      const hasResetKeyChanged = resetKeys.some(\n        (key, index) => key !== prevProps.resetKeys?.[index],\n      );\n\n      if (hasResetKeyChanged) {\n        this.resetErrorBoundary();\n      }\n    }\n\n    // Reset error boundary when any props change (if enabled)\n    if (hasError && resetOnPropsChange && prevProps !== this.props) {\n      this.resetErrorBoundary();\n    }\n  }\n\n  resetErrorBoundary = () => {\n    if (this.resetTimeoutId) {\n      clearTimeout(this.resetTimeoutId);\n    }\n\n    this.resetTimeoutId = window.setTimeout(() => {\n      this.setState({\n        hasError: false,\n        error: undefined,\n        errorInfo: undefined,\n      });\n    }, 100);\n  };\n\n  render() {\n    const { hasError, error, errorInfo } = this.state;\n    const { children, fallback, level = 'section' } = this.props;\n\n    if (hasError && error) {\n      // Use custom fallback if provided\n      if (fallback && errorInfo) {\n        return fallback(error, errorInfo, this.resetErrorBoundary);\n      }\n\n      // Default fallback UI based on level\n      return this.renderDefaultFallback(error, level, errorInfo);\n    }\n\n    return children;\n  }\n\n  private renderDefaultFallback(\n    error: Error,\n    level: string,\n    errorInfo?: { componentStack: string },\n  ) {\n    const isComponentLevel = level === 'component';\n\n    if (isComponentLevel) {\n      return (\n        <Alert\n          color=\"red\"\n          title={<Trans>Something went wrong</Trans>}\n          icon={<IconAlertTriangle size={16} />}\n        >\n          <Stack gap=\"sm\">\n            <Text size=\"sm\">\n              <Trans>\n                This component encountered an error and couldn't render\n                properly.\n              </Trans>\n            </Text>\n\n            <CopyableErrorDetails error={error} errorInfo={errorInfo} />\n\n            <Button\n              size=\"xs\"\n              leftSection={<IconRefresh size={14} />}\n              onClick={this.resetErrorBoundary}\n            >\n              <Trans>Try again</Trans>\n            </Button>\n          </Stack>\n        </Alert>\n      );\n    }\n\n    return (\n      <Container>\n        <Box ta=\"center\" py=\"xl\">\n          <Stack align=\"center\" gap=\"lg\">\n            <IconAlertTriangle size={48} color=\"var(--mantine-color-red-5)\" />\n\n            <div>\n              <Title order={2} mb=\"sm\">\n                <Trans>Something went wrong</Trans>\n              </Title>\n              <Text c=\"dimmed\" mb=\"lg\">\n                <Trans>\n                  We encountered an unexpected error. Please try refreshing the\n                  page.\n                </Trans>\n              </Text>\n            </div>\n\n            <CopyableErrorDetails error={error} errorInfo={errorInfo} />\n\n            <Stack gap=\"sm\">\n              <Button\n                leftSection={<IconRefresh size={16} />}\n                onClick={this.resetErrorBoundary}\n              >\n                <Trans>Try again</Trans>\n              </Button>\n\n              <Button variant=\"subtle\" onClick={() => window.location.reload()}>\n                <Trans>Reload page</Trans>\n              </Button>\n            </Stack>\n\n            {process.env.NODE_ENV === 'development' && (\n              <Alert color=\"gray\" mt=\"xl\">\n                <Text size=\"xs\" ff=\"monospace\">\n                  {error.message}\n                </Text>\n                {error.stack && (\n                  <Text size=\"xs\" ff=\"monospace\" mt=\"xs\">\n                    {error.stack}\n                  </Text>\n                )}\n              </Alert>\n            )}\n          </Stack>\n        </Box>\n      </Container>\n    );\n  }\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/error-boundary/index.ts",
    "content": "/**\n * Error boundary exports.\n */\n\nexport { ErrorBoundary } from './error-boundary';\nexport {\n    ComponentErrorBoundary,\n    FormErrorBoundary,\n    PageErrorBoundary,\n    RouteErrorBoundary\n} from './specialized-error-boundaries';\n\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/error-boundary/specialized-error-boundaries.tsx",
    "content": "/**\n * Specialized error boundary components for different use cases.\n * Adapted from src/components/error-boundary.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport { Alert, Button, Stack, Text } from '@mantine/core';\nimport { IconRefresh } from '@tabler/icons-react';\nimport { ReactNode } from 'react';\nimport { ErrorBoundary } from './error-boundary';\n\n/**\n * Fallback component for form errors\n */\nfunction FormErrorFallback({\n  error,\n  retry,\n}: {\n  error: Error;\n  retry: () => void;\n}) {\n  return (\n    <Alert color=\"red\" title={<Trans>Form Error</Trans>}>\n      <Stack gap=\"sm\">\n        <Text size=\"sm\">\n          <Trans>\n            The form encountered an error and couldn't be displayed properly.\n          </Trans>\n        </Text>\n        <Button\n          type=\"button\"\n          size=\"sm\"\n          leftSection={<IconRefresh size={16} />}\n          onClick={retry}\n        >\n          <Trans>Try again</Trans>\n        </Button>\n      </Stack>\n    </Alert>\n  );\n}\n\n/**\n * Stable fallback renderer for FormErrorBoundary\n */\nconst formErrorFallback = (\n  error: Error,\n  _errorInfo: { componentStack: string },\n  retry: () => void,\n) => <FormErrorFallback error={error} retry={retry} />;\n\n/**\n * Specialized error boundary for page-level components\n */\nexport function PageErrorBoundary({ children }: { children: ReactNode }) {\n  return (\n    <ErrorBoundary\n      level=\"page\"\n      onError={(error, errorInfo) => {\n        // eslint-disable-next-line lingui/no-unlocalized-strings, no-console\n        console.error('Page Error:', error, errorInfo);\n      }}\n      resetOnPropsChange\n    >\n      {children}\n    </ErrorBoundary>\n  );\n}\n\n/**\n * Specialized error boundary for form components\n */\nexport function FormErrorBoundary({\n  children,\n  onError,\n}: {\n  children: ReactNode;\n  onError?: (error: Error) => void;\n}) {\n  return (\n    <ErrorBoundary\n      level=\"section\"\n      onError={(error, errorInfo) => {\n        // eslint-disable-next-line lingui/no-unlocalized-strings, no-console\n        console.error('Form Error:', error, errorInfo);\n        onError?.(error);\n      }}\n      fallback={formErrorFallback}\n    >\n      {children}\n    </ErrorBoundary>\n  );\n}\n\n/**\n * Specialized error boundary for individual components\n */\nexport function ComponentErrorBoundary({ children }: { children: ReactNode }) {\n  return (\n    <ErrorBoundary\n      level=\"component\"\n      onError={(error, errorInfo) => {\n        // eslint-disable-next-line lingui/no-unlocalized-strings, no-console\n        console.warn('Component Error:', error, errorInfo);\n      }}\n    >\n      {children}\n    </ErrorBoundary>\n  );\n}\n\n/**\n * Error boundary that resets when route changes\n */\nexport function RouteErrorBoundary({\n  children,\n  routeKey,\n}: {\n  children: ReactNode;\n  routeKey: string;\n}) {\n  return (\n    <ErrorBoundary\n      level=\"page\"\n      resetKeys={[routeKey]}\n      onError={(error, errorInfo) => {\n        // eslint-disable-next-line lingui/no-unlocalized-strings, no-console\n        console.error('Route Error:', error, errorInfo);\n      }}\n    >\n      {children}\n    </ErrorBoundary>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/hold-to-confirm/hold-to-confirm.tsx",
    "content": "/**\n * HoldToConfirm - A component that requires users to hold down mouse or Enter key\n * for a specified duration to confirm an action. Useful for destructive actions.\n */\n\nimport { ActionIcon, type ActionIconProps, Progress } from '@mantine/core';\nimport { forwardRef, useCallback, useEffect, useRef, useState } from 'react';\n\n/** Default duration in ms to hold before confirming */\nconst DEFAULT_HOLD_DURATION = 1000;\n/** Update interval for progress animation */\nconst PROGRESS_INTERVAL = 16;\n\nexport interface UseHoldToConfirmOptions {\n  /** Callback when hold is completed */\n  onConfirm: () => void;\n  /** Whether the hold action is disabled */\n  disabled?: boolean;\n  /** Duration in ms to hold before confirming (default: 1000) */\n  duration?: number;\n}\n\nexport interface UseHoldToConfirmReturn {\n  /** Current progress percentage (0-100) */\n  progress: number;\n  /** Whether currently holding */\n  isHolding: boolean;\n  /** Start the hold action */\n  startHold: () => void;\n  /** Stop the hold action */\n  stopHold: () => void;\n}\n\n/**\n * Hook to handle hold-to-confirm logic for mouse and keyboard.\n */\nexport function useHoldToConfirm({\n  onConfirm,\n  disabled,\n  duration = DEFAULT_HOLD_DURATION,\n}: UseHoldToConfirmOptions): UseHoldToConfirmReturn {\n  const [progress, setProgress] = useState(0);\n  const [isHolding, setIsHolding] = useState(false);\n  const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);\n  const startTimeRef = useRef<number>(0);\n\n  const clearHoldInterval = useCallback(() => {\n    if (intervalRef.current) {\n      clearInterval(intervalRef.current);\n      intervalRef.current = null;\n    }\n  }, []);\n\n  const stopHold = useCallback(() => {\n    setIsHolding(false);\n    setProgress(0);\n    clearHoldInterval();\n  }, [clearHoldInterval]);\n\n  const startHold = useCallback(() => {\n    if (disabled) return;\n    setIsHolding(true);\n    startTimeRef.current = Date.now();\n    setProgress(0);\n\n    intervalRef.current = setInterval(() => {\n      const elapsed = Date.now() - startTimeRef.current;\n      const newProgress = Math.min((elapsed / duration) * 100, 100);\n      setProgress(newProgress);\n\n      if (newProgress >= 100) {\n        setIsHolding(false);\n        setProgress(0);\n        clearHoldInterval();\n        onConfirm();\n      }\n    }, PROGRESS_INTERVAL);\n  }, [disabled, onConfirm, duration, clearHoldInterval]);\n\n  // Cleanup on unmount\n  useEffect(() => clearHoldInterval, [clearHoldInterval]);\n\n  return { progress, isHolding, startHold, stopHold };\n}\n\nexport interface HoldToConfirmButtonProps\n  extends Omit<ActionIconProps, 'onMouseDown' | 'onMouseUp' | 'onMouseLeave'> {\n  /** Callback when hold is completed */\n  onConfirm: () => void;\n  /** Duration in ms to hold before confirming (default: 1000) */\n  duration?: number;\n  /** Color of the progress bar (default: inherits from button color) */\n  progressColor?: string;\n  /** Children to render inside the button */\n  children: React.ReactNode;\n}\n\n/**\n * An ActionIcon that requires holding down to confirm an action.\n * Shows a progress bar that fills as the user holds.\n */\nexport const HoldToConfirmButton = forwardRef<\n  HTMLButtonElement,\n  HoldToConfirmButtonProps\n>((props, ref) => {\n  const {\n    onConfirm,\n    duration,\n    progressColor,\n    disabled,\n    children,\n    color,\n    ...rest\n  } = props;\n\n  const { progress, isHolding, startHold, stopHold } = useHoldToConfirm({\n    onConfirm,\n    disabled,\n    duration,\n  });\n\n  const handleKeyDown = useCallback(\n    (e: React.KeyboardEvent) => {\n      // Stop propagation to prevent parent handlers (like card selection)\n      e.stopPropagation();\n      if (e.key === 'Enter' && !e.repeat) {\n        e.preventDefault();\n        startHold();\n      }\n    },\n    [startHold]\n  );\n\n  const handleKeyUp = useCallback(\n    (e: React.KeyboardEvent) => {\n      // Stop propagation to prevent parent handlers\n      e.stopPropagation();\n      if (e.key === 'Enter') {\n        stopHold();\n      }\n    },\n    [stopHold]\n  );\n\n  return (\n    <ActionIcon\n      ref={ref}\n      {...rest}\n      color={color}\n      disabled={disabled}\n      onMouseDown={startHold}\n      onMouseUp={stopHold}\n      onMouseLeave={stopHold}\n      onKeyDown={handleKeyDown}\n      onKeyUp={handleKeyUp}\n      style={{ position: 'relative', overflow: 'hidden', ...rest.style }}\n    >\n      {isHolding && (\n        <Progress\n          value={progress}\n          color={progressColor ?? (color as string) ?? 'blue'}\n          size=\"xs\"\n          style={{\n            position: 'absolute',\n            bottom: 0,\n            left: 0,\n            right: 0,\n            borderRadius: 0,\n          }}\n        />\n      )}\n      {children}\n    </ActionIcon>\n  );\n});\n\n// eslint-disable-next-line lingui/no-unlocalized-strings\nHoldToConfirmButton.displayName = 'HoldToConfirmButton';\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/hold-to-confirm/index.ts",
    "content": "export {\n    HoldToConfirmButton,\n    useHoldToConfirm,\n    type HoldToConfirmButtonProps,\n    type UseHoldToConfirmOptions,\n    type UseHoldToConfirmReturn\n} from './hold-to-confirm';\n\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/language-picker/index.ts",
    "content": "export { LanguagePicker } from './language-picker';\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/language-picker/language-picker.css",
    "content": ".postybirb__language_picker_header {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  width: 100%;\n}\n\n.postybirb__language_picker_badge {\n  font-size: 10px;\n  text-transform: uppercase;\n}\n\n.postybirb__language_picker_code {\n  font-size: var(--mantine-font-size-xs);\n  color: var(--mantine-color-dimmed);\n  font-family: var(--mantine-font-family-monospace);\n}\n\n.postybirb__language_picker_item--selected {\n  background-color: var(--mantine-color-blue-light);\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/language-picker/language-picker.tsx",
    "content": "/**\n * LanguagePicker - Language selection component for switching app locale.\n * Displays as a NavLink-style component with a menu popup for language selection.\n */\n\nimport { Trans, useLingui } from '@lingui/react/macro';\nimport {\n  Badge,\n  Box,\n  Group,\n  Kbd,\n  NavLink as MantineNavLink,\n  Menu,\n  Text,\n  Tooltip,\n} from '@mantine/core';\nimport { useDisclosure } from '@mantine/hooks';\nimport { IconCheck, IconLanguage, IconWorld } from '@tabler/icons-react';\nimport { useState } from 'react';\nimport { languages } from '../../i18n/languages';\nimport { formatKeybindingDisplay } from '../../shared/platform-utils';\nimport { useLanguageActions } from '../../stores/ui/locale-store';\nimport '../../styles/layout.css';\nimport { cn } from '../../utils/class-names';\nimport './language-picker.css';\n\ninterface LanguagePickerProps {\n  /** Whether to show in collapsed mode (icon only with tooltip) */\n  collapsed?: boolean;\n  /** Optional keyboard shortcut to display */\n  kbd?: string;\n}\n\n/**\n * Renders a language picker as a NavLink-style component with a popup menu.\n */\nexport function LanguagePicker({ collapsed = false, kbd }: LanguagePickerProps) {\n  const { t } = useLingui();\n  const { language: locale, setLanguage: setLocale } = useLanguageActions();\n  const [opened, { toggle, close }] = useDisclosure(false);\n  const [hoveredLang, setHoveredLang] = useState<string | null>(null);\n\n  // Get current language display name\n  const currentLanguage = languages.find(([, code]) => code === locale);\n  const currentLanguageName = currentLanguage ? t(currentLanguage[0]) : locale;\n\n  const labelContent = collapsed ? undefined : (\n    <Box className=\"postybirb__nav_item_label\">\n      <span>{currentLanguageName}</span>\n      {kbd && <Kbd size=\"xs\">{formatKeybindingDisplay(kbd)}</Kbd>}\n    </Box>\n  );\n\n  const navLinkContent = (\n    <MantineNavLink\n      label={labelContent}\n      leftSection={<IconLanguage size={20} />}\n      active={opened}\n    />\n  );\n\n  const menuContent = (\n    <Menu\n      opened={opened}\n      onOpen={toggle}\n      onClose={close}\n      width={220}\n      position=\"right-start\"\n      withArrow\n      shadow=\"md\"\n      offset={8}\n    >\n      <Menu.Target>\n        {collapsed ? (\n          <Tooltip\n            label={\n              <Box className=\"postybirb__tooltip_content\">\n                <span><Trans>Language</Trans></span>\n                {kbd && (\n                  <Kbd size=\"xs\" className=\"postybirb__kbd_aligned\">\n                    {formatKeybindingDisplay(kbd)}\n                  </Kbd>\n                )}\n              </Box>\n            }\n            position=\"right\"\n            withArrow\n          >\n            {navLinkContent}\n          </Tooltip>\n        ) : (\n          navLinkContent\n        )}\n      </Menu.Target>\n\n      <Menu.Dropdown>\n        <Menu.Label>\n          <Group className=\"postybirb__language_picker_header\">\n            <Text size=\"sm\" fw={500}>\n              <Trans>Select language</Trans>\n            </Text>\n            <Badge\n              size=\"xs\"\n              variant=\"filled\"\n              color=\"blue\"\n              className=\"postybirb__language_picker_badge\"\n            >\n              {locale}\n            </Badge>\n          </Group>\n        </Menu.Label>\n\n        {languages.map(([label, value]) => {\n          const isActive = value === locale;\n          const isHovered = value === hoveredLang;\n\n          return (\n            <Menu.Item\n              key={value}\n              className={cn(['postybirb__language_picker_item'], {\n                'postybirb__language_picker_item--selected': isActive,\n              })}\n              leftSection={\n                isActive ? <IconCheck size={16} /> : <IconWorld size={16} />\n              }\n              rightSection={\n                <Text className=\"postybirb__language_picker_code\">{value}</Text>\n              }\n              onClick={() => {\n                setLocale(value);\n                close();\n              }}\n              onMouseEnter={() => setHoveredLang(value)}\n              onMouseLeave={() => setHoveredLang(null)}\n              color={isActive || isHovered ? 'blue' : undefined}\n              fw={isActive ? 500 : undefined}\n            >\n              {t(label)}\n            </Menu.Item>\n          );\n        })}\n      </Menu.Dropdown>\n    </Menu>\n  );\n\n  return <Box>{menuContent}</Box>;\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/layout/content-area.tsx",
    "content": "/**\n * ContentArea - Scrollable container for primary content.\n * Provides independent scrolling and optional loading state overlay.\n */\n\nimport { Box, LoadingOverlay } from '@mantine/core';\nimport '../../styles/layout.css';\nimport type { ContentAreaProps } from '../../types/navigation';\n\n/**\n * Scrollable content area that fills available space below the content navbar.\n * Shows a loading overlay when the loading prop is true.\n */\nexport function ContentArea({ children, loading = false }: ContentAreaProps) {\n  return (\n    <Box className=\"postybirb__content_area\" pos=\"relative\">\n      <LoadingOverlay\n        visible={loading}\n        overlayProps={{ radius: 'sm', blur: 2 }}\n      />\n      {children}\n    </Box>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/layout/content-navbar.tsx",
    "content": "/**\n * ContentNavbar - Navbar at top of content area with pagination controls.\n * Shows title, pagination, and optional action buttons.\n */\n\nimport { Box, Group, Pagination, Text } from '@mantine/core';\nimport '../../styles/layout.css';\nimport type { ContentNavbarProps } from '../../types/navigation';\n\n/**\n * Content area navbar with optional pagination controls.\n * Layout: [Title] [Pagination (center)] [Actions (right)]\n */\nexport function ContentNavbar({ config, onPageChange }: ContentNavbarProps) {\n  const { showPagination, pagination, title, actions } = config;\n\n  // Hide pagination if there's only one page or less\n  const shouldShowPagination =\n    showPagination && pagination && pagination.totalPages > 1;\n\n  return (\n    <Box className=\"postybirb__content_navbar\">\n      {/* Left: Title */}\n      <Box className=\"postybirb__content_navbar_title\">\n        {title && <Text fw={500}>{title}</Text>}\n      </Box>\n\n      {/* Center: Pagination */}\n      <Box className=\"postybirb__content_navbar_center\">\n        {shouldShowPagination && pagination && (\n          <Pagination\n            total={pagination.totalPages}\n            value={pagination.currentPage}\n            onChange={onPageChange}\n            size=\"sm\"\n          />\n        )}\n      </Box>\n\n      {/* Right: Actions */}\n      <Group className=\"postybirb__content_navbar_actions\" gap=\"xs\">\n        {actions}\n      </Group>\n    </Box>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/layout/layout.tsx",
    "content": "/**\n * Layout - Main layout shell using custom flexbox structure.\n * No AppShell - fully custom layout for maximum control.\n * Uses state-driven navigation instead of React Router.\n */\n\nimport { Box } from '@mantine/core';\nimport { navItems } from '../../config/nav-items';\nimport { useKeybindings } from '../../hooks/use-keybindings';\nimport { useViewState } from '../../stores/ui/navigation-store';\nimport {\n  useSidenavCollapsed,\n  useSubNavVisible,\n  useSubmissionsUIStore,\n} from '../../stores/ui/submissions-ui-store';\nimport '../../styles/layout.css';\nimport { cn } from '../../utils/class-names';\nimport {\n  CustomShortcutsDrawer,\n  FileWatcherDrawer,\n  NotificationsDrawer,\n  ScheduleDrawer,\n  SettingsDialog,\n  TagConverterDrawer,\n  TagGroupDrawer,\n  UserConverterDrawer,\n} from '../drawers/drawers';\nimport { PrimaryContent } from './primary-content';\nimport { SectionPanel } from './section-panel';\nimport { SideNav } from './side-nav';\n\n/**\n * Root layout component that orchestrates the overall page structure.\n * Includes sidenav, section panel (master), and primary content (detail).\n */\nexport function Layout() {\n  const collapsed = useSidenavCollapsed();\n  const setSidenavCollapsed = useSubmissionsUIStore((state) => state.setSidenavCollapsed);\n  const viewState = useViewState();\n  const { visible: isSectionPanelVisible } = useSubNavVisible();\n\n  // Set up global keybindings\n  useKeybindings();\n\n  return (\n    <Box className=\"postybirb__layout\">\n      {/* Dialogs (not drawers - they render inside content_split) */}\n      <SettingsDialog />\n\n      {/* Side Navigation */}\n      <SideNav\n        items={navItems}\n        collapsed={collapsed}\n        onCollapsedChange={setSidenavCollapsed}\n      />\n\n      {/* Main Content Area */}\n      <Box className={cn(['postybirb__main'], { 'postybirb__main--sidenav_collapsed': collapsed })}>\n        {/* Split Content Area: Section Panel + Primary Content */}\n        <Box id=\"postybirb-content-split\" className=\"postybirb__content_split\">\n          {/* Section Panel (Master) - left side list */}\n          {isSectionPanelVisible ? <SectionPanel viewState={viewState} /> : null}\n\n          {/* Primary Content (Detail) - right side detail view */}\n          <PrimaryContent viewState={viewState} />\n        </Box>\n\n        {/* Section Drawers - use Portal to render into content_split */}\n        <TagGroupDrawer />\n        <TagConverterDrawer />\n        <UserConverterDrawer />\n        <NotificationsDrawer />\n        <CustomShortcutsDrawer />\n        <FileWatcherDrawer />\n        <ScheduleDrawer />\n      </Box>\n    </Box>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/layout/primary-content.tsx",
    "content": "/**\n * PrimaryContent - Main content area that displays detail view for selected items.\n * Renders different content based on the current view state and selection.\n */\n\nimport { Box, LoadingOverlay } from '@mantine/core';\nimport { SubmissionType } from '@postybirb/types';\nimport '../../styles/layout.css';\nimport { isHomeViewState, type ViewState } from '../../types/view-state';\nimport { ComponentErrorBoundary } from '../error-boundary';\nimport { AccountsContent } from '../sections/accounts-section';\nimport { HomeContent } from '../sections/home-section';\nimport { SubmissionsContent } from '../sections/submissions-section';\nimport { TemplatesContent } from '../sections/templates-section';\n\ninterface PrimaryContentProps {\n  /** Current view state */\n  viewState: ViewState;\n  /** Whether content is loading */\n  // eslint-disable-next-line react/no-unused-prop-types\n  loading?: boolean;\n}\n\n/**\n * Renders view-specific content based on current view state.\n */\nfunction ViewContent({ viewState }: PrimaryContentProps) {\n  if (isHomeViewState(viewState)) {\n    return <HomeContent />;\n  }\n\n  switch (viewState.type) {\n    case 'accounts':\n      return <AccountsContent viewState={viewState} />;\n    case 'file-submissions':\n      return (\n        <SubmissionsContent\n          viewState={viewState}\n          submissionType={SubmissionType.FILE}\n        />\n      );\n    case 'message-submissions':\n      return (\n        <SubmissionsContent\n          viewState={viewState}\n          submissionType={SubmissionType.MESSAGE}\n        />\n      );\n    case 'templates':\n      return <TemplatesContent viewState={viewState} />;\n    default:\n      return <HomeContent />;\n  }\n}\n\n/**\n * Main content area component.\n * Displays detail view for the currently selected item(s) in the active section.\n * Includes content navbar at the top for pagination and actions.\n */\nexport function PrimaryContent({\n  viewState,\n  loading = false,\n}: PrimaryContentProps) {\n  return (\n    <Box className=\"postybirb__primary_content\">\n      {/* Content Navbar with Pagination */}\n      {/* <ContentNavbar\n        config={{\n          showPagination: false,\n          title: undefined,\n        }}\n      /> */}\n\n      {/* Scrollable content area */}\n      <Box\n        id=\"postybirb__primary_content_area\"\n        className=\"postybirb__primary_content_area\"\n        pos=\"relative\"\n      >\n        <LoadingOverlay\n          visible={loading}\n          overlayProps={{ radius: 'sm', blur: 2 }}\n        />\n        <ComponentErrorBoundary>\n          <ViewContent viewState={viewState} />\n        </ComponentErrorBoundary>\n      </Box>\n    </Box>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/layout/section-panel.tsx",
    "content": "/**\n * SectionPanel - Left panel that displays section-specific list content.\n * Renders different content based on the current view state.\n */\n\nimport { Box } from '@mantine/core';\nimport { SubmissionType } from '@postybirb/types';\nimport '../../styles/layout.css';\nimport { getSectionPanelConfig, type ViewState } from '../../types/view-state';\nimport { ComponentErrorBoundary } from '../error-boundary';\nimport { AccountsSection } from '../sections/accounts-section';\nimport { SubmissionsSection } from '../sections/submissions-section';\nimport { TemplatesSection } from '../sections/templates-section';\n\ninterface SectionPanelProps {\n  /** Current view state */\n  viewState: ViewState;\n}\n\n/**\n * Renders section-specific content based on view state type.\n */\nfunction SectionContent({ viewState }: SectionPanelProps) {\n  switch (viewState.type) {\n    case 'accounts':\n      return <AccountsSection viewState={viewState} />;\n    case 'file-submissions':\n      return (\n        <SubmissionsSection\n          viewState={viewState}\n          submissionType={SubmissionType.FILE}\n        />\n      );\n    case 'message-submissions':\n      return (\n        <SubmissionsSection\n          viewState={viewState}\n          submissionType={SubmissionType.MESSAGE}\n        />\n      );\n    case 'templates':\n      return <TemplatesSection viewState={viewState} />;\n    default:\n      return null;\n  }\n}\n\n/**\n * Left panel component that displays section-specific list content.\n * Only renders when the current view state has a section panel configured.\n */\nexport function SectionPanel({ viewState }: SectionPanelProps) {\n  const config = getSectionPanelConfig(viewState);\n\n  // Don't render if this section doesn't have a panel\n  if (!config.hasPanel) {\n    return null;\n  }\n\n  return (\n    <Box\n      id=\"postybirb-section-panel\"\n      className=\"postybirb__section_panel\"\n      style={{ width: config.defaultWidth }}\n    >\n      <ComponentErrorBoundary>\n        <SectionContent viewState={viewState} />\n      </ComponentErrorBoundary>\n    </Box>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/layout/side-nav.tsx",
    "content": "/**\n * SideNav - Collapsible side navigation panel using Mantine NavLink.\n * Supports expanded and collapsed states with smooth transitions.\n * Handles view, drawer, and custom navigation items.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport {\n    Box,\n    Divider,\n    Image,\n    Kbd,\n    NavLink as MantineNavLink,\n    ScrollArea,\n    Text,\n    Title,\n    Tooltip,\n} from '@mantine/core';\nimport { IconChevronLeft, IconChevronRight, IconHelp } from '@tabler/icons-react';\nimport { useEffect, useState } from 'react';\nimport { formatKeybindingDisplay } from '../../shared/platform-utils';\nimport { useActiveDrawer, useDrawerActions } from '../../stores/ui/drawer-store';\nimport { useViewState, useViewStateActions } from '../../stores/ui/navigation-store';\nimport { useTourActions } from '../../stores/ui/tour-store';\nimport '../../styles/layout.css';\nimport type { NavigationItem, SideNavProps } from '../../types/navigation';\nimport { cn } from '../../utils/class-names';\nimport { LanguagePicker } from '../language-picker';\nimport { LAYOUT_TOUR_ID } from '../onboarding-tour/tours/layout-tour';\nimport { ThemePicker } from '../theme-picker';\nimport { UpdateButton } from '../update-button';\n\nfunction TourButton({ collapsed }: { collapsed: boolean }) {\n  const { startTour } = useTourActions();\n\n  const navLink = (\n    <MantineNavLink\n      leftSection={<IconHelp size={20} />}\n      label={collapsed ? undefined : (\n        <Trans>Take the Tour</Trans>\n      )}\n      onClick={() => startTour(LAYOUT_TOUR_ID)}\n    />\n  );\n\n  if (collapsed) {\n    return (\n      <Tooltip label={<Trans>Take the Tour</Trans>} position=\"right\" withArrow>\n        <Box data-tour-id=\"tour-button\">{navLink}</Box>\n      </Tooltip>\n    );\n  }\n\n  return <Box data-tour-id=\"tour-button\">{navLink}</Box>;\n}\n\n/**\n * Render a single navigation item based on its type.\n */\nfunction NavItemRenderer({\n  item,\n  collapsed,\n  isActive,\n}: {\n  item: Exclude<NavigationItem, { type: 'divider' }>;\n  collapsed: boolean;\n  isActive: boolean;\n}) {\n  const { toggleDrawer } = useDrawerActions();\n  const { setViewState } = useViewStateActions();\n\n  // Handle theme item separately using the ThemePicker component\n  if (item.type === 'theme') {\n    return (\n      <Box key={item.id} data-tour-id={item.id}>\n        <ThemePicker collapsed={collapsed} kbd={item.kbd} />\n      </Box>\n    );\n  }\n\n  // Handle language item separately using the LanguagePicker component\n  if (item.type === 'language') {\n    return (\n      <Box key={item.id} data-tour-id={item.id}>\n        <LanguagePicker collapsed={collapsed} kbd={item.kbd} />\n      </Box>\n    );\n  }\n\n  // Build the label with optional keyboard shortcut (only for non-theme items)\n  const labelContent = collapsed ? undefined : (\n    <Box className=\"postybirb__nav_item_label\">\n      <span>{item.label}</span>\n      {item.kbd && <Kbd size=\"xs\">{formatKeybindingDisplay(item.kbd)}</Kbd>}\n    </Box>\n  );\n\n  // Common NavLink props\n  const commonProps = {\n    label: labelContent,\n    leftSection: item.icon,\n    disabled: item.disabled,\n  };\n\n  let navLinkContent: React.ReactNode;\n\n  if (item.type === 'view') {\n    navLinkContent = (\n      <MantineNavLink\n        onClick={() => setViewState(item.viewState)}\n        active={isActive}\n        {...commonProps}\n      />\n    );\n  } else if (item.type === 'link') {\n    // Legacy link type - kept for backwards compatibility\n    navLinkContent = (\n      <MantineNavLink\n        component=\"a\"\n        href={item.path}\n        active={isActive}\n        {...commonProps}\n      />\n    );\n  } else if (item.type === 'drawer') {\n    navLinkContent = (\n      <MantineNavLink\n        onClick={() => toggleDrawer(item.drawerKey)}\n        active={isActive}\n        {...commonProps}\n      />\n    );\n  } else if (item.type === 'custom') {\n    navLinkContent = <MantineNavLink onClick={item.onClick} {...commonProps} />;\n  }\n\n  if (collapsed) {\n    return (\n      <Tooltip\n        key={item.id}\n        label={\n          <Box className=\"postybirb__tooltip_content\">\n            <span>{item.label}</span>\n            {item.kbd && (\n              <Kbd size=\"xs\" className=\"postybirb__kbd_aligned\">\n                {formatKeybindingDisplay(item.kbd)}\n              </Kbd>\n            )}\n          </Box>\n        }\n        position=\"right\"\n        withArrow\n      >\n        <Box data-tour-id={item.id}>{navLinkContent}</Box>\n      </Tooltip>\n    );\n  }\n\n  return <Box key={item.id} data-tour-id={item.id}>{navLinkContent}</Box>;\n}\n\n/**\n * Collapsible side navigation component.\n * Shows icons + labels when expanded, icons only when collapsed.\n */\nexport function SideNav({ items, collapsed, onCollapsedChange }: SideNavProps) {\n  const viewState = useViewState();\n  const activeDrawer = useActiveDrawer();\n  const [appVersion, setAppVersion] = useState<string>('4.x.x');\n\n  useEffect(() => {\n    if (window.electron?.getAppVersion) {\n      window.electron.getAppVersion().then((version) => {\n        setAppVersion(`v${version}`);\n      });\n    }\n  }, []);\n\n  return (\n    <Box\n      className={cn(['postybirb__sidenav'], {\n        'postybirb__sidenav--collapsed': collapsed,\n      })}\n    >\n      {/* Header with app icon */}\n      <Box className=\"postybirb__sidenav_header\">\n        {/* eslint-disable-next-line lingui/no-unlocalized-strings */}\n        <Image src=\"/app-icon.png\" alt=\"PostyBirb\" w={32} h={32} />\n        {!collapsed && (\n          // eslint-disable-next-line lingui/no-unlocalized-strings\n          <Title order={4} className=\"postybirb__sidenav_title\" ml=\"xs\">\n            PostyBirb\n            <Text size=\"xs\" c=\"dimmed\" span pl={4}>\n              {appVersion}\n            </Text>\n          </Title>\n        )}\n      </Box>\n\n      {/* Navigation Items */}\n      <ScrollArea\n        className=\"postybirb__sidenav_scroll\"\n        type=\"hover\"\n        scrollbarSize={6}\n      >\n        <Box className=\"postybirb__sidenav_nav\">\n          {/* Collapse/Expand toggle as first nav item */}\n\n          <MantineNavLink\n            leftSection={\n              collapsed ? (\n                <IconChevronRight size={20} />\n              ) : (\n                <IconChevronLeft size={20} />\n              )\n            }\n            onClick={() => onCollapsedChange(!collapsed)}\n            aria-label={\n              // eslint-disable-next-line lingui/no-unlocalized-strings\n              collapsed ? 'Expand navigation' : 'Collapse navigation'\n            }\n          />\n\n          {/* Update button - shows when update is available */}\n          <UpdateButton collapsed={collapsed} />\n\n          {/* Tour button */}\n          <TourButton collapsed={collapsed} />\n\n          {items.map((item) => {\n            // Handle divider\n            if (item.type === 'divider') {\n              return <Divider key={item.id} my=\"xs\" />;\n            }\n\n            // Determine if item is active based on current viewState or active drawer\n            const isActive =\n              (item.type === 'view' &&\n                item.viewState.type === viewState.type) ||\n              (item.type === 'drawer' && item.drawerKey === activeDrawer);\n\n            return (\n              <NavItemRenderer\n                key={item.id}\n                item={item}\n                collapsed={collapsed}\n                isActive={isActive}\n              />\n            );\n          })}\n        </Box>\n      </ScrollArea>\n    </Box>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/onboarding-tour/index.ts",
    "content": "export { TourProvider } from './tour-provider';\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/onboarding-tour/mantine-tooltip.tsx",
    "content": "/**\n * MantineTooltip - Custom Joyride tooltip component styled with Mantine.\n * Renders tour step content using Mantine Paper, Button, Title, etc.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport {\n    Box,\n    Button,\n    CloseButton,\n    Group,\n    Paper,\n    Progress,\n    Text,\n    Title,\n} from '@mantine/core';\nimport type { TooltipRenderProps } from 'react-joyride';\n\nexport function MantineTooltip({\n  continuous,\n  index,\n  step,\n  size,\n  backProps,\n  closeProps,\n  primaryProps,\n  skipProps,\n  tooltipProps,\n  isLastStep,\n}: TooltipRenderProps) {\n  return (\n    <Paper\n      shadow=\"lg\"\n      radius=\"md\"\n      p=\"md\"\n      maw={420}\n      miw={300}\n      {...tooltipProps}\n    >\n      {/* Header with close button */}\n      <Group justify=\"space-between\" mb=\"xs\" wrap=\"nowrap\">\n        {step.title && (\n          <Title order={5} style={{ flex: 1 }}>\n            {step.title}\n          </Title>\n        )}\n        {/* eslint-disable-next-line lingui/no-unlocalized-strings */}\n        <CloseButton onClick={skipProps.onClick} aria-label=\"Close\" size=\"xs\" variant=\"subtle\" />\n      </Group>\n\n      {/* Content */}\n      {step.content && (\n        <Box mb=\"md\">\n          {typeof step.content === 'string' ? (\n            <Text size=\"sm\" c=\"dimmed\">\n              {step.content}\n            </Text>\n          ) : (\n            step.content\n          )}\n        </Box>\n      )}\n\n      {/* Progress indicator */}\n      <Group gap=\"xs\" mb=\"md\" align=\"center\">\n        <Progress value={((index + 1) / size) * 100} size=\"sm\" style={{ flex: 1 }} />\n        <Text size=\"xs\" c=\"dimmed\" style={{ whiteSpace: 'nowrap' }}>\n          {index + 1} / {size}\n        </Text>\n      </Group>\n\n      {/* Navigation buttons */}\n      <Group justify=\"space-between\">\n        <Button variant=\"subtle\" size=\"xs\" {...skipProps}>\n          <Trans>Skip</Trans>\n        </Button>\n        <Group gap=\"xs\">\n          {index > 0 && (\n            <Button variant=\"default\" size=\"xs\" {...backProps}>\n              <Trans>Back</Trans>\n            </Button>\n          )}\n          {continuous && (\n            <Button size=\"xs\" {...primaryProps}>\n              {isLastStep ? <Trans>Finish</Trans> : <Trans>Next</Trans>}\n            </Button>\n          )}\n        </Group>\n      </Group>\n    </Paper>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/onboarding-tour/tour-provider.tsx",
    "content": "/**\n * TourProvider - Wraps the app and manages Joyride tour rendering.\n * Reads active tour from the tour store and renders the appropriate steps.\n */\n\nimport { useCallback, useEffect, useMemo } from 'react';\nimport { EVENTS, Joyride, STATUS, type Controls, type EventData } from 'react-joyride';\nimport { useActiveTourId, useIsTourCompleted, useTourActions, useTourStarted } from '../../stores/ui/tour-store';\nimport { MantineTooltip } from './mantine-tooltip';\nimport { ACCOUNTS_TOUR_ID, useAccountsTourSteps } from './tours/accounts-tour';\nimport { CUSTOM_SHORTCUTS_TOUR_ID, useCustomShortcutsTourSteps } from './tours/custom-shortcuts-tour';\nimport { FILE_WATCHERS_TOUR_ID, useFileWatchersTourSteps } from './tours/file-watchers-tour';\nimport { HOME_TOUR_ID, useHomeTourSteps } from './tours/home-tour';\nimport { LAYOUT_TOUR_ID, useLayoutTourSteps } from './tours/layout-tour';\nimport { NOTIFICATIONS_TOUR_ID, useNotificationsTourSteps } from './tours/notifications-tour';\nimport { SCHEDULE_TOUR_ID, useScheduleTourSteps } from './tours/schedule-tour';\nimport { SUBMISSION_EDIT_TOUR_ID, useSubmissionEditTourSteps } from './tours/submission-edit-tour';\nimport { SUBMISSIONS_TOUR_ID, useSubmissionsTourSteps } from './tours/submissions-tour';\nimport { TAG_CONVERTERS_TOUR_ID, useTagConvertersTourSteps } from './tours/tag-converters-tour';\nimport { TAG_GROUPS_TOUR_ID, useTagGroupsTourSteps } from './tours/tag-groups-tour';\nimport { TEMPLATES_TOUR_ID, useTemplatesTourSteps } from './tours/templates-tour';\nimport { USER_CONVERTERS_TOUR_ID, useUserConvertersTourSteps } from './tours/user-converters-tour';\n\n/**\n * Map of tour IDs to their step hooks.\n * Add new tours here as they are created.\n */\nfunction useTourSteps(tourId: string | null) {\n  const layoutSteps = useLayoutTourSteps();\n  const accountsSteps = useAccountsTourSteps();\n  const homeSteps = useHomeTourSteps();\n  const templatesSteps = useTemplatesTourSteps();\n  const tagGroupsSteps = useTagGroupsTourSteps();\n  const customShortcutsSteps = useCustomShortcutsTourSteps();\n  const fileWatchersSteps = useFileWatchersTourSteps();\n  const scheduleSteps = useScheduleTourSteps();\n  const submissionsSteps = useSubmissionsTourSteps();\n  const submissionEditSteps = useSubmissionEditTourSteps();\n  const tagConvertersSteps = useTagConvertersTourSteps();\n  const userConvertersSteps = useUserConvertersTourSteps();\n  const notificationsSteps = useNotificationsTourSteps();\n\n  return useMemo(() => {\n    switch (tourId) {\n      case LAYOUT_TOUR_ID:\n        return layoutSteps;\n      case ACCOUNTS_TOUR_ID:\n        return accountsSteps;\n      case HOME_TOUR_ID:\n        return homeSteps;\n      case TEMPLATES_TOUR_ID:\n        return templatesSteps;\n      case TAG_GROUPS_TOUR_ID:\n        return tagGroupsSteps;\n      case CUSTOM_SHORTCUTS_TOUR_ID:\n        return customShortcutsSteps;\n      case FILE_WATCHERS_TOUR_ID:\n        return fileWatchersSteps;\n      case SCHEDULE_TOUR_ID:\n        return scheduleSteps;\n      case SUBMISSIONS_TOUR_ID:\n        return submissionsSteps;\n      case SUBMISSION_EDIT_TOUR_ID:\n        return submissionEditSteps;\n      case TAG_CONVERTERS_TOUR_ID:\n        return tagConvertersSteps;\n      case USER_CONVERTERS_TOUR_ID:\n        return userConvertersSteps;\n      case NOTIFICATIONS_TOUR_ID:\n        return notificationsSteps;\n      default:\n        return [];\n    }\n  }, [tourId, layoutSteps, accountsSteps, homeSteps, templatesSteps, tagGroupsSteps, customShortcutsSteps, fileWatchersSteps, scheduleSteps, submissionsSteps, submissionEditSteps, tagConvertersSteps, userConvertersSteps, notificationsSteps]);\n}\n\nexport function TourProvider({ children }: { children: React.ReactNode }) {\n  const activeTourId = useActiveTourId();\n  const tourStarted = useTourStarted();\n  const { completeTour, skipTour, endTour, startTour } = useTourActions();\n  const layoutTourCompleted = useIsTourCompleted(LAYOUT_TOUR_ID);\n  const allSteps = useTourSteps(activeTourId);\n\n  // Auto-start layout tour on first app load if never completed\n  useEffect(() => {\n    if (!layoutTourCompleted && !tourStarted) {\n      // Small delay to ensure the DOM is fully rendered\n      const timer = setTimeout(() => startTour(LAYOUT_TOUR_ID), 500);\n      return () => clearTimeout(timer);\n    }\n    return undefined;\n  }, []); // eslint-disable-line react-hooks/exhaustive-deps\n\n  // Filter out steps whose target elements don't exist in the DOM.\n  // This handles conditional UI like the file dropzone (only for file submissions)\n  // or file management section (only for file submission edit cards).\n  const steps = useMemo(\n    () =>\n      allSteps.filter((step) => {\n        if (typeof step.target !== 'string') return true;\n        if (step.target === 'body') return true;\n        return document.querySelector(step.target) !== null;\n      }),\n    [allSteps],\n  );\n\n  const handleEvent = useCallback(\n    (data: EventData, _controls: Controls) => {\n      const { type } = data;\n\n      if (type === EVENTS.TOUR_STATUS) {\n        const { status } = data;\n        if (status === STATUS.FINISHED) {\n          if (activeTourId) {\n            completeTour(activeTourId);\n          }\n        } else if (status === STATUS.SKIPPED) {\n          if (activeTourId) {\n            skipTour(activeTourId);\n          }\n        }\n      } else if (type === EVENTS.TOUR_END) {\n        // Mark as completed on any close to ensure the localStorage flag is set\n        if (activeTourId) {\n          completeTour(activeTourId);\n        }\n        endTour();\n      }\n    },\n    [activeTourId, completeTour, skipTour, endTour],\n  );\n\n  return (\n    <>\n      {children}\n      <Joyride\n        steps={steps}\n        run={tourStarted && steps.length > 0}\n        continuous\n        onEvent={handleEvent}\n        tooltipComponent={MantineTooltip}\n        options={{\n          overlayClickAction: false,\n          // eslint-disable-next-line lingui/no-unlocalized-strings\n          overlayColor: 'rgba(0, 0, 0, 0.5)',\n          buttons: ['skip', 'back', 'close', 'primary'],\n          zIndex: 10000,\n          scrollOffset: 80,\n        }}\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/onboarding-tour/tours/accounts-tour.tsx",
    "content": "/**\n * Accounts tour step definitions.\n * Walks the user through the accounts management section.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport { Text } from '@mantine/core';\nimport type { Step } from 'react-joyride';\n\nexport const ACCOUNTS_TOUR_ID = 'accounts';\n\n/**\n * Returns the accounts tour steps with translated content.\n */\nexport function useAccountsTourSteps(): Step[] {\n  return [\n    {\n      target: 'body',\n      placement: 'center',\n      skipBeacon: true,\n      title: <Trans>Accounts Overview</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            This is where you manage your website accounts. Add accounts, log\n            in, and track your login status across all supported sites.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"accounts-search\"]',\n      placement: 'bottom',\n      skipBeacon: true,\n      title: <Trans>Search Accounts</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Filter the list by typing a website name or account name. Useful\n            when you have many accounts set up.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"accounts-login-filter\"]',\n      placement: 'bottom',\n      skipBeacon: true,\n      title: <Trans>Login Status Filter</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Quickly find accounts that need attention. Filter by All, Logged in,\n            or Not logged in status.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"accounts-visibility\"]',\n      placement: 'bottom',\n      skipBeacon: true,\n      title: <Trans>Website Visibility</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Hide websites you don&apos;t use to keep the list clean. Hidden\n            websites won&apos;t appear in the accounts list or when creating\n            submissions.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"accounts-website-card\"]',\n      placement: 'right',\n      skipBeacon: true,\n      scrollOffset: 150,\n      title: <Trans>Website Cards</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Each website has its own card. Click the header to expand or collapse\n            it. The badge shows how many accounts are logged in out of the total.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"accounts-add-account\"]',\n      placement: 'right',\n      skipBeacon: true,\n      scrollOffset: 150,\n      title: <Trans>Adding Accounts</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Click &quot;Add account&quot; to create a new account for a website.\n            You can have multiple accounts per website for posting to different\n            profiles.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"accounts-account-row\"]',\n      placement: 'right',\n      skipBeacon: true,\n      scrollOffset: 150,\n      title: <Trans>Account Details</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Each account shows its name, username, and login status. Click the\n            name to rename it. Use the action buttons to log in, reset, or\n            delete the account.\n          </Trans>\n        </Text>\n      ),\n    },\n  ];\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/onboarding-tour/tours/custom-shortcuts-tour.tsx",
    "content": "/**\n * Custom Shortcuts drawer tour step definitions.\n * Walks the user through the custom shortcuts management drawer.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport { Text } from '@mantine/core';\nimport type { Step } from 'react-joyride';\n\nexport const CUSTOM_SHORTCUTS_TOUR_ID = 'custom-shortcuts';\n\n/**\n * Returns the custom shortcuts tour steps with translated content.\n */\nexport function useCustomShortcutsTourSteps(): Step[] {\n  return [\n    {\n      target: 'body',\n      placement: 'center',\n      skipBeacon: true,\n      title: <Trans>Custom Shortcuts Overview</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Custom shortcuts let you define reusable text snippets with rich\n            formatting. Use them in description fields by typing their shortcut\n            name to quickly insert common content.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"shortcuts-create\"]',\n      placement: 'bottom',\n      skipBeacon: true,\n      title: <Trans>Create Shortcut</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Click here to create a new shortcut. Give it a unique name that\n            you&apos;ll use to reference it in your descriptions.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"shortcuts-search\"]',\n      placement: 'bottom',\n      skipBeacon: true,\n      title: <Trans>Search Shortcuts</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Filter your shortcuts by name. Useful when you have many shortcuts\n            defined.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"shortcuts-card\"]',\n      placement: 'right',\n      skipBeacon: true,\n      title: <Trans>Shortcut Card</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Each shortcut shows its name. Click the arrow to expand it and edit\n            the rich text content. Click the name to rename it. Use the delete\n            button to remove it.\n          </Trans>\n        </Text>\n      ),\n    },\n  ];\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/onboarding-tour/tours/file-watchers-tour.tsx",
    "content": "/**\n * File Watchers drawer tour step definitions.\n * Walks the user through the file watcher management drawer.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport { Text } from '@mantine/core';\nimport type { Step } from 'react-joyride';\n\nexport const FILE_WATCHERS_TOUR_ID = 'file-watchers';\n\n/**\n * Returns the file watchers tour steps with translated content.\n */\nexport function useFileWatchersTourSteps(): Step[] {\n  return [\n    {\n      target: 'body',\n      placement: 'center',\n      skipBeacon: true,\n      title: <Trans>File Watchers Overview</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            File watchers monitor folders on your computer and automatically\n            create submissions when new files appear. Great for streamlining\n            your upload workflow.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"file-watchers-create\"]',\n      placement: 'bottom',\n      skipBeacon: true,\n      title: <Trans>Create Watcher</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Click to create a new file watcher. You&apos;ll then configure which\n            folder to watch and what to do with new files.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"file-watchers-card\"]',\n      placement: 'right',\n      skipBeacon: true,\n      title: <Trans>Watcher Settings</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Each watcher card lets you pick a folder path, choose an import\n            action, and optionally apply a template to imported files. Save your\n            changes or delete the watcher using the buttons at the bottom.\n          </Trans>\n        </Text>\n      ),\n    },\n  ];\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/onboarding-tour/tours/home-tour.tsx",
    "content": "/**\n * Home dashboard tour step definitions.\n * Walks the user through the dashboard panels and stats.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport { Text } from '@mantine/core';\nimport type { Step } from 'react-joyride';\n\nexport const HOME_TOUR_ID = 'home';\n\n/**\n * Returns the home dashboard tour steps with translated content.\n */\nexport function useHomeTourSteps(): Step[] {\n  return [\n    {\n      target: 'body',\n      placement: 'center',\n      skipBeacon: true,\n      title: <Trans>Dashboard Overview</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            This is your home dashboard. It gives you a quick overview of your\n            submissions, schedule, and account health at a glance.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"home-queue-control\"]',\n      placement: 'bottom',\n      skipBeacon: true,\n      title: <Trans>Queue Control</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Pause or resume your post queue. When paused, no submissions will be\n            sent until you resume.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"home-stat-cards\"]',\n      placement: 'bottom',\n      skipBeacon: true,\n      title: <Trans>Submission Stats</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Quick counts of your file and message submissions, plus how many are\n            queued or scheduled. Click any card to navigate to that section.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"home-schedule-calendar\"]',\n      placement: 'right',\n      skipBeacon: true,\n      title: <Trans>Schedule Calendar</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            A mini calendar showing which days have scheduled posts. Click on a\n            highlighted day to see what&apos;s planned.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"home-recent-activity\"]',\n      placement: 'left',\n      skipBeacon: true,\n      title: <Trans>Recent Activity</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Shows your latest notifications — successful posts, errors, and\n            warnings. Helps you stay on top of what&apos;s happening.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"home-upcoming-posts\"]',\n      placement: 'top',\n      skipBeacon: true,\n      title: <Trans>Upcoming Posts</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Lists your next scheduled submissions with their planned times.\n            Click a post to jump to its editor.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"home-validation-issues\"]',\n      placement: 'top',\n      skipBeacon: true,\n      title: <Trans>Validation Issues</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Submissions with errors or warnings appear here. Fix any issues\n            before posting to avoid failed submissions.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"home-account-health\"]',\n      placement: 'top',\n      skipBeacon: true,\n      title: <Trans>Account Health</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Shows how many of your accounts are logged in. If any accounts need\n            attention, you&apos;ll see a warning here.\n          </Trans>\n        </Text>\n      ),\n    },\n  ];\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/onboarding-tour/tours/layout-tour.tsx",
    "content": "/**\n * Layout tour step definitions.\n * Walks the user through the main sidebar navigation items.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport { Text } from '@mantine/core';\nimport type { Step } from 'react-joyride';\n\n/**\n * Returns the layout tour steps with translated content.\n * Must be called inside a React component for i18n context.\n */\nexport function useLayoutTourSteps(): Step[] {\n  return [\n    {\n      target: 'body',\n      placement: 'center',\n      skipBeacon: true,\n      title: <Trans>Welcome to PostyBirb!</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Let&apos;s take a quick tour of the interface. We&apos;ll walk you\n            through the main features so you can start posting right away.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"home\"]',\n      placement: 'right',\n      skipBeacon: true,\n      title: <Trans>Home Dashboard</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            This is your dashboard. View submission status, control the post\n            queue, and see an overview of your activity.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"accounts\"]',\n      placement: 'right',\n      skipBeacon: true,\n      title: <Trans>Accounts</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Set up your website accounts here. Log in to the sites you want to\n            post to before creating submissions.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"file-submissions\"]',\n      placement: 'right',\n      skipBeacon: true,\n      title: <Trans>Post Files</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Create and manage file submissions — images, videos, and other media\n            you want to post across your accounts.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"message-submissions\"]',\n      placement: 'right',\n      skipBeacon: true,\n      title: <Trans>Send Messages</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Send text-only posts like journals, status updates, or announcements\n            to your accounts.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"templates\"]',\n      placement: 'right',\n      skipBeacon: true,\n      title: <Trans>Templates</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Save submission settings as reusable templates. Great for repeated\n            posts with the same tags, description, or account selection.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"schedule\"]',\n      placement: 'right',\n      skipBeacon: true,\n      title: <Trans>Schedule</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            View and manage scheduled posts on a calendar. Plan your posting\n            schedule in advance.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"notifications\"]',\n      placement: 'right',\n      skipBeacon: true,\n      title: <Trans>Notifications</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            View posting results, errors, and other important events. The badge\n            shows how many unread notifications you have.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"tag-groups\"]',\n      placement: 'right',\n      skipBeacon: true,\n      title: <Trans>Tag Groups</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Create reusable sets of tags that you can quickly apply to any\n            submission. Saves time when you use the same tags often.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"tag-converters\"]',\n      placement: 'right',\n      skipBeacon: true,\n      title: <Trans>Tag Converters</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Automatically replace tags on a per-website basis. Useful when\n            different sites use different names for the same tag.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"user-converters\"]',\n      placement: 'right',\n      skipBeacon: true,\n      title: <Trans>User Converters</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Automatically replace usernames on a per-website basis. Useful when\n            the same person has different usernames across sites.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"custom-shortcuts\"]',\n      placement: 'right',\n      skipBeacon: true,\n      title: <Trans>Custom Shortcuts</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Define text shortcuts to use in descriptions. Type a short code and\n            it expands into your full text.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"file-watchers\"]',\n      placement: 'right',\n      skipBeacon: true,\n      title: <Trans>File Watchers</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Automatically import files from watched folders on your computer.\n            New files are picked up and turned into submissions.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"settings\"]',\n      placement: 'right',\n      skipBeacon: true,\n      title: <Trans>Settings</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Configure application preferences, manage your data, and customize\n            your PostyBirb experience.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"theme-toggle\"]',\n      placement: 'right',\n      skipBeacon: true,\n      title: <Trans>Theme</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Switch between light and dark mode to match your preference.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"language-picker\"]',\n      placement: 'right',\n      skipBeacon: true,\n      title: <Trans>Language</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Change the application language. PostyBirb is available in multiple\n            languages thanks to community translations.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"tour-button\"]',\n      placement: 'right',\n      skipBeacon: true,\n      title: <Trans>Page Tours</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            You can restart this tour anytime from here. Also look for the ?\n            buttons on each page — they provide a guided tour of that specific\n            section.\n          </Trans>\n        </Text>\n      ),\n    },\n  ];\n}\n\nexport const LAYOUT_TOUR_ID = 'layout';\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/onboarding-tour/tours/notifications-tour.tsx",
    "content": "/**\n * Notifications drawer tour step definitions.\n * Walks the user through the notifications management drawer.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport { Text } from '@mantine/core';\nimport type { Step } from 'react-joyride';\n\nexport const NOTIFICATIONS_TOUR_ID = 'notifications';\n\n/**\n * Returns the notifications tour steps with translated content.\n */\nexport function useNotificationsTourSteps(): Step[] {\n  return [\n    {\n      target: 'body',\n      placement: 'center',\n      skipBeacon: true,\n      title: <Trans>Notifications Overview</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Notifications keep you informed about posting results, errors, and\n            other important events. Review them here to stay on top of your\n            activity.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"notifications-read-filter\"]',\n      placement: 'bottom',\n      skipBeacon: true,\n      title: <Trans>Read Status Filter</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Filter notifications by their read status. Quickly find unread\n            notifications that need your attention.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"notifications-type-filter\"]',\n      placement: 'bottom',\n      skipBeacon: true,\n      title: <Trans>Type Filter</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Filter by notification type — errors, warnings, successes, or info\n            messages. Helps you focus on what matters most.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"notifications-list\"]',\n      placement: 'right',\n      skipBeacon: true,\n      title: <Trans>Notification List</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Each notification shows its type, message, and time. Select\n            notifications with checkboxes to mark them as read or delete them in\n            bulk.\n          </Trans>\n        </Text>\n      ),\n    },\n  ];\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/onboarding-tour/tours/schedule-tour.tsx",
    "content": "/**\n * Schedule drawer tour step definitions.\n * Walks the user through the calendar scheduling interface.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport { Text } from '@mantine/core';\nimport type { Step } from 'react-joyride';\n\nexport const SCHEDULE_TOUR_ID = 'schedule';\n\n/**\n * Returns the schedule tour steps with translated content.\n */\nexport function useScheduleTourSteps(): Step[] {\n  return [\n    {\n      target: 'body',\n      placement: 'center',\n      skipBeacon: true,\n      title: <Trans>Schedule Overview</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            The schedule view lets you plan when your submissions will be posted.\n            Drag submissions onto the calendar to schedule them, or click\n            existing events to manage them.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"schedule-submissions\"]',\n      placement: 'right',\n      skipBeacon: true,\n      title: <Trans>Unscheduled Submissions</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            This sidebar lists your unscheduled submissions. Drag any submission\n            from here onto the calendar to schedule it for a specific date and\n            time.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"schedule-calendar\"]',\n      placement: 'center',\n      skipBeacon: true,\n      title: <Trans>Calendar View</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            The calendar shows all your scheduled submissions. Switch between\n            month, week, and day views. Click an event to reschedule or\n            unschedule it. Drag events to move them to a different time.\n          </Trans>\n        </Text>\n      ),\n    },\n  ];\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/onboarding-tour/tours/submission-edit-tour.tsx",
    "content": "/**\n * Submission edit card tour step definitions.\n * Walks the user through the submission editor.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport { Text } from '@mantine/core';\nimport type { Step } from 'react-joyride';\n\nexport const SUBMISSION_EDIT_TOUR_ID = 'submission-edit';\n\n/**\n * Returns the submission edit card tour steps with translated content.\n */\nexport function useSubmissionEditTourSteps(): Step[] {\n  return [\n    {\n      target: 'body',\n      placement: 'center',\n      skipBeacon: true,\n      title: <Trans>Submission Editor Overview</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            This is the submission editor where you configure all the details\n            for your post. Set up files, scheduling, descriptions, tags, and\n            per-website options.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"edit-card-header\"]',\n      placement: 'bottom',\n      skipBeacon: true,\n      title: <Trans>Card Header</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            The header shows the submission title and action buttons. Use the\n            template button to apply saved settings, the send button to post,\n            and the trash button to delete.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"edit-card-files\"]',\n      placement: 'bottom',\n      skipBeacon: true,\n      title: <Trans>File Management</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Upload and manage files for your submission. Drag to reorder, click\n            to preview, and edit alt text or metadata.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"edit-card-schedule\"]',\n      placement: 'bottom',\n      skipBeacon: true,\n      title: <Trans>Schedule</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Schedule when your submission should be posted. Choose a one-time\n            date or set up a recurring schedule with a CRON expression. Toggle\n            scheduling on or off with the switch.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"edit-card-defaults\"]',\n      placement: 'bottom',\n      skipBeacon: true,\n      scrollOffset: 150,\n      title: <Trans>Default Options</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Set the default title, description, tags, and content rating here.\n            These values are inherited by all website-specific options unless\n            you override them individually.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"edit-card-description\"]',\n      placement: 'bottom',\n      skipBeacon: true,\n      scrollOffset: 150,\n      title: <Trans>Description Editor</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Write your post description with rich text formatting. Type @ to\n            insert username shortcuts, &#123; to insert custom text shortcuts,\n            or use the / slash menu for headings, lists, and more. Shortcuts are\n            automatically expanded per website when posting.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"edit-card-accounts\"]',\n      placement: 'bottom',\n      skipBeacon: true,\n      scrollOffset: 150,\n      title: <Trans>Account Selection</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Choose which accounts to post to. Click to open the dropdown, then\n            select individual accounts or entire website groups.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"edit-card-website-forms\"]',\n      placement: 'center',\n      skipBeacon: true,\n      scrollOffset: 150,\n      title: <Trans>Website-Specific Options</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Each selected website gets its own form where you can override the\n            defaults. Customize titles, descriptions, tags, and site-specific\n            fields like folders or ratings per website.\n          </Trans>\n        </Text>\n      ),\n    },\n  ];\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/onboarding-tour/tours/submissions-tour.tsx",
    "content": "/**\n * Submissions section tour step definitions.\n * Walks the user through the submissions list and editor.\n * Used for both file and message submission views.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport { Text } from '@mantine/core';\nimport type { Step } from 'react-joyride';\n\nexport const SUBMISSIONS_TOUR_ID = 'submissions';\n\n/**\n * Returns the submissions tour steps with translated content.\n */\nexport function useSubmissionsTourSteps(): Step[] {\n  return [\n    {\n      target: 'body',\n      placement: 'center',\n      skipBeacon: true,\n      title: <Trans>Submissions Overview</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            This is where you create and manage your submissions. Build your\n            posts, configure per-website options, and send them out to all your\n            accounts.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"submissions-select-all\"]',\n      placement: 'bottom',\n      skipBeacon: true,\n      title: <Trans>Select All</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Use this checkbox to select or deselect all submissions at once.\n            When items are selected, bulk actions like delete, post, and apply\n            template become available.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"submissions-create\"]',\n      placement: 'bottom',\n      skipBeacon: true,\n      title: <Trans>Create Submission</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Click here to create a new submission. For file submissions, this\n            opens a file picker. For messages, enter a title to get started.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"submissions-dropzone\"]',\n      placement: 'bottom',\n      skipBeacon: true,\n      title: <Trans>File Dropzone</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Drag and drop files here to quickly create new file submissions. You\n            can also click to browse your file system.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"submissions-search\"]',\n      placement: 'bottom',\n      skipBeacon: true,\n      title: <Trans>Search Submissions</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Filter your submissions by title. Useful when you have many\n            submissions in your list.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"submissions-filter\"]',\n      placement: 'bottom',\n      skipBeacon: true,\n      title: <Trans>Status Filter</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Filter submissions by status — view all, only queued, or only\n            scheduled submissions. Use the icon on the right to toggle between\n            compact and detailed views.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"submissions-card\"]',\n      placement: 'right',\n      skipBeacon: true,\n      scrollOffset: 150,\n      title: <Trans>Submission Card</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Each submission appears as a card showing its title, status badges,\n            and action buttons. Click to select it and open the editor. Drag to\n            reorder your submissions.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"submissions-editor\"]',\n      placement: 'center',\n      skipBeacon: true,\n      title: <Trans>Submission Editor</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            When a submission is selected, its full editor appears here.\n            Configure titles, descriptions, tags, file options, and\n            website-specific settings. Use the Mass Edit toggle to edit multiple\n            submissions at once.\n          </Trans>\n        </Text>\n      ),\n    },\n  ];\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/onboarding-tour/tours/tag-converters-tour.tsx",
    "content": "/**\n * Tag Converters drawer tour step definitions.\n * Walks the user through the tag converter management drawer.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport { Text } from '@mantine/core';\nimport type { Step } from 'react-joyride';\n\nexport const TAG_CONVERTERS_TOUR_ID = 'tag-converters';\n\n/**\n * Returns the tag converters tour steps with translated content.\n */\nexport function useTagConvertersTourSteps(): Step[] {\n  return [\n    {\n      target: 'body',\n      placement: 'center',\n      skipBeacon: true,\n      title: <Trans>Tag Converters Overview</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Tag converters automatically replace tags on a per-website basis.\n            Useful when different sites use different tag names for the same\n            concept.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"converter-create\"]',\n      placement: 'bottom',\n      skipBeacon: true,\n      title: <Trans>Create Converter</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Type a tag name and click the button to create a new converter. Then\n            expand it to set up website-specific replacements.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"converter-search\"]',\n      placement: 'bottom',\n      skipBeacon: true,\n      title: <Trans>Search &amp; Delete</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Search for converters by name or conversion value. Select items with\n            checkboxes and hold the delete button to remove them.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"converter-card\"]',\n      placement: 'right',\n      skipBeacon: true,\n      title: <Trans>Converter Card</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Each card shows a tag and how many website conversions it has. Click\n            the arrow to expand and see the per-website replacements. Add new\n            websites using the dropdown inside.\n          </Trans>\n        </Text>\n      ),\n    },\n  ];\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/onboarding-tour/tours/tag-groups-tour.tsx",
    "content": "/**\n * Tag Groups drawer tour step definitions.\n * Walks the user through the tag groups management drawer.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport { Text } from '@mantine/core';\nimport type { Step } from 'react-joyride';\n\nexport const TAG_GROUPS_TOUR_ID = 'tag-groups';\n\n/**\n * Returns the tag groups tour steps with translated content.\n */\nexport function useTagGroupsTourSteps(): Step[] {\n  return [\n    {\n      target: 'body',\n      placement: 'center',\n      skipBeacon: true,\n      title: <Trans>Tag Groups Overview</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Tag groups let you save collections of tags under a name. When\n            creating a submission, pick a tag group to quickly add all its tags\n            at once.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"tag-groups-create\"]',\n      placement: 'bottom',\n      skipBeacon: true,\n      title: <Trans>Create Tag Group</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Type a name and click the button to create a new tag group. You can\n            then add tags to it in the table below.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"tag-groups-search\"]',\n      placement: 'bottom',\n      skipBeacon: true,\n      title: <Trans>Search &amp; Delete</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Search for tag groups by name. Select rows using the checkboxes,\n            then hold the delete button to remove them.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"tag-groups-table\"]',\n      placement: 'right',\n      skipBeacon: true,\n      title: <Trans>Tag Groups Table</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Each row shows a tag group with its name and tags. Click the name to\n            rename it. Add or remove tags directly in the tags column.\n          </Trans>\n        </Text>\n      ),\n    },\n  ];\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/onboarding-tour/tours/templates-tour.tsx",
    "content": "/**\n * Templates tour step definitions.\n * Walks the user through the templates management section.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport { Text } from '@mantine/core';\nimport type { Step } from 'react-joyride';\n\nexport const TEMPLATES_TOUR_ID = 'templates';\n\n/**\n * Returns the templates tour steps with translated content.\n */\nexport function useTemplatesTourSteps(): Step[] {\n  return [\n    {\n      target: 'body',\n      placement: 'center',\n      skipBeacon: true,\n      title: <Trans>Templates Overview</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Templates let you save submission settings that you reuse often.\n            Create a template once and apply it to new submissions to save time.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"templates-search\"]',\n      placement: 'bottom',\n      skipBeacon: true,\n      title: <Trans>Search Templates</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Filter your templates by name. Useful when you have many templates\n            saved.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"templates-type-tabs\"]',\n      placement: 'bottom',\n      skipBeacon: true,\n      title: <Trans>Template Type</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Switch between File and Message templates. File templates are for\n            image or file uploads, while Message templates are for text-only\n            posts.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"templates-create\"]',\n      placement: 'bottom',\n      skipBeacon: true,\n      title: <Trans>Create Template</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Click here to create a new template. Give it a name, then configure\n            the submission settings you want to reuse.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"templates-card\"]',\n      placement: 'right',\n      skipBeacon: true,\n      scrollOffset: 150,\n      title: <Trans>Template Card</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Each template appears as a card. Click to select it and open the\n            editor. Use the action buttons to rename, duplicate, or delete a\n            template.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"templates-editor\"]',\n      placement: 'left',\n      skipBeacon: true,\n      title: <Trans>Template Editor</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            When a template is selected, its settings appear here. Configure\n            titles, descriptions, tags, and website-specific options just like a\n            regular submission.\n          </Trans>\n        </Text>\n      ),\n    },\n  ];\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/onboarding-tour/tours/user-converters-tour.tsx",
    "content": "/**\n * User Converters drawer tour step definitions.\n * Walks the user through the user converter management drawer.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport { Text } from '@mantine/core';\nimport type { Step } from 'react-joyride';\n\nexport const USER_CONVERTERS_TOUR_ID = 'user-converters';\n\n/**\n * Returns the user converters tour steps with translated content.\n */\nexport function useUserConvertersTourSteps(): Step[] {\n  return [\n    {\n      target: 'body',\n      placement: 'center',\n      skipBeacon: true,\n      title: <Trans>User Converters Overview</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            User converters automatically replace usernames on a per-website\n            basis. Useful when the same person has different usernames across\n            sites.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"converter-create\"]',\n      placement: 'bottom',\n      skipBeacon: true,\n      title: <Trans>Create Converter</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Type a username and click the button to create a new converter. Then\n            expand it to set up website-specific replacements.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"converter-search\"]',\n      placement: 'bottom',\n      skipBeacon: true,\n      title: <Trans>Search &amp; Delete</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Search for converters by username or conversion value. Select items\n            with checkboxes and hold the delete button to remove them.\n          </Trans>\n        </Text>\n      ),\n    },\n    {\n      target: '[data-tour-id=\"converter-card\"]',\n      placement: 'right',\n      skipBeacon: true,\n      title: <Trans>Converter Card</Trans>,\n      content: (\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            Each card shows a username and how many website conversions it has.\n            Click the arrow to expand and see the per-website replacements. Add\n            new websites using the dropdown inside.\n          </Trans>\n        </Text>\n      ),\n    },\n  ];\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/accounts-section/account-section-header.tsx",
    "content": "/**\n * AccountSectionHeader - Sticky header for accounts section panel.\n * Contains title, website visibility picker, and search/filter controls.\n */\n\nimport { Trans, useLingui } from '@lingui/react/macro';\nimport {\n    ActionIcon,\n    Box,\n    Group,\n    SegmentedControl,\n    Stack,\n    Text,\n    Tooltip,\n} from '@mantine/core';\nimport { IconHelp } from '@tabler/icons-react';\nimport { AccountLoginFilter, useAccountsFilter } from '../../../stores/ui/accounts-ui-store';\nimport { useTourActions } from '../../../stores/ui/tour-store';\nimport { ACCOUNTS_TOUR_ID } from '../../onboarding-tour/tours/accounts-tour';\nimport { SearchInput } from '../../shared';\nimport { WebsiteVisibilityPicker } from './website-visibility-picker';\n\n/**\n * Sticky header for the accounts section panel.\n * Provides title, visibility toggles, and search/filter functionality.\n */\nexport function AccountSectionHeader() {\n  const { searchQuery, loginFilter, setSearchQuery, setLoginFilter } =\n    useAccountsFilter();\n  const { startTour } = useTourActions();\n  const { t } = useLingui();\n\n  return (\n    <Box\n      p=\"sm\"\n      style={{\n        position: 'sticky',\n        top: 0,\n        zIndex: 'var(--z-sticky)',\n        backgroundColor: 'var(--mantine-color-body)',\n        // eslint-disable-next-line lingui/no-unlocalized-strings\n        borderBottom: '1px solid var(--mantine-color-default-border)',\n      }}\n    >\n      <Stack gap=\"xs\">\n        {/* Title row with visibility picker */}\n        <Group justify=\"space-between\" align=\"center\">\n          <Text fw={600} size=\"sm\">\n            <Trans>Accounts</Trans>\n          </Text>\n          <Group gap=\"xs\">\n            <Tooltip label={<Trans>Take the tour</Trans>}>\n              <ActionIcon\n                variant=\"subtle\"\n                size=\"sm\"\n                onClick={() => startTour(ACCOUNTS_TOUR_ID)}\n              >\n                <IconHelp size={16} />\n              </ActionIcon>\n            </Tooltip>\n            <WebsiteVisibilityPicker />\n          </Group>\n        </Group>\n\n        {/* Search input */}\n        <Box data-tour-id=\"accounts-search\">\n          <SearchInput\n            size=\"xs\"\n            value={searchQuery}\n            onChange={setSearchQuery}\n            onClear={() => setSearchQuery('')}\n          />\n        </Box>\n\n        {/* Login status filter */}\n        <SegmentedControl\n          data-tour-id=\"accounts-login-filter\"\n          size=\"xs\"\n          fullWidth\n          value={loginFilter}\n          onChange={(value) => setLoginFilter(value as AccountLoginFilter)}\n          data={[\n            { value: AccountLoginFilter.All, label: t`All` },\n            { value: AccountLoginFilter.LoggedIn, label: t`Logged in` },\n            { value: AccountLoginFilter.NotLoggedIn, label: t`Not logged in` },\n          ]}\n        />\n      </Stack>\n    </Box>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/accounts-section/accounts-content.tsx",
    "content": "/**\n * AccountsContent - Primary content area for accounts view.\n * Displays login webview or custom login component when an account is selected.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport {\n  ActionIcon,\n  Box,\n  Divider,\n  Group,\n  Loader,\n  ScrollArea,\n  Text,\n  Title,\n} from '@mantine/core';\nimport type { CustomLoginType, UserLoginType } from '@postybirb/types';\nimport { IconWorld, IconX } from '@tabler/icons-react';\nimport { useCallback } from 'react';\nimport { useNavigationStore } from '../../../stores';\nimport { useAccount } from '../../../stores/entity/account-store';\nimport { useWebsite } from '../../../stores/entity/website-store';\nimport { isAccountsViewState, type ViewState } from '../../../types/view-state';\nimport { EmptyState } from '../../empty-state';\nimport { CustomLoginPlaceholder } from './custom-login-placeholder';\nimport { LoginWebview } from './login-webview';\n\ninterface AccountsContentProps {\n  /** Current view state */\n  viewState: ViewState;\n}\n\n/**\n * Header component showing website and account info.\n */\ninterface AccountHeaderProps {\n  websiteName: string;\n  accountName: string;\n  onClose: () => void;\n}\n\nfunction AccountHeader({\n  websiteName,\n  accountName,\n  onClose,\n}: AccountHeaderProps) {\n  return (\n    <Box\n      p=\"md\"\n      style={{\n        flexShrink: 0,\n        backgroundColor: 'var(--mantine-color-body)',\n      }}\n    >\n      <Group\n        gap=\"sm\"\n        style={{\n          justifyContent: 'space-between',\n        }}\n      >\n        <Group>\n          <IconWorld size={24} stroke={1.5} />\n          <Box>\n            <Title order={4} lh={1.2}>\n              {websiteName}\n            </Title>\n            <Text size=\"sm\" c=\"dimmed\">\n              {accountName}\n            </Text>\n          </Box>\n        </Group>\n        <ActionIcon variant=\"subtle\" size=\"sm\" onClick={onClose} color=\"gray\">\n          <IconX size={16} />\n        </ActionIcon>\n      </Group>\n    </Box>\n  );\n}\n\n/**\n * Content component for user login type (webview).\n */\ninterface UserLoginContentProps {\n  loginType: UserLoginType;\n  accountId: string;\n  websiteName: string;\n  accountName: string;\n  onClose: () => void;\n}\n\nfunction UserLoginContent({\n  loginType,\n  accountId,\n  websiteName,\n  accountName,\n  onClose,\n}: UserLoginContentProps) {\n  return (\n    <Box h=\"100%\" style={{ display: 'flex', flexDirection: 'column' }}>\n      <AccountHeader\n        websiteName={websiteName}\n        accountName={accountName}\n        onClose={onClose}\n      />\n      <Divider />\n      <Box style={{ flex: 1, minHeight: 0 }}>\n        <LoginWebview src={loginType.url} accountId={accountId} />\n      </Box>\n    </Box>\n  );\n}\n\n/**\n * Content component for custom login type.\n */\ninterface CustomLoginContentProps {\n  loginType: CustomLoginType;\n  account: NonNullable<ReturnType<typeof useAccount>>;\n  website: NonNullable<ReturnType<typeof useWebsite>>;\n  onClose: () => void;\n}\n\nfunction CustomLoginContent({\n  loginType,\n  account,\n  website,\n  onClose,\n}: CustomLoginContentProps) {\n  return (\n    <Box h=\"100%\" style={{ display: 'flex', flexDirection: 'column' }}>\n      <AccountHeader\n        websiteName={website.displayName}\n        accountName={account.name}\n        onClose={onClose}\n      />\n      <Divider />\n      <ScrollArea style={{ flex: 1 }} type=\"hover\" scrollbarSize={6}>\n        <Box p=\"md\">\n          <CustomLoginPlaceholder\n            account={account}\n            website={website}\n            loginComponentName={loginType.loginComponentName}\n          />\n        </Box>\n      </ScrollArea>\n    </Box>\n  );\n}\n\n/**\n * Primary content for the accounts view.\n * Shows login webview or custom login when an account is selected.\n */\nexport function AccountsContent({ viewState }: AccountsContentProps) {\n  // Extract selectedId safely (empty string if not accounts view)\n  const selectedId = isAccountsViewState(viewState)\n    ? viewState.params.selectedId\n    : null;\n\n  const account = useAccount(selectedId ?? '');\n  const website = useWebsite(account?.website ?? '');\n\n  const { setViewState } = useNavigationStore();\n\n  const onClose = useCallback(() => {\n    if (!isAccountsViewState(viewState)) return;\n\n    setViewState({\n      ...viewState,\n      params: {\n        ...viewState.params,\n        selectedId: null,\n      },\n    });\n  }, [viewState, setViewState]);\n\n  // Not an accounts view state\n  if (!isAccountsViewState(viewState)) return null;\n\n  // No account selected\n  if (!selectedId) {\n    return (\n      <EmptyState\n        preset=\"no-selection\"\n        message={<Trans>Select an account to log in</Trans>}\n        description={<Trans>Choose an account from the list on the left</Trans>}\n        size=\"lg\"\n      />\n    );\n  }\n\n  // Loading state (account or website not yet loaded)\n  if (!account || !website) {\n    return (\n      <Box\n        h=\"100%\"\n        style={{\n          display: 'flex',\n          alignItems: 'center',\n          justifyContent: 'center',\n        }}\n      >\n        <Loader size=\"lg\" />\n      </Box>\n    );\n  }\n\n  // Render based on login type\n  if (website.loginType.type === 'user') {\n    return (\n      <UserLoginContent\n        loginType={website.loginType}\n        accountId={account.id}\n        websiteName={website.displayName}\n        accountName={account.name}\n        onClose={onClose}\n      />\n    );\n  }\n\n  if (website.loginType.type === 'custom') {\n    return (\n      <CustomLoginContent\n        loginType={website.loginType}\n        account={account}\n        website={website}\n        onClose={onClose}\n      />\n    );\n  }\n\n  // Fallback for unknown login type\n  return (\n    <EmptyState\n      preset=\"no-records\"\n      message={<Trans>Unknown login type</Trans>}\n      description={\n        <Trans>This website has an unsupported login configuration</Trans>\n      }\n      size=\"lg\"\n    />\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/accounts-section/accounts-section.tsx",
    "content": "/**\n * AccountsSection - Section panel content for accounts view.\n * Displays a list of websites with their accounts, with search and filter support.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport { Box, Divider, Loader, ScrollArea, Stack } from '@mantine/core';\nimport { useMemo } from 'react';\nimport accountApi from '../../../api/account.api';\nimport {\n  useAccounts,\n  useAccountsLoading,\n} from '../../../stores/entity/account-store';\nimport {\n  useWebsites,\n  useWebsitesLoading,\n} from '../../../stores/entity/website-store';\nimport { useAccountsFilter } from '../../../stores/ui/accounts-ui-store';\nimport { useNavigationStore } from '../../../stores/ui/navigation-store';\nimport { AccountLoginFilter } from '../../../types/account-filters';\nimport { isAccountsViewState, type ViewState } from '../../../types/view-state';\nimport {\n  showDeletedNotification,\n  showDeleteErrorNotification,\n  showErrorNotification,\n  showSuccessNotification,\n} from '../../../utils/notifications';\nimport { EmptyState } from '../../empty-state';\nimport { AccountSectionHeader } from './account-section-header';\nimport { AccountsProvider } from './context';\nimport { WebsiteAccountCard } from './website-account-card';\n\ninterface AccountsSectionProps {\n  /** Current view state */\n  viewState: ViewState;\n}\n\n/**\n * Section panel content for the accounts view.\n * Displays a list of accounts organized by website with filtering.\n */\nexport function AccountsSection({ viewState }: AccountsSectionProps) {\n  const websites = useWebsites();\n  const accounts = useAccounts();\n  const { isLoading: websitesLoading } = useWebsitesLoading();\n  const { isLoading: accountsLoading } = useAccountsLoading();\n  const { searchQuery, loginFilter, hiddenWebsites } = useAccountsFilter();\n  const setViewState = useNavigationStore((state) => state.setViewState);\n\n  // Get selected account ID from view state\n  const selectedAccountId = isAccountsViewState(viewState)\n    ? viewState.params.selectedId\n    : null;\n\n  // Handle selecting an account (updates view state)\n  const handleSelectAccount = (accountId: string | null) => {\n    if (isAccountsViewState(viewState)) {\n      setViewState({\n        ...viewState,\n        params: {\n          ...viewState.params,\n          selectedId: accountId,\n        },\n      });\n    }\n  };\n\n  const handleClearSelection = () => handleSelectAccount(null);\n\n  // Handle deleting an account\n  const handleDeleteAccount = async (accountId: string) => {\n    try {\n      await accountApi.remove([accountId]);\n      showDeletedNotification(1);\n      if (selectedAccountId === accountId) handleClearSelection();\n    } catch {\n      showDeleteErrorNotification();\n    }\n  };\n\n  // Handle resetting an account (clears data/cookies)\n  const handleResetAccount = async (accountId: string) => {\n    try {\n      await accountApi.clear(accountId);\n      handleClearSelection();\n      showSuccessNotification(<Trans>Account data cleared</Trans>);\n    } catch {\n      showErrorNotification(<Trans>Failed to clear account data</Trans>);\n    }\n  };\n\n  // Group accounts by website\n  const accountsByWebsite = useMemo(() => {\n    const grouped = new Map<string, typeof accounts>();\n    accounts.forEach((account) => {\n      const existing = grouped.get(account.website) ?? [];\n      existing.push(account);\n      grouped.set(account.website, existing);\n    });\n    return grouped;\n  }, [accounts]);\n\n  // Filter websites based on visibility, search, and login status\n  // eslint-disable-next-line arrow-body-style\n  const filteredWebsites = useMemo(() => {\n    return websites.filter((website) => {\n      // Filter out hidden websites\n      if (hiddenWebsites.includes(website.id)) {\n        return false;\n      }\n\n      // Get accounts for this website\n      const websiteAccounts = accountsByWebsite.get(website.id) ?? [];\n\n      // Filter by search query (website name or account name)\n      if (searchQuery) {\n        const query = searchQuery.toLowerCase();\n        const matchesWebsite = website.displayName\n          .toLowerCase()\n          .includes(query);\n        const matchesAccount = websiteAccounts.some(\n          (acc) =>\n            acc.name.toLowerCase().includes(query) ||\n            acc.username?.toLowerCase().includes(query),\n        );\n        if (!matchesWebsite && !matchesAccount) {\n          return false;\n        }\n      }\n\n      // Filter by login status\n      if (loginFilter === AccountLoginFilter.LoggedIn) {\n        // Hide websites with no accounts when filtering by logged in\n        if (websiteAccounts.length === 0) {\n          return false;\n        }\n        const hasLoggedIn = websiteAccounts.some((acc) => acc.isLoggedIn);\n        if (!hasLoggedIn) {\n          return false;\n        }\n      } else if (loginFilter === AccountLoginFilter.NotLoggedIn) {\n        // Hide websites with no accounts when filtering by not logged in\n        if (websiteAccounts.length === 0) {\n          return false;\n        }\n        const hasNotLoggedIn = websiteAccounts.some((acc) => !acc.isLoggedIn);\n        if (!hasNotLoggedIn) {\n          return false;\n        }\n      }\n\n      return true;\n    });\n  }, [websites, hiddenWebsites, searchQuery, loginFilter, accountsByWebsite]);\n\n  // Sort websites: those with accounts first (alphabetically), then those without (alphabetically)\n  // eslint-disable-next-line arrow-body-style\n  const sortedWebsites = useMemo(() => {\n    return [...filteredWebsites].sort((a, b) => {\n      const aHasAccounts = (accountsByWebsite.get(a.id)?.length ?? 0) > 0;\n      const bHasAccounts = (accountsByWebsite.get(b.id)?.length ?? 0) > 0;\n\n      // Prioritize websites with accounts\n      if (aHasAccounts && !bHasAccounts) return -1;\n      if (!aHasAccounts && bHasAccounts) return 1;\n\n      // Within each group, sort alphabetically\n      return a.displayName.localeCompare(b.displayName);\n    });\n  }, [filteredWebsites, accountsByWebsite]);\n\n  // Filter accounts within each website based on search\n  const getFilteredAccounts = (websiteId: string) => {\n    const websiteAccounts = accountsByWebsite.get(websiteId) ?? [];\n\n    if (!searchQuery) {\n      return websiteAccounts;\n    }\n\n    const query = searchQuery.toLowerCase();\n    const filtered = websiteAccounts.filter(\n      (acc) =>\n        acc.name.toLowerCase().includes(query) ||\n        acc.username?.toLowerCase().includes(query),\n    );\n\n    // If no accounts matched but the website name matches the query,\n    // show all accounts (user likely intended to filter to the website level)\n    if (filtered.length === 0) {\n      const website = websites.find((w) => w.id === websiteId);\n      if (website?.displayName.toLowerCase().includes(query)) {\n        return websiteAccounts;\n      }\n    }\n\n    return filtered;\n  };\n\n  const isLoading = websitesLoading || accountsLoading;\n\n  return (\n    <Box h=\"100%\" style={{ display: 'flex', flexDirection: 'column' }}>\n      {/* Sticky header */}\n      <AccountSectionHeader />\n\n      <Divider />\n\n      {/* Scrollable website list */}\n      <ScrollArea style={{ flex: 1 }} type=\"hover\" scrollbarSize={6}>\n        {isLoading ? (\n          <Box p=\"md\" ta=\"center\">\n            <Loader size=\"sm\" />\n          </Box>\n        ) : sortedWebsites.length === 0 ? (\n          <EmptyState preset=\"no-results\" />\n        ) : (\n          <AccountsProvider\n            selectedAccountId={selectedAccountId}\n            onSelectAccount={handleSelectAccount}\n            onDeleteAccount={handleDeleteAccount}\n            onResetAccount={handleResetAccount}\n            onLoginRequest={handleSelectAccount}\n            onAccountCreated={handleSelectAccount}\n          >\n            <Stack gap=\"xs\" p=\"xs\">\n              {sortedWebsites.map((website) => (\n                <WebsiteAccountCard\n                  key={website.id}\n                  website={website}\n                  accounts={getFilteredAccounts(website.id)}\n                />\n              ))}\n            </Stack>\n          </AccountsProvider>\n        )}\n      </ScrollArea>\n    </Box>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/accounts-section/context/accounts-context.tsx",
    "content": "/**\n * AccountsContext - Provides account actions and selection state to child components.\n * Eliminates prop drilling by making handlers and state available via context.\n */\n\nimport { createContext, ReactNode, useContext, useMemo } from 'react';\n\n/**\n * Shape of the accounts context value\n */\nexport interface AccountsContextValue {\n  /** Currently selected account ID */\n  selectedAccountId: string | null;\n  /** Handle selecting an account */\n  onSelectAccount: (accountId: string) => void;\n  /** Handle deleting an account */\n  onDeleteAccount: (accountId: string) => void;\n  /** Handle resetting an account (clearing data/cookies) */\n  onResetAccount: (accountId: string) => void;\n  /** Handle login request for an account */\n  onLoginRequest: (accountId: string) => void;\n  /** Handle account creation - selects the new account */\n  onAccountCreated: (accountId: string) => void;\n}\n\nconst AccountsContext = createContext<AccountsContextValue | null>(null);\n\n/**\n * Hook to access the accounts context.\n * Must be used within an AccountsProvider.\n * @throws Error if used outside of AccountsProvider\n */\nexport function useAccountsContext(): AccountsContextValue {\n  const context = useContext(AccountsContext);\n  if (!context) {\n    throw new Error(\n      // eslint-disable-next-line lingui/no-unlocalized-strings\n      'useAccountsContext must be used within an AccountsProvider',\n    );\n  }\n  return context;\n}\n\n/**\n * Optional hook that returns undefined if not within a provider.\n * Useful for components that can optionally use context.\n */\nexport function useAccountsContextOptional(): AccountsContextValue | null {\n  return useContext(AccountsContext);\n}\n\nexport interface AccountsProviderProps extends AccountsContextValue {\n  children: ReactNode;\n}\n\n/**\n * Provider component that supplies accounts context to children.\n * Wrap account lists and cards with this to enable context-based access.\n */\nexport function AccountsProvider({\n  children,\n  selectedAccountId,\n  onSelectAccount,\n  onDeleteAccount,\n  onResetAccount,\n  onLoginRequest,\n  onAccountCreated,\n}: AccountsProviderProps) {\n  const value = useMemo<AccountsContextValue>(\n    () => ({\n      selectedAccountId,\n      onSelectAccount,\n      onDeleteAccount,\n      onResetAccount,\n      onLoginRequest,\n      onAccountCreated,\n    }),\n    [\n      selectedAccountId,\n      onSelectAccount,\n      onDeleteAccount,\n      onResetAccount,\n      onLoginRequest,\n      onAccountCreated,\n    ],\n  );\n\n  return (\n    <AccountsContext.Provider value={value}>\n      {children}\n    </AccountsContext.Provider>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/accounts-section/context/index.ts",
    "content": "export {\n    AccountsProvider,\n    useAccountsContext,\n    useAccountsContextOptional,\n    type AccountsContextValue,\n    type AccountsProviderProps\n} from './accounts-context';\n\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/accounts-section/custom-login-placeholder.tsx",
    "content": "/**\n * CustomLoginPlaceholder - Renders custom login components for websites.\n * Falls back to a placeholder if the login component is not yet implemented.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport { IconLogin } from '@tabler/icons-react';\nimport type { AccountRecord } from '../../../stores/records/account-record';\nimport type { WebsiteRecord } from '../../../stores/records/website-record';\nimport { EmptyState } from '../../empty-state';\nimport { getLoginViewComponent } from '../../website-login-views';\n\ninterface CustomLoginPlaceholderProps {\n  /** The account being logged into */\n  account: AccountRecord;\n  /** The website configuration */\n  website: WebsiteRecord;\n  /** Name of the custom login component */\n  loginComponentName: string;\n}\n\n/**\n * Renders the custom login component for a website.\n * Falls back to a placeholder if the component is not implemented.\n */\nexport function CustomLoginPlaceholder({\n  account,\n  website,\n  loginComponentName,\n}: CustomLoginPlaceholderProps) {\n  const LoginComponent = getLoginViewComponent(loginComponentName);\n\n  // Render the actual login component if available\n  if (LoginComponent) {\n    return (\n      <LoginComponent\n        key={account.id}\n        account={account}\n        website={website}\n        data={account.data}\n      />\n    );\n  }\n\n  // Fallback placeholder for unimplemented login components\n  return (\n    <EmptyState\n      icon={<IconLogin size={32} stroke={1.5} />}\n      message={<Trans>Custom login for {website.displayName}</Trans>}\n      description={\n        <Trans>\n          This website uses a custom login form. Support coming soon.\n        </Trans>\n      }\n      size=\"lg\"\n    />\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/accounts-section/hooks/index.ts",
    "content": "/**\n * Hooks for AccountsSection.\n */\n\nexport {\n    useAccountActions,\n    type BoundAccountActions\n} from './use-account-actions';\n\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/accounts-section/hooks/use-account-actions.ts",
    "content": "/**\n * Hook that provides bound action handlers for a specific account.\n * Eliminates the need to create wrapper callbacks in each AccountRow.\n */\n\nimport { useMemo } from 'react';\nimport { useAccountsContext } from '../context';\n\n/**\n * Bound action handlers for a single account.\n */\nexport interface BoundAccountActions {\n  /** Select this account */\n  handleSelect: () => void;\n  /** Delete this account */\n  handleDelete: () => void;\n  /** Reset this account (clear data/cookies) */\n  handleReset: () => void;\n  /** Request login for this account */\n  handleLoginRequest: () => void;\n  /** Whether this account is currently selected */\n  isSelected: boolean;\n}\n\n/**\n * Hook that returns action handlers bound to a specific account ID.\n * Uses the AccountsContext internally, so must be used within an AccountsProvider.\n *\n * @param accountId - The ID of the account to bind actions to\n * @returns Object with bound action handlers and selection state\n *\n * @example\n * ```tsx\n * function MyAccountRow({ account }) {\n *   const { handleDelete, handleSelect, isSelected } = useAccountActions(account.id);\n *   return (\n *     <div className={isSelected ? 'selected' : ''}>\n *       <button onClick={handleSelect}>Select</button>\n *       <button onClick={handleDelete}>Delete</button>\n *     </div>\n *   );\n * }\n * ```\n */\nexport function useAccountActions(accountId: string): BoundAccountActions {\n  const context = useAccountsContext();\n\n  return useMemo<BoundAccountActions>(\n    () => ({\n      handleSelect: () => context.onSelectAccount(accountId),\n      handleDelete: () => context.onDeleteAccount(accountId),\n      handleReset: () => context.onResetAccount(accountId),\n      handleLoginRequest: () => context.onLoginRequest(accountId),\n      isSelected: context.selectedAccountId === accountId,\n    }),\n    [context, accountId]\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/accounts-section/index.ts",
    "content": "export { AccountSectionHeader } from './account-section-header';\nexport { AccountsContent } from './accounts-content';\nexport { AccountsSection } from './accounts-section';\nexport * from './context';\nexport { CustomLoginPlaceholder } from './custom-login-placeholder';\nexport * from './hooks';\nexport { LoginWebview } from './login-webview';\nexport { WebsiteAccountCard } from './website-account-card';\nexport { WebsiteVisibilityPicker } from './website-visibility-picker';\n\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/accounts-section/login-webview.tsx",
    "content": "/**\n * LoginWebview - A polished webview component for website login.\n * Displays a webview with a toolbar containing refresh button, URL display,\n * login status indicator, and manual login check button.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport {\n  ActionIcon,\n  Badge,\n  Box,\n  Group,\n  Loader,\n  Paper,\n  Text,\n  Tooltip,\n} from '@mantine/core';\nimport type { AccountId } from '@postybirb/types';\nimport {\n  IconArrowLeft,\n  IconArrowRight,\n  IconRefresh,\n  IconUserCheck,\n} from '@tabler/icons-react';\nimport { useCallback, useEffect, useRef, useState } from 'react';\nimport accountApi from '../../../api/account.api';\nimport { useAccount } from '../../../stores';\nimport { notifyLoginSuccess } from '../../website-login-views/helpers';\nimport type { WebviewTag } from './webview-tag';\n\ninterface LoginWebviewProps {\n  /** The URL to load in the webview */\n  src: string;\n  /** The account ID for session partitioning */\n  accountId: AccountId;\n}\n\n/**\n * A polished webview component for website login.\n * Features a toolbar with refresh button, login check button, URL display,\n * login status indicator, plus a loading overlay while the page loads.\n */\nexport function LoginWebview({ src, accountId }: LoginWebviewProps) {\n  const [isLoading, setIsLoading] = useState(true);\n  const [currentUrl, setCurrentUrl] = useState(src);\n  const webviewRef = useRef<WebviewTag | null>(null);\n\n  // Subscribe to account state for real-time login status updates\n  const account = useAccount(accountId);\n  const isPending = account?.isPending ?? false;\n  const isLoggedIn = account?.isLoggedIn ?? false;\n  const username = account?.username;\n\n  // Track to which account we've shown the success notification to avoid duplicates\n  const hasShownSuccessNotification = useRef<string | null>(null);\n\n  // Track whether we have an in-flight login request to prevent duplicate calls\n  const loginInFlight = useRef(false);\n\n  // Track the last time we fired a login check to debounce navigation events\n  const lastLoginCheckTime = useRef(0);\n\n  // Minimum ms between automatic (navigation-triggered) login checks\n  const AUTO_CHECK_DEBOUNCE_MS = 2_000;\n\n  /**\n   * Fire a login check, deduplicating against in-flight requests.\n   * @param force - If true, skip the time-based debounce (for manual clicks)\n   */\n  const triggerLoginCheck = useCallback(\n    async (force = false) => {\n      const now = Date.now();\n\n      // Skip if a request is already in flight\n      if (loginInFlight.current) {\n        return;\n      }\n\n      // Skip automatic checks that are too close together\n      if (!force && now - lastLoginCheckTime.current < AUTO_CHECK_DEBOUNCE_MS) {\n        return;\n      }\n\n      loginInFlight.current = true;\n      lastLoginCheckTime.current = now;\n\n      try {\n        await accountApi.refreshLogin(accountId);\n      } finally {\n        loginInFlight.current = false;\n      }\n    },\n    [accountId],\n  );\n\n  // Manual login check handler — always forces, shows spinner via isPending\n  const handleCheckLogin = useCallback(() => {\n    triggerLoginCheck(true);\n  }, [triggerLoginCheck]);\n\n  // Show notification on first successful login\n  useEffect(() => {\n    if (isLoggedIn && hasShownSuccessNotification.current !== accountId) {\n      hasShownSuccessNotification.current = accountId;\n      notifyLoginSuccess(username || account?.name || '', account);\n    }\n  }, [isLoggedIn, username, account?.name, accountId, account]);\n\n  // Handle webview events\n  useEffect(() => {\n    const webview = webviewRef.current;\n    if (!webview) return undefined;\n\n    const handleStartLoading = () => {\n      setIsLoading(true);\n    };\n\n    const handleStopLoading = () => {\n      setIsLoading(false);\n      // Automatic check after page finishes loading — debounced\n      triggerLoginCheck(false);\n    };\n\n    const handleDidNavigate = (event: Electron.DidNavigateEvent) => {\n      setCurrentUrl(event.url);\n    };\n\n    // SPA navigations (pushState, replaceState, hashchange) don't trigger\n    // did-navigate or did-stop-loading — catch them explicitly\n    const handleDidNavigateInPage = (\n      event: Electron.DidNavigateInPageEvent,\n    ) => {\n      setCurrentUrl(event.url);\n      triggerLoginCheck(false);\n    };\n\n    // Iframe navigations (OAuth callback frames, CAPTCHA frames)\n    const handleFrameNavigation = () => {\n      triggerLoginCheck(false);\n    };\n\n    // HTTP redirects (302 during OAuth flows) — the final landing page\n    // may set cookies before did-stop-loading fires\n    const handleRedirect = () => {\n      triggerLoginCheck(false);\n    };\n\n    webview.addEventListener('did-start-loading', handleStartLoading);\n    webview.addEventListener('did-stop-loading', handleStopLoading);\n    webview.addEventListener('did-navigate', handleDidNavigate);\n    webview.addEventListener('did-navigate-in-page', handleDidNavigateInPage);\n    webview.addEventListener('did-frame-navigate', handleFrameNavigation);\n    webview.addEventListener('did-redirect-navigation', handleRedirect);\n\n    return () => {\n      webview.removeEventListener('did-start-loading', handleStartLoading);\n      webview.removeEventListener('did-stop-loading', handleStopLoading);\n      webview.removeEventListener('did-navigate', handleDidNavigate);\n      webview.removeEventListener(\n        'did-navigate-in-page',\n        handleDidNavigateInPage,\n      );\n      webview.removeEventListener('did-frame-navigate', handleFrameNavigation);\n      webview.removeEventListener('did-redirect-navigation', handleRedirect);\n      // Fire a final login check when the webview closes (user is done)\n      triggerLoginCheck(true);\n    };\n  }, [triggerLoginCheck, accountId]);\n\n  // Handle refresh button click\n  const handleRefresh = () => {\n    if (webviewRef.current) {\n      webviewRef.current.reload();\n    }\n  };\n\n  const handleGoBack = () => webviewRef.current?.goBack();\n\n  const handleGoForward = () => webviewRef.current?.goForward();\n\n  // If user had navigated in webview and tries to change account (partition) to another webview\n  // it throws 'The object has already navigated, so its partition cannot be changed.'\n  // so we must recreate webview\n  const lastAccount = useRef(accountId);\n  const [resetWebview, setResetWebview] = useState(false);\n\n  useEffect(() => {\n    if (lastAccount.current !== accountId) {\n      lastAccount.current = accountId;\n      setCurrentUrl(src);\n      setResetWebview(true);\n    } else {\n      setResetWebview(false);\n    }\n  }, [lastAccount, accountId, resetWebview, src]);\n\n  if (resetWebview) return null;\n\n  return (\n    <Box\n      h=\"100%\"\n      style={{\n        display: 'flex',\n        flexDirection: 'column',\n      }}\n    >\n      {/* Toolbar */}\n      <Paper p=\"xs\" withBorder radius={0} style={{ flexShrink: 0 }}>\n        <Group gap=\"sm\">\n          <Tooltip label={<Trans>Go back</Trans>}>\n            <ActionIcon variant=\"subtle\" size=\"sm\" onClick={handleGoBack}>\n              <IconArrowLeft size={16} />\n            </ActionIcon>\n          </Tooltip>\n          <Tooltip label={<Trans>Go forward</Trans>}>\n            <ActionIcon variant=\"subtle\" size=\"sm\" onClick={handleGoForward}>\n              <IconArrowRight size={16} />\n            </ActionIcon>\n          </Tooltip>\n          <Tooltip label={<Trans>Refresh page</Trans>}>\n            <ActionIcon variant=\"subtle\" size=\"sm\" onClick={handleRefresh}>\n              {isLoading ? <Loader size={16} /> : <IconRefresh size={16} />}\n            </ActionIcon>\n          </Tooltip>\n\n          <Tooltip\n            label={\n              isPending ? (\n                <Trans>Login check in progress...</Trans>\n              ) : (\n                <Trans>Check login status</Trans>\n              )\n            }\n          >\n            <ActionIcon\n              variant=\"subtle\"\n              size=\"sm\"\n              onClick={handleCheckLogin}\n              color=\"blue\"\n              loading={isPending}\n            >\n              <IconUserCheck size={16} />\n            </ActionIcon>\n          </Tooltip>\n          <Text size=\"xs\" c=\"dimmed\" truncate style={{ flex: 1, minWidth: 0 }}>\n            {/** THIS IS CRITICAL, without the slice webview will turn blank on accounts.google.com. This is really strange bug in electron or maybe security defense mechanism but its just better to keep it like this */}\n            {currentUrl.slice(0, 100)}\n          </Text>\n          {/* Login status badge */}\n          {isPending ? (\n            <Badge size=\"xs\" color=\"yellow\" variant=\"light\">\n              <Trans>Checking...</Trans>\n            </Badge>\n          ) : isLoggedIn ? (\n            <Badge size=\"xs\" color=\"green\" variant=\"light\">\n              <Trans>Logged in{username ? ` as ${username}` : ''}</Trans>\n            </Badge>\n          ) : (\n            <Badge size=\"xs\" color=\"gray\" variant=\"light\">\n              <Trans>Not logged in</Trans>\n            </Badge>\n          )}\n        </Group>\n      </Paper>\n\n      {/* Webview container */}\n      <Box\n        style={{\n          flex: 1,\n          position: 'relative',\n          minHeight: 0,\n        }}\n      >\n        <webview\n          src={src}\n          ref={(ref) => {\n            webviewRef.current = ref as WebviewTag;\n          }}\n          style={{\n            width: '100%',\n            height: '100%',\n          }}\n          // eslint-disable-next-line react/no-unknown-property\n          webpreferences=\"nativeWindowOpen=1\"\n          // eslint-disable-next-line react/no-unknown-property\n          partition={`persist:${accountId}`}\n          // eslint-disable-next-line react/no-unknown-property, @typescript-eslint/no-explicit-any\n          allowpopups={'true' as any}\n        />\n      </Box>\n    </Box>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/accounts-section/website-account-card.tsx",
    "content": "/**\n * WebsiteAccountCard - Compact card showing a website with its accounts.\n * Displays login status and allows account selection.\n */\n\nimport { Trans, useLingui } from '@lingui/react/macro';\nimport {\n    ActionIcon,\n    Badge,\n    Box,\n    Button,\n    Collapse,\n    Group,\n    Paper,\n    Popover,\n    Stack,\n    Text,\n    TextInput,\n    Tooltip,\n    UnstyledButton,\n} from '@mantine/core';\nimport { useDisclosure } from '@mantine/hooks';\nimport {\n    IconChevronDown,\n    IconChevronRight,\n    IconLogin,\n    IconPlus,\n    IconRefresh,\n    IconTrash,\n    IconUser,\n} from '@tabler/icons-react';\nimport { memo, useCallback, useState } from 'react';\nimport accountApi from '../../../api/account.api';\nimport type { AccountRecord } from '../../../stores/records';\nimport type { WebsiteRecord } from '../../../stores/records/website-record';\nimport {\n    showCreateErrorNotification,\n    showCreatedNotification,\n    showUpdateErrorNotification,\n} from '../../../utils/notifications';\nimport { HoldToConfirmButton } from '../../hold-to-confirm';\nimport { useAccountsContext } from './context';\nimport { useAccountActions } from './hooks';\n\ninterface WebsiteAccountCardProps {\n  /** Website record */\n  website: WebsiteRecord;\n  /** Accounts for this website */\n  accounts: AccountRecord[];\n}\n\n/** Maximum characters allowed for account name */\nconst MAX_ACCOUNT_NAME_LENGTH = 24;\n\n/**\n * Single account row within a website card.\n * Uses AccountsContext via useAccountActions hook.\n * Memoized to prevent re-renders when sibling rows or parent card re-renders.\n */\nconst AccountRow = memo(({ account }: { account: AccountRecord }) => {\n  const {\n    isSelected,\n    handleSelect,\n    handleLoginRequest,\n    handleDelete,\n    handleReset,\n  } = useAccountActions(account.id);\n\n  const [\n    resetPopoverOpened,\n    { open: openResetPopover, close: closeResetPopover },\n  ] = useDisclosure(false);\n  const [isEditingName, setIsEditingName] = useState(false);\n  const [editName, setEditName] = useState(account.name);\n\n  const onReset = useCallback(() => {\n    handleReset();\n    closeResetPopover();\n  }, [handleReset, closeResetPopover]);\n\n  const handleNameBlur = useCallback(async () => {\n    const trimmedName = editName.trim();\n\n    // If empty or unchanged, revert to original\n    if (!trimmedName || trimmedName === account.name) {\n      setEditName(account.name);\n      setIsEditingName(false);\n      return;\n    }\n\n    // Save the new name\n    try {\n      await accountApi.update(account.id, {\n        name: trimmedName,\n        groups: account.groups,\n      });\n      setIsEditingName(false);\n    } catch {\n      showUpdateErrorNotification(account.name);\n      setEditName(account.name);\n      setIsEditingName(false);\n    }\n  }, [editName, account.id, account.name, account.groups]);\n\n  const handleNameKeyDown = useCallback((e: React.KeyboardEvent) => {\n    if (e.key === 'Enter') {\n      (e.target as HTMLInputElement).blur();\n    } else if (e.key === 'Escape') {\n      setEditName(account.name);\n      setIsEditingName(false);\n    }\n  }, [account.name]);\n\n  const handleNameClick = useCallback((e: React.MouseEvent) => {\n    e.stopPropagation();\n    setIsEditingName(true);\n  }, []);\n\n  return (\n    <Group\n      data-tour-id=\"accounts-account-row\"\n      gap=\"xs\"\n      px=\"xs\"\n      py={4}\n      wrap=\"nowrap\"\n      style={{\n        borderRadius: 'var(--mantine-radius-sm)',\n        backgroundColor: isSelected\n          ? 'var(--mantine-primary-color-light)'\n          : undefined,\n      }}\n    >\n      <UnstyledButton onClick={handleSelect} style={{ flex: 1, minWidth: 0 }}>\n        <Group gap=\"xs\" wrap=\"nowrap\">\n          <IconUser size={14} style={{ opacity: 0.5, flexShrink: 0 }} />\n\n          <Box style={{ flex: 1, minWidth: 0 }}>\n            {isEditingName ? (\n              <TextInput\n                size=\"xs\"\n                value={editName}\n                onChange={(e) =>\n                  setEditName(\n                    e.currentTarget.value.slice(0, MAX_ACCOUNT_NAME_LENGTH),\n                  )\n                }\n                onBlur={handleNameBlur}\n                onKeyDown={handleNameKeyDown}\n                autoFocus\n                styles={{\n                  input: {\n                    minHeight: 'unset',\n                    height: 'auto',\n                    // eslint-disable-next-line lingui/no-unlocalized-strings\n                    padding: '2px 4px',\n                  },\n                }}\n              />\n            ) : (\n              <Text\n                size=\"xs\"\n                truncate\n                onClick={handleNameClick}\n                style={{ cursor: 'text' }}\n              >\n                {account.name}\n              </Text>\n            )}\n            {account.username && (\n              <Text size=\"xs\" c=\"dimmed\" truncate>\n                {account.username}\n              </Text>\n            )}\n          </Box>\n\n          {account.isLoggedIn ? (\n            <Badge size=\"xs\" color=\"green\" variant=\"light\">\n              <Trans>Online</Trans>\n            </Badge>\n          ) : account.isPending ? (\n            <Badge size=\"xs\" color=\"yellow\" variant=\"light\">\n              <Trans>Pending</Trans>\n            </Badge>\n          ) : (\n            <Badge size=\"xs\" color=\"gray\" variant=\"light\">\n              <Trans>Offline</Trans>\n            </Badge>\n          )}\n        </Group>\n      </UnstyledButton>\n\n      {/* Action buttons */}\n      <Group gap={4} wrap=\"nowrap\">\n        {/* Login button */}\n        <Tooltip label={<Trans>Login</Trans>}>\n          <ActionIcon\n            size=\"xs\"\n            variant=\"subtle\"\n            color=\"blue\"\n            onClick={(e) => {\n              e.stopPropagation();\n              handleLoginRequest();\n            }}\n          >\n            <IconLogin size={14} />\n          </ActionIcon>\n        </Tooltip>\n\n        {/* Reset button with confirmation popover */}\n        <Popover\n          trapFocus\n          returnFocus\n          withArrow\n          opened={resetPopoverOpened}\n          onChange={(opened) => {\n            if (!opened) closeResetPopover();\n          }}\n          position=\"right\"\n          shadow=\"md\"\n        >\n          <Popover.Target>\n            <Tooltip label={<Trans>Reset account data</Trans>}>\n              <ActionIcon\n                size=\"xs\"\n                variant=\"subtle\"\n                color=\"yellow\"\n                onClick={(e) => {\n                  e.stopPropagation();\n                  openResetPopover();\n                }}\n              >\n                <IconRefresh size={14} />\n              </ActionIcon>\n            </Tooltip>\n          </Popover.Target>\n          <Popover.Dropdown>\n            <Stack gap=\"xs\">\n              <Text size=\"xs\" fw={500}>\n                <Trans>Reset account?</Trans>\n              </Text>\n              <Text size=\"xs\" c=\"dimmed\">\n                <Trans>This will clear all account data and cookies.</Trans>\n              </Text>\n              <Group gap=\"xs\" justify=\"flex-end\">\n                <Button size=\"xs\" variant=\"default\" onClick={closeResetPopover}>\n                  <Trans>Cancel</Trans>\n                </Button>\n                <Button size=\"xs\" color=\"yellow\" onClick={onReset}>\n                  <Trans>Reset</Trans>\n                </Button>\n              </Group>\n            </Stack>\n          </Popover.Dropdown>\n        </Popover>\n\n        {/* Delete button - hold to confirm */}\n        <Tooltip label={<Trans>Hold to delete</Trans>}>\n          <Box onClick={(e) => e.stopPropagation()}>\n            <HoldToConfirmButton\n              size=\"xs\"\n              variant=\"subtle\"\n              color=\"red\"\n              onConfirm={() => handleDelete()}\n            >\n              <IconTrash size={14} />\n            </HoldToConfirmButton>\n          </Box>\n        </Tooltip>\n      </Group>\n    </Group>\n  );\n});\n\n/**\n * Compact card for a website showing its accounts.\n * Memoized to prevent re-renders when sibling cards haven't changed.\n */\nexport const WebsiteAccountCard = memo(({\n  website,\n  accounts,\n}: WebsiteAccountCardProps) => {\n  const { t } = useLingui();\n  const { onAccountCreated } = useAccountsContext();\n  // Default to collapsed if no accounts, expanded otherwise\n  const [expanded, { toggle }] = useDisclosure(accounts.length > 0);\n  const [addPopoverOpened, { open: openAddPopover, close: closeAddPopover }] =\n    useDisclosure(false);\n  const [newAccountName, setNewAccountName] = useState('');\n  const [isCreating, setIsCreating] = useState(false);\n\n  const loggedInCount = accounts.filter((a) => a.isLoggedIn).length;\n  const totalCount = accounts.length;\n\n  const handleCreateAccount = useCallback(async () => {\n    const trimmedName = newAccountName.trim();\n    if (!trimmedName || isCreating) return;\n\n    setIsCreating(true);\n    try {\n      const response = await accountApi.create({\n        name: trimmedName,\n        website: website.id,\n        groups: [],\n      });\n      showCreatedNotification(trimmedName);\n      setNewAccountName('');\n      closeAddPopover();\n      // Select the newly created account\n      onAccountCreated(response.body.id);\n    } catch {\n      showCreateErrorNotification(trimmedName);\n    } finally {\n      setIsCreating(false);\n    }\n  }, [newAccountName, isCreating, website.id, closeAddPopover, onAccountCreated]);\n\n  const handleAddKeyDown = useCallback((e: React.KeyboardEvent) => {\n    if (e.key === 'Enter') {\n      handleCreateAccount();\n    } else if (e.key === 'Escape') {\n      setNewAccountName('');\n      closeAddPopover();\n    }\n  }, [handleCreateAccount, closeAddPopover]);\n\n  return (\n    <Paper withBorder radius=\"sm\" p={0} data-tour-id=\"accounts-website-card\">\n      {/* Website header */}\n      <UnstyledButton onClick={toggle} style={{ width: '100%' }}>\n        <Group gap=\"xs\" px=\"sm\" py=\"xs\" wrap=\"nowrap\">\n          {expanded ? (\n            <IconChevronDown size={14} style={{ flexShrink: 0 }} />\n          ) : (\n            <IconChevronRight size={14} style={{ flexShrink: 0 }} />\n          )}\n\n          <Text size=\"sm\" fw={500} style={{ flex: 1 }} truncate>\n            {website.displayName}\n          </Text>\n\n          <Badge size=\"xs\" variant=\"light\">\n            {loggedInCount}/{totalCount}\n          </Badge>\n        </Group>\n      </UnstyledButton>\n\n      {/* Accounts list */}\n      <Collapse in={expanded}>\n        <Stack gap={2} pb=\"xs\" px=\"xs\">\n          {accounts.length === 0\n            ? null\n            : accounts.map((account) => (\n                <AccountRow key={account.id} account={account} />\n              ))}\n\n          {/* Add account button with popover form */}\n          <Popover\n            opened={addPopoverOpened}\n            onClose={closeAddPopover}\n            position=\"bottom-start\"\n            withArrow\n            shadow=\"md\"\n          >\n            <Popover.Target>\n              <UnstyledButton onClick={openAddPopover}>\n                <Group gap=\"xs\" px=\"xs\" py={4} data-tour-id=\"accounts-add-account\">\n                  <IconPlus size={14} style={{ opacity: 0.5 }} />\n                  <Text size=\"xs\" c=\"dimmed\">\n                    <Trans>Add account</Trans>\n                  </Text>\n                </Group>\n              </UnstyledButton>\n            </Popover.Target>\n            <Popover.Dropdown>\n              <Stack gap=\"xs\">\n                <Text size=\"xs\" fw={500}>\n                  <Trans>New account</Trans>\n                </Text>\n                <TextInput\n                  size=\"xs\"\n                  placeholder={t`Account name`}\n                  value={newAccountName}\n                  onChange={(e) =>\n                    setNewAccountName(\n                      e.currentTarget.value.slice(0, MAX_ACCOUNT_NAME_LENGTH),\n                    )\n                  }\n                  onKeyDown={handleAddKeyDown}\n                  autoFocus\n                  disabled={isCreating}\n                />\n                <Group gap=\"xs\" justify=\"flex-end\">\n                  <Button\n                    size=\"xs\"\n                    variant=\"default\"\n                    onClick={() => {\n                      setNewAccountName('');\n                      closeAddPopover();\n                    }}\n                    disabled={isCreating}\n                  >\n                    <Trans>Cancel</Trans>\n                  </Button>\n                  <Button\n                    size=\"xs\"\n                    onClick={handleCreateAccount}\n                    disabled={!newAccountName.trim() || isCreating}\n                    loading={isCreating}\n                  >\n                    <Trans>Create</Trans>\n                  </Button>\n                </Group>\n              </Stack>\n            </Popover.Dropdown>\n          </Popover>\n        </Stack>\n      </Collapse>\n    </Paper>\n  );\n});\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/accounts-section/website-visibility-picker.tsx",
    "content": "/**\n * WebsiteVisibilityPicker - Popover to toggle website visibility.\n * Allows users to show/hide specific websites from the accounts list.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport {\n    ActionIcon,\n    Badge,\n    Box,\n    Checkbox,\n    Popover,\n    ScrollArea,\n    Stack,\n    Text,\n    Tooltip,\n} from '@mantine/core';\nimport { IconEye, IconEyeOff } from '@tabler/icons-react';\nimport { useWebsites } from '../../../stores/entity/website-store';\nimport { useAccountsFilter } from '../../../stores/ui/accounts-ui-store';\n\n/**\n * Popover component for toggling website visibility in the accounts list.\n */\nexport function WebsiteVisibilityPicker() {\n  const websites = useWebsites();\n  const { hiddenWebsites, toggleWebsiteVisibility } = useAccountsFilter();\n\n  const hiddenCount = hiddenWebsites.length;\n  const hasHidden = hiddenCount > 0;\n\n  return (\n    <Popover\n      position=\"bottom-end\"\n      width={250}\n      shadow=\"md\"\n      closeOnEscape\n      closeOnClickOutside\n    >\n      <Popover.Target>\n        <Tooltip label={<Trans>Toggle website visibility</Trans>}>\n          <Box data-tour-id=\"accounts-visibility\">\n            <ActionIcon\n              variant={hasHidden ? 'light' : 'subtle'}\n              color={hasHidden ? 'yellow' : 'gray'}\n              size=\"sm\"\n              // eslint-disable-next-line lingui/no-unlocalized-strings\n              aria-label=\"Toggle website visibility\"\n            >\n              {hasHidden ? <IconEyeOff size={16} /> : <IconEye size={16} />}\n            </ActionIcon>\n\n            <Badge\n              size=\"xs\"\n              style={{ verticalAlign: 'text-top' }}\n              variant=\"transparent\"\n              c=\"dimmed\"\n            >\n              {hasHidden\n                ? ` ${websites.length - hiddenCount} / ${websites.length}`\n                : ''}\n            </Badge>\n          </Box>\n        </Tooltip>\n      </Popover.Target>\n\n      <Popover.Dropdown>\n        <Stack gap=\"xs\">\n          <Text size=\"xs\" fw={500} c=\"dimmed\">\n            <Trans>Show/Hide Websites</Trans>\n          </Text>\n\n          <ScrollArea.Autosize mah={300} type=\"auto\" offsetScrollbars>\n            <Stack gap={4}>\n              {websites.length === 0 ? (\n                <Text size=\"xs\" c=\"dimmed\" ta=\"center\" py=\"sm\">\n                  <Trans>No websites available</Trans>\n                </Text>\n              ) : (\n                websites.map((website) => (\n                  <Checkbox\n                    key={website.id}\n                    size=\"xs\"\n                    label={website.displayName}\n                    checked={!hiddenWebsites.includes(website.id)}\n                    onChange={() => toggleWebsiteVisibility(website.id)}\n                  />\n                ))\n              )}\n            </Stack>\n          </ScrollArea.Autosize>\n\n          {hasHidden && (\n            <Text size=\"xs\" c=\"dimmed\" ta=\"center\">\n              <Trans>{hiddenCount} hidden</Trans>\n            </Text>\n          )}\n        </Stack>\n      </Popover.Dropdown>\n    </Popover>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/accounts-section/webview-tag.ts",
    "content": "/* eslint-disable @typescript-eslint/adjacent-overload-signatures */\n/* eslint-disable @typescript-eslint/no-explicit-any */\n\nimport {\n  ConsoleMessageEvent,\n  ContextMenuEvent,\n  DidChangeThemeColorEvent,\n  DidFailLoadEvent,\n  DidFrameFinishLoadEvent,\n  DidFrameNavigateEvent,\n  DidNavigateEvent,\n  DidNavigateInPageEvent,\n  DidRedirectNavigationEvent,\n  DidStartNavigationEvent,\n  FoundInPageEvent,\n  IpcMessageEvent,\n  KeyboardInputEvent,\n  LoadCommitEvent,\n  LoadURLOptions,\n  MouseInputEvent,\n  MouseWheelInputEvent,\n  PageFaviconUpdatedEvent,\n  PageTitleUpdatedEvent,\n  PluginCrashedEvent,\n  PrintToPDFOptions,\n  UpdateTargetUrlEvent,\n  WebviewTagPrintOptions,\n  WillNavigateEvent,\n} from 'electron';\n\nexport interface WebviewTag extends HTMLElement {\n  // Docs: https://electronjs.org/docs/api/webview-tag\n\n  /**\n   * Fired when a load has committed. This includes navigation within the current\n   * document as well as subframe document-level loads, but does not include\n   * asynchronous resource loads.\n   */\n  addEventListener(\n    event: 'load-commit',\n    listener: (event: LoadCommitEvent) => void,\n    useCapture?: boolean,\n  ): this;\n  removeEventListener(\n    event: 'load-commit',\n    listener: (event: LoadCommitEvent) => void,\n  ): this;\n  /**\n   * Fired when the navigation is done, i.e. the spinner of the tab will stop\n   * spinning, and the `onload` event is dispatched.\n   */\n  addEventListener(\n    event: 'did-finish-load',\n    listener: (event: Event) => void,\n    useCapture?: boolean,\n  ): this;\n  removeEventListener(\n    event: 'did-finish-load',\n    listener: (event: Event) => void,\n  ): this;\n  /**\n   * This event is like `did-finish-load`, but fired when the load failed or was\n   * cancelled, e.g. `window.stop()` is invoked.\n   */\n  addEventListener(\n    event: 'did-fail-load',\n    listener: (event: DidFailLoadEvent) => void,\n    useCapture?: boolean,\n  ): this;\n  removeEventListener(\n    event: 'did-fail-load',\n    listener: (event: DidFailLoadEvent) => void,\n  ): this;\n  /**\n   * Fired when a frame has done navigation.\n   */\n  addEventListener(\n    event: 'did-frame-finish-load',\n    listener: (event: DidFrameFinishLoadEvent) => void,\n    useCapture?: boolean,\n  ): this;\n  removeEventListener(\n    event: 'did-frame-finish-load',\n    listener: (event: DidFrameFinishLoadEvent) => void,\n  ): this;\n  /**\n   * Corresponds to the points in time when the spinner of the tab starts spinning.\n   */\n  addEventListener(\n    event: 'did-start-loading',\n    listener: (event: Event) => void,\n    useCapture?: boolean,\n  ): this;\n  removeEventListener(\n    event: 'did-start-loading',\n    listener: (event: Event) => void,\n  ): this;\n  /**\n   * Corresponds to the points in time when the spinner of the tab stops spinning.\n   */\n  addEventListener(\n    event: 'did-stop-loading',\n    listener: (event: Event) => void,\n    useCapture?: boolean,\n  ): this;\n  removeEventListener(\n    event: 'did-stop-loading',\n    listener: (event: Event) => void,\n  ): this;\n  /**\n   * Fired when attached to the embedder web contents.\n   */\n  addEventListener(\n    event: 'did-attach',\n    listener: (event: Event) => void,\n    useCapture?: boolean,\n  ): this;\n  removeEventListener(\n    event: 'did-attach',\n    listener: (event: Event) => void,\n  ): this;\n  /**\n   * Fired when document in the given frame is loaded.\n   */\n  addEventListener(\n    event: 'dom-ready',\n    listener: (event: Event) => void,\n    useCapture?: boolean,\n  ): this;\n  removeEventListener(\n    event: 'dom-ready',\n    listener: (event: Event) => void,\n  ): this;\n  /**\n   * Fired when page title is set during navigation. `explicitSet` is false when\n   * title is synthesized from file url.\n   */\n  addEventListener(\n    event: 'page-title-updated',\n    listener: (event: PageTitleUpdatedEvent) => void,\n    useCapture?: boolean,\n  ): this;\n  removeEventListener(\n    event: 'page-title-updated',\n    listener: (event: PageTitleUpdatedEvent) => void,\n  ): this;\n  /**\n   * Fired when page receives favicon urls.\n   */\n  addEventListener(\n    event: 'page-favicon-updated',\n    listener: (event: PageFaviconUpdatedEvent) => void,\n    useCapture?: boolean,\n  ): this;\n  removeEventListener(\n    event: 'page-favicon-updated',\n    listener: (event: PageFaviconUpdatedEvent) => void,\n  ): this;\n  /**\n   * Fired when page enters fullscreen triggered by HTML API.\n   */\n  addEventListener(\n    event: 'enter-html-full-screen',\n    listener: (event: Event) => void,\n    useCapture?: boolean,\n  ): this;\n  removeEventListener(\n    event: 'enter-html-full-screen',\n    listener: (event: Event) => void,\n  ): this;\n  /**\n   * Fired when page leaves fullscreen triggered by HTML API.\n   */\n  addEventListener(\n    event: 'leave-html-full-screen',\n    listener: (event: Event) => void,\n    useCapture?: boolean,\n  ): this;\n  removeEventListener(\n    event: 'leave-html-full-screen',\n    listener: (event: Event) => void,\n  ): this;\n  /**\n   * Fired when the guest window logs a console message.\n   *\n   * The following example code forwards all log messages to the embedder's console\n   * without regard for log level or other properties.\n   */\n  addEventListener(\n    event: 'console-message',\n    listener: (event: ConsoleMessageEvent) => void,\n    useCapture?: boolean,\n  ): this;\n  removeEventListener(\n    event: 'console-message',\n    listener: (event: ConsoleMessageEvent) => void,\n  ): this;\n  /**\n   * Fired when a result is available for `webview.findInPage` request.\n   */\n  addEventListener(\n    event: 'found-in-page',\n    listener: (event: FoundInPageEvent) => void,\n    useCapture?: boolean,\n  ): this;\n  removeEventListener(\n    event: 'found-in-page',\n    listener: (event: FoundInPageEvent) => void,\n  ): this;\n  /**\n   * Emitted when a user or the page wants to start navigation. It can happen when\n   * the `window.location` object is changed or a user clicks a link in the page.\n   *\n   * This event will not emit when the navigation is started programmatically with\n   * APIs like `<webview>.loadURL` and `<webview>.back`.\n   *\n   * It is also not emitted during in-page navigation, such as clicking anchor links\n   * or updating the `window.location.hash`. Use `did-navigate-in-page` event for\n   * this purpose.\n   *\n   * Calling `event.preventDefault()` does __NOT__ have any effect.\n   */\n  addEventListener(\n    event: 'will-navigate',\n    listener: (event: WillNavigateEvent) => void,\n    useCapture?: boolean,\n  ): this;\n  removeEventListener(\n    event: 'will-navigate',\n    listener: (event: WillNavigateEvent) => void,\n  ): this;\n  /**\n   * Emitted when any frame (including main) starts navigating. `isInPlace` will be\n   * `true` for in-page navigations.\n   */\n  addEventListener(\n    event: 'did-start-navigation',\n    listener: (event: DidStartNavigationEvent) => void,\n    useCapture?: boolean,\n  ): this;\n  removeEventListener(\n    event: 'did-start-navigation',\n    listener: (event: DidStartNavigationEvent) => void,\n  ): this;\n  /**\n   * Emitted after a server side redirect occurs during navigation. For example a 302\n   * redirect.\n   */\n  addEventListener(\n    event: 'did-redirect-navigation',\n    listener: (event: DidRedirectNavigationEvent) => void,\n    useCapture?: boolean,\n  ): this;\n  removeEventListener(\n    event: 'did-redirect-navigation',\n    listener: (event: DidRedirectNavigationEvent) => void,\n  ): this;\n  /**\n   * Emitted when a navigation is done.\n   *\n   * This event is not emitted for in-page navigations, such as clicking anchor links\n   * or updating the `window.location.hash`. Use `did-navigate-in-page` event for\n   * this purpose.\n   */\n  addEventListener(\n    event: 'did-navigate',\n    listener: (event: DidNavigateEvent) => void,\n    useCapture?: boolean,\n  ): this;\n  removeEventListener(\n    event: 'did-navigate',\n    listener: (event: DidNavigateEvent) => void,\n  ): this;\n  /**\n   * Emitted when any frame navigation is done.\n   *\n   * This event is not emitted for in-page navigations, such as clicking anchor links\n   * or updating the `window.location.hash`. Use `did-navigate-in-page` event for\n   * this purpose.\n   */\n  addEventListener(\n    event: 'did-frame-navigate',\n    listener: (event: DidFrameNavigateEvent) => void,\n    useCapture?: boolean,\n  ): this;\n  removeEventListener(\n    event: 'did-frame-navigate',\n    listener: (event: DidFrameNavigateEvent) => void,\n  ): this;\n  /**\n   * Emitted when an in-page navigation happened.\n   *\n   * When in-page navigation happens, the page URL changes but does not cause\n   * navigation outside of the page. Examples of this occurring are when anchor links\n   * are clicked or when the DOM `hashchange` event is triggered.\n   */\n  addEventListener(\n    event: 'did-navigate-in-page',\n    listener: (event: DidNavigateInPageEvent) => void,\n    useCapture?: boolean,\n  ): this;\n  removeEventListener(\n    event: 'did-navigate-in-page',\n    listener: (event: DidNavigateInPageEvent) => void,\n  ): this;\n  /**\n   * Fired when the guest page attempts to close itself.\n   *\n   * The following example code navigates the `webview` to `about:blank` when the\n   * guest attempts to close itself.\n   */\n  addEventListener(\n    event: 'close',\n    listener: (event: Event) => void,\n    useCapture?: boolean,\n  ): this;\n  removeEventListener(event: 'close', listener: (event: Event) => void): this;\n  /**\n   * Fired when the guest page has sent an asynchronous message to embedder page.\n   *\n   * With `sendToHost` method and `ipc-message` event you can communicate between\n   * guest page and embedder page:\n   */\n  addEventListener(\n    event: 'ipc-message',\n    listener: (event: IpcMessageEvent) => void,\n    useCapture?: boolean,\n  ): this;\n  removeEventListener(\n    event: 'ipc-message',\n    listener: (event: IpcMessageEvent) => void,\n  ): this;\n  /**\n   * Fired when the renderer process is crashed.\n   */\n  addEventListener(\n    event: 'crashed',\n    listener: (event: Event) => void,\n    useCapture?: boolean,\n  ): this;\n  removeEventListener(event: 'crashed', listener: (event: Event) => void): this;\n  /**\n   * Fired when a plugin process is crashed.\n   */\n  addEventListener(\n    event: 'plugin-crashed',\n    listener: (event: PluginCrashedEvent) => void,\n    useCapture?: boolean,\n  ): this;\n  removeEventListener(\n    event: 'plugin-crashed',\n    listener: (event: PluginCrashedEvent) => void,\n  ): this;\n  /**\n   * Fired when the WebContents is destroyed.\n   */\n  addEventListener(\n    event: 'destroyed',\n    listener: (event: Event) => void,\n    useCapture?: boolean,\n  ): this;\n  removeEventListener(\n    event: 'destroyed',\n    listener: (event: Event) => void,\n  ): this;\n  /**\n   * Emitted when media starts playing.\n   */\n  addEventListener(\n    event: 'media-started-playing',\n    listener: (event: Event) => void,\n    useCapture?: boolean,\n  ): this;\n  removeEventListener(\n    event: 'media-started-playing',\n    listener: (event: Event) => void,\n  ): this;\n  /**\n   * Emitted when media is paused or done playing.\n   */\n  addEventListener(\n    event: 'media-paused',\n    listener: (event: Event) => void,\n    useCapture?: boolean,\n  ): this;\n  removeEventListener(\n    event: 'media-paused',\n    listener: (event: Event) => void,\n  ): this;\n  /**\n   * Emitted when a page's theme color changes. This is usually due to encountering a\n   * meta tag:\n   */\n  addEventListener(\n    event: 'did-change-theme-color',\n    listener: (event: DidChangeThemeColorEvent) => void,\n    useCapture?: boolean,\n  ): this;\n  removeEventListener(\n    event: 'did-change-theme-color',\n    listener: (event: DidChangeThemeColorEvent) => void,\n  ): this;\n  /**\n   * Emitted when mouse moves over a link or the keyboard moves the focus to a link.\n   */\n  addEventListener(\n    event: 'update-target-url',\n    listener: (event: UpdateTargetUrlEvent) => void,\n    useCapture?: boolean,\n  ): this;\n  removeEventListener(\n    event: 'update-target-url',\n    listener: (event: UpdateTargetUrlEvent) => void,\n  ): this;\n  /**\n   * Emitted when DevTools is opened.\n   */\n  addEventListener(\n    event: 'devtools-opened',\n    listener: (event: Event) => void,\n    useCapture?: boolean,\n  ): this;\n  removeEventListener(\n    event: 'devtools-opened',\n    listener: (event: Event) => void,\n  ): this;\n  /**\n   * Emitted when DevTools is closed.\n   */\n  addEventListener(\n    event: 'devtools-closed',\n    listener: (event: Event) => void,\n    useCapture?: boolean,\n  ): this;\n  removeEventListener(\n    event: 'devtools-closed',\n    listener: (event: Event) => void,\n  ): this;\n  /**\n   * Emitted when DevTools is focused / opened.\n   */\n  addEventListener(\n    event: 'devtools-focused',\n    listener: (event: Event) => void,\n    useCapture?: boolean,\n  ): this;\n  removeEventListener(\n    event: 'devtools-focused',\n    listener: (event: Event) => void,\n  ): this;\n  /**\n   * Emitted when there is a new context menu that needs to be handled.\n   */\n  addEventListener(\n    event: 'context-menu',\n    listener: (event: ContextMenuEvent) => void,\n    useCapture?: boolean,\n  ): this;\n  removeEventListener(\n    event: 'context-menu',\n    listener: (event: ContextMenuEvent) => void,\n  ): this;\n  addEventListener<K extends keyof HTMLElementEventMap>(\n    type: K,\n    listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any,\n    useCapture?: boolean,\n  ): void;\n  addEventListener(\n    type: string,\n    listener: EventListenerOrEventListenerObject,\n    useCapture?: boolean,\n  ): void;\n  removeEventListener<K extends keyof HTMLElementEventMap>(\n    type: K,\n    listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any,\n    useCapture?: boolean,\n  ): void;\n  removeEventListener(\n    type: string,\n    listener: EventListenerOrEventListenerObject,\n    useCapture?: boolean,\n  ): void;\n  /**\n   * Whether the guest page can go back.\n   */\n  canGoBack(): boolean;\n  /**\n   * Whether the guest page can go forward.\n   */\n  canGoForward(): boolean;\n  /**\n   * Whether the guest page can go to `offset`.\n   */\n  canGoToOffset(offset: number): boolean;\n  /**\n   * Resolves with a NativeImage\n   *\n   * Captures a snapshot of the page within `rect`. Omitting `rect` will capture the\n   * whole visible page.\n   */\n  //   capturePage(rect?: Rectangle): Promise<Electron.NativeImage>;\n  /**\n   * Clears the navigation history.\n   */\n  clearHistory(): void;\n  /**\n   * Closes the DevTools window of guest page.\n   */\n  closeDevTools(): void;\n  /**\n   * Executes editing command `copy` in page.\n   */\n  copy(): void;\n  /**\n   * Executes editing command `cut` in page.\n   */\n  cut(): void;\n  /**\n   * Executes editing command `delete` in page.\n   */\n  delete(): void;\n  /**\n   * Initiates a download of the resource at `url` without navigating.\n   */\n  downloadURL(url: string): void;\n  /**\n   * A promise that resolves with the result of the executed code or is rejected if\n   * the result of the code is a rejected promise.\n   *\n   * Evaluates `code` in page. If `userGesture` is set, it will create the user\n   * gesture context in the page. HTML APIs like `requestFullScreen`, which require\n   * user action, can take advantage of this option for automation.\n   */\n  executeJavaScript(code: string, userGesture?: boolean): Promise<any>;\n  /**\n   * The request id used for the request.\n   *\n   * Starts a request to find all matches for the `text` in the web page. The result\n   * of the request can be obtained by subscribing to `found-in-page` event.\n   */\n  //   findInPage(text: string, options?: FindInPageOptions): number;\n  /**\n   * The title of guest page.\n   */\n  getTitle(): string;\n  /**\n   * The URL of guest page.\n   */\n  getURL(): string;\n  /**\n   * The user agent for guest page.\n   */\n  getUserAgent(): string;\n  /**\n   * The WebContents ID of this `webview`.\n   */\n  getWebContentsId(): number;\n  /**\n   * the current zoom factor.\n   */\n  getZoomFactor(): number;\n  /**\n   * the current zoom level.\n   */\n  getZoomLevel(): number;\n  /**\n   * Makes the guest page go back.\n   */\n  goBack(): void;\n  /**\n   * Makes the guest page go forward.\n   */\n  goForward(): void;\n  /**\n   * Navigates to the specified absolute index.\n   */\n  goToIndex(index: number): void;\n  /**\n   * Navigates to the specified offset from the \"current entry\".\n   */\n  goToOffset(offset: number): void;\n  /**\n   * A promise that resolves with a key for the inserted CSS that can later be used\n   * to remove the CSS via `<webview>.removeInsertedCSS(key)`.\n   *\n   * Injects CSS into the current web page and returns a unique key for the inserted\n   * stylesheet.\n   */\n  insertCSS(css: string): Promise<string>;\n  /**\n   * Inserts `text` to the focused element.\n   */\n  insertText(text: string): Promise<void>;\n  /**\n   * Starts inspecting element at position (`x`, `y`) of guest page.\n   */\n  inspectElement(x: number, y: number): void;\n  /**\n   * Opens the DevTools for the service worker context present in the guest page.\n   */\n  inspectServiceWorker(): void;\n  /**\n   * Opens the DevTools for the shared worker context present in the guest page.\n   */\n  inspectSharedWorker(): void;\n  /**\n   * Whether guest page has been muted.\n   */\n  isAudioMuted(): boolean;\n  /**\n   * Whether the renderer process has crashed.\n   */\n  isCrashed(): boolean;\n  /**\n   * Whether audio is currently playing.\n   */\n  isCurrentlyAudible(): boolean;\n  /**\n   * Whether DevTools window of guest page is focused.\n   */\n  isDevToolsFocused(): boolean;\n  /**\n   * Whether guest page has a DevTools window attached.\n   */\n  isDevToolsOpened(): boolean;\n  /**\n   * Whether guest page is still loading resources.\n   */\n  isLoading(): boolean;\n  /**\n   * Whether the main frame (and not just iframes or frames within it) is still\n   * loading.\n   */\n  isLoadingMainFrame(): boolean;\n  /**\n   * Whether the guest page is waiting for a first-response for the main resource of\n   * the page.\n   */\n  isWaitingForResponse(): boolean;\n  /**\n   * The promise will resolve when the page has finished loading (see\n   * `did-finish-load`), and rejects if the page fails to load (see `did-fail-load`).\n   *\n   * Loads the `url` in the webview, the `url` must contain the protocol prefix, e.g.\n   * the `http://` or `file://`.\n   */\n  loadURL(url: string, options?: LoadURLOptions): Promise<void>;\n  /**\n   * Opens a DevTools window for guest page.\n   */\n  openDevTools(): void;\n  /**\n   * Executes editing command `paste` in page.\n   */\n  paste(): void;\n  /**\n   * Executes editing command `pasteAndMatchStyle` in page.\n   */\n  pasteAndMatchStyle(): void;\n  /**\n   * Prints `webview`'s web page. Same as `webContents.print([options])`.\n   */\n  print(options?: WebviewTagPrintOptions): Promise<void>;\n  /**\n   * Resolves with the generated PDF data.\n   *\n   * Prints `webview`'s web page as PDF, Same as `webContents.printToPDF(options)`.\n   */\n  printToPDF(options: PrintToPDFOptions): Promise<Uint8Array>;\n  /**\n   * Executes editing command `redo` in page.\n   */\n  redo(): void;\n  /**\n   * Reloads the guest page.\n   */\n  reload(): void;\n  /**\n   * Reloads the guest page and ignores cache.\n   */\n  reloadIgnoringCache(): void;\n  /**\n   * Resolves if the removal was successful.\n   *\n   * Removes the inserted CSS from the current web page. The stylesheet is identified\n   * by its key, which is returned from `<webview>.insertCSS(css)`.\n   */\n  removeInsertedCSS(key: string): Promise<void>;\n  /**\n   * Executes editing command `replace` in page.\n   */\n  replace(text: string): void;\n  /**\n   * Executes editing command `replaceMisspelling` in page.\n   */\n  replaceMisspelling(text: string): void;\n  /**\n   * Executes editing command `selectAll` in page.\n   */\n  selectAll(): void;\n  /**\n   * Send an asynchronous message to renderer process via `channel`, you can also\n   * send arbitrary arguments. The renderer process can handle the message by\n   * listening to the `channel` event with the `ipcRenderer` module.\n   *\n   * See webContents.send for examples.\n   */\n  send(channel: string, ...args: any[]): Promise<void>;\n  /**\n   * Sends an input `event` to the page.\n   *\n   * See webContents.sendInputEvent for detailed description of `event` object.\n   */\n  sendInputEvent(\n    event: MouseInputEvent | MouseWheelInputEvent | KeyboardInputEvent,\n  ): Promise<void>;\n  /**\n   * Send an asynchronous message to renderer process via `channel`, you can also\n   * send arbitrary arguments. The renderer process can handle the message by\n   * listening to the `channel` event with the `ipcRenderer` module.\n   *\n   * See webContents.sendToFrame for examples.\n   */\n  sendToFrame(\n    frameId: [number, number],\n    channel: string,\n    ...args: any[]\n  ): Promise<void>;\n  /**\n   * Set guest page muted.\n   */\n  setAudioMuted(muted: boolean): void;\n  /**\n   * Overrides the user agent for the guest page.\n   */\n  setUserAgent(userAgent: string): void;\n  /**\n   * Sets the maximum and minimum pinch-to-zoom level.\n   */\n  setVisualZoomLevelLimits(\n    minimumLevel: number,\n    maximumLevel: number,\n  ): Promise<void>;\n  /**\n   * Changes the zoom factor to the specified factor. Zoom factor is zoom percent\n   * divided by 100, so 300% = 3.0.\n   */\n  setZoomFactor(factor: number): void;\n  /**\n   * Changes the zoom level to the specified level. The original size is 0 and each\n   * increment above or below represents zooming 20% larger or smaller to default\n   * limits of 300% and 50% of original size, respectively. The formula for this is\n   * `scale := 1.2 ^ level`.\n   *\n   * > **NOTE**: The zoom policy at the Chromium level is same-origin, meaning that\n   * the zoom level for a specific domain propagates across all instances of windows\n   * with the same domain. Differentiating the window URLs will make zoom work\n   * per-window.\n   */\n  setZoomLevel(level: number): void;\n  /**\n   * Shows pop-up dictionary that searches the selected word on the page.\n   *\n   * @platform darwin\n   */\n  showDefinitionForSelection(): void;\n  /**\n   * Stops any pending navigation.\n   */\n  stop(): void;\n  /**\n   * Stops any `findInPage` request for the `webview` with the provided `action`.\n   */\n  stopFindInPage(\n    action: 'clearSelection' | 'keepSelection' | 'activateSelection',\n  ): void;\n  /**\n   * Executes editing command `undo` in page.\n   */\n  undo(): void;\n  /**\n   * Executes editing command `unselect` in page.\n   */\n  unselect(): void;\n  /**\n   * A `Boolean`. When this attribute is present the guest page will be allowed to\n   * open new windows. Popups are disabled by default.\n   */\n  allowpopups: boolean | string;\n  /**\n   * A `String` which is a list of strings which specifies the blink features to be\n   * disabled separated by `,`. The full list of supported feature strings can be\n   * found in the RuntimeEnabledFeatures.json5 file.\n   */\n  disableblinkfeatures: string;\n  /**\n   * A `Boolean`. When this attribute is present the guest page will have web\n   * security disabled. Web security is enabled by default.\n   */\n  disablewebsecurity: boolean;\n  /**\n   * A `String` which is a list of strings which specifies the blink features to be\n   * enabled separated by `,`. The full list of supported feature strings can be\n   * found in the RuntimeEnabledFeatures.json5 file.\n   */\n  enableblinkfeatures: string;\n  /**\n   * A `String` that sets the referrer URL for the guest page.\n   */\n  httpreferrer: string;\n  /**\n   * A `Boolean`. When this attribute is present the guest page in `webview` will\n   * have node integration and can use node APIs like `require` and `process` to\n   * access low level system resources. Node integration is disabled by default in\n   * the guest page.\n   */\n  nodeintegration: boolean;\n  /**\n   * A `Boolean` for the experimental option for enabling NodeJS support in\n   * sub-frames such as iframes inside the `webview`. All your preloads will load for\n   * every iframe, you can use `process.isMainFrame` to determine if you are in the\n   * main frame or not. This option is disabled by default in the guest page.\n   */\n  nodeintegrationinsubframes: boolean;\n  /**\n   * A `String` that sets the session used by the page. If `partition` starts with\n   * `persist:`, the page will use a persistent session available to all pages in the\n   * app with the same `partition`. if there is no `persist:` prefix, the page will\n   * use an in-memory session. By assigning the same `partition`, multiple pages can\n   * share the same session. If the `partition` is unset then default session of the\n   * app will be used.\n   *\n   * This value can only be modified before the first navigation, since the session\n   * of an active renderer process cannot change. Subsequent attempts to modify the\n   * value will fail with a DOM exception.\n   */\n  partition: string;\n  /**\n   * A `Boolean`. When this attribute is present the guest page in `webview` will be\n   * able to use browser plugins. Plugins are disabled by default.\n   */\n  plugins: boolean;\n  /**\n   * A `String` that specifies a script that will be loaded before other scripts run\n   * in the guest page. The protocol of script's URL must be `file:` (even when using\n   * `asar:` archives) because it will be loaded by Node's `require` under the hood,\n   * which treats `asar:` archives as virtual directories.\n   *\n   * When the guest page doesn't have node integration this script will still have\n   * access to all Node APIs, but global objects injected by Node will be deleted\n   * after this script has finished executing.\n   *\n   * **Note:** This option will appear as `preloadURL` (not `preload`) in the\n   * `webPreferences` specified to the `will-attach-webview` event.\n   */\n  preload: string;\n  /**\n   * A `String` representing the visible URL. Writing to this attribute initiates\n   * top-level navigation.\n   *\n   * Assigning `src` its own value will reload the current page.\n   *\n   * The `src` attribute can also accept data URLs, such as `data:text/plain,Hello,\n   * world!`.\n   */\n  src: string;\n  /**\n   * A `String` that sets the user agent for the guest page before the page is\n   * navigated to. Once the page is loaded, use the `setUserAgent` method to change\n   * the user agent.\n   */\n  useragent: string;\n  /**\n   * A `String` which is a comma separated list of strings which specifies the web\n   * preferences to be set on the webview. The full list of supported preference\n   * strings can be found in BrowserWindow.\n   *\n   * The string follows the same format as the features string in `window.open`. A\n   * name by itself is given a `true` boolean value. A preference can be set to\n   * another value by including an `=`, followed by the value. Special values `yes`\n   * and `1` are interpreted as `true`, while `no` and `0` are interpreted as\n   * `false`.\n   */\n  webpreferences: string;\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/file-submissions-section/hooks/use-file-submissions.ts",
    "content": "/**\n * Hook for filtering and ordering file submissions.\n */\n\nimport { SubmissionType } from '@postybirb/types';\nimport { useEffect, useMemo, useState } from 'react';\nimport { useSubmissionsByType } from '../../../../stores/entity/submission-store';\nimport type { SubmissionRecord } from '../../../../stores/records';\nimport { useFileSubmissionsFilter } from '../../../../stores/ui/submissions-ui-store';\n\ninterface UseFileSubmissionsResult {\n  /** All file submissions (unfiltered) */\n  allSubmissions: SubmissionRecord[];\n  /** Filtered submissions based on search and filter */\n  filteredSubmissions: SubmissionRecord[];\n  /** Ordered submissions (for optimistic reordering) */\n  orderedSubmissions: SubmissionRecord[];\n  /** Update ordered submissions */\n  setOrderedSubmissions: React.Dispatch<React.SetStateAction<SubmissionRecord[]>>;\n  /** Current filter value */\n  filter: string;\n  /** Current search query */\n  searchQuery: string;\n  /** Whether drag is enabled (only when not filtering) */\n  isDragEnabled: boolean;\n}\n\n/**\n * Hook for filtering, searching, and ordering file submissions.\n */\nexport function useFileSubmissions(): UseFileSubmissionsResult {\n  const allSubmissions = useSubmissionsByType(SubmissionType.FILE);\n  const { filter, searchQuery } = useFileSubmissionsFilter();\n\n  // Filter submissions based on search query and filter\n  const filteredSubmissions = useMemo(() => {\n    let result = allSubmissions.filter(\n      (s) => !s.isTemplate && !s.isMultiSubmission && !s.isArchived\n    );\n\n    // Sort by order\n    result = result.sort((a, b) => a.order - b.order);\n\n    // Apply status filter\n    switch (filter) {\n      case 'queued':\n        result = result\n          .filter((s) => s.isQueued)\n          .sort((a, b) => {\n            // Sort by postQueueRecord.createdAt ascending (oldest first = top of queue)\n            const aCreatedAt = a.postQueueRecord?.createdAt ?? '';\n            const bCreatedAt = b.postQueueRecord?.createdAt ?? '';\n            return aCreatedAt.localeCompare(bCreatedAt);\n          });\n        break;\n      case 'scheduled':\n        result = result.filter((s) => s.isScheduled);\n        break;\n      case 'posted':\n        result = result.filter((s) => s.isArchived);\n        break;\n      case 'failed':\n        result = result.filter((s) => s.hasErrors);\n        break;\n      default:\n        // 'all' - no additional filtering\n        break;\n    }\n\n    // Apply search filter\n    if (searchQuery) {\n      const query = searchQuery.toLowerCase();\n      result = result.filter((s) => s.title.toLowerCase().includes(query));\n    }\n\n    return result;\n  }, [allSubmissions, filter, searchQuery]);\n\n  // Local ordered state for optimistic reordering\n  const [orderedSubmissions, setOrderedSubmissions] =\n    useState<SubmissionRecord[]>(filteredSubmissions);\n\n  // Sync ordered submissions with filtered submissions\n  useEffect(() => {\n    setOrderedSubmissions(filteredSubmissions);\n  }, [filteredSubmissions]);\n\n  // Only enable drag when showing 'all' filter (no filtering applied)\n  const isDragEnabled = filter === 'all' && !searchQuery;\n\n  return {\n    allSubmissions,\n    filteredSubmissions,\n    orderedSubmissions,\n    setOrderedSubmissions,\n    filter,\n    searchQuery,\n    isDragEnabled,\n  };\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/home-section/account-health-panel.tsx",
    "content": "/**\n * AccountHealthPanel - Panel showing account login status overview.\n * Displays logged in vs total accounts with warning indicators.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport {\n    Badge,\n    Box,\n    Group,\n    Paper,\n    Progress,\n    Stack,\n    Text,\n    ThemeIcon,\n    UnstyledButton,\n} from '@mantine/core';\nimport {\n    IconAlertCircle,\n    IconCircleCheck,\n    IconUsers,\n} from '@tabler/icons-react';\nimport { useAccounts } from '../../../stores/entity/account-store';\nimport { useViewStateActions } from '../../../stores/ui/navigation-store';\nimport { createAccountsViewState } from '../../../types/view-state';\nimport { EmptyState } from '../../empty-state';\n\n/**\n * AccountHealthPanel component.\n * Shows a summary of account login states.\n */\nexport function AccountHealthPanel() {\n  const accounts = useAccounts();\n  const { setViewState } = useViewStateActions();\n\n  const handleNavigateToAccounts = () => {\n    setViewState(createAccountsViewState());\n  };\n\n  const totalAccounts = accounts.length;\n  const loggedInAccounts = accounts.filter((a) => a.isLoggedIn).length;\n  const loggedOutAccounts = totalAccounts - loggedInAccounts;\n  const healthPercentage =\n    totalAccounts > 0 ? (loggedInAccounts / totalAccounts) * 100 : 0;\n\n  const allHealthy = loggedOutAccounts === 0 && totalAccounts > 0;\n  const hasIssues = loggedOutAccounts > 0;\n\n  return (\n    <Paper withBorder p=\"md\" radius=\"md\" h=\"100%\" data-tour-id=\"home-account-health\">\n      <Stack gap=\"sm\" h=\"100%\">\n        <Group justify=\"space-between\">\n          <Group gap=\"xs\">\n            <ThemeIcon\n              size=\"md\"\n              variant=\"light\"\n              color={allHealthy ? 'green' : hasIssues ? 'orange' : 'gray'}\n              radius=\"md\"\n            >\n              <IconUsers size={16} />\n            </ThemeIcon>\n            <Text size=\"sm\" fw={500}>\n              <Trans>Account Status</Trans>\n            </Text>\n          </Group>\n          <UnstyledButton onClick={handleNavigateToAccounts}>\n            <Text size=\"xs\" c=\"dimmed\" style={{ textDecoration: 'underline' }}>\n              <Trans>Manage accounts</Trans>\n            </Text>\n          </UnstyledButton>\n        </Group>\n\n        {totalAccounts === 0 ? (\n          <Box style={{ alignItems: 'center' }}>\n            <EmptyState\n              preset=\"no-records\"\n              description={<Trans>Add an account to start posting</Trans>}\n              size=\"sm\"\n            />\n          </Box>\n        ) : (\n          <Stack gap=\"xs\">\n            <Group justify=\"space-between\" align=\"center\">\n              <Text size=\"sm\">\n                <Text component=\"span\" fw={700} size=\"lg\">\n                  {loggedInAccounts}\n                </Text>\n                <Text component=\"span\" c=\"dimmed\">\n                  {' '}\n                  / {totalAccounts} <Trans>logged in</Trans>\n                </Text>\n              </Text>\n              {allHealthy ? (\n                <Badge\n                  color=\"green\"\n                  variant=\"light\"\n                  size=\"sm\"\n                  leftSection={<IconCircleCheck size={12} />}\n                >\n                  <Trans>All healthy</Trans>\n                </Badge>\n              ) : hasIssues ? (\n                <Badge\n                  color=\"orange\"\n                  variant=\"light\"\n                  size=\"sm\"\n                  leftSection={<IconAlertCircle size={12} />}\n                >\n                  {loggedOutAccounts} <Trans>need attention</Trans>\n                </Badge>\n              ) : null}\n            </Group>\n\n            <Progress\n              value={healthPercentage}\n              color={allHealthy ? 'green' : hasIssues ? 'orange' : 'gray'}\n              size=\"sm\"\n              radius=\"xl\"\n            />\n\n            {hasIssues && (\n              <Text size=\"xs\" c=\"dimmed\">\n                <Trans>\n                  Some accounts need to be logged in before you can post to\n                  them.\n                </Trans>\n              </Text>\n            )}\n          </Stack>\n        )}\n      </Stack>\n    </Paper>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/home-section/home-content.tsx",
    "content": "/**\n * HomeContent - Main dashboard content for the home view.\n * Displays stats, queue control, and status panels.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport {\n    ActionIcon,\n    Button,\n    Center,\n    Container,\n    Group,\n    ScrollArea,\n    SimpleGrid,\n    Stack,\n    Text,\n    ThemeIcon,\n    Title,\n    Tooltip,\n} from '@mantine/core';\nimport { SubmissionType } from '@postybirb/types';\nimport {\n    IconCalendar,\n    IconFile,\n    IconHelp,\n    IconHome,\n    IconMessage,\n    IconStack2,\n} from '@tabler/icons-react';\nimport { useAccounts } from '../../../stores/entity/account-store';\nimport {\n    useQueuedSubmissions,\n    useRegularSubmissions,\n    useScheduledSubmissions,\n    useSubmissionsByType,\n} from '../../../stores/entity/submission-store';\nimport { useDrawerActions } from '../../../stores/ui/drawer-store';\nimport { useViewStateActions } from '../../../stores/ui/navigation-store';\nimport { useTourActions } from '../../../stores/ui/tour-store';\nimport '../../../styles/layout.css';\nimport {\n    createFileSubmissionsViewState,\n    createMessageSubmissionsViewState,\n} from '../../../types/view-state';\nimport { HOME_TOUR_ID } from '../../onboarding-tour/tours/home-tour';\nimport { LAYOUT_TOUR_ID } from '../../onboarding-tour/tours/layout-tour';\nimport { AccountHealthPanel } from './account-health-panel';\nimport { QueueControlCard } from './queue-control-card';\nimport { RecentActivityPanel } from './recent-activity-panel';\nimport { ScheduleCalendarPanel } from './schedule-calendar-panel';\nimport { StatCard } from './stat-card';\nimport { UpcomingPostsPanel } from './upcoming-posts-panel';\nimport { ValidationIssuesPanel } from './validation-issues-panel';\n\n/**\n * Empty state for new users with onboarding tips.\n */\nfunction WelcomeEmptyState() {\n  const { startTour } = useTourActions();\n\n  return (\n    <Center h=\"100%\">\n      <Stack align=\"center\" gap=\"lg\" maw={500}>\n        <ThemeIcon size={80} variant=\"light\" color=\"blue\" radius=\"xl\">\n          <IconHome size={48} stroke={1.5} />\n        </ThemeIcon>\n        <Title order={2} ta=\"center\">\n          <Trans>Welcome to PostyBirb!</Trans>\n        </Title>\n        <Text size=\"md\" c=\"dimmed\" ta=\"center\">\n          <Trans>\n            Get started by adding your accounts and creating your first\n            submission.\n          </Trans>\n        </Text>\n        <Button\n          variant=\"gradient\"\n          size=\"md\"\n          radius=\"xl\"\n          onClick={() => startTour(LAYOUT_TOUR_ID)}\n        >\n          <Trans>Take the Tour</Trans>\n        </Button>\n        <Stack gap=\"xs\" align=\"center\">\n          <Text size=\"sm\" fw={500}>\n            <Trans>Quick tips to get started:</Trans>\n          </Text>\n          <Stack gap={4}>\n            <Text size=\"sm\" c=\"dimmed\">\n              • <Trans>Go to Accounts to add and log into your websites</Trans>\n            </Text>\n            <Text size=\"sm\" c=\"dimmed\">\n              •{' '}\n              <Trans>\n                Create a File or Message submission to start posting\n              </Trans>\n            </Text>\n            <Text size=\"sm\" c=\"dimmed\">\n              • <Trans>Use templates to save time on repeated posts</Trans>\n            </Text>\n            <Text size=\"sm\" c=\"dimmed\">\n              •{' '}\n              <Trans>\n                Schedule posts to automatically publish at specific times\n              </Trans>\n            </Text>\n          </Stack>\n        </Stack>\n      </Stack>\n    </Center>\n  );\n}\n\n/**\n * HomeContent component.\n * Shows dashboard with stats and panels for returning users,\n * or welcome screen for new users.\n */\nexport function HomeContent() {\n  const { setViewState } = useViewStateActions();\n  const { openDrawer } = useDrawerActions();\n  const regularSubmissions = useRegularSubmissions();\n  const accounts = useAccounts();\n  const fileSubmissions = useSubmissionsByType(SubmissionType.FILE);\n  const messageSubmissions = useSubmissionsByType(SubmissionType.MESSAGE);\n  const queuedSubmissions = useQueuedSubmissions();\n  const scheduledSubmissions = useScheduledSubmissions();\n\n  const { startTour } = useTourActions();\n\n  // New user detection: no submissions and no accounts\n  const isNewUser = regularSubmissions.length === 0 && accounts.length === 0;\n\n  if (isNewUser) {\n    return <WelcomeEmptyState />;\n  }\n\n  // Filter to only non-template submissions for stats\n  const fileCount = fileSubmissions\n    .filter((s) => !s.isTemplate)\n    .filter((s) => !s.isMultiSubmission).length;\n  const messageCount = messageSubmissions\n    .filter((s) => !s.isTemplate)\n    .filter((s) => !s.isMultiSubmission).length;\n\n  return (\n    <ScrollArea h=\"100%\" type=\"hover\" scrollbarSize={6}>\n      <Container size=\"xl\" py=\"md\">\n        <Stack gap=\"md\">\n          {/* Header */}\n          <Group justify=\"space-between\" align=\"center\">\n            <Title order={3}>\n              <Trans>Dashboard</Trans>\n            </Title>\n            <Tooltip label={<Trans>Dashboard Tour</Trans>}>\n              <ActionIcon\n                variant=\"subtle\"\n                size=\"sm\"\n                onClick={() => startTour(HOME_TOUR_ID)}\n              >\n                <IconHelp size={18} />\n              </ActionIcon>\n            </Tooltip>\n          </Group>\n\n          <QueueControlCard />\n          {/* Stats Row */}\n          <SimpleGrid cols={{ base: 4 }} spacing=\"md\" data-tour-id=\"home-stat-cards\">\n            <StatCard\n              icon={<IconFile size={20} />}\n              count={fileCount}\n              label={<Trans>File Submissions</Trans>}\n              color=\"blue\"\n              onClick={() => setViewState(createFileSubmissionsViewState())}\n            />\n            <StatCard\n              icon={<IconMessage size={20} />}\n              count={messageCount}\n              label={<Trans>Message Submissions</Trans>}\n              color=\"teal\"\n              onClick={() => setViewState(createMessageSubmissionsViewState())}\n            />\n            <StatCard\n              icon={<IconStack2 size={20} />}\n              count={queuedSubmissions.length}\n              label={<Trans>Queued</Trans>}\n              color=\"grape\"\n              onClick={() => openDrawer('schedule')}\n            />\n            <StatCard\n              icon={<IconCalendar size={20} />}\n              count={scheduledSubmissions.length}\n              label={<Trans>Scheduled</Trans>}\n              color=\"violet\"\n              onClick={() => openDrawer('schedule')}\n            />\n          </SimpleGrid>\n\n          {/* Panels Row */}\n          <SimpleGrid cols={{ base: 1, md: 2 }} spacing=\"md\">\n            <ScheduleCalendarPanel />\n            <RecentActivityPanel />\n          </SimpleGrid>\n\n          {/* Upcoming Posts & Validation Row */}\n          <SimpleGrid cols={{ base: 1, md: 3 }} spacing=\"md\">\n            <UpcomingPostsPanel />\n            <ValidationIssuesPanel />\n            <AccountHealthPanel />\n          </SimpleGrid>\n        </Stack>\n      </Container>\n    </ScrollArea>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/home-section/index.ts",
    "content": "/**\n * Home Section - Dashboard home view components.\n */\n\nexport { HomeContent } from './home-content';\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/home-section/queue-control-card.tsx",
    "content": "/**\n * QueueControlCard - Card for controlling the post queue (pause/resume).\n * Displays queue status and provides toggle functionality.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport { Button, Loader, Paper, Stack } from '@mantine/core';\nimport { IconPlayerPause, IconPlayerPlay } from '@tabler/icons-react';\nimport { useState } from 'react';\nimport postQueueApi from '../../../api/post-queue.api';\nimport { useQueuePaused } from '../../../stores/entity/settings-store';\n\n/**\n * QueueControlCard component for the home dashboard.\n * Shows queue status and allows pause/resume control.\n */\nexport function QueueControlCard() {\n  const queuePaused = useQueuePaused();\n  const [isLoading, setIsLoading] = useState(false);\n\n  const handleToggle = async () => {\n    setIsLoading(true);\n    try {\n      if (queuePaused) {\n        await postQueueApi.resume();\n      } else {\n        await postQueueApi.pause();\n      }\n    } finally {\n      setIsLoading(false);\n    }\n  };\n\n  return (\n    <Paper withBorder p=\"md\" radius=\"md\" h=\"100%\" data-tour-id=\"home-queue-control\">\n      <Stack gap=\"sm\" justify=\"center\" h=\"100%\">\n        <Button\n          variant=\"light\"\n          color={queuePaused ? 'green' : 'orange'}\n          size=\"xs\"\n          leftSection={\n            isLoading ? (\n              <Loader size={14} />\n            ) : queuePaused ? (\n              <IconPlayerPlay size={14} />\n            ) : (\n              <IconPlayerPause size={14} />\n            )\n          }\n          onClick={handleToggle}\n          disabled={isLoading}\n          fullWidth\n        >\n          {queuePaused ? (\n            <Trans>Resume Posting</Trans>\n          ) : (\n            <Trans>Pause Posting</Trans>\n          )}\n        </Button>\n      </Stack>\n    </Paper>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/home-section/recent-activity-panel.tsx",
    "content": "/**\n * RecentActivityPanel - Panel showing recent notifications/activity.\n * Displays the last 5 notifications with type-based icons.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport { Box, Group, Paper, Stack, Text, ThemeIcon } from '@mantine/core';\nimport {\n    IconActivity,\n    IconAlertTriangle,\n    IconCheck,\n    IconInfoCircle,\n    IconX,\n} from '@tabler/icons-react';\nimport { useMemo } from 'react';\nimport { useLocale } from '../../../hooks';\nimport { useNotifications } from '../../../stores/entity/notification-store';\nimport type { NotificationRecord } from '../../../stores/records';\nimport { EmptyState } from '../../empty-state';\n\n/**\n * Get icon and color for notification type.\n */\nfunction getNotificationIcon(type: NotificationRecord['type']): {\n  icon: React.ReactNode;\n  color: string;\n} {\n  switch (type) {\n    case 'success':\n      return { icon: <IconCheck size={14} />, color: 'green' };\n    case 'error':\n      return { icon: <IconX size={14} />, color: 'red' };\n    case 'warning':\n      return { icon: <IconAlertTriangle size={14} />, color: 'yellow' };\n    case 'info':\n    default:\n      return { icon: <IconInfoCircle size={14} />, color: 'blue' };\n  }\n}\n\n/**\n * RecentActivityPanel component.\n * Shows the last 5 notifications sorted by creation date.\n */\nexport function RecentActivityPanel() {\n  const notifications = useNotifications();\n  const { formatRelativeTime } = useLocale();\n\n  const recentActivity = useMemo(\n    () =>\n      [...notifications]\n        .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())\n        .slice(0, 5),\n    [notifications],\n  );\n\n  return (\n    <Paper withBorder p=\"md\" radius=\"md\" h=\"100%\" data-tour-id=\"home-recent-activity\">\n      <Stack gap=\"sm\" h=\"100%\">\n        <Group gap=\"xs\">\n          <ThemeIcon size=\"md\" variant=\"light\" color=\"cyan\" radius=\"md\">\n            <IconActivity size={16} />\n          </ThemeIcon>\n          <Text size=\"sm\" fw={500}>\n            <Trans>Recent Activity</Trans>\n          </Text>\n        </Group>\n\n        {recentActivity.length === 0 ? (\n          <Box style={{ alignItems: 'center' }}>\n            <EmptyState\n              preset=\"no-notifications\"\n              description={\n                <Trans>Activity will appear here as you use the app</Trans>\n              }\n              size=\"sm\"\n            />\n          </Box>\n        ) : (\n          <Stack gap=\"xs\">\n            {recentActivity.map((notification) => {\n              const { icon, color } = getNotificationIcon(notification.type);\n              return (\n                <Paper\n                  key={notification.id}\n                  withBorder\n                  p=\"xs\"\n                  radius=\"sm\"\n                  bg=\"var(--mantine-color-default)\"\n                >\n                  <Group\n                    justify=\"space-between\"\n                    wrap=\"nowrap\"\n                    align=\"flex-start\"\n                  >\n                    <Group\n                      gap=\"xs\"\n                      wrap=\"nowrap\"\n                      style={{ minWidth: 0, flex: 1 }}\n                    >\n                      <ThemeIcon\n                        size=\"sm\"\n                        variant=\"light\"\n                        color={color}\n                        radius=\"xl\"\n                      >\n                        {icon}\n                      </ThemeIcon>\n                      <Box style={{ minWidth: 0, flex: 1 }}>\n                        <Text size=\"sm\" truncate fw={500}>\n                          {notification.title}\n                        </Text>\n                        <Text size=\"xs\" c=\"dimmed\" truncate>\n                          {notification.message}\n                        </Text>\n                      </Box>\n                    </Group>\n                    <Text size=\"xs\" c=\"dimmed\" style={{ flexShrink: 0 }}>\n                      {formatRelativeTime(notification.createdAt)}\n                    </Text>\n                  </Group>\n                </Paper>\n              );\n            })}\n          </Stack>\n        )}\n      </Stack>\n    </Paper>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/home-section/schedule-calendar-panel.tsx",
    "content": "/**\n * ScheduleCalendarPanel - Mini calendar showing scheduled submissions.\n * Displays a month view with indicators for days with scheduled posts.\n */\n\nimport { Trans, useLingui } from '@lingui/react/macro';\nimport {\n    ActionIcon,\n    Box,\n    Group,\n    Paper,\n    SimpleGrid,\n    Stack,\n    Text,\n    ThemeIcon,\n    Tooltip,\n    UnstyledButton,\n} from '@mantine/core';\nimport {\n    IconCalendarEvent,\n    IconChevronLeft,\n    IconChevronRight,\n} from '@tabler/icons-react';\nimport { useCallback, useMemo, useState } from 'react';\nimport { useLocale } from '../../../hooks';\nimport { useScheduledSubmissions } from '../../../stores/entity/submission-store';\nimport { useDrawerActions } from '../../../stores/ui/drawer-store';\n\n/**\n * Get weekday abbreviations for calendar header.\n */\nfunction useWeekdays() {\n  const { t } = useLingui();\n  return useMemo(\n    () => [\n      { key: 'sun', label: t`Su` },\n      { key: 'mon', label: t`Mo` },\n      { key: 'tue', label: t`Tu` },\n      { key: 'wed', label: t`We` },\n      { key: 'thu', label: t`Th` },\n      { key: 'fri', label: t`Fr` },\n      { key: 'sat', label: t`Sa` },\n    ],\n    [t],\n  );\n}\n\n/**\n * Day cell data for the calendar grid.\n */\ntype DayCell =\n  | { type: 'empty'; position: number }\n  | { type: 'day'; date: Date };\n\n/**\n * Get all days in a month as a grid (includes padding days from prev/next month).\n */\nfunction getMonthDays(year: number, month: number): DayCell[] {\n  const firstDay = new Date(year, month, 1);\n  const lastDay = new Date(year, month + 1, 0);\n  const daysInMonth = lastDay.getDate();\n  const startDayOfWeek = firstDay.getDay();\n\n  const days: DayCell[] = [];\n\n  // Padding for days before the first of the month\n  for (let i = 0; i < startDayOfWeek; i++) {\n    days.push({ type: 'empty', position: i });\n  }\n\n  // Days of the month\n  for (let day = 1; day <= daysInMonth; day++) {\n    days.push({ type: 'day', date: new Date(year, month, day) });\n  }\n\n  return days;\n}\n\n/**\n * Check if two dates are the same day.\n */\nfunction isSameDay(date1: Date, date2: Date): boolean {\n  return (\n    date1.getFullYear() === date2.getFullYear() &&\n    date1.getMonth() === date2.getMonth() &&\n    date1.getDate() === date2.getDate()\n  );\n}\n\n/**\n * ScheduleCalendarPanel component.\n * Shows a mini month calendar with scheduled submission indicators.\n */\nexport function ScheduleCalendarPanel() {\n  const scheduledSubmissions = useScheduledSubmissions();\n  const { openDrawer } = useDrawerActions();\n  const { locale } = useLocale();\n  const [currentMonth, setCurrentMonth] = useState(() => new Date());\n  const weekdays = useWeekdays();\n\n  /**\n   * Format month name using the current locale.\n   */\n  const formatMonth = useCallback(\n    (date: Date): string =>\n      date.toLocaleDateString(locale, {\n        month: 'long',\n        year: 'numeric',\n      }),\n    [locale],\n  );\n\n  // Get scheduled dates for the current month\n  const scheduledDates = useMemo(() => {\n    const dates = new Map<string, number>();\n    scheduledSubmissions.forEach((s) => {\n      if (s.schedule.scheduledFor) {\n        const date = new Date(s.schedule.scheduledFor);\n        const key = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;\n        dates.set(key, (dates.get(key) ?? 0) + 1);\n      }\n    });\n    return dates;\n  }, [scheduledSubmissions]);\n\n  const days = useMemo(\n    () => getMonthDays(currentMonth.getFullYear(), currentMonth.getMonth()),\n    [currentMonth],\n  );\n\n  const today = new Date();\n\n  const handlePrevMonth = () => {\n    setCurrentMonth(\n      (prev) => new Date(prev.getFullYear(), prev.getMonth() - 1, 1),\n    );\n  };\n\n  const handleNextMonth = () => {\n    setCurrentMonth(\n      (prev) => new Date(prev.getFullYear(), prev.getMonth() + 1, 1),\n    );\n  };\n\n  const handleOpenScheduleDrawer = () => {\n    openDrawer('schedule');\n  };\n\n  return (\n    <Paper withBorder p=\"md\" radius=\"md\" h=\"100%\" data-tour-id=\"home-schedule-calendar\">\n      <Stack gap=\"sm\" h=\"100%\">\n        {/* Header */}\n        <Group justify=\"space-between\">\n          <Group gap=\"xs\">\n            <ThemeIcon size=\"md\" variant=\"light\" color=\"violet\" radius=\"md\">\n              <IconCalendarEvent size={16} />\n            </ThemeIcon>\n            <Text size=\"sm\" fw={500}>\n              <Trans>Schedule</Trans>\n            </Text>\n          </Group>\n          <UnstyledButton onClick={handleOpenScheduleDrawer}>\n            <Text size=\"xs\" c=\"dimmed\" style={{ textDecoration: 'underline' }}>\n              <Trans>Open full calendar</Trans>\n            </Text>\n          </UnstyledButton>\n        </Group>\n\n        {/* Month Navigation */}\n        <Group justify=\"space-between\" align=\"center\">\n          <ActionIcon variant=\"subtle\" size=\"sm\" onClick={handlePrevMonth}>\n            <IconChevronLeft size={16} />\n          </ActionIcon>\n          <Text size=\"sm\" fw={500}>\n            {formatMonth(currentMonth)}\n          </Text>\n          <ActionIcon variant=\"subtle\" size=\"sm\" onClick={handleNextMonth}>\n            <IconChevronRight size={16} />\n          </ActionIcon>\n        </Group>\n\n        {/* Weekday Headers */}\n        <SimpleGrid cols={7} spacing={2}>\n          {weekdays.map((day) => (\n            <Text key={day.key} size=\"xs\" c=\"dimmed\" ta=\"center\" fw={500}>\n              {day.label}\n            </Text>\n          ))}\n        </SimpleGrid>\n\n        {/* Calendar Grid */}\n        <SimpleGrid cols={7} spacing={2}>\n          {days.map((cell) => {\n            if (cell.type === 'empty') {\n              return (\n                <Box\n                  key={`empty-${currentMonth.getFullYear()}-${currentMonth.getMonth()}-${cell.position}`}\n                  h={32}\n                />\n              );\n            }\n\n            const { date } = cell;\n            const key = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;\n            const count = scheduledDates.get(key) ?? 0;\n            const isToday = isSameDay(date, today);\n\n            const dayContent = (\n              <Tooltip\n                label={count > 0 ? count : undefined}\n                withArrow\n                position=\"top\"\n                disabled={count === 0}\n              >\n                <Box\n                  h={32}\n                  style={{\n                    display: 'flex',\n                    flexDirection: 'column',\n                    alignItems: 'center',\n                    justifyContent: 'center',\n                    borderRadius: 'var(--mantine-radius-sm)',\n                    backgroundColor: isToday\n                      ? 'var(--mantine-color-violet-light)'\n                      : undefined,\n                    gap: 2,\n                  }}\n                >\n                  <Text\n                    size=\"xs\"\n                    fw={isToday ? 700 : 400}\n                    c={isToday ? 'violet' : undefined}\n                  >\n                    {date.getDate()}\n                  </Text>\n                  {count > 0 && (\n                    <Box\n                      w={6}\n                      h={6}\n                      style={{\n                        borderRadius: '50%',\n                        backgroundColor: 'var(--mantine-color-violet-6)',\n                      }}\n                    />\n                  )}\n                </Box>\n              </Tooltip>\n            );\n\n            return <Box key={key}>{dayContent}</Box>;\n          })}\n        </SimpleGrid>\n\n        {/* Legend */}\n        <Group gap=\"xs\" justify=\"center\">\n          <Group gap={4}>\n            <Box\n              w={8}\n              h={8}\n              style={{\n                borderRadius: '50%',\n                backgroundColor: 'var(--mantine-color-violet-6)',\n              }}\n            />\n            <Text size=\"xs\" c=\"dimmed\">\n              <Trans>Scheduled</Trans>\n            </Text>\n          </Group>\n        </Group>\n      </Stack>\n    </Paper>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/home-section/stat-card.tsx",
    "content": "/**\n * StatCard - Reusable stat card for dashboard metrics.\n * Shows an icon, count, and label with optional click navigation.\n */\n\nimport type { MantineColor } from '@mantine/core';\nimport {\n  Group,\n  Paper,\n  Stack,\n  Text,\n  ThemeIcon,\n  UnstyledButton,\n} from '@mantine/core';\nimport type { ReactNode } from 'react';\nimport { cn } from '../../../utils/class-names';\n\ninterface StatCardProps {\n  /** Icon to display */\n  icon: ReactNode;\n  /** Numeric count to display */\n  count: number;\n  /** Label describing the stat */\n  label: ReactNode;\n  /** Theme color for the icon */\n  color?: MantineColor;\n  /** Click handler - if provided, card becomes clickable with hover effect */\n  onClick?: () => void;\n}\n\n/**\n * StatCard component for displaying dashboard metrics.\n * Becomes clickable with hover effect when onClick is provided.\n */\nexport function StatCard({\n  icon,\n  count,\n  label,\n  color = 'blue',\n  onClick,\n}: StatCardProps) {\n  const content = (\n    <Paper\n      h=\"100%\"\n      withBorder\n      p=\"md\"\n      radius=\"md\"\n      className={cn(['postybirb__stat-card'], {\n        'postybirb__stat-card--clickable': !!onClick,\n      })}\n    >\n      <Group justify=\"space-between\" align=\"flex-start\" wrap=\"nowrap\">\n        <Stack gap={4}>\n          <Text size=\"xl\" fw={700} lh={1}>\n            {count}\n          </Text>\n          <Text size=\"sm\" c=\"dimmed\" lh={1.2}>\n            {label}\n          </Text>\n        </Stack>\n        <ThemeIcon size=\"lg\" variant=\"light\" color={color} radius=\"md\">\n          {icon}\n        </ThemeIcon>\n      </Group>\n    </Paper>\n  );\n\n  if (onClick) {\n    return (\n      <UnstyledButton\n        onClick={onClick}\n        style={{ display: 'block', width: '100%' }}\n        className=\"postybirb__stat-card-button\"\n      >\n        {content}\n      </UnstyledButton>\n    );\n  }\n\n  return content;\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/home-section/upcoming-posts-panel.tsx",
    "content": "/**\n * UpcomingPostsPanel - Panel showing the next scheduled submissions.\n * Displays up to 5 upcoming posts with relative times.\n */\n\nimport { t } from '@lingui/core/macro';\nimport { useLingui } from '@lingui/react';\nimport { Trans } from '@lingui/react/macro';\nimport { Box, Group, Paper, Stack, Text, ThemeIcon } from '@mantine/core';\nimport { SubmissionType } from '@postybirb/types';\nimport { IconCalendarEvent, IconClock } from '@tabler/icons-react';\nimport { useCallback, useMemo } from 'react';\nimport { useLocale } from '../../../hooks';\nimport { useScheduledSubmissions } from '../../../stores/entity/submission-store';\nimport { useViewStateActions } from '../../../stores/ui/navigation-store';\nimport { EmptyState } from '../../empty-state';\n\n/**\n * UpcomingPostsPanel component.\n * Shows the next 5 scheduled submissions sorted by scheduled date.\n */\nexport function UpcomingPostsPanel() {\n  const { _ } = useLingui();\n  const scheduledSubmissions = useScheduledSubmissions();\n  const { formatRelativeTime, formatDateTime } = useLocale();\n  const { setViewState } = useViewStateActions();\n\n  const handleNavigateToSubmission = useCallback(\n    (id: string, type: SubmissionType) => {\n      if (type === SubmissionType.FILE) {\n        setViewState({\n          type: 'file-submissions',\n          params: {\n            selectedIds: [id],\n            mode: 'single',\n            submissionType: SubmissionType.FILE,\n          },\n        });\n      } else {\n        setViewState({\n          type: 'message-submissions',\n          params: {\n            selectedIds: [id],\n            mode: 'single',\n            submissionType: SubmissionType.MESSAGE,\n          },\n        });\n      }\n    },\n    [setViewState],\n  );\n\n  const upcomingPosts = useMemo(() => {\n    const now = new Date();\n    return [...scheduledSubmissions]\n      .filter((s) => {\n        if (!s.schedule.scheduledFor) return false;\n        // Only include future posts (not past due)\n        const scheduledDate = new Date(s.schedule.scheduledFor);\n        return scheduledDate > now;\n      })\n      .sort((a, b) => {\n        // Safe to cast - we filtered out items without scheduledFor above\n        const dateA = new Date(a.schedule.scheduledFor as string);\n        const dateB = new Date(b.schedule.scheduledFor as string);\n        return dateA.getTime() - dateB.getTime();\n      })\n      .slice(0, 5);\n  }, [scheduledSubmissions]);\n\n  return (\n    <Paper withBorder p=\"md\" radius=\"md\" h=\"100%\" data-tour-id=\"home-upcoming-posts\">\n      <Stack gap=\"sm\" h=\"100%\">\n        <Group gap=\"xs\">\n          <ThemeIcon size=\"md\" variant=\"light\" color=\"violet\" radius=\"md\">\n            <IconCalendarEvent size={16} />\n          </ThemeIcon>\n          <Text size=\"sm\" fw={500}>\n            <Trans>Upcoming Posts</Trans>\n          </Text>\n        </Group>\n\n        {upcomingPosts.length === 0 ? (\n          <Box style={{ alignItems: 'center' }}>\n            <EmptyState\n              preset=\"no-records\"\n              description={<Trans>Schedule a submission to see it here</Trans>}\n              size=\"sm\"\n            />\n          </Box>\n        ) : (\n          <Stack gap=\"xs\">\n            {upcomingPosts.map((submission) => (\n              <Paper\n                key={submission.id}\n                withBorder\n                p=\"xs\"\n                radius=\"sm\"\n                bg=\"var(--mantine-color-default)\"\n                style={{ cursor: 'pointer' }}\n                onClick={() =>\n                  handleNavigateToSubmission(submission.id, submission.type)\n                }\n              >\n                <Group justify=\"space-between\" wrap=\"nowrap\">\n                  <Box style={{ minWidth: 0, flex: 1 }}>\n                    <Text size=\"sm\" truncate fw={500}>\n                      {submission.title || t`Untitled`}\n                    </Text>\n                    <Group gap={4}>\n                      <IconClock size={12} style={{ opacity: 0.6 }} />\n                      <Text size=\"xs\" c=\"dimmed\">\n                        {formatDateTime(\n                          submission.schedule.scheduledFor as string,\n                        )}\n                      </Text>\n                    </Group>\n                  </Box>\n                  <Text size=\"xs\" c=\"violet\" fw={500}>\n                    {formatRelativeTime(\n                      submission.schedule.scheduledFor as string,\n                    )}\n                  </Text>\n                </Group>\n              </Paper>\n            ))}\n          </Stack>\n        )}\n      </Stack>\n    </Paper>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/home-section/validation-issues-panel.tsx",
    "content": "/**\n * ValidationIssuesPanel - Panel showing submissions with validation errors/warnings.\n * Helps users quickly identify and fix issues before posting.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport {\n    Badge,\n    Box,\n    Group,\n    Paper,\n    Stack,\n    Text,\n    ThemeIcon,\n} from '@mantine/core';\nimport { SubmissionType } from '@postybirb/types';\nimport { IconAlertTriangle, IconExclamationCircle } from '@tabler/icons-react';\nimport { useCallback, useMemo } from 'react';\nimport { useSubmissionsWithErrors } from '../../../stores/entity/submission-store';\nimport { useViewStateActions } from '../../../stores/ui/navigation-store';\nimport { EmptyState } from '../../empty-state';\n\n/**\n * ValidationIssuesPanel component.\n * Shows submissions that have validation errors or warnings.\n */\nexport function ValidationIssuesPanel() {\n  const submissionsWithErrors = useSubmissionsWithErrors();\n  const { setViewState } = useViewStateActions();\n\n  const issues = useMemo(\n    () =>\n      submissionsWithErrors\n        .filter((s) => !s.isTemplate && !s.isArchived)\n        .slice(0, 5)\n        .map((submission) => {\n          const errorCount = submission.validations.reduce(\n            (acc, v) => acc + (v.errors?.length ?? 0),\n            0,\n          );\n          const warningCount = submission.validations.reduce(\n            (acc, v) => acc + (v.warnings?.length ?? 0),\n            0,\n          );\n          const title = submission.getDefaultOptions()?.data?.title;\n          return {\n            id: submission.id,\n            title,\n            errorCount,\n            warningCount,\n            type: submission.type,\n          };\n        }),\n    [submissionsWithErrors],\n  );\n\n  const handleNavigateToSubmission = useCallback(\n    (id: string, type: SubmissionType) => {\n      if (type === SubmissionType.FILE) {\n        setViewState({\n          type: 'file-submissions',\n          params: {\n            selectedIds: [id],\n            mode: 'single',\n            submissionType: SubmissionType.FILE,\n          },\n        });\n      } else {\n        setViewState({\n          type: 'message-submissions',\n          params: {\n            selectedIds: [id],\n            mode: 'single',\n            submissionType: SubmissionType.MESSAGE,\n          },\n        });\n      }\n    },\n    [setViewState],\n  );\n\n  const totalErrors = issues.reduce((acc, i) => acc + i.errorCount, 0);\n  const totalWarnings = issues.reduce((acc, i) => acc + i.warningCount, 0);\n\n  return (\n    <Paper withBorder p=\"md\" radius=\"md\" h=\"100%\" data-tour-id=\"home-validation-issues\">\n      <Stack gap=\"sm\" h=\"100%\">\n        <Group gap=\"xs\" justify=\"space-between\">\n          <Group gap=\"xs\">\n            <ThemeIcon size=\"md\" variant=\"light\" color=\"red\" radius=\"md\">\n              <IconAlertTriangle size={16} />\n            </ThemeIcon>\n            <Text size=\"sm\" fw={500}>\n              <Trans>Validation Issues</Trans>\n            </Text>\n          </Group>\n          {issues.length > 0 && (\n            <Group gap={4}>\n              {totalErrors > 0 && (\n                <Badge color=\"red\" variant=\"light\" size=\"sm\">\n                  {totalErrors} <Trans>errors</Trans>\n                </Badge>\n              )}\n              {totalWarnings > 0 && (\n                <Badge color=\"yellow\" variant=\"light\" size=\"sm\">\n                  {totalWarnings} <Trans>warnings</Trans>\n                </Badge>\n              )}\n            </Group>\n          )}\n        </Group>\n\n        {issues.length === 0 ? (\n          <Box style={{ alignItems: 'center' }}>\n            <EmptyState\n              icon={<IconExclamationCircle size={32} />}\n              message={<Trans>No validation issues</Trans>}\n              description={<Trans>All submissions are ready to post</Trans>}\n              size=\"sm\"\n            />\n          </Box>\n        ) : (\n          <Stack gap=\"xs\">\n            {issues.map((issue) => (\n              <Paper\n                key={issue.id}\n                withBorder\n                p=\"xs\"\n                radius=\"sm\"\n                bg=\"var(--mantine-color-default)\"\n                style={{ cursor: 'pointer' }}\n                onClick={() => handleNavigateToSubmission(issue.id, issue.type)}\n              >\n                <Group justify=\"space-between\" wrap=\"nowrap\">\n                  <Text\n                    size=\"sm\"\n                    truncate\n                    fw={500}\n                    style={{ flex: 1, minWidth: 0 }}\n                  >\n                    {issue.title || <Trans>Untitled</Trans>}\n                  </Text>\n                  <Group gap={4}>\n                    {issue.errorCount > 0 && (\n                      <Badge color=\"red\" variant=\"light\" size=\"xs\">\n                        {issue.errorCount}\n                      </Badge>\n                    )}\n                    {issue.warningCount > 0 && (\n                      <Badge color=\"yellow\" variant=\"light\" size=\"xs\">\n                        {issue.warningCount}\n                      </Badge>\n                    )}\n                  </Group>\n                </Group>\n              </Paper>\n            ))}\n          </Stack>\n        )}\n      </Stack>\n    </Paper>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/index.ts",
    "content": "/**\n * Section components barrel export.\n * Each section corresponds to a view state type and renders in the section panel.\n */\n\nexport { AccountsContent, AccountsSection } from './accounts-section';\nexport {\n    SubmissionsContent,\n    SubmissionsSection\n} from './submissions-section';\nexport { TemplatesContent, TemplatesSection } from './templates-section';\n\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/archived-submission-list.tsx",
    "content": "/**\n * ArchivedSubmissionList - Displays archived submissions with limited actions.\n * Shows cards with delete, unarchive, and view history options.\n */\n\nimport { ScrollArea, Stack } from '@mantine/core';\nimport { useDebouncedValue } from '@mantine/hooks';\nimport { SubmissionType } from '@postybirb/types';\nimport { useMemo, useState } from 'react';\nimport { SubmissionRecord, useArchivedSubmissions } from '../../../stores';\nimport { useIsCompactView } from '../../../stores/ui/appearance-store';\nimport { useSubmissionsFilter } from '../../../stores/ui/submissions-ui-store';\nimport { EmptyState } from '../../empty-state';\nimport { useSubmissionsData } from './context';\nimport { ArchivedSubmissionCard } from './submission-card/archived-submission-card';\nimport { SubmissionHistoryDrawer } from './submission-history-drawer';\nimport './submissions-section.css';\n\ninterface ArchivedSubmissionListProps {\n  /** Type of submissions to display */\n  submissionType: SubmissionType;\n}\n\n/**\n * List of archived submissions with history drawer.\n */\nexport function ArchivedSubmissionList({\n  submissionType,\n}: ArchivedSubmissionListProps) {\n  const { selectedIds } = useSubmissionsData();\n  const { searchQuery } = useSubmissionsFilter(submissionType);\n  const [debouncedSearch] = useDebouncedValue(searchQuery, 300);\n  const archivedSubmissions = useArchivedSubmissions();\n  const isCompact = useIsCompactView();\n  const [historySubmission, setHistorySubmission] =\n    useState<SubmissionRecord | null>(null);\n\n  // Filter by type, search query, and sort by last modified (most recent first)\n  const filteredSubmissions = useMemo(() => {\n    let results = archivedSubmissions.filter(\n      (sub) => sub.type === submissionType,\n    );\n\n    if (debouncedSearch.trim()) {\n      const search = debouncedSearch.toLowerCase();\n      results = results.filter((sub) =>\n        sub.title.toLowerCase().includes(search),\n      );\n    }\n\n    return results.sort(\n      (a, b) => b.lastModified.getTime() - a.lastModified.getTime(),\n    );\n  }, [archivedSubmissions, submissionType, debouncedSearch]);\n\n  const handleViewHistory = (submission: SubmissionRecord) => {\n    setHistorySubmission(submission);\n  };\n\n  const handleCloseHistory = () => {\n    setHistorySubmission(null);\n  };\n\n  if (filteredSubmissions.length === 0) {\n    return <EmptyState preset=\"no-results\" />;\n  }\n\n  return (\n    <>\n      <Stack gap=\"xs\" h=\"100%\">\n        {/* Scrollable list */}\n        <ScrollArea style={{ flex: 1 }}>\n          <Stack gap=\"xs\" className=\"postybirb__submission__list\">\n            {filteredSubmissions.map((submission) => (\n              <ArchivedSubmissionCard\n                key={submission.id}\n                submission={submission}\n                submissionType={submissionType}\n                isSelected={selectedIds.includes(submission.id)}\n                isCompact={isCompact}\n                onViewHistory={() => handleViewHistory(submission)}\n              />\n            ))}\n          </Stack>\n        </ScrollArea>\n      </Stack>\n\n      {/* History drawer */}\n      <SubmissionHistoryDrawer\n        opened={historySubmission !== null}\n        onClose={handleCloseHistory}\n        submission={historySubmission}\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/context/index.ts",
    "content": "export {\n    SubmissionsProvider,\n    useSubmissionsActions,\n    useSubmissionsData,\n    type SubmissionsActionsValue,\n    type SubmissionsDataValue,\n    type SubmissionsProviderProps\n} from './submissions-context';\n\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/context/submissions-context.tsx",
    "content": "/**\n * SubmissionsContext - Split into Data and Actions contexts for performance.\n * Data context changes on selection; Actions context is stable across selections.\n * This prevents action-only consumers (cards) from re-rendering on every selection change.\n */\n\nimport {\n    ISubmissionScheduleInfo,\n    IWebsiteFormFields,\n    SubmissionType,\n} from '@postybirb/types';\nimport { createContext, ReactNode, useContext, useMemo } from 'react';\n\n/**\n * Data portion of submissions context — changes frequently (e.g. on selection).\n */\nexport interface SubmissionsDataValue {\n  /** The type of submissions being displayed */\n  submissionType: SubmissionType;\n  /** Currently selected submission IDs */\n  selectedIds: string[];\n  /** Whether drag-to-reorder is enabled */\n  isDragEnabled: boolean;\n}\n\n/**\n * Actions portion of submissions context — stable across selection changes.\n */\nexport interface SubmissionsActionsValue {\n  /** Handle selection of a submission (supports shift+click for range, ctrl+click toggle, checkbox toggle) */\n  onSelect: (id: string, event: React.MouseEvent | React.KeyboardEvent, isCheckbox?: boolean) => void;\n  /** Delete a submission */\n  onDelete: (id: string) => void;\n  /** Duplicate a submission */\n  onDuplicate: (id: string) => void;\n  /** Open submission for editing */\n  onEdit: (id: string) => void;\n  /** Post a submission immediately */\n  onPost: (id: string) => void;\n  /** Cancel a queued/posting submission */\n  onCancel?: (id: string) => void;\n  /** Archive a submission */\n  onArchive?: (id: string) => void;\n  /** View submission history (optional - only available when drawer is configured) */\n  onViewHistory?: (id: string) => void;\n  /** Update submission's default option fields */\n  onDefaultOptionChange: (id: string, update: Partial<IWebsiteFormFields>) => void;\n  /** Update submission's schedule */\n  onScheduleChange: (\n    id: string,\n    schedule: ISubmissionScheduleInfo,\n    isScheduled: boolean\n  ) => void;\n}\n\nconst SubmissionsDataContext = createContext<SubmissionsDataValue | null>(null);\nconst SubmissionsActionsContext = createContext<SubmissionsActionsValue | null>(null);\n\n/**\n * Hook to access submission data (selectedIds, submissionType, isDragEnabled).\n * Re-renders when data changes (e.g. selection).\n * @throws Error if used outside of SubmissionsProvider\n */\nexport function useSubmissionsData(): SubmissionsDataValue {\n  const context = useContext(SubmissionsDataContext);\n  if (!context) {\n    throw new Error(\n      // eslint-disable-next-line lingui/no-unlocalized-strings\n      'useSubmissionsData must be used within a SubmissionsProvider'\n    );\n  }\n  return context;\n}\n\n/**\n * Hook to access submission action handlers.\n * Does NOT re-render when selection changes.\n * @throws Error if used outside of SubmissionsProvider\n */\nexport function useSubmissionsActions(): SubmissionsActionsValue {\n  const context = useContext(SubmissionsActionsContext);\n  if (!context) {\n    throw new Error(\n      // eslint-disable-next-line lingui/no-unlocalized-strings\n      'useSubmissionsActions must be used within a SubmissionsProvider'\n    );\n  }\n  return context;\n}\n\nexport interface SubmissionsProviderProps {\n  children: ReactNode;\n  /** The type of submissions (FILE or MESSAGE) */\n  submissionType: SubmissionType;\n  /** Currently selected submission IDs */\n  selectedIds: string[];\n  /** Whether drag-to-reorder is enabled */\n  isDragEnabled: boolean;\n  /** Selection handler */\n  onSelect: (id: string, event: React.MouseEvent | React.KeyboardEvent, isCheckbox?: boolean) => void;\n  /** Delete handler */\n  onDelete: (id: string) => void;\n  /** Duplicate handler */\n  onDuplicate: (id: string) => void;\n  /** Edit handler */\n  onEdit: (id: string) => void;\n  /** Post handler */\n  onPost: (id: string) => void;\n  /** Cancel handler (optional) */\n  onCancel?: (id: string) => void;\n  /** Archive handler (optional) */\n  onArchive?: (id: string) => void;\n  /** View history handler (optional) */\n  onViewHistory?: (id: string) => void;\n  /** Default option change handler */\n  onDefaultOptionChange: (id: string, update: Partial<IWebsiteFormFields>) => void;\n  /** Schedule change handler */\n  onScheduleChange: (\n    id: string,\n    schedule: ISubmissionScheduleInfo,\n    isScheduled: boolean\n  ) => void;\n}\n\n/**\n * Provider component that supplies submission context to children.\n * Uses two separate contexts so action-only consumers don't re-render on selection changes.\n */\nexport function SubmissionsProvider({\n  children,\n  submissionType,\n  selectedIds,\n  isDragEnabled,\n  onSelect,\n  onDelete,\n  onDuplicate,\n  onEdit,\n  onPost,\n  onCancel,\n  onArchive,\n  onViewHistory,\n  onDefaultOptionChange,\n  onScheduleChange,\n}: SubmissionsProviderProps) {\n  const dataValue = useMemo<SubmissionsDataValue>(\n    () => ({\n      submissionType,\n      selectedIds,\n      isDragEnabled,\n    }),\n    [submissionType, selectedIds, isDragEnabled]\n  );\n\n  const actionsValue = useMemo<SubmissionsActionsValue>(\n    () => ({\n      onSelect,\n      onDelete,\n      onDuplicate,\n      onEdit,\n      onPost,\n      onCancel,\n      onArchive,\n      onViewHistory,\n      onDefaultOptionChange,\n      onScheduleChange,\n    }),\n    [\n      onSelect,\n      onDelete,\n      onDuplicate,\n      onEdit,\n      onPost,\n      onCancel,\n      onArchive,\n      onViewHistory,\n      onDefaultOptionChange,\n      onScheduleChange,\n    ]\n  );\n\n  return (\n    <SubmissionsActionsContext.Provider value={actionsValue}>\n      <SubmissionsDataContext.Provider value={dataValue}>\n        {children}\n      </SubmissionsDataContext.Provider>\n    </SubmissionsActionsContext.Provider>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/file-submission-modal/file-dropzone.tsx",
    "content": "/**\n * FileDropzone - Dropzone component for file uploads.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport { Box, Button, Group, Text, Tooltip } from '@mantine/core';\nimport {\n    Dropzone,\n    FileWithPath,\n    IMAGE_MIME_TYPE,\n    MS_WORD_MIME_TYPE,\n    PDF_MIME_TYPE,\n} from '@mantine/dropzone';\nimport { SubmissionType } from '@postybirb/types';\nimport {\n    IconClipboard,\n    IconPhoto,\n    IconUpload,\n    IconX,\n} from '@tabler/icons-react';\nimport { useCallback, useEffect, useMemo, useRef } from 'react';\nimport './file-submission-modal.css';\nimport {\n    TEXT_MIME_TYPES,\n    VIDEO_MIME_TYPES,\n} from './file-submission-modal.utils';\n\nexport interface FileDropzoneProps {\n  /** Callback when files are dropped */\n  onDrop: (files: FileWithPath[]) => void;\n  /** Whether uploads are in progress */\n  isUploading: boolean;\n  /** Submission type to determine accepted file types */\n  type: SubmissionType;\n}\n\n/**\n * Convert clipboard items to FileWithPath array.\n */\nasync function getFilesFromClipboard(): Promise<FileWithPath[]> {\n  try {\n    const clipboardItems = await navigator.clipboard.read();\n    const files: FileWithPath[] = [];\n\n    for (const item of clipboardItems) {\n      // Check for image types\n      for (const mimeType of item.types) {\n        if (mimeType.startsWith('image/')) {\n          const blob = await item.getType(mimeType);\n          // Generate a filename based on type and timestamp\n          const extension = mimeType.split('/')[1] || 'png';\n          const filename = `clipboard-${Date.now()}.${extension}`;\n          const file = new File([blob], filename, { type: mimeType });\n          // Add path property to match FileWithPath interface\n          files.push(Object.assign(file, { path: filename }));\n        }\n      }\n    }\n\n    return files;\n  } catch {\n    // Clipboard API not available or permission denied\n    return [];\n  }\n}\n\n/**\n * File dropzone for selecting and dropping files.\n */\nexport function FileDropzone({ onDrop, isUploading, type }: FileDropzoneProps) {\n  const containerRef = useRef<HTMLDivElement>(null);\n\n  // Determine accepted MIME types based on submission type\n  const acceptedMimeTypes = useMemo(() => {\n    if (type === SubmissionType.MESSAGE) {\n      return [...IMAGE_MIME_TYPE];\n    }\n    return [\n      ...IMAGE_MIME_TYPE,\n      ...VIDEO_MIME_TYPES,\n      ...TEXT_MIME_TYPES,\n      ...PDF_MIME_TYPE,\n      ...MS_WORD_MIME_TYPE,\n    ];\n  }, [type]);\n\n  // Handle paste from clipboard button click\n  const handlePasteFromClipboard = useCallback(async () => {\n    if (isUploading) return;\n\n    const files = await getFilesFromClipboard();\n    if (files.length > 0) {\n      onDrop(files);\n    }\n  }, [isUploading, onDrop]);\n\n  // Listen for paste events on the document when modal is open\n  useEffect(() => {\n    const handlePaste = async (e: ClipboardEvent) => {\n      if (isUploading) return;\n\n      const items = e.clipboardData?.items;\n      if (!items) return;\n\n      const files: FileWithPath[] = [];\n\n      for (const item of items) {\n        if (item.kind === 'file') {\n          const file = item.getAsFile();\n          if (file) {\n            // Check if the file type is accepted\n            const isAccepted = acceptedMimeTypes.some(\n              (mime) =>\n                file.type === mime ||\n                file.type.startsWith(mime.replace('/*', '/')),\n            );\n            if (isAccepted) {\n              files.push(Object.assign(file, { path: file.name }));\n            }\n          }\n        }\n      }\n\n      if (files.length > 0) {\n        e.preventDefault();\n        onDrop(files);\n      }\n    };\n\n    document.addEventListener('paste', handlePaste);\n    return () => document.removeEventListener('paste', handlePaste);\n  }, [isUploading, acceptedMimeTypes, onDrop]);\n\n  return (\n    <Box\n      ref={containerRef}\n      p=\"md\"\n      pb={0}\n      className=\"postybirb__file_submission_modal_dropzone_container\"\n    >\n      <Dropzone\n        onDrop={onDrop}\n        accept={acceptedMimeTypes}\n        useFsAccessApi={false}\n        loading={isUploading}\n        disabled={isUploading}\n        styles={{\n          root: {\n            borderStyle: 'solid',\n            borderWidth: 1,\n            backgroundColor: 'var(--mantine-color-default-hover)',\n            borderRadius: '4px',\n          },\n        }}\n      >\n        <Group\n          justify=\"center\"\n          gap=\"xl\"\n          mih={80}\n          className=\"postybirb__file_submission_modal_dropzone_content\"\n        >\n          <Dropzone.Accept>\n            <IconUpload size={40} stroke={1.5} />\n          </Dropzone.Accept>\n          <Dropzone.Reject>\n            <IconX size={40} stroke={1.5} />\n          </Dropzone.Reject>\n          <Dropzone.Idle>\n            <IconPhoto size={40} stroke={1.5} />\n          </Dropzone.Idle>\n\n          <div>\n            <Text size=\"lg\" inline>\n              <Trans>Drop files here or click to browse</Trans>\n            </Text>\n            <Text size=\"sm\" c=\"dimmed\" inline mt={7}>\n              <Trans>\n                Each file becomes a separate submission\n              </Trans>\n            </Text>\n          </div>\n        </Group>\n      </Dropzone>\n\n      {/* Paste from clipboard button */}\n      <Group justify=\"center\" mt=\"xs\" w=\"100%\">\n        <Tooltip label={<Trans>Paste image from clipboard (Ctrl+V)</Trans>}>\n          <Button\n            fullWidth\n            variant=\"subtle\"\n            size=\"xs\"\n            leftSection={<IconClipboard size={14} />}\n            onClick={handlePasteFromClipboard}\n            disabled={isUploading}\n          >\n            <Trans>Paste from clipboard</Trans>\n          </Button>\n        </Tooltip>\n      </Group>\n    </Box>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/file-submission-modal/file-list.tsx",
    "content": "/**\n * FileList - Virtualized scrollable file list with previews.\n * Uses TanStack Virtual for performance with large file sets.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport { Box, Card, Stack, Text, ThemeIcon } from '@mantine/core';\nimport { FileWithPath } from '@mantine/dropzone';\nimport { IconFileUpload } from '@tabler/icons-react';\nimport { useVirtualizer } from '@tanstack/react-virtual';\nimport { useRef } from 'react';\nimport { FilePreview } from './file-preview';\nimport './file-submission-modal.css';\nimport { FileItem } from './file-submission-modal.utils';\n\n/** Estimated height of each file preview card */\nconst ESTIMATED_ITEM_HEIGHT = 72;\n/** Number of items to render outside visible area */\nconst OVERSCAN_COUNT = 3;\n\nexport interface FileListProps {\n  /** List of files with metadata */\n  fileItems: FileItem[];\n  /** Callback when a file is deleted */\n  onDelete: (file: FileWithPath) => void;\n  /** Callback when a file title is changed */\n  onTitleChange: (file: FileWithPath, title: string) => void;\n  /** Optional callback for image editing */\n  onEdit?: (file: FileWithPath) => void;\n}\n\n/**\n * Virtualized file list with file previews.\n */\nexport function FileList({\n  fileItems,\n  onDelete,\n  onTitleChange,\n  onEdit,\n}: FileListProps) {\n  const scrollContainerRef = useRef<HTMLDivElement>(null);\n\n  const virtualizer = useVirtualizer({\n    count: fileItems.length,\n    getScrollElement: () => scrollContainerRef.current,\n    estimateSize: () => ESTIMATED_ITEM_HEIGHT,\n    overscan: OVERSCAN_COUNT,\n  });\n\n  const virtualItems = virtualizer.getVirtualItems();\n\n  return (\n    <Box className=\"postybirb__file_submission_modal_column\">\n      <Text size=\"sm\" fw={500} mb=\"xs\">\n        <Trans>Files</Trans>\n        {fileItems.length > 0 && (\n          <Text span c=\"dimmed\" ml=\"xs\">\n            ({fileItems.length})\n          </Text>\n        )}\n      </Text>\n\n      {fileItems.length === 0 ? (\n        <Card\n          withBorder\n          p=\"xl\"\n          radius=\"sm\"\n          className=\"postybirb__file_submission_modal_empty\"\n        >\n          <Stack align=\"center\" justify=\"center\" h=\"100%\" gap=\"xs\">\n            <ThemeIcon size={60} radius=\"xl\" variant=\"light\" color=\"gray\">\n              <IconFileUpload size={30} />\n            </ThemeIcon>\n            <Text c=\"dimmed\" ta=\"center\">\n              <Trans>No files added yet</Trans>\n            </Text>\n            <Text c=\"dimmed\" size=\"xs\" ta=\"center\">\n              <Trans>Drop files here or click to browse</Trans>\n            </Text>\n          </Stack>\n        </Card>\n      ) : (\n        <div\n          ref={scrollContainerRef}\n          className=\"postybirb__file_submission_modal_file_list\"\n          style={{\n            flex: 1,\n            overflow: 'auto',\n            minHeight: 0,\n          }}\n        >\n          <div\n            style={{\n              height: `${virtualizer.getTotalSize()}px`,\n              width: '100%',\n              position: 'relative',\n            }}\n          >\n            {virtualItems.map((virtualRow) => {\n              const item = fileItems[virtualRow.index];\n              return (\n                <div\n                  key={item.file.name}\n                  data-index={virtualRow.index}\n                  ref={virtualizer.measureElement}\n                  style={{\n                    position: 'absolute',\n                    top: 0,\n                    left: 0,\n                    width: '100%',\n                    transform: `translateY(${virtualRow.start}px)`,\n                    paddingBottom: '8px',\n                  }}\n                >\n                  <FilePreview\n                    item={item}\n                    onDelete={onDelete}\n                    onTitleChange={onTitleChange}\n                    onEdit={onEdit}\n                  />\n                </div>\n              );\n            })}\n          </div>\n        </div>\n      )}\n    </Box>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/file-submission-modal/file-preview.tsx",
    "content": "/**\n * FilePreview - File preview card with thumbnail and title editing.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport {\n    ActionIcon,\n    Card,\n    Flex,\n    Group,\n    Image,\n    Text,\n    TextInput,\n    ThemeIcon,\n    Tooltip,\n} from '@mantine/core';\nimport { FileWithPath } from '@mantine/dropzone';\nimport { FileType } from '@postybirb/types';\nimport { getFileType } from '@postybirb/utils/file-type';\nimport {\n    IconDeviceAudioTape,\n    IconPencil,\n    IconPhoto,\n    IconPhotoEdit,\n    IconTextCaption,\n    IconTrash,\n    IconVideo,\n} from '@tabler/icons-react';\nimport { useCallback, useEffect, useMemo, useState } from 'react';\nimport './file-submission-modal.css';\nimport { FileItem, generateThumbnail } from './file-submission-modal.utils';\n\nexport interface FilePreviewProps {\n  item: FileItem;\n  onDelete: (file: FileWithPath) => void;\n  onEdit?: (file: FileWithPath) => void;\n  onTitleChange: (file: FileWithPath, title: string) => void;\n}\n\n/**\n * File preview card with optional title editing.\n * Generates a small thumbnail on mount to reduce memory usage.\n */\nexport function FilePreview({\n  item,\n  onDelete,\n  onEdit,\n  onTitleChange,\n}: FilePreviewProps) {\n  const { file, title } = item;\n  const type = getFileType(file.name);\n  const [isEditingTitle, setIsEditingTitle] = useState(false);\n  const [editedTitle, setEditedTitle] = useState(title);\n  const [imageUrl, setImageUrl] = useState<string | null>(null);\n\n  // Generate thumbnail on mount\n  useEffect(() => {\n    if (type !== FileType.IMAGE) {\n      return undefined;\n    }\n\n    let cancelled = false;\n    let url: string | null = null;\n\n    generateThumbnail(file, 100)\n      .then((generatedUrl: string) => {\n        if (!cancelled) {\n          url = generatedUrl;\n          setImageUrl(generatedUrl);\n        } else {\n          URL.revokeObjectURL(generatedUrl);\n        }\n      })\n      .catch(() => {\n        // Silently fail - will show placeholder icon\n      });\n\n    // Cleanup on unmount\n    return () => {\n      cancelled = true;\n      if (url) {\n        URL.revokeObjectURL(url);\n      }\n    };\n  }, [type, file]);\n\n  const getTypeInfo = useCallback(() => {\n    switch (type) {\n      case FileType.VIDEO:\n        return { icon: <IconVideo size={20} />, color: 'violet' };\n      case FileType.AUDIO:\n        return { icon: <IconDeviceAudioTape size={20} />, color: 'orange' };\n      case FileType.TEXT:\n        return { icon: <IconTextCaption size={20} />, color: 'teal' };\n      case FileType.IMAGE:\n      default:\n        return { icon: <IconPhoto size={20} />, color: 'blue' };\n    }\n  }, [type]);\n\n  const { icon, color } = getTypeInfo();\n\n  // Create 50px thumbnail preview\n  const preview = useMemo(() => {\n    if (type === FileType.IMAGE && imageUrl) {\n      return (\n        <Image\n          src={imageUrl}\n          alt={file.name}\n          h={50}\n          w={50}\n          radius=\"sm\"\n          fit=\"cover\"\n          className=\"postybirb__file_preview_thumbnail\"\n        />\n      );\n    }\n    return (\n      <ThemeIcon size={50} radius=\"sm\" variant=\"light\" color={color}>\n        {icon}\n      </ThemeIcon>\n    );\n  }, [type, imageUrl, file.name, color, icon]);\n\n  const handleTitleSave = useCallback(() => {\n    onTitleChange(file, editedTitle);\n    setIsEditingTitle(false);\n  }, [file, editedTitle, onTitleChange]);\n\n  const handleTitleKeyDown = useCallback(\n    (e: React.KeyboardEvent) => {\n      if (e.key === 'Enter') {\n        handleTitleSave();\n      } else if (e.key === 'Escape') {\n        setEditedTitle(title);\n        setIsEditingTitle(false);\n      }\n    },\n    [handleTitleSave, title],\n  );\n\n  return (\n    <Card withBorder p=\"xs\" radius=\"sm\" className=\"postybirb__file_preview\">\n      <Flex align=\"center\" gap=\"sm\">\n        {preview}\n        <div className=\"postybirb__file_preview_content\">\n          {isEditingTitle ? (\n            <TextInput\n              size=\"xs\"\n              value={editedTitle}\n              onChange={(e) => setEditedTitle(e.target.value)}\n              onBlur={handleTitleSave}\n              onKeyDown={handleTitleKeyDown}\n              autoFocus\n            />\n          ) : (\n            <Text size=\"sm\" lineClamp={1} fw={500}>\n              {title}\n            </Text>\n          )}\n          <Text size=\"xs\" c=\"dimmed\" lineClamp={1}>\n            {/* eslint-disable-next-line lingui/no-unlocalized-strings */}\n            {(file.size / 1024 / 1024).toFixed(2)} MB • {type}\n          </Text>\n        </div>\n        <Group gap={4} wrap=\"nowrap\">\n          <Tooltip label={<Trans>Edit title</Trans>} withArrow position=\"top\">\n            <ActionIcon\n              size=\"sm\"\n              variant=\"subtle\"\n              onClick={() => setIsEditingTitle(true)}\n            >\n              <IconPencil size={14} />\n            </ActionIcon>\n          </Tooltip>\n          {type === FileType.IMAGE && !file.type.includes('gif') && onEdit && (\n            <Tooltip label={<Trans>Edit image</Trans>} withArrow position=\"top\">\n              <ActionIcon\n                size=\"sm\"\n                variant=\"subtle\"\n                color=\"blue\"\n                onClick={() => onEdit(file)}\n              >\n                <IconPhotoEdit size={14} />\n              </ActionIcon>\n            </Tooltip>\n          )}\n          <Tooltip label={<Trans>Delete</Trans>} withArrow position=\"top\">\n            <ActionIcon\n              size=\"sm\"\n              variant=\"subtle\"\n              color=\"red\"\n              onClick={() => onDelete(file)}\n            >\n              <IconTrash size={14} />\n            </ActionIcon>\n          </Tooltip>\n        </Group>\n      </Flex>\n    </Card>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/file-submission-modal/file-submission-modal.css",
    "content": "/**\n * FileSubmissionModal styles.\n * Following postybirb__snake_case naming convention.\n */\n\n/* Modal overlay */\n.postybirb__file_submission_modal_overlay {\n  display: flex;\n  align-items: stretch;\n  justify-content: stretch;\n  z-index: var(--z-modal-overlay);\n}\n\n/* Modal container */\n.postybirb__file_submission_modal {\n  flex: 1;\n  display: flex;\n  flex-direction: column;\n  height: 100vh;\n  width: 100vw;\n  overflow: hidden;\n}\n\n/* Header section */\n.postybirb__file_submission_modal_header {\n  border-bottom: 1px solid var(--mantine-color-default-border);\n  flex-shrink: 0;\n}\n\n/* Dropzone container */\n.postybirb__file_submission_modal_dropzone_container {\n  flex-shrink: 0;\n}\n\n/* Dropzone inner content - prevents pointer events on children */\n.postybirb__file_submission_modal_dropzone_content {\n  pointer-events: none;\n}\n\n/* Main content area */\n.postybirb__file_submission_modal_content {\n  flex: 1;\n  min-height: 0;\n  overflow: hidden;\n}\n\n/* Column layout for file list and options */\n.postybirb__file_submission_modal_column {\n  flex: 1;\n  display: flex;\n  flex-direction: column;\n  min-width: 0;\n  overflow: hidden;\n}\n\n/* Empty state card */\n.postybirb__file_submission_modal_empty {\n  flex: 1;\n}\n\n/* File list container */\n.postybirb__file_submission_modal_file_list {\n  flex: 1;\n}\n\n/* Footer section */\n.postybirb__file_submission_modal_footer {\n  border-top: 1px solid var(--mantine-color-default-border);\n  flex-shrink: 0;\n}\n\n/* File preview card - additional styling if needed */\n\n/* File preview content container */\n.postybirb__file_preview_content {\n  flex: 1;\n  min-width: 0;\n}\n\n/* File preview thumbnail */\n.postybirb__file_preview_thumbnail {\n  flex-shrink: 0;\n}\n\n/* ===== Image Editor Styles ===== */\n\n/* Modal body */\n.postybirb__image_editor_modal_body {\n  height: 100vh;\n  padding: 0;\n}\n\n.postybirb__image_editor_modal_content {\n  height: 100vh;\n  padding: 0 !important;\n  margin: 0 !important;\n  border: 0 !important;\n}\n\n/* Header */\n.postybirb__image_editor_header {\n  flex-shrink: 0;\n  border-bottom: 1px solid var(--mantine-color-default-border);\n}\n\n/* Canvas container */\n.postybirb__image_editor_canvas {\n  background: var(--mantine-color-dark-8);\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  overflow: hidden;\n}\n\n/* Image element - cropper.js will transform this */\n.postybirb__image_editor_image {\n  display: block;\n  max-width: 100%;\n  max-height: calc(100vh - 100px);\n}\n\n/* Sidebar */\n.postybirb__image_editor_sidebar {\n  width: 280px;\n  flex-shrink: 0;\n  overflow-y: auto;\n  border-left: 1px solid var(--mantine-color-default-border);\n}\n\n/* Cropper.js overrides for better styling */\n.cropper-view-box,\n.cropper-face {\n  border-radius: 0;\n}\n\n.cropper-view-box {\n  outline: 2px solid var(--mantine-color-blue-5);\n  outline-offset: -2px;\n}\n\n.cropper-point {\n  background-color: var(--mantine-color-blue-5);\n  opacity: 1;\n  width: 10px;\n  height: 10px;\n  border-radius: 50%;\n}\n\n.cropper-point.point-se {\n  width: 10px;\n  height: 10px;\n}\n\n.cropper-line {\n  background-color: var(--mantine-color-blue-5);\n  opacity: 0.5;\n}\n\n.cropper-dashed {\n  border-color: rgba(255, 255, 255, 0.5);\n}\n\n.cropper-modal {\n  background-color: rgba(0, 0, 0, 0.6);\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/file-submission-modal/file-submission-modal.tsx",
    "content": "/**\n * FileSubmissionModal - Modal for creating file submissions with enhanced options.\n *\n * Features:\n * - File dropzone with preview\n * - Per-file title editing\n * - Default tags, description, and rating\n * - Template selection\n * - Image editing support\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport {\n  Box,\n  Button,\n  CloseButton,\n  Flex,\n  Group,\n  Overlay,\n  Paper,\n  Portal,\n  Progress,\n  Text,\n  Transition,\n} from '@mantine/core';\nimport { FileWithPath } from '@mantine/dropzone';\nimport {\n  DefaultDescription,\n  Description,\n  IFileMetadata,\n  SubmissionId,\n  SubmissionRating,\n  SubmissionType,\n  Tag,\n} from '@postybirb/types';\nimport { IconPlus } from '@tabler/icons-react';\nimport { useCallback, useEffect, useState } from 'react';\nimport {\n  showUploadErrorNotification,\n  showUploadSuccessNotification,\n} from '../../../../utils/notifications';\nimport { FileDropzone } from './file-dropzone';\nimport { FileList } from './file-list';\nimport './file-submission-modal.css';\nimport { FileItem, getDefaultTitle } from './file-submission-modal.utils';\nimport { ImageEditor } from './image-editor';\nimport { SubmissionOptions } from './submission-options';\n\nexport interface FileSubmissionModalProps {\n  /** Whether the modal is open */\n  opened: boolean;\n  /** Callback when modal is closed */\n  onClose: () => void;\n  /** Callback when files are uploaded */\n  onUpload: (params: {\n    files: File[];\n    fileMetadata: IFileMetadata[];\n    defaultOptions: {\n      tags?: Tag[];\n      description?: Description;\n      rating?: SubmissionRating;\n    };\n    templateId?: SubmissionId;\n  }) => Promise<void>;\n  /** Submission type (FILE or MESSAGE) */\n  type?: SubmissionType;\n  /** Initial files to pre-populate (e.g., from header dropzone) */\n  initialFiles?: FileWithPath[];\n}\n\n/**\n * FileSubmissionModal - Enhanced file upload modal.\n */\nexport function FileSubmissionModal({\n  opened,\n  onClose,\n  onUpload,\n  type = SubmissionType.FILE,\n  initialFiles,\n}: FileSubmissionModalProps) {\n  // File state\n  const [fileItems, setFileItems] = useState<FileItem[]>([]);\n\n  // Handle initial files when modal opens\n  useEffect(() => {\n    if (opened && initialFiles && initialFiles.length > 0) {\n      const newItems: FileItem[] = initialFiles.map((file) => ({\n        file,\n        title: getDefaultTitle(file.name),\n      }));\n      setFileItems((prev) => [...prev, ...newItems]);\n    }\n  }, [opened, initialFiles]);\n\n  // Clear files when modal is closed\n  useEffect(() => {\n    if (!opened) {\n      setFileItems([]);\n      setTags([]);\n      setDescription(DefaultDescription());\n      setRating(SubmissionRating.GENERAL);\n      setSelectedTemplateId(undefined);\n      setProgress(0);\n    }\n  }, [opened]);\n\n  // Default options state\n  const [tags, setTags] = useState<Tag[]>([]);\n  const [description, setDescription] =\n    useState<Description>(DefaultDescription());\n  const [rating, setRating] = useState<SubmissionRating>(\n    SubmissionRating.GENERAL,\n  );\n\n  // Template state\n  const [selectedTemplateId, setSelectedTemplateId] = useState<\n    SubmissionId | undefined\n  >();\n\n  // Upload state\n  const [isUploading, setIsUploading] = useState(false);\n  const [progress, setProgress] = useState(0);\n\n  // Image editor state\n  const [editingFile, setEditingFile] = useState<FileWithPath | null>(null);\n\n  // Handle file drop\n  const handleDrop = useCallback((acceptedFiles: FileWithPath[]) => {\n    const newItems: FileItem[] = acceptedFiles.map((file) => ({\n      file,\n      title: getDefaultTitle(file.name),\n    }));\n    setFileItems((prev) => [...prev, ...newItems]);\n  }, []);\n\n  // Handle file deletion\n  const handleDelete = useCallback((file: FileWithPath) => {\n    setFileItems((prev) => prev.filter((item) => item.file !== file));\n  }, []);\n\n  // Handle title change\n  const handleTitleChange = useCallback(\n    (file: FileWithPath, newTitle: string) => {\n      setFileItems((prev) =>\n        prev.map((item) =>\n          item.file === file ? { ...item, title: newTitle } : item,\n        ),\n      );\n    },\n    [],\n  );\n\n  // Handle image edit\n  const handleEdit = useCallback((file: FileWithPath) => {\n    setEditingFile(file);\n  }, []);\n\n  // Handle image edit apply - replace the original file with the edited version\n  const handleEditApply = useCallback(\n    (originalFile: FileWithPath, editedBlob: Blob) => {\n      // Create a new File from the blob with the same name\n      const baseFile = new File([editedBlob], originalFile.name, {\n        type: originalFile.type || 'image/jpeg',\n        lastModified: Date.now(),\n      });\n\n      // Create FileWithPath by adding path property\n      const editedFile: FileWithPath = Object.assign(baseFile, {\n        path: originalFile.path,\n      });\n\n      // Replace the file in the list\n      setFileItems((prev) =>\n        prev.map((item) =>\n          item.file === originalFile ? { ...item, file: editedFile } : item,\n        ),\n      );\n\n      setEditingFile(null);\n    },\n    [],\n  );\n\n  // Handle upload\n  const handleUpload = useCallback(async () => {\n    if (fileItems.length === 0) return;\n\n    setIsUploading(true);\n    setProgress(0);\n\n    try {\n      // Simulate progress\n      const interval = setInterval(() => {\n        setProgress((current) => {\n          const next = current + 10;\n          if (next >= 90) {\n            clearInterval(interval);\n            return 90;\n          }\n          return next;\n        });\n      }, 300);\n\n      const files = fileItems.map((item) => item.file as File);\n      const fileMetadata: IFileMetadata[] = fileItems.map((item) => ({\n        filename: item.file.name,\n        title: item.title,\n      }));\n\n      await onUpload({\n        files,\n        fileMetadata,\n        defaultOptions: {\n          tags: tags.length > 0 ? tags : undefined,\n          description: description.content?.length ? description : undefined,\n          rating: rating !== SubmissionRating.GENERAL ? rating : undefined,\n        },\n        templateId: selectedTemplateId,\n      });\n\n      clearInterval(interval);\n      setProgress(100);\n\n      showUploadSuccessNotification();\n\n      // Reset and close\n      setTimeout(() => {\n        setIsUploading(false);\n        setFileItems([]);\n        setTags([]);\n        setDescription(DefaultDescription());\n        setRating(SubmissionRating.GENERAL);\n        setSelectedTemplateId(undefined);\n        onClose();\n      }, 500);\n    } catch (error) {\n      showUploadErrorNotification(\n        error instanceof Error ? error.message : undefined,\n      );\n      setIsUploading(false);\n    }\n  }, [\n    fileItems,\n    tags,\n    description,\n    rating,\n    selectedTemplateId,\n    onUpload,\n    onClose,\n  ]);\n\n  // Handle close\n  const handleClose = useCallback(() => {\n    if (!isUploading) {\n      onClose();\n    }\n  }, [isUploading, onClose]);\n\n  // Handle escape key to close modal\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key === 'Escape' && opened && !isUploading) {\n        onClose();\n      }\n    };\n    document.addEventListener('keydown', handleKeyDown);\n    return () => document.removeEventListener('keydown', handleKeyDown);\n  }, [opened, isUploading, onClose]);\n\n  return (\n    <Portal target=\"#root\">\n      <Transition mounted={opened} transition=\"fade\" duration={200}>\n        {(styles) => (\n          <Overlay\n            fixed\n            style={styles}\n            className=\"postybirb__file_submission_modal_overlay\"\n          >\n            <Paper radius={0} className=\"postybirb__file_submission_modal\">\n              {/* Header */}\n              <Group\n                justify=\"space-between\"\n                p=\"md\"\n                className=\"postybirb__file_submission_modal_header\"\n              >\n                <Text size=\"lg\" fw={500}>\n                  <Trans>Create File Submissions</Trans>\n                </Text>\n                <CloseButton\n                  onClick={handleClose}\n                  disabled={isUploading}\n                  size=\"lg\"\n                />\n              </Group>\n\n              {/* Dropzone */}\n              <FileDropzone\n                onDrop={handleDrop}\n                isUploading={isUploading}\n                type={type}\n              />\n\n              {/* Main content area - Two columns */}\n              <Flex\n                p=\"md\"\n                gap=\"md\"\n                className=\"postybirb__file_submission_modal_content\"\n              >\n                {/* Left column - File list */}\n                <FileList\n                  fileItems={fileItems}\n                  onDelete={handleDelete}\n                  onTitleChange={handleTitleChange}\n                  onEdit={handleEdit}\n                />\n\n                {/* Right column - Options */}\n                <SubmissionOptions\n                  type={type}\n                  rating={rating}\n                  onRatingChange={setRating}\n                  tags={tags}\n                  onTagsChange={setTags}\n                  description={description}\n                  onDescriptionChange={setDescription}\n                  selectedTemplateId={selectedTemplateId}\n                  onTemplateChange={setSelectedTemplateId}\n                />\n              </Flex>\n\n              {/* Footer - Upload button */}\n              <Box p=\"md\" className=\"postybirb__file_submission_modal_footer\">\n                {isUploading && (\n                  <Progress value={progress} mb=\"xs\" size=\"sm\" radius=\"xl\" />\n                )}\n                <Button\n                  loading={isUploading}\n                  onClick={handleUpload}\n                  variant={isUploading ? 'light' : 'filled'}\n                  leftSection={<IconPlus size={16} />}\n                  disabled={fileItems.length === 0}\n                  fullWidth\n                  radius=\"md\"\n                  size=\"md\"\n                >\n                  <Trans>Create</Trans>\n                </Button>\n              </Box>\n            </Paper>\n          </Overlay>\n        )}\n      </Transition>\n\n      {/* Image Editor Modal */}\n      {editingFile && (\n        <ImageEditor\n          file={editingFile}\n          opened={!!editingFile}\n          onClose={() => setEditingFile(null)}\n          onApply={handleEditApply}\n        />\n      )}\n    </Portal>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/file-submission-modal/file-submission-modal.utils.ts",
    "content": "/**\n * FileSubmissionModal utilities and types.\n */\n\nimport { FileWithPath } from '@mantine/dropzone';\n\n/**\n * File metadata including the file and its custom title.\n */\nexport interface FileItem {\n  file: FileWithPath;\n  title: string;\n}\n\n/**\n * Extract file name without extension for default title.\n */\nexport function getDefaultTitle(filename: string): string {\n  const lastDot = filename.lastIndexOf('.');\n  if (lastDot > 0) {\n    return filename.substring(0, lastDot);\n  }\n  return filename;\n}\n\n/**\n * Generate a small thumbnail from a file using Canvas.\n * This significantly reduces memory usage compared to displaying full images.\n */\nexport async function generateThumbnail(\n  file: Blob,\n  maxSize = 100\n): Promise<string> {\n  return new Promise((resolve, reject) => {\n    const img = new window.Image();\n    const originalUrl = URL.createObjectURL(file);\n\n    img.onload = () => {\n      URL.revokeObjectURL(originalUrl); // Free original immediately\n\n      // Calculate scaled dimensions maintaining aspect ratio\n      let { width, height } = img;\n      if (width > height) {\n        if (width > maxSize) {\n          height = Math.round((height * maxSize) / width);\n          width = maxSize;\n        }\n      } else if (height > maxSize) {\n        width = Math.round((width * maxSize) / height);\n        height = maxSize;\n      }\n\n      // Draw to canvas at reduced size\n      const canvas = document.createElement('canvas');\n      canvas.width = width;\n      canvas.height = height;\n      const ctx = canvas.getContext('2d');\n      if (!ctx) {\n        // eslint-disable-next-line lingui/no-unlocalized-strings\n        reject(new Error('Canvas context error'));\n        return;\n      }\n\n      ctx.drawImage(img, 0, 0, width, height);\n\n      // Convert to small blob URL\n      canvas.toBlob(\n        (blob) => {\n          if (blob) {\n            resolve(URL.createObjectURL(blob));\n          } else {\n            // eslint-disable-next-line lingui/no-unlocalized-strings\n            reject(new Error('Thumbnail error'));\n          }\n        },\n        'image/jpeg',\n        0.7 // 70% quality is plenty for thumbnails\n      );\n    };\n\n    img.onerror = () => {\n      URL.revokeObjectURL(originalUrl);\n      // eslint-disable-next-line lingui/no-unlocalized-strings\n      reject(new Error('Image load error'));\n    };\n\n    img.src = originalUrl;\n  });\n}\n\n// Reuse mime types from old uploader\nexport const TEXT_MIME_TYPES = [\n  'text/plain',\n  'text/html',\n  'text/css',\n  'text/javascript',\n  'application/json',\n  'application/xml',\n  'text/*',\n  'application/rtf',\n  'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n  'application/msword',\n];\n\nexport const VIDEO_MIME_TYPES = ['video/mp4', 'video/x-m4v', 'video/*'];\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/file-submission-modal/image-editor.tsx",
    "content": "/**\n * ImageEditor - Modern image cropping/editing popover.\n *\n * Features:\n * - Cropper.js integration\n * - Aspect ratio presets\n * - Zoom controls\n * - Rotation controls\n * - Flip controls\n * - Preview before applying\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport {\n  ActionIcon,\n  Badge,\n  Box,\n  Button,\n  Divider,\n  Flex,\n  Group,\n  Modal,\n  Paper,\n  SegmentedControl,\n  Slider,\n  Stack,\n  Text,\n  ThemeIcon,\n  Tooltip,\n} from '@mantine/core';\nimport { FileWithPath } from '@mantine/dropzone';\nimport {\n  IconCheck,\n  IconCrop,\n  IconFlipHorizontal,\n  IconFlipVertical,\n  IconRefresh,\n  IconRotate,\n  IconRotateClockwise,\n  IconX,\n  IconZoomIn,\n  IconZoomOut,\n} from '@tabler/icons-react';\nimport Cropper from 'cropperjs';\nimport 'cropperjs/dist/cropper.css';\nimport { useCallback, useEffect, useRef, useState } from 'react';\nimport './file-submission-modal.css';\n\nexport interface ImageEditorProps {\n  /** File to edit */\n  file: FileWithPath;\n  /** Whether the editor is open */\n  opened: boolean;\n  /** Callback when editor is closed */\n  onClose: () => void;\n  /** Callback when edit is applied */\n  onApply: (file: FileWithPath, editedBlob: Blob) => void;\n}\n\n/** Aspect ratio presets - these are UI labels not user-facing text */\n/* eslint-disable lingui/no-unlocalized-strings */\nconst ASPECT_RATIOS = [\n  { label: 'Free', value: 'free', ratio: NaN },\n  { label: '1:1', value: '1:1', ratio: 1 },\n  { label: '4:5', value: '4:5', ratio: 4 / 5 },\n  { label: '1.91:1', value: '1.91:1', ratio: 1.91 / 1 },\n  { label: '4:3', value: '4:3', ratio: 4 / 3 },\n  { label: '16:9', value: '16:9', ratio: 16 / 9 },\n  { label: '3:2', value: '3:2', ratio: 3 / 2 },\n  { label: '2:3', value: '2:3', ratio: 2 / 3 },\n] as const;\n/* eslint-enable lingui/no-unlocalized-strings */\n\n/**\n * Modern image editor with cropping, rotation, and zoom.\n */\nexport function ImageEditor({\n  file,\n  opened,\n  onClose,\n  onApply,\n}: ImageEditorProps) {\n  const imageRef = useRef<HTMLImageElement>(null);\n  const cropperRef = useRef<Cropper | null>(null);\n  const [imageUrl, setImageUrl] = useState<string | null>(null);\n  const [isReady, setIsReady] = useState(false);\n  const [aspectRatio, setAspectRatio] = useState<string>('free');\n  const [zoom, setZoom] = useState(0);\n  const [hasChanges, setHasChanges] = useState(false);\n\n  // Create object URL for the image\n  useEffect(() => {\n    if (opened && file) {\n      const url = URL.createObjectURL(file);\n      setImageUrl(url);\n      return () => {\n        URL.revokeObjectURL(url);\n        setImageUrl(null);\n      };\n    }\n    return undefined;\n  }, [opened, file]);\n\n  // Initialize Cropper when image loads\n  const handleImageLoad = useCallback(() => {\n    if (!imageRef.current || cropperRef.current) return;\n\n    const cropper = new Cropper(imageRef.current, {\n      viewMode: 1,\n      dragMode: 'move',\n      autoCropArea: 1,\n      restore: false,\n      guides: true,\n      center: true,\n      highlight: true,\n      cropBoxMovable: true,\n      cropBoxResizable: true,\n      toggleDragModeOnDblclick: true,\n      ready() {\n        setIsReady(true);\n      },\n      crop() {\n        setHasChanges(true);\n      },\n      zoom(event) {\n        // Update zoom slider when zooming with scroll/pinch\n        const newZoom = Math.round((event.detail.ratio - 1) * 100);\n        setZoom(Math.max(-50, Math.min(100, newZoom)));\n      },\n    });\n\n    cropperRef.current = cropper;\n  }, []);\n\n  // Cleanup cropper on close\n  useEffect(() => {\n    if (!opened) {\n      if (cropperRef.current) {\n        cropperRef.current.destroy();\n        cropperRef.current = null;\n      }\n      setIsReady(false);\n      setAspectRatio('free');\n      setZoom(0);\n      setHasChanges(false);\n    }\n  }, [opened]);\n\n  // Handle aspect ratio change\n  const handleAspectRatioChange = useCallback((value: string) => {\n    setAspectRatio(value);\n    const selected = ASPECT_RATIOS.find((ar) => ar.value === value);\n    if (selected && cropperRef.current) {\n      cropperRef.current.setAspectRatio(selected.ratio);\n      setHasChanges(true);\n    }\n  }, []);\n\n  // Handle zoom change\n  const handleZoomChange = useCallback((value: number) => {\n    setZoom(value);\n    if (cropperRef.current) {\n      cropperRef.current.zoomTo(1 + value / 100);\n    }\n  }, []);\n\n  // Zoom controls\n  const handleZoomIn = useCallback(() => {\n    setZoom((prev) => {\n      const newZoom = Math.min(prev + 10, 100);\n      if (cropperRef.current) {\n        cropperRef.current.zoomTo(1 + newZoom / 100);\n      }\n      return newZoom;\n    });\n  }, []);\n\n  const handleZoomOut = useCallback(() => {\n    setZoom((prev) => {\n      const newZoom = Math.max(prev - 10, -50);\n      if (cropperRef.current) {\n        cropperRef.current.zoomTo(1 + newZoom / 100);\n      }\n      return newZoom;\n    });\n  }, []);\n\n  // Rotation controls\n  const handleRotateLeft = useCallback(() => {\n    cropperRef.current?.rotate(-90);\n    setHasChanges(true);\n  }, []);\n\n  const handleRotateRight = useCallback(() => {\n    cropperRef.current?.rotate(90);\n    setHasChanges(true);\n  }, []);\n\n  // Flip controls\n  const handleFlipHorizontal = useCallback(() => {\n    if (cropperRef.current) {\n      const data = cropperRef.current.getData();\n      cropperRef.current.scaleX(data.scaleX === -1 ? 1 : -1);\n      setHasChanges(true);\n    }\n  }, []);\n\n  const handleFlipVertical = useCallback(() => {\n    if (cropperRef.current) {\n      const data = cropperRef.current.getData();\n      cropperRef.current.scaleY(data.scaleY === -1 ? 1 : -1);\n      setHasChanges(true);\n    }\n  }, []);\n\n  // Reset all changes\n  const handleReset = useCallback(() => {\n    if (cropperRef.current) {\n      cropperRef.current.reset();\n      setAspectRatio('free');\n      setZoom(0);\n      setHasChanges(false);\n    }\n  }, []);\n\n  // Apply changes and close\n  const handleApply = useCallback(() => {\n    if (!cropperRef.current) return;\n\n    const canvas = cropperRef.current.getCroppedCanvas({\n      imageSmoothingEnabled: true,\n      imageSmoothingQuality: 'high',\n    });\n\n    canvas.toBlob(\n      (blob) => {\n        if (blob) {\n          onApply(file, blob);\n          onClose();\n        }\n      },\n      file.type || 'image/jpeg',\n      0.95, // High quality\n    );\n  }, [file, onApply, onClose]);\n\n  // Handle keyboard shortcuts\n  useEffect(() => {\n    if (!opened) return undefined;\n\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key === 'Escape') {\n        onClose();\n      } else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {\n        handleApply();\n      }\n    };\n\n    window.addEventListener('keydown', handleKeyDown);\n    return () => window.removeEventListener('keydown', handleKeyDown);\n  }, [opened, onClose, handleApply]);\n\n  return (\n    <Modal\n      opened={opened}\n      onClose={onClose}\n      size=\"xl\"\n      fullScreen\n      padding={0}\n      withCloseButton={false}\n      classNames={{\n        body: 'postybirb__image_editor_modal_body',\n        content: 'postybirb__image_editor_modal_content',\n      }}\n      zIndex=\"var(--z-popover)\"\n    >\n      <Flex direction=\"column\" h=\"100vh\">\n        {/* Header */}\n        <Paper\n          p=\"sm\"\n          radius={0}\n          className=\"postybirb__image_editor_header\"\n          withBorder\n        >\n          <Group justify=\"space-between\">\n            <Group gap=\"sm\">\n              <ThemeIcon size=\"lg\" variant=\"light\" color=\"blue\">\n                <IconCrop size={20} />\n              </ThemeIcon>\n              <div>\n                <Text fw={500} size=\"sm\">\n                  <Trans>Edit image</Trans>\n                </Text>\n                <Text size=\"xs\" c=\"dimmed\" lineClamp={1}>\n                  {file.name}\n                </Text>\n              </div>\n              {hasChanges && (\n                <Badge size=\"sm\" variant=\"light\" color=\"yellow\">\n                  <Trans>Modified</Trans>\n                </Badge>\n              )}\n            </Group>\n            <Group gap=\"xs\">\n              <Button\n                variant=\"subtle\"\n                color=\"gray\"\n                leftSection={<IconX size={16} />}\n                onClick={onClose}\n              >\n                <Trans>Cancel</Trans>\n              </Button>\n              <Button\n                leftSection={<IconCheck size={16} />}\n                onClick={handleApply}\n                disabled={!isReady}\n              >\n                <Trans>Apply</Trans>\n              </Button>\n            </Group>\n          </Group>\n        </Paper>\n\n        {/* Main content */}\n        <Flex flex={1} style={{ minHeight: 0 }}>\n          {/* Image canvas */}\n          <Box flex={1} p=\"md\" className=\"postybirb__image_editor_canvas\">\n            {imageUrl && (\n              <img\n                ref={imageRef}\n                src={imageUrl}\n                alt={file.name}\n                onLoad={handleImageLoad}\n                className=\"postybirb__image_editor_image\"\n                loading=\"lazy\"\n              />\n            )}\n          </Box>\n\n          {/* Toolbar sidebar */}\n          <Paper\n            p=\"md\"\n            radius={0}\n            withBorder\n            className=\"postybirb__image_editor_sidebar\"\n          >\n            <Stack gap=\"lg\">\n              {/* Aspect Ratio */}\n              <div>\n                <Text size=\"sm\" fw={500} mb=\"xs\">\n                  <Trans>Aspect Ratio</Trans>\n                </Text>\n                <SegmentedControl\n                  value={aspectRatio}\n                  onChange={handleAspectRatioChange}\n                  data={ASPECT_RATIOS.map((ar) => ({\n                    label: ar.label,\n                    value: ar.value,\n                  }))}\n                  fullWidth\n                  size=\"xs\"\n                  disabled={!isReady}\n                />\n              </div>\n\n              <Divider />\n\n              {/* Zoom */}\n              <div>\n                <Group justify=\"space-between\" mb=\"xs\">\n                  <Text size=\"sm\" fw={500}>\n                    <Trans>Zoom</Trans>\n                  </Text>\n                  <Text size=\"xs\" c=\"dimmed\">\n                    {zoom > 0 ? '+' : ''}\n                    {zoom}%\n                  </Text>\n                </Group>\n                <Group gap=\"xs\" align=\"center\">\n                  <Tooltip label={<Trans>Zoom out</Trans>}>\n                    <ActionIcon\n                      variant=\"light\"\n                      onClick={handleZoomOut}\n                      disabled={!isReady || zoom <= -50}\n                    >\n                      <IconZoomOut size={16} />\n                    </ActionIcon>\n                  </Tooltip>\n                  <Slider\n                    value={zoom}\n                    onChange={handleZoomChange}\n                    min={-50}\n                    max={100}\n                    step={5}\n                    flex={1}\n                    disabled={!isReady}\n                    label={(value) => `${value > 0 ? '+' : ''}${value}%`}\n                  />\n                  <Tooltip label={<Trans>Zoom in</Trans>}>\n                    <ActionIcon\n                      variant=\"light\"\n                      onClick={handleZoomIn}\n                      disabled={!isReady || zoom >= 100}\n                    >\n                      <IconZoomIn size={16} />\n                    </ActionIcon>\n                  </Tooltip>\n                </Group>\n              </div>\n\n              <Divider />\n\n              {/* Rotate */}\n              <div>\n                <Text size=\"sm\" fw={500} mb=\"xs\">\n                  <Trans>Rotate</Trans>\n                </Text>\n                <Group gap=\"xs\">\n                  <Tooltip label={<Trans>Rotate left 90°</Trans>}>\n                    <ActionIcon\n                      variant=\"light\"\n                      size=\"lg\"\n                      onClick={handleRotateLeft}\n                      disabled={!isReady}\n                    >\n                      <IconRotate size={18} />\n                    </ActionIcon>\n                  </Tooltip>\n                  <Tooltip label={<Trans>Rotate right 90°</Trans>}>\n                    <ActionIcon\n                      variant=\"light\"\n                      size=\"lg\"\n                      onClick={handleRotateRight}\n                      disabled={!isReady}\n                    >\n                      <IconRotateClockwise size={18} />\n                    </ActionIcon>\n                  </Tooltip>\n                </Group>\n              </div>\n\n              <Divider />\n\n              {/* Flip */}\n              <div>\n                <Text size=\"sm\" fw={500} mb=\"xs\">\n                  <Trans>Flip</Trans>\n                </Text>\n                <Group gap=\"xs\">\n                  <Tooltip label={<Trans>Flip horizontal</Trans>}>\n                    <ActionIcon\n                      variant=\"light\"\n                      size=\"lg\"\n                      onClick={handleFlipHorizontal}\n                      disabled={!isReady}\n                    >\n                      <IconFlipHorizontal size={18} />\n                    </ActionIcon>\n                  </Tooltip>\n                  <Tooltip label={<Trans>Flip vertical</Trans>}>\n                    <ActionIcon\n                      variant=\"light\"\n                      size=\"lg\"\n                      onClick={handleFlipVertical}\n                      disabled={!isReady}\n                    >\n                      <IconFlipVertical size={18} />\n                    </ActionIcon>\n                  </Tooltip>\n                </Group>\n              </div>\n\n              <Divider />\n\n              {/* Reset */}\n              <Button\n                variant=\"light\"\n                color=\"gray\"\n                leftSection={<IconRefresh size={16} />}\n                onClick={handleReset}\n                disabled={!isReady || !hasChanges}\n                fullWidth\n              >\n                <Trans>Reset</Trans>\n              </Button>\n\n              {/* Tips */}\n              <Paper\n                p=\"sm\"\n                radius=\"sm\"\n                bg=\"var(--mantine-color-dark-6)\"\n                shadow=\"none\"\n              >\n                <Text size=\"xs\" c=\"dimmed\">\n                  <Trans>Tips</Trans>:\n                </Text>\n                <Text size=\"xs\" c=\"dimmed\" mt={4}>\n                  • <Trans>Double-click to toggle crop/move mode</Trans>\n                </Text>\n                <Text size=\"xs\" c=\"dimmed\">\n                  • <Trans>Scroll to zoom</Trans>\n                </Text>\n                <Text size=\"xs\" c=\"dimmed\">\n                  • <Trans>Ctrl+Enter to apply</Trans>\n                </Text>\n              </Paper>\n            </Stack>\n          </Paper>\n        </Flex>\n      </Flex>\n    </Modal>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/file-submission-modal/index.ts",
    "content": "/**\n * FileSubmissionModal exports.\n */\n\nexport { FileSubmissionModal, type FileSubmissionModalProps } from './file-submission-modal';\nexport { ImageEditor, type ImageEditorProps } from './image-editor';\n\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/file-submission-modal/submission-options.tsx",
    "content": "/**\n * SubmissionOptions - Options panel for file submissions.\n */\n\nimport { Trans, useLingui } from '@lingui/react/macro';\nimport {\n    Box,\n    ScrollArea,\n    SegmentedControl,\n    Stack,\n    Text\n} from '@mantine/core';\nimport {\n    DefaultDescription,\n    Description,\n    SubmissionId,\n    SubmissionRating,\n    SubmissionType,\n    Tag,\n} from '@postybirb/types';\nimport { IconFileText, IconTemplate } from '@tabler/icons-react';\nimport { useState } from 'react';\nimport { DescriptionEditor } from '../../../shared/description-editor';\nimport { RatingInput } from '../../../shared/rating-input';\nimport { SimpleTagInput } from '../../../shared/simple-tag-input';\nimport { TemplatePicker } from '../../../shared/template-picker/template-picker';\nimport './file-submission-modal.css';\n\ntype OptionsMode = 'custom' | 'template';\n\nexport interface SubmissionOptionsProps {\n  /** Submission type for template filtering */\n  type: SubmissionType;\n  /** Current rating */\n  rating: SubmissionRating;\n  /** Rating change handler */\n  onRatingChange: (rating: SubmissionRating) => void;\n  /** Current tags */\n  tags: Tag[];\n  /** Tags change handler */\n  onTagsChange: (tags: Tag[]) => void;\n  /** Current description */\n  description: Description;\n  /** Description change handler */\n  onDescriptionChange: (description: Description) => void;\n  /** Currently selected template ID */\n  selectedTemplateId?: SubmissionId;\n  /** Template selection handler */\n  onTemplateChange: (id: SubmissionId | undefined) => void;\n}\n\n/**\n * Options panel for customizing submission defaults.\n * Users can choose between custom options OR a template, not both.\n */\nexport function SubmissionOptions({\n  type,\n  rating,\n  onRatingChange,\n  tags,\n  onTagsChange,\n  description,\n  onDescriptionChange,\n  selectedTemplateId,\n  onTemplateChange,\n}: SubmissionOptionsProps) {\n  const { t } = useLingui();\n  const [mode, setMode] = useState<OptionsMode>('custom');\n\n  const handleModeChange = (newMode: string) => {\n    setMode(newMode as OptionsMode);\n    // Clear the other mode's data when switching\n    if (newMode === 'template') {\n      // Clear custom options when switching to template\n      onRatingChange(SubmissionRating.GENERAL);\n      onTagsChange([]);\n      onDescriptionChange(DefaultDescription());\n    } else {\n      // Clear template when switching to custom\n      onTemplateChange(undefined);\n    }\n  };\n\n  return (\n    <Box className=\"postybirb__file_submission_modal_column\">\n      <Text size=\"sm\" fw={500} mb=\"xs\">\n        <Trans>Defaults</Trans>\n      </Text>\n\n      {/* Mode Toggle */}\n      <SegmentedControl\n        value={mode}\n        onChange={handleModeChange}\n        fullWidth\n        mb=\"md\"\n        data={[\n          {\n            value: 'custom',\n            label: (\n              <Box style={{ display: 'flex', alignItems: 'center', gap: 6 }}>\n                <IconFileText size={14} />\n                <Trans>Custom</Trans>\n              </Box>\n            ),\n          },\n          {\n            value: 'template',\n            label: (\n              <Box style={{ display: 'flex', alignItems: 'center', gap: 6 }}>\n                <IconTemplate size={14} />\n                <Trans>Template</Trans>\n              </Box>\n            ),\n          },\n        ]}\n      />\n\n      <ScrollArea style={{ flex: 1 }} offsetScrollbars type=\"auto\">\n        {mode === 'custom' ? (\n          <Stack gap=\"md\" pr=\"xs\">\n            {/* Rating */}\n            <Box>\n              <Text size=\"sm\" fw={500} mb=\"xs\">\n                <Trans>Rating</Trans>\n              </Text>\n              <RatingInput value={rating} onChange={onRatingChange} size=\"md\" />\n            </Box>\n\n            {/* Tags */}\n            <SimpleTagInput\n              label={t`Tags`}\n              value={tags}\n              onChange={onTagsChange}\n              placeholder={t`Add tags...`}\n            />\n\n            {/* Description */}\n            <Box>\n              <Text size=\"sm\" fw={500} mb=\"xs\">\n                <Trans>Description</Trans>\n              </Text>\n              <DescriptionEditor\n                value={description}\n                onChange={onDescriptionChange}\n              />\n            </Box>\n          </Stack>\n        ) : (\n          <Stack gap=\"md\" pr=\"xs\">\n            {/* Template Picker */}\n            <Box>\n              <TemplatePicker\n                type={type}\n                value={selectedTemplateId}\n                onChange={(id) => onTemplateChange(id ?? undefined)}\n              />\n            </Box>\n          </Stack>\n        )}\n      </ScrollArea>\n    </Box>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/hooks/index.ts",
    "content": "/**\n * Hooks for SubmissionsSection.\n */\n\nexport { useGlobalDropzone } from './use-global-dropzone';\nexport {\n    useSubmissionActions,\n    type BoundSubmissionActions\n} from './use-submission-actions';\nexport { useSubmissionCreate } from './use-submission-create';\nexport { useSubmissionDelete } from './use-submission-delete';\nexport { useSubmissionHandlers } from './use-submission-handlers';\nexport { useSubmissionPost } from './use-submission-post';\nexport { useSubmissionSelection } from './use-submission-selection';\nexport { useSubmissionUpdate } from './use-submission-update';\nexport { useSubmissions } from './use-submissions';\n\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/hooks/use-global-dropzone.ts",
    "content": "/**\n * Hook for global window drag-and-drop to open file submission modal.\n * Detects when files are dragged into a specific target element.\n */\n\nimport { useCallback, useEffect, useState } from 'react';\n\ninterface UseGlobalDropzoneProps {\n  /** Whether the modal is currently open */\n  isOpen: boolean;\n  /** Callback to open the modal */\n  onOpen: () => void;\n  /** Callback to close the modal (called when drag leaves the window) */\n  onClose?: () => void;\n  /** Whether this feature is enabled (e.g., only for FILE submission type) */\n  enabled?: boolean;\n  /** The ID of the target element to listen for drag events on */\n  targetElementId?: string;\n}\n\ninterface UseGlobalDropzoneResult {\n  /** Whether a file is currently being dragged over the window */\n  isDraggingOver: boolean;\n}\n\n/**\n * Hook that listens for files being dragged into a specific target element.\n * Opens the file submission modal when files are detected over the target.\n */\nexport function useGlobalDropzone({\n  isOpen,\n  onOpen,\n  onClose,\n  enabled = true,\n  targetElementId,\n}: UseGlobalDropzoneProps): UseGlobalDropzoneResult {\n  const [isDraggingOver, setIsDraggingOver] = useState(false);\n  const [dragCounter, setDragCounter] = useState(0);\n\n  // Check if the drag event contains files\n  const hasFiles = useCallback((event: DragEvent): boolean => {\n    if (!event.dataTransfer) return false;\n    \n    // Check types for files\n    const { types } = event.dataTransfer;\n    return types.includes('Files') || types.includes('application/x-moz-file');\n  }, []);\n\n  // Check if the event target is within the target element\n  const isWithinTarget = useCallback(\n    (event: DragEvent): boolean => {\n      if (!targetElementId) return true; // If no target specified, allow anywhere\n      \n      const targetElement = document.getElementById(targetElementId);\n      if (!targetElement) return false;\n      \n      const eventTarget = event.target as Node;\n      return targetElement.contains(eventTarget);\n    },\n    [targetElementId]\n  );\n\n  // Handle drag enter\n  const handleDragEnter = useCallback(\n    (event: DragEvent) => {\n      if (!enabled || isOpen) return;\n      \n      event.preventDefault();\n      \n      if (hasFiles(event) && isWithinTarget(event)) {\n        setDragCounter((c) => c + 1);\n        setIsDraggingOver(true);\n        \n        // Open the modal when files are dragged in\n        onOpen();\n      }\n    },\n    [enabled, isOpen, hasFiles, isWithinTarget, onOpen]\n  );\n\n  // Handle drag leave\n  const handleDragLeave = useCallback(\n    (event: DragEvent) => {\n      if (!enabled) return;\n      \n      event.preventDefault();\n      \n      // Check if drag is leaving the window (relatedTarget is null when leaving window)\n      const isLeavingWindow = event.relatedTarget === null;\n      \n      setDragCounter((c) => {\n        const newCount = c - 1;\n        if (newCount <= 0) {\n          setIsDraggingOver(false);\n          \n          // Close modal if drag leaves the window entirely\n          if (isLeavingWindow && isOpen && onClose) {\n            onClose();\n          }\n          \n          return 0;\n        }\n        return newCount;\n      });\n    },\n    [enabled, isOpen, onClose]\n  );\n\n  // Handle drag over (required to allow drop)\n  const handleDragOver = useCallback(\n    (event: DragEvent) => {\n      if (!enabled) return;\n      \n      event.preventDefault();\n      if (event.dataTransfer) {\n        // eslint-disable-next-line no-param-reassign\n        event.dataTransfer.dropEffect = 'copy';\n      }\n    },\n    [enabled]\n  );\n\n  // Handle drop - reset state (the modal's dropzone handles the actual files)\n  const handleDrop = useCallback(\n    (event: DragEvent) => {\n      if (!enabled) return;\n      \n      // Don't prevent default here - let the modal's dropzone handle it\n      setDragCounter(0);\n      setIsDraggingOver(false);\n    },\n    [enabled]\n  );\n\n  // Add global event listeners\n  useEffect(() => {\n    if (!enabled) return undefined;\n\n    window.addEventListener('dragenter', handleDragEnter);\n    window.addEventListener('dragleave', handleDragLeave);\n    window.addEventListener('dragover', handleDragOver);\n    window.addEventListener('drop', handleDrop);\n\n    return () => {\n      window.removeEventListener('dragenter', handleDragEnter);\n      window.removeEventListener('dragleave', handleDragLeave);\n      window.removeEventListener('dragover', handleDragOver);\n      window.removeEventListener('drop', handleDrop);\n    };\n  }, [enabled, handleDragEnter, handleDragLeave, handleDragOver, handleDrop]);\n\n  // Reset state when modal closes\n  useEffect(() => {\n    if (!isOpen) {\n      setDragCounter(0);\n      setIsDraggingOver(false);\n    }\n  }, [isOpen]);\n\n  return { isDraggingOver };\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/hooks/use-submission-actions.ts",
    "content": "/**\n * Hook that provides bound action handlers for a specific submission.\n * Eliminates the need to create wrapper callbacks in each SubmissionCard.\n */\n\nimport { ISubmissionScheduleInfo, IWebsiteFormFields } from '@postybirb/types';\nimport { useMemo } from 'react';\nimport { useSubmissionsActions } from '../context';\n\n/**\n * Bound action handlers for a single submission.\n * Named 'BoundSubmissionActions' to avoid collision with the SubmissionActions component.\n */\nexport interface BoundSubmissionActions {\n  /** Delete this submission */\n  handleDelete: () => void;\n  /** Duplicate this submission */\n  handleDuplicate: () => void;\n  /** Open this submission for editing */\n  handleEdit: () => void;\n  /** Post this submission immediately */\n  handlePost: () => void;\n  /** Cancel this submission if queued/posting */\n  handleCancel?: () => void;\n  /** Archive this submission (if available) */\n  handleArchive?: () => void;\n  /** View this submission's post history (if available) */\n  handleViewHistory?: () => void;\n  /** Update this submission's schedule */\n  handleScheduleChange: (\n    schedule: ISubmissionScheduleInfo,\n    isScheduled: boolean,\n  ) => void;\n  /** Update this submission's default option fields */\n  handleDefaultOptionChange: (update: Partial<IWebsiteFormFields>) => void;\n}\n\n/**\n * Hook that returns action handlers bound to a specific submission ID.\n * Uses the SubmissionsContext internally, so must be used within a SubmissionsProvider.\n *\n * @param submissionId - The ID of the submission to bind actions to\n * @returns Object with bound action handlers\n *\n * @example\n * ```tsx\n * function MySubmissionCard({ submission }) {\n *   const actions = useSubmissionActions(submission.id);\n *   return (\n *     <button onClick={actions.handleDelete}>Delete</button>\n *   );\n * }\n * ```\n */\nexport function useSubmissionActions(\n  submissionId: string,\n): BoundSubmissionActions {\n  const context = useSubmissionsActions();\n\n  return useMemo<BoundSubmissionActions>(\n    () => ({\n      handleDelete: () => context.onDelete(submissionId),\n      handleDuplicate: () => context.onDuplicate(submissionId),\n      handleEdit: () => context.onEdit(submissionId),\n      handlePost: () => context.onPost(submissionId),\n      handleCancel: context.onCancel\n        ? () =>\n            context.onCancel\n              ? context.onCancel(submissionId)\n              : () => {\n                  // eslint-disable-next-line lingui/no-unlocalized-strings, no-console\n                  console.warn('Cancel function not available');\n                }\n        : undefined,\n      handleArchive: context.onArchive\n        ? () =>\n            context.onArchive\n              ? context.onArchive(submissionId)\n              : () => {\n                  // eslint-disable-next-line lingui/no-unlocalized-strings, no-console\n                  console.warn('Archive function not available');\n                }\n        : undefined,\n      handleViewHistory: context.onViewHistory\n        ? () =>\n            context.onViewHistory\n              ? context.onViewHistory(submissionId)\n              : () => {\n                  // eslint-disable-next-line lingui/no-unlocalized-strings, no-console\n                  console.warn('View history function not available');\n                }\n        : undefined,\n      handleScheduleChange: (\n        schedule: ISubmissionScheduleInfo,\n        isScheduled: boolean,\n      ) => context.onScheduleChange(submissionId, schedule, isScheduled),\n      handleDefaultOptionChange: (update: Partial<IWebsiteFormFields>) =>\n        context.onDefaultOptionChange(submissionId, update),\n    }),\n    [context, submissionId],\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/hooks/use-submission-create.ts",
    "content": "/**\n * Hook for submission creation handlers (file upload, message creation).\n */\n\nimport {\n  Description,\n  IFileMetadata,\n  SubmissionId,\n  SubmissionRating,\n  SubmissionType,\n  Tag,\n} from '@postybirb/types';\nimport { useCallback, useRef, useState } from 'react';\nimport submissionApi from '../../../../api/submission.api';\nimport { showErrorNotification } from '../../../../utils/notifications';\n\n/**\n * Parameters for file submission upload.\n */\nexport interface FileSubmissionUploadParams {\n  files: File[];\n  fileMetadata: IFileMetadata[];\n  defaultOptions: {\n    tags?: Tag[];\n    description?: Description;\n    rating?: SubmissionRating;\n  };\n  templateId?: SubmissionId;\n}\n\ninterface UseSubmissionCreateProps {\n  /** Type of submissions (FILE or MESSAGE) */\n  submissionType: SubmissionType;\n}\n\ninterface UseSubmissionCreateResult {\n  /** File input ref for creating file submissions (legacy fallback) */\n  fileInputRef: React.RefObject<HTMLInputElement>;\n  /** Whether the file submission modal is open */\n  isFileModalOpen: boolean;\n  /** Open the file submission modal */\n  openFileModal: () => void;\n  /** Close the file submission modal */\n  closeFileModal: () => void;\n  /** Handle uploading files from the modal */\n  handleFileUpload: (params: FileSubmissionUploadParams) => Promise<void>;\n  /** Handle creating a new submission (opens modal for FILE, handled by header for MESSAGE) */\n  handleCreateSubmission: () => void;\n  /** Handle creating a message submission with a title */\n  handleCreateMessageSubmission: (title: string) => Promise<void>;\n  /** Handle file selection for new file submission (legacy/fallback) */\n  handleFileChange: (\n    event: React.ChangeEvent<HTMLInputElement>,\n  ) => Promise<void>;\n}\n\n/**\n * Hook for handling submission creation.\n */\nexport function useSubmissionCreate({\n  submissionType,\n}: UseSubmissionCreateProps): UseSubmissionCreateResult {\n  const fileInputRef = useRef<HTMLInputElement>(null);\n\n  // File submission modal state\n  const [isFileModalOpen, setIsFileModalOpen] = useState(false);\n\n  const openFileModal = useCallback(() => setIsFileModalOpen(true), []);\n  const closeFileModal = useCallback(() => setIsFileModalOpen(false), []);\n\n  // Handle uploading files from the modal\n  const handleFileUpload = useCallback(\n    async (params: FileSubmissionUploadParams) => {\n      const { files, fileMetadata, defaultOptions, templateId } = params;\n\n      // Convert Description to DescriptionValue for the API\n      const apiDefaultOptions = defaultOptions\n        ? {\n            tags: defaultOptions.tags,\n            rating: defaultOptions.rating,\n            description: defaultOptions.description\n              ? {\n                  overrideDefault: false,\n                  description: defaultOptions.description,\n                  insertTags: undefined,\n                  insertTitle: undefined,\n                }\n              : undefined,\n          }\n        : undefined;\n\n      // Create file submissions with metadata and default options\n      const response = await submissionApi.createFileSubmission({\n        type: SubmissionType.FILE,\n        files,\n        fileMetadata,\n        defaultOptions: apiDefaultOptions,\n      });\n\n      // Apply template if selected\n      if (templateId && response.body) {\n        const submissions = Array.isArray(response.body)\n          ? response.body\n          : [response.body];\n        await Promise.all(\n          submissions.map((sub: { id: SubmissionId }) =>\n            submissionApi.applyTemplate(sub.id, templateId),\n          ),\n        );\n      }\n    },\n    [],\n  );\n\n  // Handle creating a new submission\n  const handleCreateSubmission = useCallback(() => {\n    if (submissionType === SubmissionType.FILE) {\n      // Open the file submission modal\n      openFileModal();\n    }\n    // For MESSAGE type, the popover handles creation via handleCreateMessageSubmission\n  }, [submissionType, openFileModal]);\n\n  // Handle creating a message submission with title\n  const handleCreateMessageSubmission = useCallback(async (title: string) => {\n    try {\n      await submissionApi.create({\n        type: SubmissionType.MESSAGE,\n        // Backend data value - not UI text\n        // eslint-disable-next-line lingui/no-unlocalized-strings\n        name: title || 'New Message',\n      });\n    } catch {\n      showErrorNotification();\n    }\n  }, []);\n\n  // Handle file selection for new file submission\n  const handleFileChange = useCallback(\n    async (event: React.ChangeEvent<HTMLInputElement>) => {\n      const { files } = event.target;\n      if (!files || files.length === 0) return;\n\n      try {\n        await submissionApi.createFileSubmission(\n          SubmissionType.FILE,\n          Array.from(files),\n        );\n      } catch {\n        showErrorNotification();\n      }\n\n      // Reset the input\n      if (fileInputRef.current) {\n        fileInputRef.current.value = '';\n      }\n    },\n    [],\n  );\n\n  return {\n    fileInputRef,\n    isFileModalOpen,\n    openFileModal,\n    closeFileModal,\n    handleFileUpload,\n    handleCreateSubmission,\n    handleCreateMessageSubmission,\n    handleFileChange,\n  };\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/hooks/use-submission-delete.ts",
    "content": "/**\n * Hook for submission deletion handlers.\n */\n\nimport { useCallback } from 'react';\nimport submissionApi from '../../../../api/submission.api';\nimport { useNavigationStore } from '../../../../stores/ui/navigation-store';\nimport { type ViewState } from '../../../../types/view-state';\nimport {\n    showDeletedNotification,\n    showDeleteErrorNotification,\n} from '../../../../utils/notifications';\nimport { isSubmissionsViewState } from '../types';\n\ninterface UseSubmissionDeleteResult {\n  /** Handle deleting a submission */\n  handleDelete: (id: string) => Promise<void>;\n  /** Handle deleting all selected submissions */\n  handleDeleteSelected: () => Promise<void>;\n}\n\n/**\n * Hook for handling submission deletion.\n * Reads viewState at call time via getState() for stable callbacks.\n */\nexport function useSubmissionDelete(): UseSubmissionDeleteResult {\n  const setViewState = useNavigationStore((state) => state.setViewState);\n\n  // Handle deleting a submission — reads state at call time\n  const handleDelete = useCallback(\n    async (id: string) => {\n      try {\n        await submissionApi.remove([id]);\n        showDeletedNotification(1);\n\n        // Remove from selection if selected\n        const currentViewState = useNavigationStore.getState().viewState;\n        if (isSubmissionsViewState(currentViewState)) {\n          const currentSelectedIds = currentViewState.params.selectedIds;\n          if (currentSelectedIds.includes(id)) {\n            setViewState({\n              ...currentViewState,\n              params: {\n                ...currentViewState.params,\n                selectedIds: currentSelectedIds.filter((sid) => sid !== id),\n              },\n            } as ViewState);\n          }\n        }\n      } catch {\n        showDeleteErrorNotification();\n      }\n    },\n    [setViewState],\n  );\n\n  // Handle deleting all selected submissions — reads state at call time\n  const handleDeleteSelected = useCallback(async () => {\n    const currentViewState = useNavigationStore.getState().viewState;\n    if (!isSubmissionsViewState(currentViewState)) return;\n\n    const currentSelectedIds = currentViewState.params.selectedIds;\n    if (currentSelectedIds.length === 0) return;\n\n    try {\n      await submissionApi.remove(currentSelectedIds);\n      showDeletedNotification(currentSelectedIds.length);\n\n      // Clear selection\n      setViewState({\n        ...currentViewState,\n        params: {\n          ...currentViewState.params,\n          selectedIds: [],\n          mode: 'single',\n        },\n      } as ViewState);\n    } catch {\n      showDeleteErrorNotification();\n    }\n  }, [setViewState]);\n\n  return {\n    handleDelete,\n    handleDeleteSelected,\n  };\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/hooks/use-submission-handlers.ts",
    "content": "/**\n * Hook for submission action handlers.\n * Composes smaller focused hooks for better maintainability.\n */\n\nimport {\n    ISubmissionScheduleInfo,\n    IWebsiteFormFields,\n    PostRecordResumeMode,\n    SubmissionType,\n} from '@postybirb/types';\nimport {\n    FileSubmissionUploadParams,\n    useSubmissionCreate,\n} from './use-submission-create';\nimport { useSubmissionDelete } from './use-submission-delete';\nimport { useSubmissionPost } from './use-submission-post';\nimport { useSubmissionUpdate } from './use-submission-update';\n\n// Re-export types for convenience\nexport type { FileSubmissionUploadParams };\n\ninterface UseSubmissionHandlersProps {\n  /** Type of submissions (FILE or MESSAGE) */\n  submissionType: SubmissionType;\n}\n\ninterface UseSubmissionHandlersResult {\n  /** File input ref for creating file submissions (legacy fallback) */\n  fileInputRef: React.RefObject<HTMLInputElement>;\n  /** Whether the file submission modal is open */\n  isFileModalOpen: boolean;\n  /** Open the file submission modal */\n  openFileModal: () => void;\n  /** Close the file submission modal */\n  closeFileModal: () => void;\n  /** Handle uploading files from the modal */\n  handleFileUpload: (params: FileSubmissionUploadParams) => Promise<void>;\n  /** Handle creating a new submission (opens modal for FILE, handled by header for MESSAGE) */\n  handleCreateSubmission: () => void;\n  /** Handle creating a message submission with a title */\n  handleCreateMessageSubmission: (title: string) => Promise<void>;\n  /** Handle file selection for new file submission (legacy/fallback) */\n  handleFileChange: (\n    event: React.ChangeEvent<HTMLInputElement>,\n  ) => Promise<void>;\n  /** Handle deleting a submission */\n  handleDelete: (id: string) => Promise<void>;\n  /** Handle deleting all selected submissions */\n  handleDeleteSelected: () => Promise<void>;\n  /** Handle posting submissions with specified order */\n  handlePostSelected: (orderedIds: string[]) => Promise<void>;\n  /** Handle duplicating a submission */\n  handleDuplicate: (id: string) => Promise<void>;\n  /** Handle archiving a submission */\n  handleArchive: (id: string) => Promise<void>;\n  /** Handle editing a submission (select it) */\n  handleEdit: (id: string) => void;\n  /** Handle changing a default option field (title, tags, rating, etc.) */\n  handleDefaultOptionChange: (\n    id: string,\n    update: Partial<IWebsiteFormFields>,\n  ) => Promise<void>;\n  /** Handle posting a submission */\n  handlePost: (id: string) => Promise<void>;\n  /** Handle canceling a queued/posting submission */\n  handleCancel: (id: string) => Promise<void>;\n  /** Handle schedule changes */\n  handleScheduleChange: (\n    id: string,\n    schedule: ISubmissionScheduleInfo,\n    isScheduled: boolean,\n  ) => Promise<void>;\n  /** ID of submission waiting for resume mode selection */\n  pendingResumeSubmissionId: string | null;\n  /** Close the resume mode modal without posting */\n  cancelResume: () => void;\n  /** Post with the selected resume mode */\n  confirmResume: (resumeMode: PostRecordResumeMode) => Promise<void>;\n}\n\n/**\n * Hook for handling submission actions like delete, duplicate, edit, etc.\n * Composes useSubmissionCreate, useSubmissionDelete, useSubmissionPost, and useSubmissionUpdate.\n */\nexport function useSubmissionHandlers({\n  submissionType,\n}: UseSubmissionHandlersProps): UseSubmissionHandlersResult {\n  // Compose smaller hooks\n  const {\n    fileInputRef,\n    isFileModalOpen,\n    openFileModal,\n    closeFileModal,\n    handleFileUpload,\n    handleCreateSubmission,\n    handleCreateMessageSubmission,\n    handleFileChange,\n  } = useSubmissionCreate({ submissionType });\n\n  const { handleDelete, handleDeleteSelected } = useSubmissionDelete();\n\n  const {\n    handlePost,\n    handleCancel,\n    handlePostSelected,\n    pendingResumeSubmissionId,\n    cancelResume,\n    confirmResume,\n  } = useSubmissionPost();\n\n  const {\n    handleDuplicate,\n    handleArchive,\n    handleEdit,\n    handleDefaultOptionChange,\n    handleScheduleChange,\n  } = useSubmissionUpdate();\n\n  return {\n    fileInputRef,\n    isFileModalOpen,\n    openFileModal,\n    closeFileModal,\n    handleFileUpload,\n    handleCreateSubmission,\n    handleCreateMessageSubmission,\n    handleFileChange,\n    handleDelete,\n    handleDeleteSelected,\n    handleDuplicate,\n    handleArchive,\n    handleEdit,\n    handleDefaultOptionChange,\n    handlePost,\n    handleCancel,\n    handlePostSelected,\n    handleScheduleChange,\n    pendingResumeSubmissionId,\n    cancelResume,\n    confirmResume,\n  };\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/hooks/use-submission-post.ts",
    "content": "/**\n * Hook for submission posting handlers.\n */\n\nimport { PostRecordResumeMode, PostRecordState } from '@postybirb/types';\nimport { useCallback, useState } from 'react';\nimport postManagerApi from '../../../../api/post-manager.api';\nimport postQueueApi from '../../../../api/post-queue.api';\nimport { useSubmissionStore } from '../../../../stores';\nimport { useNavigationStore } from '../../../../stores/ui/navigation-store';\nimport { type ViewState } from '../../../../types/view-state';\nimport { showPostErrorNotification } from '../../../../utils/notifications';\nimport { isSubmissionsViewState } from '../types';\n\ninterface UseSubmissionPostResult {\n  /** Handle posting a submission */\n  handlePost: (id: string) => Promise<void>;\n  /** Handle canceling a queued/posting submission */\n  handleCancel: (id: string) => Promise<void>;\n  /** Handle posting submissions with specified order */\n  handlePostSelected: (orderedIds: string[], resumeMode?: PostRecordResumeMode) => Promise<void>;\n  /** ID of submission waiting for resume mode selection */\n  pendingResumeSubmissionId: string | null;\n  /** Close the resume mode modal without posting */\n  cancelResume: () => void;\n  /** Post with the selected resume mode */\n  confirmResume: (resumeMode: PostRecordResumeMode) => Promise<void>;\n}\n\n/**\n * Hook for handling submission posting.\n * Reads store state at call time via getState() for stable callbacks.\n */\nexport function useSubmissionPost(): UseSubmissionPostResult {\n  const setViewState = useNavigationStore((state) => state.setViewState);\n  const [pendingResumeSubmissionId, setPendingResumeSubmissionId] = useState<\n    string | null\n  >(null);\n\n  // Handle posting a submission — reads submissionsMap at call time\n  const handlePost = useCallback(\n    async (id: string) => {\n      try {\n        // Get the submission to check if last post failed\n        const submission = useSubmissionStore.getState().recordsMap.get(id);\n\n        if (!submission) {\n          showPostErrorNotification();\n          return;\n        }\n\n        // Check if the last post record was failed\n        const lastPost = submission.latestPost;\n        const shouldPromptResumeMode =\n          lastPost && lastPost.state === PostRecordState.FAILED;\n\n        if (shouldPromptResumeMode) {\n          // Set pending state to show the modal\n          setPendingResumeSubmissionId(id);\n          return;\n        }\n\n        // No failed post, proceed normally\n        await postQueueApi.enqueue([id]);\n      } catch {\n        showPostErrorNotification();\n      }\n    },\n    [],\n  );\n\n  // Cancel resume mode selection\n  const cancelResume = useCallback(() => {\n    setPendingResumeSubmissionId(null);\n  }, []);\n\n  // Confirm and post with selected resume mode\n  const confirmResume = useCallback(\n    async (resumeMode: PostRecordResumeMode) => {\n      if (!pendingResumeSubmissionId) return;\n\n      try {\n        await postQueueApi.enqueue([pendingResumeSubmissionId], resumeMode);\n        setPendingResumeSubmissionId(null);\n      } catch {\n        showPostErrorNotification();\n        setPendingResumeSubmissionId(null);\n      }\n    },\n    [pendingResumeSubmissionId],\n  );\n\n  // Handle canceling a queued/posting submission\n  const handleCancel = useCallback(async (id: string) => {\n    try {\n      await postManagerApi.cancelIfRunning(id);\n    } catch {\n      // Silently handle if not running\n    }\n  }, []);\n\n  // Handle posting submissions in the specified order — reads viewState at call time\n  const handlePostSelected = useCallback(\n    async (orderedIds: string[], resumeMode?: PostRecordResumeMode) => {\n      if (orderedIds.length === 0) return;\n\n      try {\n        await postQueueApi.enqueue(orderedIds, resumeMode);\n\n        // Clear selection after posting\n        const currentViewState = useNavigationStore.getState().viewState;\n        if (isSubmissionsViewState(currentViewState)) {\n          setViewState({\n            ...currentViewState,\n            params: {\n              ...currentViewState.params,\n              selectedIds: [],\n              mode: 'single',\n            },\n          } as ViewState);\n        }\n      } catch {\n        showPostErrorNotification();\n      }\n    },\n    [setViewState]\n  );\n\n  return {\n    handlePost,\n    handleCancel,\n    handlePostSelected,\n    pendingResumeSubmissionId,\n    cancelResume,\n    confirmResume,\n  };\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/hooks/use-submission-selection.ts",
    "content": "/**\n * Hook for managing submission selection with multi-select support.\n */\n\nimport { useCallback, useEffect, useMemo, useRef } from 'react';\nimport type { SubmissionRecord } from '../../../../stores/records';\nimport { useNavigationStore } from '../../../../stores/ui/navigation-store';\nimport { type ViewState } from '../../../../types/view-state';\nimport type { SelectionState } from '../submission-section-header';\nimport { isSubmissionsViewState } from '../types';\n\ninterface UseSubmissionSelectionProps {\n  /** Current view state */\n  viewState: ViewState;\n  /** Ordered submissions list */\n  orderedSubmissions: SubmissionRecord[];\n}\n\ninterface UseSubmissionSelectionResult {\n  /** Currently selected IDs */\n  selectedIds: string[];\n  /** Selection state for checkbox (none/partial/all) */\n  selectionState: SelectionState;\n  /** Handle selecting a submission (supports shift+click, ctrl+click, checkbox toggle, and keyboard) */\n  handleSelect: (id: string, event: React.MouseEvent | React.KeyboardEvent, isCheckbox?: boolean) => void;\n  /** Toggle select all/none */\n  handleToggleSelectAll: () => void;\n  /** Update selection programmatically */\n  setSelectedIds: (ids: string[]) => void;\n}\n\n/**\n * Hook for managing submission selection with support for:\n * - Single click selection\n * - Ctrl/Cmd+click multi-select toggle\n * - Shift+click range selection\n * - Select all/none toggle\n */\nexport function useSubmissionSelection({\n  viewState,\n  orderedSubmissions,\n}: UseSubmissionSelectionProps): UseSubmissionSelectionResult {\n  const setViewState = useNavigationStore((state) => state.setViewState);\n\n  // Get selected IDs from view state (memoized to prevent unnecessary rerenders)\n  const selectedIds = useMemo(() => {\n    if (isSubmissionsViewState(viewState)) {\n      return viewState.params.selectedIds;\n    }\n    return [];\n  }, [viewState]);\n\n  // Track the last selected item for shift+click range selection\n  const lastSelectedIdRef = useRef<string | null>(null);\n\n  // Keep a ref to orderedSubmissions so callbacks can read current value without re-creating\n  const orderedSubmissionsRef = useRef(orderedSubmissions);\n  useEffect(() => {\n    orderedSubmissionsRef.current = orderedSubmissions;\n  }, [orderedSubmissions]);\n\n  // Compute selection state for the header checkbox\n  const selectionState: SelectionState = useMemo(() => {\n    if (selectedIds.length === 0) return 'none';\n    if (\n      selectedIds.length === orderedSubmissions.length &&\n      orderedSubmissions.length > 0\n    )\n      return 'all';\n    return 'partial';\n  }, [selectedIds.length, orderedSubmissions.length]);\n\n  // Update view state with new selection — reads viewState at call time for stability\n  const updateSelection = useCallback(\n    (newSelectedIds: string[]) => {\n      const currentViewState = useNavigationStore.getState().viewState;\n      if (!isSubmissionsViewState(currentViewState)) return;\n\n      const newParams = {\n        ...currentViewState.params,\n        selectedIds: newSelectedIds,\n        mode: newSelectedIds.length > 1 ? 'multi' : 'single',\n      };\n\n      setViewState({\n        ...currentViewState,\n        params: newParams,\n      } as ViewState);\n    },\n    [setViewState],\n  );\n\n  // Handle selecting a submission — reads viewState and orderedSubmissions at call time\n  const handleSelect = useCallback(\n    (id: string, event: React.MouseEvent | React.KeyboardEvent, isCheckbox = false) => {\n      const currentViewState = useNavigationStore.getState().viewState;\n      if (!isSubmissionsViewState(currentViewState)) return;\n\n      const currentSelectedIds = currentViewState.params.selectedIds;\n      const submissions = orderedSubmissionsRef.current;\n      let newSelectedIds: string[];\n\n      // If no anchor exists yet, set it to the clicked item\n      if (!lastSelectedIdRef.current) {\n        lastSelectedIdRef.current = id;\n      }\n\n      if (event.shiftKey) {\n        // Shift+click: select range from anchor to current\n        const anchorIndex = submissions.findIndex(\n          (s) => s.id === lastSelectedIdRef.current,\n        );\n        const currentIndex = submissions.findIndex((s) => s.id === id);\n\n        if (anchorIndex !== -1 && currentIndex !== -1) {\n          const startIndex = Math.min(anchorIndex, currentIndex);\n          const endIndex = Math.max(anchorIndex, currentIndex);\n          const rangeIds = submissions\n            .slice(startIndex, endIndex + 1)\n            .map((s) => s.id);\n\n          // Merge with existing selection if Ctrl is also held\n          if (event.ctrlKey || event.metaKey) {\n            const combined = new Set([...currentSelectedIds, ...rangeIds]);\n            newSelectedIds = [...combined];\n          } else {\n            newSelectedIds = rangeIds;\n          }\n          // Note: Don't update anchor on shift-click - keep it stable for range extension\n        } else {\n          // Fallback to single selection if indices not found\n          newSelectedIds = [id];\n          lastSelectedIdRef.current = id;\n        }\n      } else if (event.ctrlKey || event.metaKey || isCheckbox) {\n        // Toggle selection with Ctrl/Cmd click OR checkbox click\n        // Checkbox clicks should always toggle, not replace selection\n        if (currentSelectedIds.includes(id)) {\n          newSelectedIds = currentSelectedIds.filter((sid: string) => sid !== id);\n        } else {\n          newSelectedIds = [...currentSelectedIds, id];\n        }\n        // Update anchor on ctrl-click/checkbox to enable shift-extending from this item\n        lastSelectedIdRef.current = id;\n      } else {\n        // Single selection (card click without modifiers)\n        newSelectedIds = [id];\n        lastSelectedIdRef.current = id;\n      }\n\n      updateSelection(newSelectedIds);\n    },\n    [updateSelection],\n  );\n\n  // Handle toggling select all/none — reads current state at call time\n  const handleToggleSelectAll = useCallback(() => {\n    const currentViewState = useNavigationStore.getState().viewState;\n    if (!isSubmissionsViewState(currentViewState)) return;\n\n    const currentSelectedIds = currentViewState.params.selectedIds;\n    const submissions = orderedSubmissionsRef.current;\n    const isAllSelected =\n      currentSelectedIds.length === submissions.length && submissions.length > 0;\n\n    const newSelectedIds = isAllSelected\n      ? [] // Deselect all\n      : submissions.map((s) => s.id); // Select all\n\n    updateSelection(newSelectedIds);\n  }, [updateSelection]);\n\n  // Set selected IDs programmatically\n  const setSelectedIds = useCallback(\n    (ids: string[]) => {\n      updateSelection(ids);\n    },\n    [updateSelection],\n  );\n\n  return {\n    selectedIds,\n    selectionState,\n    handleSelect,\n    handleToggleSelectAll,\n    setSelectedIds,\n  };\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/hooks/use-submission-update.ts",
    "content": "/**\n * Hook for submission update handlers (edit, duplicate, archive, schedule, default options).\n */\n\nimport { ISubmissionScheduleInfo, IWebsiteFormFields } from '@postybirb/types';\nimport { useCallback } from 'react';\nimport submissionApi from '../../../../api/submission.api';\nimport websiteOptionsApi from '../../../../api/website-options.api';\nimport { useSubmissionStore } from '../../../../stores/entity/submission-store';\nimport { useNavigationStore } from '../../../../stores/ui/navigation-store';\nimport { type ViewState } from '../../../../types/view-state';\nimport {\n    showDuplicateErrorNotification,\n    showErrorNotification,\n    showUpdateErrorNotification,\n} from '../../../../utils/notifications';\nimport { isSubmissionsViewState } from '../types';\n\ninterface UseSubmissionUpdateResult {\n  /** Handle duplicating a submission */\n  handleDuplicate: (id: string) => Promise<void>;\n  /** Handle archiving a submission */\n  handleArchive: (id: string) => Promise<void>;\n  /** Handle editing a submission (select it) */\n  handleEdit: (id: string) => void;\n  /** Handle changing a default option field (title, tags, rating, etc.) */\n  handleDefaultOptionChange: (\n    id: string,\n    update: Partial<IWebsiteFormFields>,\n  ) => Promise<void>;\n  /** Handle schedule changes */\n  handleScheduleChange: (\n    id: string,\n    schedule: ISubmissionScheduleInfo,\n    isScheduled: boolean,\n  ) => Promise<void>;\n}\n\n/**\n * Hook for handling submission updates.\n * Reads viewState at call time via getState() for stable callbacks.\n */\nexport function useSubmissionUpdate(): UseSubmissionUpdateResult {\n  const setViewState = useNavigationStore((state) => state.setViewState);\n\n  // Handle duplicating a submission\n  const handleDuplicate = useCallback(async (id: string) => {\n    try {\n      await submissionApi.duplicate(id);\n    } catch {\n      showDuplicateErrorNotification();\n    }\n  }, []);\n\n  // Handle archiving a submission\n  const handleArchive = useCallback(async (id: string) => {\n    try {\n      await submissionApi.archive(id);\n    } catch {\n      showErrorNotification();\n    }\n  }, []);\n\n  // Handle editing a submission (select it) — reads viewState at call time\n  const handleEdit = useCallback(\n    (id: string) => {\n      const currentViewState = useNavigationStore.getState().viewState;\n      if (!isSubmissionsViewState(currentViewState)) return;\n      setViewState({\n        ...currentViewState,\n        params: {\n          ...currentViewState.params,\n          selectedIds: [id],\n          mode: 'single',\n        },\n      } as ViewState);\n    },\n    [setViewState],\n  );\n\n  // Handle changing any default option field (title, tags, rating, etc.)\n  // Uses getState() to get current submission at call time, avoiding stale closures\n  const handleDefaultOptionChange = useCallback(\n    async (id: string, update: Partial<IWebsiteFormFields>) => {\n      const submission = useSubmissionStore.getState().recordsMap.get(id);\n      if (!submission) return;\n\n      const defaultOptions = submission.getDefaultOptions();\n      if (!defaultOptions) return;\n\n      try {\n        await websiteOptionsApi.update(defaultOptions.id, {\n          data: {\n            ...defaultOptions.data,\n            ...update,\n          },\n        });\n      } catch {\n        showUpdateErrorNotification();\n      }\n    },\n    [],\n  );\n\n  // Handle scheduling a submission\n  const handleScheduleChange = useCallback(\n    async (\n      id: string,\n      schedule: ISubmissionScheduleInfo,\n      isScheduled: boolean,\n    ) => {\n      try {\n        await submissionApi.update(id, {\n          isScheduled,\n          ...schedule,\n        });\n      } catch {\n        showUpdateErrorNotification();\n      }\n    },\n    [],\n  );\n\n  return {\n    handleDuplicate,\n    handleArchive,\n    handleEdit,\n    handleDefaultOptionChange,\n    handleScheduleChange,\n  };\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/hooks/use-submissions.ts",
    "content": "/**\n * Hook for filtering and ordering submissions by type.\n */\n\nimport { SubmissionType } from '@postybirb/types';\nimport { useEffect, useMemo, useState } from 'react';\nimport { useSubmissionsByType } from '../../../../stores/entity/submission-store';\nimport type { SubmissionRecord } from '../../../../stores/records';\nimport { useSubmissionsFilter } from '../../../../stores/ui/submissions-ui-store';\n\ninterface UseSubmissionsResult {\n  /** All submissions of the given type (unfiltered) */\n  allSubmissions: SubmissionRecord[];\n  /** Filtered submissions based on search and filter */\n  filteredSubmissions: SubmissionRecord[];\n  /** Ordered submissions (for optimistic reordering) */\n  orderedSubmissions: SubmissionRecord[];\n  /** Update ordered submissions */\n  setOrderedSubmissions: React.Dispatch<\n    React.SetStateAction<SubmissionRecord[]>\n  >;\n  /** Current filter value */\n  filter: string;\n  /** Current search query */\n  searchQuery: string;\n  /** Whether drag is enabled (only when not filtering) */\n  isDragEnabled: boolean;\n}\n\ninterface UseSubmissionsProps {\n  /** Type of submissions to filter (FILE or MESSAGE) */\n  submissionType: SubmissionType;\n}\n\n/**\n * Hook for filtering, searching, and ordering submissions.\n */\nexport function useSubmissions({\n  submissionType,\n}: UseSubmissionsProps): UseSubmissionsResult {\n  const allSubmissions = useSubmissionsByType(submissionType);\n  const { filter, searchQuery } = useSubmissionsFilter(submissionType);\n\n  // Filter submissions based on search query and filter\n  const filteredSubmissions = useMemo(() => {\n    let result = allSubmissions.filter(\n      (s) => !s.isTemplate && !s.isMultiSubmission && !s.isArchived\n    );\n\n    // Sort by order\n    result = result.sort((a, b) => a.order - b.order);\n\n    // Apply status filter\n    switch (filter) {\n      case 'queued':\n        result = result\n          .filter((s) => s.isQueued)\n          .sort((a, b) => {\n            // Sort by postQueueRecord.createdAt ascending (oldest first = top of queue)\n            const aCreatedAt = a.postQueueRecord?.createdAt ?? '';\n            const bCreatedAt = b.postQueueRecord?.createdAt ?? '';\n            return aCreatedAt.localeCompare(bCreatedAt);\n          });\n        break;\n      case 'scheduled':\n        result = result.filter((s) => s.isScheduled);\n        break;\n      case 'posted':\n        result = result.filter((s) => s.isArchived);\n        break;\n      case 'failed':\n        result = result.filter((s) => s.hasErrors);\n        break;\n      default:\n        // 'all' - no additional filtering\n        break;\n    }\n\n    // Apply search filter\n    if (searchQuery) {\n      const query = searchQuery.toLowerCase();\n      result = result.filter((s) => s.title.toLowerCase().includes(query));\n    }\n\n    return result;\n  }, [allSubmissions, filter, searchQuery]);\n\n  // Local ordered state for optimistic reordering\n  const [orderedSubmissions, setOrderedSubmissions] =\n    useState<SubmissionRecord[]>(filteredSubmissions);\n\n  // Sync ordered submissions with filtered submissions\n  useEffect(() => {\n    setOrderedSubmissions(filteredSubmissions);\n  }, [filteredSubmissions]);\n\n  // Only enable drag when showing 'all' filter (no filtering applied)\n  const isDragEnabled = filter === 'all' && !searchQuery;\n\n  return {\n    allSubmissions,\n    filteredSubmissions,\n    orderedSubmissions,\n    setOrderedSubmissions,\n    filter,\n    searchQuery,\n    isDragEnabled,\n  };\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/index.ts",
    "content": "/**\n * Submissions section components barrel export.\n * Works for both FILE and MESSAGE submission types.\n */\n\nexport * from './archived-submission-list';\nexport * from './context';\nexport * from './hooks';\nexport * from './submission-card';\nexport * from './submission-list';\nexport * from './submission-section-header';\nexport * from './submissions-content';\nexport * from './submissions-section';\nexport * from './types';\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/post-confirm-modal/index.ts",
    "content": "export { PostConfirmModal, type PostConfirmModalProps } from './post-confirm-modal';\n\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx",
    "content": "/**\n * PostConfirmModal - Modal for confirming and reordering submissions before posting.\n * Displays a reorderable list allowing users to set the queue order.\n */\n\nimport { Trans, useLingui } from '@lingui/react/macro';\nimport { Alert, Button, Group, Modal, Radio, Stack, Text } from '@mantine/core';\nimport { PostRecordResumeMode, PostRecordState } from '@postybirb/types';\nimport { IconAlertCircle } from '@tabler/icons-react';\nimport { useCallback, useEffect, useMemo, useState } from 'react';\nimport type { SubmissionRecord } from '../../../../stores/records';\nimport { ReorderableSubmissionList } from '../../../shared/reorderable-submission-list';\n\nexport interface PostConfirmModalProps {\n  /** Whether the modal is open */\n  opened: boolean;\n  /** Handler to close the modal */\n  onClose: () => void;\n  /** Handler when user confirms - receives the ordered submission IDs and optional resume mode */\n  onConfirm: (orderedIds: string[], resumeMode?: PostRecordResumeMode) => void;\n  /** All selected submissions (will be filtered to only valid ones) */\n  selectedSubmissions: SubmissionRecord[];\n  /** Total number of selected submissions (including invalid) */\n  totalSelectedCount: number;\n  /** Whether the confirm action is loading */\n  loading?: boolean;\n}\n\n/**\n * Modal for confirming submission posting with reorderable queue.\n * Shows only valid submissions (with website options and no errors).\n */\nexport function PostConfirmModal({\n  opened,\n  onClose,\n  onConfirm,\n  selectedSubmissions,\n  totalSelectedCount,\n  loading = false,\n}: PostConfirmModalProps) {\n  const { t } = useLingui();\n\n  // Filter to only valid submissions that can be posted\n  const validSubmissions = selectedSubmissions.filter(\n    (s) => s.hasWebsiteOptions && !s.hasErrors,\n  );\n\n  // Check if any submission has a failed last post\n  const hasFailedPosts = useMemo(\n    () =>\n      validSubmissions.some((s) => {\n        const lastPost = s.latestPost;\n        return lastPost && lastPost.state === PostRecordState.FAILED;\n      }),\n    [validSubmissions],\n  );\n\n  // Track the ordered list (reset when modal opens with new submissions)\n  const [orderedSubmissions, setOrderedSubmissions] = useState<\n    SubmissionRecord[]\n  >([]);\n\n  // Track selected resume mode\n  const [resumeMode, setResumeMode] = useState<PostRecordResumeMode>(\n    PostRecordResumeMode.CONTINUE,\n  );\n\n  // Reset order when modal opens or submissions change\n  useEffect(() => {\n    if (opened) {\n      setOrderedSubmissions(validSubmissions);\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [opened]);\n\n  const handleConfirm = useCallback(() => {\n    const orderedIds = orderedSubmissions.map((s) => s.id);\n    onConfirm(orderedIds, hasFailedPosts ? resumeMode : undefined);\n    onClose();\n  }, [orderedSubmissions, onConfirm, onClose, hasFailedPosts, resumeMode]);\n\n  const validCount = validSubmissions.length;\n  const hasSkippedSubmissions = validCount < totalSelectedCount;\n\n  return (\n    <Modal\n      opened={opened}\n      onClose={onClose}\n      title={<Trans>Post Submissions</Trans>}\n      centered\n      radius=\"md\"\n      size=\"md\"\n    >\n      <Stack>\n        {/* Info message */}\n        <Text size=\"sm\">\n          {hasSkippedSubmissions ? (\n            <Trans>\n              {validCount} of {totalSelectedCount} selected submission(s) are\n              ready to post. Submissions without websites or with validation\n              errors will be skipped.\n            </Trans>\n          ) : (\n            <Trans>\n              {validCount} submission(s) will be posted in the order shown\n              below.\n            </Trans>\n          )}\n        </Text>\n\n        {/* Resume mode selector for failed posts */}\n        {hasFailedPosts && (\n          <Alert\n            icon={<IconAlertCircle size={16} />}\n            title={<Trans>Failed Posts Detected</Trans>}\n            color=\"orange\"\n          >\n            <Stack gap=\"sm\">\n              <Text size=\"sm\">\n                <Trans>\n                  Some submissions have failed posting attempts. Choose how to\n                  handle them:\n                </Trans>\n              </Text>\n              <Radio.Group\n                value={resumeMode}\n                onChange={(value) =>\n                  setResumeMode(value as PostRecordResumeMode)\n                }\n              >\n                <Stack gap=\"xs\">\n                  <Radio\n                    value={PostRecordResumeMode.CONTINUE}\n                    label={t`Continue from where it left off`}\n                    description={t`Skip websites that posted successfully and only attempt failed or unattempted ones.`}\n                  />\n                  <Radio\n                    value={PostRecordResumeMode.CONTINUE_RETRY}\n                    label={t`Retry all failed or unattempted websites`}\n                    description={t`Retry posting to all websites that failed or were not attempted. All files will be re-uploaded.`}\n                  />\n                  <Radio\n                    value={PostRecordResumeMode.NEW}\n                    label={t`Start completely fresh`}\n                    description={t`Discard all progress from the previous attempt and start as if this were the first time posting.`}\n                  />\n                </Stack>\n              </Radio.Group>\n            </Stack>\n          </Alert>\n        )}\n\n        {/* Reorderable list */}\n        {validCount > 0 && (\n          <ReorderableSubmissionList\n            submissions={orderedSubmissions}\n            onReorder={setOrderedSubmissions}\n            maxHeight=\"300px\"\n          />\n        )}\n\n        {/* Action buttons */}\n        <Group justify=\"flex-end\">\n          <Button variant=\"default\" onClick={onClose} disabled={loading}>\n            <Trans>Cancel</Trans>\n          </Button>\n          <Button\n            color=\"blue\"\n            onClick={handleConfirm}\n            loading={loading}\n            disabled={validCount === 0}\n          >\n            <Trans>Post</Trans>\n          </Button>\n        </Group>\n      </Stack>\n    </Modal>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/resume-mode-modal/index.ts",
    "content": "export { ResumeModeModal } from './resume-mode-modal';\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx",
    "content": "/**\n * ResumeModeModal - Modal for selecting how to resume a failed posting attempt.\n * Displays user-friendly descriptions for each resume mode option.\n */\n\nimport { Trans, useLingui } from '@lingui/react/macro';\nimport { Button, Group, Modal, Radio, Stack, Text } from '@mantine/core';\nimport { PostRecordResumeMode } from '@postybirb/types';\nimport { useState } from 'react';\n\ninterface ResumeModeModalProps {\n  /** Whether the modal is open */\n  opened: boolean;\n  /** Called when the modal should close without posting */\n  onClose: () => void;\n  /** Called when the user selects a resume mode and confirms */\n  onConfirm: (resumeMode: PostRecordResumeMode) => void;\n}\n\n/**\n * Modal that allows the user to select how to handle a failed posting attempt.\n * Shows three options with clear descriptions:\n * - CONTINUE: Resume from where it left off\n * - CONTINUE_RETRY: Retry failed websites but keep successful ones\n * - NEW: Start completely fresh\n */\nexport function ResumeModeModal({\n  opened,\n  onClose,\n  onConfirm,\n}: ResumeModeModalProps) {\n  const { t } = useLingui();\n  const [selectedMode, setSelectedMode] = useState<PostRecordResumeMode>(\n    PostRecordResumeMode.CONTINUE,\n  );\n\n  const handleConfirm = () => {\n    onConfirm(selectedMode);\n  };\n\n  return (\n    <Modal\n      opened={opened}\n      onClose={onClose}\n      title={<Trans>Resume Failed Posting</Trans>}\n      size=\"lg\"\n    >\n      <Stack gap=\"md\">\n        <Text size=\"sm\" c=\"dimmed\">\n          <Trans>\n            The last posting attempt failed. How would you like to proceed?\n          </Trans>\n        </Text>\n\n        <Radio.Group\n          value={selectedMode}\n          onChange={(value) => setSelectedMode(value as PostRecordResumeMode)}\n        >\n          <Stack gap=\"md\">\n            <Radio\n              value={PostRecordResumeMode.CONTINUE}\n              label={t`Continue from where it left off`}\n              description={t`Skip websites that posted successfully and only attempt failed or unattempted ones. Continues from the last successful batch of files.`}\n            />\n\n            <Radio\n              value={PostRecordResumeMode.CONTINUE_RETRY}\n              label={t`Retry all failed or unattempted websites`}\n              description={t`Retry posting to all websites that failed or were not attempted. All files will be re-uploaded.`}\n            />\n\n            <Radio\n              value={PostRecordResumeMode.NEW}\n              label={t`Start completely fresh`}\n              description={t`Discard all progress from the previous attempt and start as if this were the first time posting.`}\n            />\n          </Stack>\n        </Radio.Group>\n\n        <Group justify=\"flex-end\" mt=\"md\">\n          <Button variant=\"default\" onClick={onClose}>\n            <Trans>Cancel</Trans>\n          </Button>\n          <Button onClick={handleConfirm}>\n            <Trans>Post</Trans>\n          </Button>\n        </Group>\n      </Stack>\n    </Modal>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-card/archived-submission-card.tsx",
    "content": "/**\n * ArchivedSubmissionCard - Card for archived submissions with limited actions.\n * Only allows viewing, unarchiving, viewing history, and deleting.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport {\n    ActionIcon,\n    Card,\n    Group,\n    Menu,\n    Stack,\n    Text,\n    Tooltip,\n} from '@mantine/core';\nimport { SubmissionType } from '@postybirb/types';\nimport {\n    IconArchiveOff,\n    IconDotsVertical,\n    IconHistory,\n    IconTrash\n} from '@tabler/icons-react';\nimport { memo, useCallback, useMemo } from 'react';\nimport submissionApi from '../../../../api/submission.api';\nimport { useLocale } from '../../../../hooks';\nimport {\n    showDeletedNotification,\n    showDeleteErrorNotification,\n    showRestoredNotification,\n    showRestoreErrorNotification,\n} from '../../../../utils/notifications';\nimport { useSubmissionsActions } from '../context';\nimport { SubmissionBadges } from './submission-badges';\nimport { SubmissionThumbnail } from './submission-thumbnail';\nimport { SubmissionTitle } from './submission-title';\nimport type { SubmissionCardProps } from './types';\nimport { getThumbnailUrl } from './utils';\n\ninterface ArchivedSubmissionCardProps extends Omit<\n  SubmissionCardProps,\n  'draggable'\n> {\n  /** Whether to show compact view (hides last modified) */\n  isCompact?: boolean;\n  /** Handler to open history drawer */\n  onViewHistory?: () => void;\n}\n\n/**\n * Card component for archived submissions with limited actions.\n */\nexport const ArchivedSubmissionCard = memo(({\n  submission,\n  submissionType,\n  isSelected = false,\n  isCompact = false,\n  className,\n  onViewHistory,\n}: ArchivedSubmissionCardProps) => {\n  const { onSelect } = useSubmissionsActions();\n  const { formatRelativeTime, formatDateTime } = useLocale();\n  const thumbnailUrl = getThumbnailUrl(submission);\n\n  // Check if the primary file is an image that can be previewed\n  const canPreviewImage =\n    submissionType === SubmissionType.FILE &&\n    submission.primaryFile?.mimeType?.startsWith('image/');\n\n  const showThumbnail = submissionType === SubmissionType.FILE;\n\n  const cardClassName = useMemo(() => {\n    const classes = ['postybirb__submission__card'];\n    if (isSelected) classes.push('postybirb__submission__card--selected');\n    if (isCompact) classes.push('postybirb__submission__card--compact');\n    if (className) classes.push(className);\n    return classes.join(' ');\n  }, [isSelected, isCompact, className]);\n\n  const handleClick = useCallback(\n    (event: React.MouseEvent) => {\n      onSelect(submission.id, event);\n    },\n    [onSelect, submission.id],\n  );\n\n  const handleKeyDown = useCallback(\n    (event: React.KeyboardEvent) => {\n      if (event.key === 'Enter' || event.key === ' ') {\n        event.preventDefault();\n        onSelect(submission.id, event);\n      } else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {\n        event.preventDefault();\n        const currentCard = event.currentTarget as HTMLElement;\n        const cards = Array.from(\n          currentCard.closest('.postybirb__submission__list')?.querySelectorAll('.postybirb__submission__card') ?? []\n        ) as HTMLElement[];\n        const currentIndex = cards.indexOf(currentCard);\n        const nextIndex = event.key === 'ArrowDown' ? currentIndex + 1 : currentIndex - 1;\n        if (nextIndex >= 0 && nextIndex < cards.length) {\n          cards[nextIndex].focus();\n        }\n      }\n    },\n    [onSelect, submission.id],\n  );\n\n  const handleUnarchive = useCallback(\n    async (e: React.MouseEvent) => {\n      e.stopPropagation();\n      try {\n        await submissionApi.unarchive(submission.submissionId);\n        showRestoredNotification();\n      } catch {\n        showRestoreErrorNotification();\n      }\n    },\n    [submission.submissionId],\n  );\n\n  const handleDelete = useCallback(\n    async (e: React.MouseEvent) => {\n      e.stopPropagation();\n      try {\n        await submissionApi.remove([submission.submissionId]);\n        showDeletedNotification();\n      } catch {\n        showDeleteErrorNotification();\n      }\n    },\n    [submission.submissionId],\n  );\n\n  const handleViewHistory = useCallback(\n    (e: React.MouseEvent) => {\n      e.stopPropagation();\n      onViewHistory?.();\n    },\n    [onViewHistory],\n  );\n\n  return (\n    <Card\n      p=\"xs\"\n      radius=\"0\"\n      withBorder\n      className={cardClassName}\n      onClick={handleClick}\n      onKeyDown={handleKeyDown}\n      tabIndex={0}\n      role=\"button\"\n    >\n      <Stack gap=\"xs\">\n        <Group gap=\"xs\" wrap=\"nowrap\" align=\"center\">\n          {/* Thumbnail - only for FILE type */}\n          {showThumbnail && (\n            <SubmissionThumbnail\n              thumbnailUrl={thumbnailUrl}\n              alt={submission.title}\n              canPreview={canPreviewImage}\n              fileCount={submission.files.length}\n            />\n          )}\n\n          {/* Content */}\n          <Stack gap={4} className=\"postybirb__submission__card_content\">\n            {/* Title (read-only for archived) */}\n            <SubmissionTitle\n              title={submission.title}\n              name={submission.title}\n              readOnly\n            />\n\n            {/* Status badges */}\n            <SubmissionBadges\n              submission={submission}\n              submissionType={submissionType}\n            />\n\n            {/* Last modified - hidden in compact mode */}\n            {!isCompact && (\n              <Text\n                size=\"xs\"\n                c=\"dimmed\"\n                title={formatDateTime(submission.lastModified)}\n              >\n                {formatRelativeTime(submission.lastModified)}\n              </Text>\n            )}\n          </Stack>\n\n          {/* Action buttons */}\n          <Group gap={4}>\n            {/* View history button */}\n            {submission.posts.length > 0 && (\n              <Tooltip label={<Trans>View history</Trans>}>\n                <ActionIcon\n                  variant=\"subtle\"\n                  size=\"sm\"\n                  onClick={handleViewHistory}\n                  onKeyDown={(e) => e.stopPropagation()}\n                >\n                  <IconHistory size={16} />\n                </ActionIcon>\n              </Tooltip>\n            )}\n\n            {/* Unarchive button */}\n            <Tooltip label={<Trans>Restore</Trans>}>\n              <ActionIcon\n                variant=\"subtle\"\n                size=\"sm\"\n                color=\"blue\"\n                onClick={handleUnarchive}\n                onKeyDown={(e) => e.stopPropagation()}\n              >\n                <IconArchiveOff size={16} />\n              </ActionIcon>\n            </Tooltip>\n\n            {/* Actions menu */}\n            <Menu position=\"bottom-end\" withinPortal trapFocus returnFocus>\n              <Menu.Target>\n                <ActionIcon\n                  variant=\"subtle\"\n                  size=\"sm\"\n                  color=\"gray\"\n                  onClick={(e) => e.stopPropagation()}\n                  onKeyDown={(e) => e.stopPropagation()}\n                >\n                  <IconDotsVertical size={16} />\n                </ActionIcon>\n              </Menu.Target>\n              <Menu.Dropdown>\n                {submission.posts.length > 0 && (\n                  <Menu.Item\n                    leftSection={<IconHistory size={14} />}\n                    onClick={handleViewHistory}\n                  >\n                    <Trans>View history</Trans>\n                  </Menu.Item>\n                )}\n                <Menu.Item\n                  leftSection={<IconArchiveOff size={14} />}\n                  onClick={handleUnarchive}\n                >\n                  <Trans>Restore</Trans>\n                </Menu.Item>\n                <Menu.Divider />\n                <Menu.Item\n                  leftSection={<IconTrash size={14} />}\n                  color=\"red\"\n                  onClick={handleDelete}\n                >\n                  <Trans>Delete permanently</Trans>\n                </Menu.Item>\n              </Menu.Dropdown>\n            </Menu>\n          </Group>\n        </Group>\n      </Stack>\n    </Card>\n  );\n});\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-card/index.ts",
    "content": "/**\n * SubmissionCard module exports.\n */\n\nexport { ArchivedSubmissionCard } from './archived-submission-card';\nexport { SortableSubmissionCard } from './sortable-submission-card';\nexport type { SortableSubmissionCardProps } from './sortable-submission-card';\nexport { SubmissionActions } from './submission-actions';\nexport { SubmissionBadges } from './submission-badges';\nexport { SubmissionCard } from './submission-card';\nexport { SubmissionThumbnail } from './submission-thumbnail';\nexport { SubmissionTitle } from './submission-title';\nexport type { SubmissionCardProps } from './types';\nexport { getThumbnailUrl } from './utils';\n\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-card/sortable-submission-card.tsx",
    "content": "/**\n * SortableSubmissionCard - Wrapper for SubmissionCard that provides dnd-kit sortable functionality.\n * Handles drag transforms and provides the sortable ref for virtualization compatibility.\n */\n\nimport { useSortable } from '@dnd-kit/sortable';\nimport { CSS } from '@dnd-kit/utilities';\nimport { CSSProperties, forwardRef, memo } from 'react';\nimport { SubmissionCard } from './submission-card';\nimport type { SubmissionCardProps } from './types';\n\n/* eslint-disable react/no-unused-prop-types -- Props are destructured and used in the component, but memo(forwardRef(...)) confuses the lint rule */\nexport interface SortableSubmissionCardProps extends SubmissionCardProps {\n  /** Unique ID for dnd-kit (usually submission.id) */\n  id: string;\n  /** Index in the virtual list - used for measuring */\n  virtualIndex?: number;\n}\n/* eslint-enable react/no-unused-prop-types */\n\n/**\n * Sortable wrapper for SubmissionCard using dnd-kit.\n * When dragging is disabled, renders SubmissionCard directly without sortable overhead.\n */\nexport const SortableSubmissionCard = memo(forwardRef<\n  HTMLDivElement,\n  SortableSubmissionCardProps\n>(\n  (\n    { id, virtualIndex, draggable = false, ...cardProps },\n    forwardedRef\n  ) => {\n    const {\n      attributes,\n      listeners,\n      setNodeRef,\n      transform,\n      transition,\n      isDragging,\n    } = useSortable({\n      id,\n      disabled: !draggable,\n    });\n\n    const style: CSSProperties = {\n      transform: CSS.Transform.toString(transform),\n      transition,\n      opacity: isDragging ? 0.5 : 1,\n      zIndex: isDragging ? 1 : 0,\n    };\n\n    // Merge refs - we need both the sortable ref and the virtualizer's measurement ref\n    const setRefs = (node: HTMLDivElement | null) => {\n      setNodeRef(node);\n      if (typeof forwardedRef === 'function') {\n        forwardedRef(node);\n      } else if (forwardedRef) {\n        // Use Object.assign to avoid no-param-reassign lint error\n        Object.assign(forwardedRef, { current: node });\n      }\n    };\n\n    return (\n      <div\n        ref={setRefs}\n        style={style}\n        data-index={virtualIndex}\n        {...attributes}\n      >\n        <SubmissionCard\n          {...cardProps}\n          draggable={draggable}\n          dragHandleListeners={listeners}\n        />\n    </div>\n  );\n}));\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-card/submission-actions.tsx",
    "content": "/* eslint-disable jsx-a11y/no-static-element-interactions */\n/* eslint-disable jsx-a11y/click-events-have-key-events */\n/**\n * SubmissionActions - Action buttons and menu for submissions.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport { ActionIcon, Group, Menu, Tooltip } from '@mantine/core';\nimport { ISubmissionScheduleInfo, PostRecordState } from '@postybirb/types';\nimport {\n    IconArchive,\n    IconCancel,\n    IconCopy,\n    IconDotsVertical,\n    IconEdit,\n    IconHistory,\n    IconSend,\n    IconTrash,\n} from '@tabler/icons-react';\nimport { useCallback } from 'react';\nimport { HoldToConfirmButton } from '../../../hold-to-confirm';\nimport { SchedulePopover } from '../../../shared/schedule-popover';\n\ninterface SubmissionActionsProps {\n  /** Whether the submission can be posted */\n  canPost: boolean;\n  /** Current schedule info */\n  schedule: ISubmissionScheduleInfo;\n  /** Whether the submission is currently scheduled */\n  isScheduled: boolean;\n  /** Whether the submission is currently queued/posting */\n  isQueued?: boolean;\n  /** Whether the submission has post history */\n  hasHistory?: boolean;\n  /** The state of the most recent post record for history button coloring */\n  mostRecentPostState?: PostRecordState | null;\n  /** Handler for posting the submission */\n  onPost?: () => void;\n  /** Handler for canceling a queued submission */\n  onCancel?: () => void;\n  /** Handler for schedule changes */\n  onScheduleChange?: (\n    schedule: ISubmissionScheduleInfo,\n    isScheduled: boolean,\n  ) => void;\n  /** Handler for editing the submission */\n  onEdit?: () => void;\n  /** Handler for duplicating the submission */\n  onDuplicate?: () => void;\n  /** Handler for viewing submission history */\n  onViewHistory?: () => void;\n  /** Handler for archiving the submission */\n  onArchive?: () => void;\n  /** Handler for deleting the submission */\n  onDelete?: () => void;\n}\n\n/**\n * Action buttons (schedule, post) and dropdown menu for submissions.\n */\nexport function SubmissionActions({\n  canPost,\n  schedule,\n  isScheduled,\n  isQueued,\n  hasHistory,\n  mostRecentPostState,\n  onPost,\n  onCancel,\n  onScheduleChange,\n  onEdit,\n  onDuplicate,\n  onViewHistory,\n  onArchive,\n  onDelete,\n}: SubmissionActionsProps) {\n  const handleEdit = useCallback(\n    (e: React.MouseEvent) => {\n      e.stopPropagation();\n      onEdit?.();\n    },\n    [onEdit],\n  );\n\n  const handleDuplicate = useCallback(\n    (e: React.MouseEvent) => {\n      e.stopPropagation();\n      onDuplicate?.();\n    },\n    [onDuplicate],\n  );\n\n  const handleDelete = useCallback(\n    (e: React.MouseEvent) => {\n      e.stopPropagation();\n      onDelete?.();\n    },\n    [onDelete],\n  );\n\n  const handleViewHistory = useCallback(\n    (e: React.MouseEvent) => {\n      e.stopPropagation();\n      onViewHistory?.();\n    },\n    [onViewHistory],\n  );\n\n  const handleArchive = useCallback(\n    (e: React.MouseEvent) => {\n      e.stopPropagation();\n      onArchive?.();\n    },\n    [onArchive],\n  );\n\n  const handleCancel = useCallback(\n    (e: React.MouseEvent) => {\n      e.stopPropagation();\n      onCancel?.();\n    },\n    [onCancel],\n  );\n\n  const handleScheduleChange = useCallback(\n    (newSchedule: ISubmissionScheduleInfo, newIsScheduled: boolean) => {\n      onScheduleChange?.(newSchedule, newIsScheduled);\n    },\n    [onScheduleChange],\n  );\n\n  return (\n    <>\n      {/* Action buttons */}\n      <Group gap={4}>\n        {/* Schedule popover */}\n        <span\n          onClick={(e) => e.stopPropagation()}\n          onKeyDown={(e) => e.stopPropagation()}\n        >\n          <SchedulePopover\n            schedule={schedule}\n            isScheduled={isScheduled}\n            onChange={handleScheduleChange}\n            size=\"sm\"\n          />\n        </span>\n\n        {/* History button - always visible, color-coded by most recent post state */}\n        <Tooltip label={<Trans>View history</Trans>}>\n          <ActionIcon\n            variant=\"subtle\"\n            size=\"sm\"\n            color={\n              mostRecentPostState === PostRecordState.DONE\n                ? 'green'\n                : mostRecentPostState === PostRecordState.FAILED\n                ? 'red'\n                : 'gray'\n            }\n            disabled={!onViewHistory}\n            onClick={handleViewHistory}\n            // eslint-disable-next-line lingui/no-unlocalized-strings\n            aria-label=\"View history\"\n          >\n            <IconHistory size={16} />\n          </ActionIcon>\n        </Tooltip>\n\n        {/* Post button or Cancel button based on queue state */}\n        {isQueued ? (\n          <Tooltip label={<Trans>Cancel posting</Trans>}>\n            <ActionIcon\n              variant=\"subtle\"\n              size=\"sm\"\n              color=\"orange\"\n              onClick={handleCancel}\n              // eslint-disable-next-line lingui/no-unlocalized-strings\n              aria-label=\"Cancel posting\"\n            >\n              <IconCancel size={16} />\n            </ActionIcon>\n          </Tooltip>\n        ) : (\n          <Tooltip label={<Trans>Hold to post</Trans>}>\n            <HoldToConfirmButton\n              onConfirm={onPost ?? (() => {})}\n              disabled={!canPost}\n              variant=\"subtle\"\n              size=\"sm\"\n              // eslint-disable-next-line lingui/no-unlocalized-strings\n              aria-label=\"Post submission\"\n            >\n              <IconSend size={16} />\n            </HoldToConfirmButton>\n          </Tooltip>\n        )}\n      </Group>\n\n      {/* Actions menu */}\n      <Menu position=\"bottom-end\" withinPortal trapFocus returnFocus>\n        <Menu.Target>\n          <ActionIcon\n            variant=\"subtle\"\n            size=\"sm\"\n            color=\"gray\"\n            onClick={(e) => e.stopPropagation()}\n            onKeyDown={(e) => e.stopPropagation()}\n            // eslint-disable-next-line lingui/no-unlocalized-strings\n            aria-label=\"Submission actions\"\n          >\n            <IconDotsVertical size={16} />\n          </ActionIcon>\n        </Menu.Target>\n        <Menu.Dropdown>\n          <Menu.Item leftSection={<IconEdit size={14} />} onClick={handleEdit}>\n            <Trans>Edit</Trans>\n          </Menu.Item>\n          <Menu.Item\n            leftSection={<IconCopy size={14} />}\n            onClick={handleDuplicate}\n          >\n            <Trans>Duplicate</Trans>\n          </Menu.Item>\n          {hasHistory && onViewHistory && (\n            <Menu.Item\n              leftSection={<IconHistory size={14} />}\n              onClick={handleViewHistory}\n            >\n              <Trans>View history</Trans>\n            </Menu.Item>\n          )}\n          <Menu.Divider />\n          {onArchive && (\n            <Menu.Item\n              leftSection={<IconArchive size={14} />}\n              onClick={handleArchive}\n            >\n              <Trans>Archive</Trans>\n            </Menu.Item>\n          )}\n          <Menu.Item\n            leftSection={<IconTrash size={14} />}\n            color=\"red\"\n            onClick={handleDelete}\n          >\n            <Trans>Delete</Trans>\n          </Menu.Item>\n        </Menu.Dropdown>\n      </Menu>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-card/submission-badges.tsx",
    "content": "/**\n * SubmissionBadges - Status badges for submissions.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport { Badge, Group, Tooltip } from '@mantine/core';\nimport { SubmissionType } from '@postybirb/types';\nimport {\n  IconAlertTriangle,\n  IconCalendar,\n  IconCircleCheck,\n  IconGlobe,\n  IconLoader,\n  IconX,\n} from '@tabler/icons-react';\nimport { useLocale } from '../../../../hooks';\nimport type { SubmissionRecord } from '../../../../stores/records';\n\ninterface SubmissionBadgesProps {\n  /** The submission record to display badges for */\n  submission: SubmissionRecord;\n  /** Type of submission (FILE or MESSAGE) - used to conditionally show file count */\n  submissionType: SubmissionType;\n}\n\n/**\n * Displays status badges for a submission.\n * Shows scheduled, queued, errors, warnings, ready, no websites, and file count badges.\n */\nexport function SubmissionBadges({\n  submission,\n  submissionType,\n}: SubmissionBadgesProps) {\n  const { formatDateTime } = useLocale();\n\n  return (\n    <Group gap={4}>\n      {/* Queued badge */}\n      {submission.isQueued && (\n        <Badge\n          size=\"xs\"\n          variant=\"light\"\n          color=\"cyan\"\n          leftSection={<IconLoader size={10} />}\n        >\n          <Trans>Queued</Trans>\n        </Badge>\n      )}\n\n      {/* Validation errors */}\n      {submission.hasErrors && (\n        <Tooltip label={<Trans>Has validation errors</Trans>}>\n          <Badge\n            size=\"xs\"\n            variant=\"light\"\n            color=\"red\"\n            leftSection={<IconX size={10} />}\n          >\n            <Trans>Errors</Trans>\n          </Badge>\n        </Tooltip>\n      )}\n\n      {/* Validation warnings */}\n      {submission.hasWarnings && !submission.hasErrors && (\n        <Tooltip label={<Trans>Has validation warnings</Trans>}>\n          <Badge\n            size=\"xs\"\n            variant=\"light\"\n            color=\"yellow\"\n            leftSection={<IconAlertTriangle size={10} />}\n          >\n            <Trans>Warnings</Trans>\n          </Badge>\n        </Tooltip>\n      )}\n\n      {/* Valid badge (no errors/warnings) */}\n      {!submission.hasErrors &&\n        !submission.hasWarnings &&\n        submission.hasWebsiteOptions && (\n          <Tooltip label={<Trans>Ready to post</Trans>}>\n            <Badge\n              size=\"xs\"\n              variant=\"light\"\n              color=\"green\"\n              leftSection={<IconCircleCheck size={10} />}\n            >\n              <Trans>Ready</Trans>\n            </Badge>\n          </Tooltip>\n        )}\n\n      {/* No websites badge */}\n      {!submission.hasWebsiteOptions && (\n        <Tooltip label={<Trans>No websites selected</Trans>}>\n          <Badge\n            size=\"xs\"\n            variant=\"light\"\n            color=\"gray\"\n            leftSection={<IconGlobe size={10} />}\n          >\n            <Trans>No websites</Trans>\n          </Badge>\n        </Tooltip>\n      )}\n    </Group>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-card/submission-card.tsx",
    "content": "/**\n * SubmissionCard - Card component for displaying a submission in the list.\n * Shows thumbnail (for FILE type), editable title, status badges, and action buttons.\n * Uses SubmissionsContext for actions.\n */\n\nimport { Box, Card, Checkbox, Group, Stack, Text } from '@mantine/core';\nimport { PostRecordState, SubmissionType } from '@postybirb/types';\nimport { IconClock, IconGripVertical } from '@tabler/icons-react';\nimport { memo, useCallback, useMemo } from 'react';\nimport { useLocale } from '../../../../hooks';\nimport { cn } from '../../../../utils/class-names';\nimport { useSubmissionsActions } from '../context';\nimport { useSubmissionActions } from '../hooks';\nimport '../submissions-section.css';\nimport { SubmissionActions } from './submission-actions';\nimport { SubmissionBadges } from './submission-badges';\nimport { SubmissionQuickEditActions } from './submission-quick-edit-actions';\nimport { SubmissionThumbnail } from './submission-thumbnail';\nimport { SubmissionTitle } from './submission-title';\nimport type { SubmissionCardProps } from './types';\nimport { getThumbnailUrl } from './utils';\n\n/**\n * Card component for displaying a submission in the section list.\n * Actions are provided via SubmissionsContext.\n */\nexport const SubmissionCard = memo(({\n  submission,\n  submissionType,\n  isSelected = false,\n  draggable = false,\n  isCompact = false,\n  className,\n  dragHandleListeners,\n}: SubmissionCardProps) => {\n  const { onSelect } = useSubmissionsActions();\n  const { formatRelativeTime, formatDateTime } = useLocale();\n  const {\n    handleDelete,\n    handleDuplicate,\n    handleEdit,\n    handlePost,\n    handleCancel,\n    handleArchive,\n    handleViewHistory,\n    handleScheduleChange,\n    handleDefaultOptionChange,\n  } = useSubmissionActions(submission.id);\n\n  const thumbnailUrl = getThumbnailUrl(submission);\n\n  const handleKeyDown = useCallback(\n    (event: React.KeyboardEvent) => {\n      if (event.key === 'Enter' || event.key === ' ') {\n        event.preventDefault();\n        onSelect(submission.id, event);\n      } else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {\n        event.preventDefault();\n        const currentCard = event.currentTarget as HTMLElement;\n        const cards = Array.from(\n          currentCard\n            .closest('.postybirb__submission__list')\n            ?.querySelectorAll('.postybirb__submission__card') ?? [],\n        ) as HTMLElement[];\n        const currentIndex = cards.indexOf(currentCard);\n        const nextIndex =\n          event.key === 'ArrowDown' ? currentIndex + 1 : currentIndex - 1;\n        if (nextIndex >= 0 && nextIndex < cards.length) {\n          cards[nextIndex].focus();\n        }\n      }\n    },\n    [onSelect, submission.id],\n  );\n\n  const canPost =\n    !submission.hasErrors &&\n    submission.hasWebsiteOptions &&\n    !submission.isQueued;\n\n  // Check if the primary file is an image that can be previewed (only for FILE type)\n  const canPreviewImage =\n    submissionType === SubmissionType.FILE &&\n    submission.primaryFile?.mimeType?.startsWith('image/');\n\n  // Only show thumbnail for FILE type submissions\n  const showThumbnail = submissionType === SubmissionType.FILE;\n\n  // Check if the most recent post record failed\n  const mostRecentPostHasErrors = useMemo(() => {\n    if (submission.posts.length === 0) return false;\n    // Get the most recent post record (last in the array or sort by date)\n    const sortedPosts = [...submission.posts].sort(\n      (a, b) =>\n        new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),\n    );\n    const mostRecentPost = sortedPosts[0];\n    // Check if the state is FAILED\n    return mostRecentPost.state === PostRecordState.FAILED;\n  }, [submission.posts]);\n\n  // Get the most recent post record state for history button coloring\n  const mostRecentPostState = useMemo(() => {\n    if (submission.posts.length === 0) return null;\n    const sortedPosts = [...submission.posts].sort(\n      (a, b) =>\n        new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),\n    );\n    return sortedPosts[0].state;\n  }, [submission.posts]);\n\n  // Build className list\n  const cardClassName = useMemo(\n    () =>\n      cn(['postybirb__submission__card', className], {\n        'postybirb__submission__card--selected': isSelected,\n        'postybirb__submission__card--scheduled': submission.isScheduled,\n        'postybirb__submission__card--has-errors': mostRecentPostHasErrors,\n        'postybirb__submission__card--compact': isCompact,\n      }),\n    [\n      isSelected,\n      submission.isScheduled,\n      mostRecentPostHasErrors,\n      isCompact,\n      className,\n    ],\n  );\n\n  const handleCheckboxChange = useCallback(\n    (event: React.ChangeEvent<HTMLInputElement>) => {\n      // Use the native event to get modifier keys for shift-click support\n      const nativeEvent = event.nativeEvent as MouseEvent;\n      // Pass isCheckbox=true so the selection hook knows to toggle rather than single-select\n      onSelect(\n        submission.id,\n        {\n          shiftKey: nativeEvent.shiftKey,\n          ctrlKey: nativeEvent.ctrlKey,\n          metaKey: nativeEvent.metaKey,\n        } as React.MouseEvent,\n        true, // isCheckbox - enables toggle behavior\n      );\n    },\n    [onSelect, submission.id],\n  );\n\n  const handleCardClick = useCallback(\n    (event: React.MouseEvent) => {\n      // If Ctrl/Cmd or Shift is held, treat as selection instead of edit\n      if (event.ctrlKey || event.metaKey || event.shiftKey) {\n        onSelect(submission.id, event);\n      } else {\n        onSelect(submission.id, event);\n        handleEdit();\n      }\n    },\n    [onSelect, submission.id, handleEdit],\n  );\n\n  const cardContent = (\n    <Card\n      p=\"xs\"\n      radius=\"0\"\n      withBorder\n      data-tour-id=\"submissions-card\"\n      onClick={handleCardClick}\n      onKeyDown={handleKeyDown}\n      tabIndex={0}\n      role=\"listitem\"\n      className={cardClassName}\n      style={{ position: 'relative' }}\n    >\n      {/* Last modified - absolute position in upper right */}\n      <Text\n        size=\"xs\"\n        c=\"dimmed\"\n        title={formatDateTime(submission.lastModified)}\n        style={{\n          position: 'absolute',\n          top: '1px',\n          right: 'var(--mantine-spacing-xs)',\n          opacity: submission.isScheduled ? 0.7 : 1,\n        }}\n      >\n        {formatRelativeTime(submission.lastModified)}\n      </Text>\n      <Group gap=\"xs\" wrap=\"nowrap\" align=\"stretch\">\n        {/* Card actions column: checkbox + drag handle - full height */}\n        <Stack\n          gap=\"md\"\n          align=\"center\"\n          justify=\"center\"\n          className=\"postybirb__submission__card_actions_column\"\n        >\n          <Checkbox\n            size=\"xs\"\n            checked={isSelected}\n            onChange={handleCheckboxChange}\n            onClick={(e) => e.stopPropagation()}\n            // eslint-disable-next-line lingui/no-unlocalized-strings\n            aria-label={`Select ${submission.title}`}\n          />\n          {draggable && (\n            <Box\n              className=\"sort-handle postybirb__submission__drag_handle\"\n              onClick={(e) => e.stopPropagation()}\n              {...dragHandleListeners}\n            >\n              <IconGripVertical size={16} />\n            </Box>\n          )}\n        </Stack>\n\n        {/* Card details column */}\n        <Stack gap=\"xs\" style={{ flex: 1, minWidth: 0 }}>\n          <Group gap=\"xs\" wrap=\"nowrap\" align=\"center\">\n            {/* Thumbnail with optional HoverCard preview - only for FILE type */}\n            {showThumbnail && (\n              <SubmissionThumbnail\n                thumbnailUrl={thumbnailUrl}\n                alt={submission.title}\n                canPreview={canPreviewImage}\n                fileCount={submission.files.length}\n              />\n            )}\n\n            {/* Content */}\n            <Stack gap={4} className=\"postybirb__submission__card_content\">\n              {/* Editable Title */}\n              <SubmissionTitle\n                title={submission.title}\n                name={submission.title}\n                onTitleChange={(title) => handleDefaultOptionChange({ title })}\n              />\n\n              {/* Status badges */}\n              <SubmissionBadges\n                submission={submission}\n                submissionType={submissionType}\n              />\n\n              {/* Scheduled date - prominent when active, dimmed when inactive */}\n              {(submission.scheduledDate || submission.schedule.cron) && (\n                <Group gap={4}>\n                  <IconClock\n                    size={12}\n                    style={{\n                      color: submission.isScheduled\n                        ? 'var(--mantine-color-blue-6)'\n                        : 'var(--mantine-color-dimmed)',\n                    }}\n                  />\n                  <Text\n                    size=\"xs\"\n                    c={submission.isScheduled ? 'blue.6' : 'dimmed'}\n                    fw={submission.isScheduled ? '500' : undefined}\n                  >\n                    {submission.scheduledDate\n                      ? formatDateTime(submission.scheduledDate)\n                      : null}\n                  </Text>\n                </Group>\n              )}\n            </Stack>\n\n            {/* Action buttons and menu */}\n            <SubmissionActions\n              canPost={canPost}\n              schedule={submission.schedule}\n              isScheduled={submission.isScheduled}\n              isQueued={submission.isQueued}\n              hasHistory={submission.posts.length > 0}\n              mostRecentPostState={mostRecentPostState}\n              onPost={handlePost}\n              onCancel={handleCancel}\n              onScheduleChange={handleScheduleChange}\n              onEdit={handleEdit}\n              onDuplicate={handleDuplicate}\n              onViewHistory={handleViewHistory}\n              onArchive={handleArchive}\n              onDelete={handleDelete}\n            />\n          </Group>\n          {/* Quick edit actions - hidden in compact mode */}\n          {!isCompact && <SubmissionQuickEditActions submission={submission} />}\n        </Stack>\n      </Group>\n    </Card>\n  );\n\n  return cardContent;\n});\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-card/submission-quick-edit-actions.tsx",
    "content": "/**\n * SubmissionQuickEditActions - Quick edit controls for submissions.\n * Provides inline editing for tags and rating without opening the full editor.\n * Uses SubmissionsContext for actions via useSubmissionActions hook.\n */\n\nimport { Box, Group, TagsInput } from '@mantine/core';\nimport { useDebouncedCallback } from '@mantine/hooks';\nimport { DefaultTagValue, SubmissionRating, TagValue } from '@postybirb/types';\nimport { IconTag } from '@tabler/icons-react';\nimport { useCallback, useEffect, useRef, useState } from 'react';\nimport { SubmissionRecord } from '../../../../stores';\nimport { RatingInput } from '../../../shared/rating-input';\nimport { useSubmissionActions } from '../hooks';\n\ntype SubmissionQuickEditActionsProps = {\n  submission: SubmissionRecord;\n};\n\ntype QuickEditTagsProps = {\n  tags: TagValue;\n  onChange: (tags: TagValue) => void;\n};\n\n/**\n * Inline tags editor that saves changes on blur or after debounced input.\n */\nfunction QuickEditTags({ tags, onChange }: QuickEditTagsProps) {\n  const [localTags, setLocalTags] = useState<string[]>(tags.tags);\n  const hasChanges = useRef(false);\n\n  // Sync local tags with prop\n  useEffect(() => {\n    setLocalTags(tags.tags);\n    hasChanges.current = false;\n  }, [tags.tags]);\n\n  // Debounced save - saves after user stops typing for 500ms\n  const debouncedSave = useDebouncedCallback((newTags: string[]) => {\n    if (hasChanges.current) {\n      onChange({ ...tags, tags: newTags });\n      hasChanges.current = false;\n    }\n  }, 500);\n\n  const handleChange = useCallback(\n    (value: string[]) => {\n      setLocalTags(value);\n      hasChanges.current = true;\n      debouncedSave(value);\n    },\n    [debouncedSave],\n  );\n\n  const handleBlur = useCallback(() => {\n    // Save immediately on blur if there are pending changes\n    // The hasChanges flag prevents the debounced callback from double-saving\n    if (hasChanges.current) {\n      onChange({ ...tags, tags: localTags });\n      hasChanges.current = false;\n    }\n  }, [localTags, tags, onChange]);\n\n  return (\n    <Box onClick={(e) => e.stopPropagation()}>\n      <TagsInput\n        clearable\n        size=\"xs\"\n        className=\"postybirb__submission__quick_edit_tags\"\n        leftSection={<IconTag size=\"13\" />}\n        value={localTags}\n        onChange={handleChange}\n        onBlur={handleBlur}\n      />\n    </Box>\n  );\n}\n\nexport function SubmissionQuickEditActions({\n  submission,\n}: SubmissionQuickEditActionsProps) {\n  const { handleDefaultOptionChange } = useSubmissionActions(submission.id);\n  const defaultOptions = submission.getDefaultOptions();\n  const tags = defaultOptions?.data.tags ?? DefaultTagValue();\n  const rating = defaultOptions?.data.rating ?? SubmissionRating.GENERAL;\n\n  const handleTagsChange = useCallback(\n    (newTags: TagValue) => {\n      handleDefaultOptionChange({ tags: newTags });\n    },\n    [handleDefaultOptionChange],\n  );\n\n  const handleRatingChange = useCallback(\n    (newRating: SubmissionRating) => {\n      handleDefaultOptionChange({ rating: newRating });\n    },\n    [handleDefaultOptionChange],\n  );\n\n  return (\n    <Group\n      className=\"postybirb__submission__quick_edit_actions\"\n      gap=\"2\"\n      align=\"flex-end\"\n    >\n      <RatingInput value={rating} onChange={handleRatingChange} size=\"sm\" />\n      <QuickEditTags tags={tags} onChange={handleTagsChange} />\n    </Group>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-card/submission-thumbnail.tsx",
    "content": "/**\n * SubmissionThumbnail - Thumbnail component with optional HoverCard preview.\n * Images load immediately since virtualization ensures only near-viewport cards are mounted.\n */\n\nimport { Box, HoverCard, Image, Indicator } from '@mantine/core';\nimport { IconFile } from '@tabler/icons-react';\nimport { memo } from 'react';\nimport '../submissions-section.css';\n\ninterface SubmissionThumbnailProps {\n  /** URL of the thumbnail image */\n  thumbnailUrl: string | undefined;\n  /** Alt text for the image */\n  alt: string;\n  /** Whether the image can be previewed in a HoverCard */\n  canPreview?: boolean;\n  /** Total number of files (shows indicator if > 1) */\n  fileCount?: number;\n}\n\n/**\n * Thumbnail component for submissions.\n * Shows the thumbnail image or a placeholder icon.\n * Optionally wraps in a HoverCard for image preview on hover.\n */\nexport const SubmissionThumbnail = memo(({\n  thumbnailUrl,\n  alt,\n  canPreview = false,\n  fileCount = 1,\n}: SubmissionThumbnailProps) => {\n  // Calculate additional files (total - 1 for the primary file shown)\n  const additionalFiles = fileCount > 1 ? fileCount - 1 : 0;\n\n  const thumbnailBox = (\n    <Indicator\n      label={`+${additionalFiles}`}\n      size={16}\n      position=\"bottom-end\"\n      offset={4}\n      disabled={additionalFiles === 0}\n    >\n      <Box className=\"postybirb__submission__thumbnail\">\n        {thumbnailUrl ? (\n          <Image src={thumbnailUrl} alt={alt} w={40} h={40} fit=\"cover\" />\n        ) : (\n          <IconFile\n            size={20}\n            stroke={1.5}\n            className=\"postybirb__submission__thumbnail_placeholder\"\n          />\n        )}\n      </Box>\n    </Indicator>\n  );\n\n  if (canPreview && thumbnailUrl) {\n    return (\n      <HoverCard width={280} position=\"right\" openDelay={400} shadow=\"md\">\n        <HoverCard.Target>{thumbnailBox}</HoverCard.Target>\n        <HoverCard.Dropdown p=\"xs\">\n          <Image\n            loading=\"lazy\"\n            src={thumbnailUrl}\n            alt={alt}\n            fit=\"contain\"\n            radius=\"sm\"\n          />\n        </HoverCard.Dropdown>\n      </HoverCard>\n    );\n  }\n\n  return thumbnailBox;\n});\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-card/submission-title.tsx",
    "content": "/**\n * SubmissionTitle - Editable title component for submissions.\n */\n\nimport { Text, TextInput } from '@mantine/core';\nimport { IconEdit } from '@tabler/icons-react';\nimport { useCallback, useEffect, useState } from 'react';\n\ninterface SubmissionTitleProps {\n  /** Current title value */\n  title: string | undefined;\n  /** Fallback name if title is empty */\n  name: string;\n  /** Handler for title changes */\n  onTitleChange?: (title: string) => void;\n  /** If true, title is not editable */\n  readOnly?: boolean;\n}\n\n/**\n * Editable title component that shows as text and becomes an input on click.\n */\nexport function SubmissionTitle({\n  title,\n  name,\n  onTitleChange,\n  readOnly = false,\n}: SubmissionTitleProps) {\n  const [localTitle, setLocalTitle] = useState(title ?? '');\n  const [isEditing, setIsEditing] = useState(false);\n\n  // Sync local title with prop\n  useEffect(() => {\n    setLocalTitle(title ?? '');\n  }, [title]);\n\n  const handleClick = useCallback(\n    (e: React.MouseEvent) => {\n      if (readOnly) return;\n      e.stopPropagation();\n      setIsEditing(true);\n    },\n    [readOnly],\n  );\n\n  const handleBlur = useCallback(() => {\n    setIsEditing(false);\n    const trimmedTitle = localTitle.trim();\n    if (trimmedTitle !== title) {\n      onTitleChange?.(trimmedTitle);\n    }\n  }, [localTitle, title, onTitleChange]);\n\n  const handleKeyDown = useCallback(\n    (e: React.KeyboardEvent) => {\n      e.stopPropagation();\n      if (e.key === 'Enter') {\n        e.preventDefault();\n        (e.target as HTMLInputElement).blur();\n      }\n      if (e.key === 'Escape') {\n        setLocalTitle(title ?? '');\n        setIsEditing(false);\n      }\n    },\n    [title],\n  );\n\n  if (isEditing) {\n    return (\n      <TextInput\n        size=\"xs\"\n        value={localTitle}\n        onChange={(e) => setLocalTitle(e.currentTarget.value)}\n        onBlur={handleBlur}\n        onKeyDown={handleKeyDown}\n        onClick={(e) => e.stopPropagation()}\n        autoFocus\n        styles={{\n          input: {\n            fontWeight: 500,\n            // eslint-disable-next-line lingui/no-unlocalized-strings\n            padding: '2px 6px',\n            // eslint-disable-next-line lingui/no-unlocalized-strings\n            height: 'auto',\n            // eslint-disable-next-line lingui/no-unlocalized-strings\n            minHeight: 'unset',\n          },\n        }}\n      />\n    );\n  }\n\n  return (\n    <Text\n      onKeyDown={(event: React.KeyboardEvent) => {\n        if (event.key === 'Enter') {\n          handleClick(event as unknown as React.MouseEvent);\n        }\n      }}\n      tabIndex={0}\n      size=\"sm\"\n      fw={500}\n      lineClamp={1}\n      onClick={handleClick}\n      style={{ cursor: readOnly ? 'default' : 'text' }}\n      title={title || name}\n    >\n      {title || name}{' '}\n      <IconEdit size={12} style={{ verticalAlign: 'middle', opacity: 0.6 }} />\n    </Text>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-card/types.ts",
    "content": "/**\n * Shared types for SubmissionCard components.\n */\n\nimport type { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities';\nimport { SubmissionType } from '@postybirb/types';\nimport type { SubmissionRecord } from '../../../../stores/records';\n\n/**\n * Props for the SubmissionCard component.\n * Actions are obtained from SubmissionsContext via useSubmissionActions hook.\n */\nexport interface SubmissionCardProps {\n  /** The submission record to display */\n  submission: SubmissionRecord;\n  /** Type of submission (FILE or MESSAGE) */\n  submissionType: SubmissionType;\n  /** Whether this card is selected */\n  isSelected?: boolean;\n  /** Whether this card is draggable for reordering */\n  draggable?: boolean;\n  /** Whether to show compact view (hides quick-edit actions and last modified) */\n  isCompact?: boolean;\n  /** Additional class name for the card */\n  className?: string;\n  /** dnd-kit drag handle listeners - passed from SortableSubmissionCard */\n  dragHandleListeners?: SyntheticListenerMap;\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-card/utils.ts",
    "content": "/**\n * Utility functions for SubmissionCard components.\n */\n\nimport type { SubmissionRecord } from '../../../../stores/records';\nimport { defaultTargetProvider } from '../../../../transports/http-client';\n\n/**\n * Get the thumbnail URL for a submission.\n * Returns undefined if no thumbnail is available.\n */\nexport function getThumbnailUrl(\n  submission: SubmissionRecord,\n): string | undefined {\n  const { primaryFile } = submission;\n  if (!primaryFile) return undefined;\n\n  const baseUrl = defaultTargetProvider();\n\n  // Use the thumbnail if available\n  if (primaryFile.hasThumbnail) {\n    return `${baseUrl}/api/file/thumbnail/${primaryFile.id}?${primaryFile.hash}`;\n  }\n\n  // Check if it's an image type that can be displayed directly\n  if (primaryFile.mimeType?.startsWith('image/')) {\n    return `${baseUrl}/api/file/file/${primaryFile.id}?${primaryFile.hash}`;\n  }\n\n  return undefined;\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/account-selection/account-option-row.tsx",
    "content": "/**\n * AccountOptionRow - A single account checkbox row with expandable inline form section.\n * When checked, creates a website option via API. When unchecked, removes it.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport {\n  Badge,\n  Box,\n  Checkbox,\n  Collapse,\n  Group,\n  Loader,\n  Paper,\n  Text,\n} from '@mantine/core';\nimport { type WebsiteOptionsDto } from '@postybirb/types';\nimport { useCallback, useEffect, useState } from 'react';\nimport websiteOptionsApi from '../../../../../api/website-options.api';\nimport type { AccountRecord } from '../../../../../stores/records';\nimport { ComponentErrorBoundary } from '../../../../error-boundary';\nimport { useSubmissionEditCardContext } from '../context';\nimport './account-selection.css';\nimport { FormFieldsProvider, SectionLayout } from './form';\n\nexport interface AccountOptionRowProps {\n  /** The account to display */\n  account: AccountRecord;\n  /** The existing website option for this account, if selected */\n  websiteOption: WebsiteOptionsDto | null;\n  /** Whether this account has validation errors */\n  hasErrors: boolean;\n  /** Whether this account has validation warnings */\n  hasWarnings: boolean;\n}\n\n/**\n * A single account row with checkbox and expandable form section.\n */\nexport function AccountOptionRow({\n  account,\n  websiteOption,\n  hasErrors,\n  hasWarnings,\n}: AccountOptionRowProps) {\n  const { submission } = useSubmissionEditCardContext();\n  const [isLoading, setIsLoading] = useState(false);\n  // Track manual collapse state - null means use default (expanded when selected)\n  const [manualExpanded, setManualExpanded] = useState<boolean | null>(null);\n\n  const isSelected = websiteOption !== null;\n\n  // Determine expanded state: use manual override if set, otherwise default to selected state\n  // Reset manual state when selection changes\n  const expanded = manualExpanded ?? isSelected;\n\n  // Reset manual expanded state when websiteOption changes (e.g., template applied or removed)\n  useEffect(() => {\n    setManualExpanded(null);\n  }, [websiteOption]);\n\n  // Handle checkbox toggle - calls API to add/remove website option\n  const handleToggle = useCallback(\n    async (checked: boolean) => {\n      setIsLoading(true);\n      try {\n        if (checked) {\n          // Create a new website option for this account\n          // Rating is intentionally omitted so it defaults to\n          // \"inherit from default\" mode on the server.\n          await websiteOptionsApi.create({\n            submissionId: submission.id,\n            accountId: account.accountId,\n            data: {},\n          });\n          setManualExpanded(true);\n        } else if (websiteOption) {\n          // Remove the existing website option\n          await websiteOptionsApi.remove([websiteOption.id]);\n          setManualExpanded(false);\n        }\n      } catch (error) {\n        // eslint-disable-next-line no-console, lingui/no-unlocalized-strings\n        console.error('Failed to update website option:', error);\n      } finally {\n        setIsLoading(false);\n      }\n    },\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [submission.id, account.accountId, websiteOption],\n  );\n\n  return (\n    <ComponentErrorBoundary>\n      <Box className=\"postybirb__account_option_row\">\n        <Group gap=\"xs\" px=\"sm\" py={6} wrap=\"nowrap\">\n          {isLoading ? (\n            <Loader size=\"xs\" />\n          ) : (\n            <Checkbox\n              size=\"xs\"\n              checked={isSelected}\n              onChange={(e) => handleToggle(e.currentTarget.checked)}\n              // eslint-disable-next-line lingui/no-unlocalized-strings\n              aria-label={`Select ${account.name}`}\n            />\n          )}\n          <Text size=\"sm\" style={{ flex: 1 }}>\n            {account.name}\n          </Text>\n          {account.username && (\n            <Badge\n              color=\"green\"\n              variant=\"transparent\"\n              style={{ textTransform: 'none' }}\n            >\n              {account.username}\n            </Badge>\n          )}\n          {hasErrors && (\n            <Badge size=\"xs\" variant=\"light\" color=\"red\">\n              <Trans>Error</Trans>\n            </Badge>\n          )}\n          {hasWarnings && (\n            <Badge size=\"xs\" variant=\"light\" color=\"yellow\">\n              <Trans>Warning</Trans>\n            </Badge>\n          )}\n          {!account.isLoggedIn && (\n            <Badge size=\"xs\" variant=\"light\" color=\"orange\">\n              <Trans>Not logged in</Trans>\n            </Badge>\n          )}\n        </Group>\n\n        {/* Expandable inline form section - shown when selected */}\n        <Collapse\n          in={expanded && isSelected}\n          key={`${account.accountId}-${websiteOption?.id ?? 'none'}`}\n        >\n          <Paper\n            withBorder\n            radius=\"sm\"\n            p=\"sm\"\n            ml=\"xs\"\n            mr=\"sm\"\n            mb=\"xs\"\n            className=\"postybirb__account_option_form\"\n          >\n            {websiteOption && (\n              <ComponentErrorBoundary>\n                <FormFieldsProvider\n                  option={websiteOption}\n                  submission={submission}\n                >\n                  <SectionLayout key={websiteOption.id} />\n                </FormFieldsProvider>\n              </ComponentErrorBoundary>\n            )}\n          </Paper>\n        </Collapse>\n      </Box>\n    </ComponentErrorBoundary>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/account-selection/account-select.tsx",
    "content": "/**\n * AccountSelect - A custom multi-select dropdown for selecting accounts grouped by website.\n * Uses Mantine's Combobox primitives to allow clickable group headers that toggle\n * all accounts in that website group. Login state is indicated by color.\n */\n\nimport { Trans, useLingui } from '@lingui/react/macro';\nimport {\n  Badge,\n  Box,\n  Button,\n  Checkbox,\n  CloseButton,\n  Combobox,\n  Group,\n  Pill,\n  PillsInput,\n  ScrollArea,\n  Stack,\n  Text,\n  useCombobox,\n} from '@mantine/core';\nimport { SubmissionRating, SubmissionType } from '@postybirb/types';\nimport {\n  IconCircleFilled,\n  IconSquare,\n  IconSquareCheck,\n} from '@tabler/icons-react';\nimport { useCallback, useMemo, useState } from 'react';\nimport websiteOptionsApi from '../../../../../api/website-options.api';\nimport { useAccounts } from '../../../../../stores/entity/account-store';\nimport {\n  useFileWebsites,\n  useMessageWebsites,\n} from '../../../../../stores/entity/website-store';\nimport type {\n  AccountRecord,\n  WebsiteRecord,\n} from '../../../../../stores/records';\nimport { useSubmissionEditCardContext } from '../context';\n\ninterface AccountGroupItem {\n  websiteId: string;\n  websiteDisplayName: string;\n  accounts: AccountRecord[];\n}\n\n/**\n * Custom multi-select for account selection with grouped options and clickable group headers.\n */\nexport function AccountSelect() {\n  const { submission } = useSubmissionEditCardContext();\n  const { t } = useLingui();\n  const accounts = useAccounts();\n  const fileWebsites = useFileWebsites();\n  const messageWebsites = useMessageWebsites();\n  const [isSelectingAll, setIsSelectingAll] = useState(false);\n  const [isDeselectingAll, setIsDeselectingAll] = useState(false);\n  const [search, setSearch] = useState('');\n\n  const combobox = useCombobox({\n    onDropdownClose: () => {\n      combobox.resetSelectedOption();\n      setSearch('');\n    },\n    onDropdownOpen: () => {\n      combobox.updateSelectedOptionIndex('active');\n    },\n  });\n\n  // Map of accountId -> WebsiteOptionsDto for quick lookup\n  const optionsByAccount = useMemo(() => {\n    const map = new Map<\n      string,\n      { id: string; accountId: string; isDefault: boolean }\n    >();\n    submission.options.forEach((opt) => {\n      if (!opt.isDefault) {\n        map.set(opt.accountId, opt);\n      }\n    });\n    return map;\n  }, [submission.options]);\n\n  // Filter websites based on submission type\n  const websites = useMemo(\n    () =>\n      submission.type === SubmissionType.FILE ? fileWebsites : messageWebsites,\n    [submission.type, fileWebsites, messageWebsites],\n  );\n\n  // Group accounts by website, filtered to eligible websites\n  const accountGroups: AccountGroupItem[] = useMemo(() => {\n    const websiteIds = new Set(websites.map((w: WebsiteRecord) => w.id));\n    const grouped = new Map<string, AccountRecord[]>();\n\n    accounts.forEach((account: AccountRecord) => {\n      if (websiteIds.has(account.website)) {\n        const existing = grouped.get(account.website) ?? [];\n        existing.push(account);\n        grouped.set(account.website, existing);\n      }\n    });\n\n    return websites\n      .filter((w: WebsiteRecord) => (grouped.get(w.id)?.length ?? 0) > 0)\n      .map((w: WebsiteRecord) => ({\n        websiteId: w.id,\n        websiteDisplayName: w.displayName,\n        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion\n        accounts: grouped.get(w.id)!,\n      }));\n  }, [accounts, websites]);\n\n  // Selected account IDs\n  const selectedAccountIds = useMemo(\n    () => new Set(optionsByAccount.keys()),\n    [optionsByAccount],\n  );\n\n  // All eligible accounts\n  const allEligibleAccounts = useMemo(\n    () => accountGroups.flatMap((g) => g.accounts),\n    [accountGroups],\n  );\n\n  // Map accountId -> AccountRecord for display\n  const accountById = useMemo(() => {\n    const map = new Map<string, AccountRecord>();\n    allEligibleAccounts.forEach((acc) => {\n      map.set(acc.accountId, acc);\n    });\n    return map;\n  }, [allEligibleAccounts]);\n\n  // Get default rating for new options\n  const getDefaultRating = useCallback(() => {\n    const defaultOption = submission.options.find((opt) => opt.isDefault);\n    return defaultOption?.data?.rating ?? SubmissionRating.GENERAL;\n  }, [submission.options]);\n\n  // Handle individual account toggle\n  const handleAccountToggle = useCallback(\n    async (accountId: string) => {\n      try {\n        if (selectedAccountIds.has(accountId)) {\n          // Remove\n          const opt = optionsByAccount.get(accountId);\n          if (opt) {\n            await websiteOptionsApi.remove([opt.id]);\n          }\n        } else {\n          // Add\n          const rating = getDefaultRating();\n          await websiteOptionsApi.create({\n            submissionId: submission.id,\n            accountId,\n            data: { rating },\n          });\n        }\n      } catch (error) {\n        // eslint-disable-next-line no-console, lingui/no-unlocalized-strings\n        console.error('Failed to toggle account:', error);\n      }\n    },\n    [selectedAccountIds, optionsByAccount, submission.id, getDefaultRating],\n  );\n\n  // Handle website group header click - toggle all accounts in this group\n  const handleGroupToggle = useCallback(\n    async (group: AccountGroupItem) => {\n      const allSelected = group.accounts.every((acc) =>\n        selectedAccountIds.has(acc.accountId),\n      );\n\n      try {\n        if (allSelected) {\n          // Deselect all in group\n          const optionIds = group.accounts\n            .map((acc) => optionsByAccount.get(acc.accountId)?.id)\n            .filter(Boolean) as string[];\n          if (optionIds.length > 0) {\n            await websiteOptionsApi.remove(optionIds);\n          }\n        } else {\n          // Select all unselected in group\n          const rating = getDefaultRating();\n          const unselected = group.accounts.filter(\n            (acc) => !selectedAccountIds.has(acc.accountId),\n          );\n          await Promise.all(\n            unselected.map((acc) =>\n              websiteOptionsApi.create({\n                submissionId: submission.id,\n                accountId: acc.accountId,\n                data: { rating },\n              }),\n            ),\n          );\n        }\n      } catch (error) {\n        // eslint-disable-next-line no-console, lingui/no-unlocalized-strings\n        console.error('Failed to toggle website group:', error);\n      }\n    },\n    [selectedAccountIds, optionsByAccount, submission.id, getDefaultRating],\n  );\n\n  // Handle pill remove (deselect from pills area)\n  const handlePillRemove = useCallback(\n    async (accountId: string) => {\n      const opt = optionsByAccount.get(accountId);\n      if (opt) {\n        try {\n          await websiteOptionsApi.remove([opt.id]);\n        } catch (error) {\n          // eslint-disable-next-line no-console, lingui/no-unlocalized-strings\n          console.error('Failed to remove account:', error);\n        }\n      }\n    },\n    [optionsByAccount],\n  );\n\n  // Select all eligible accounts\n  const handleSelectAll = useCallback(async () => {\n    const unselected = allEligibleAccounts.filter(\n      (acc) => !selectedAccountIds.has(acc.accountId),\n    );\n    if (unselected.length === 0) return;\n\n    setIsSelectingAll(true);\n    try {\n      const rating = getDefaultRating();\n      await Promise.all(\n        unselected.map((acc) =>\n          websiteOptionsApi.create({\n            submissionId: submission.id,\n            accountId: acc.accountId,\n            data: { rating },\n          }),\n        ),\n      );\n    } catch (error) {\n      // eslint-disable-next-line no-console, lingui/no-unlocalized-strings\n      console.error('Failed to select all accounts:', error);\n    } finally {\n      setIsSelectingAll(false);\n    }\n  }, [\n    allEligibleAccounts,\n    selectedAccountIds,\n    submission.id,\n    getDefaultRating,\n  ]);\n\n  // Deselect all accounts\n  const handleDeselectAll = useCallback(async () => {\n    const selectedOptions = Array.from(optionsByAccount.values());\n    if (selectedOptions.length === 0) return;\n\n    setIsDeselectingAll(true);\n    try {\n      await websiteOptionsApi.remove(selectedOptions.map((opt) => opt.id));\n    } catch (error) {\n      // eslint-disable-next-line no-console, lingui/no-unlocalized-strings\n      console.error('Failed to deselect all accounts:', error);\n    } finally {\n      setIsDeselectingAll(false);\n    }\n  }, [optionsByAccount]);\n\n  // Filter groups by search\n  const filteredGroups = useMemo(() => {\n    if (!search.trim()) return accountGroups;\n\n    const searchLower = search.toLowerCase();\n    return accountGroups\n      .map((group) => ({\n        ...group,\n        accounts: group.accounts.filter(\n          (acc) =>\n            acc.name.toLowerCase().includes(searchLower) ||\n            (acc.username?.toLowerCase().includes(searchLower) ?? false) ||\n            group.websiteDisplayName.toLowerCase().includes(searchLower),\n        ),\n      }))\n      .filter((group) => group.accounts.length > 0);\n  }, [accountGroups, search]);\n\n  const hasSelectedAccounts = selectedAccountIds.size > 0;\n  const hasUnselectedAccounts =\n    allEligibleAccounts.length > selectedAccountIds.size;\n\n  const { isArchived } = submission;\n\n  // Render selected account pills\n  const selectedPills = useMemo(() => {\n    const pills: JSX.Element[] = [];\n    selectedAccountIds.forEach((accountId) => {\n      const acc = accountById.get(accountId);\n      if (acc) {\n        pills.push(\n          <Pill\n            key={accountId}\n            withRemoveButton={!isArchived}\n            onRemove={\n              isArchived ? undefined : () => handlePillRemove(accountId)\n            }\n          >\n            {acc.websiteDisplayName} - {acc.name}\n            {acc.username ? ` (${acc.username})` : ''}\n          </Pill>,\n        );\n      }\n    });\n    return pills;\n  }, [selectedAccountIds, accountById, handlePillRemove, isArchived]);\n\n  return (\n    <Stack gap=\"xs\">\n      <Group justify=\"space-between\" align=\"center\">\n        <Text fw={600} size=\"sm\">\n          <Trans>Websites</Trans>\n        </Text>\n        <Group gap=\"xs\">\n          {hasUnselectedAccounts && !isArchived && (\n            <Button\n              size=\"xs\"\n              variant=\"subtle\"\n              leftSection={<IconSquareCheck size={14} />}\n              onClick={handleSelectAll}\n              loading={isSelectingAll}\n              disabled={isDeselectingAll}\n            >\n              <Trans>Select all</Trans>\n            </Button>\n          )}\n          {hasSelectedAccounts && !isArchived && (\n            <Button\n              size=\"xs\"\n              variant=\"subtle\"\n              leftSection={<IconSquare size={14} />}\n              onClick={handleDeselectAll}\n              loading={isDeselectingAll}\n              disabled={isSelectingAll}\n            >\n              <Trans>Deselect all</Trans>\n            </Button>\n          )}\n        </Group>\n      </Group>\n\n      <Combobox\n        store={combobox}\n        onOptionSubmit={(val) => {\n          if (!isArchived) handleAccountToggle(val);\n        }}\n        withinPortal={false}\n      >\n        <Combobox.DropdownTarget>\n          <PillsInput\n            pointer\n            disabled={isArchived}\n            onClick={() => !isArchived && combobox.openDropdown()}\n            rightSection={\n              selectedAccountIds.size > 0 && !isArchived ? (\n                <CloseButton\n                  size=\"sm\"\n                  onMouseDown={(e) => e.preventDefault()}\n                  onClick={handleDeselectAll}\n                  // eslint-disable-next-line lingui/no-unlocalized-strings\n                  aria-label=\"Clear all\"\n                />\n              ) : (\n                !isArchived && <Combobox.Chevron />\n              )\n            }\n          >\n            <Pill.Group>\n              {selectedPills.length > 0 ? (\n                selectedPills\n              ) : (\n                <PillsInput.Field\n                  placeholder={t`Select accounts...`}\n                  value={search}\n                  disabled={isArchived}\n                  onChange={(e) => {\n                    setSearch(e.currentTarget.value);\n                    combobox.openDropdown();\n                    combobox.updateSelectedOptionIndex();\n                  }}\n                  onFocus={() => !isArchived && combobox.openDropdown()}\n                  onKeyDown={(e) => {\n                    if (e.key === 'Backspace' && search.length === 0) {\n                      e.preventDefault();\n                      // Remove last selected\n                      const lastId = Array.from(selectedAccountIds).pop();\n                      if (lastId && !isArchived) handlePillRemove(lastId);\n                    }\n                  }}\n                />\n              )}\n              {selectedPills.length > 0 && (\n                <PillsInput.Field\n                  placeholder=\"\"\n                  value={search}\n                  disabled={isArchived}\n                  onChange={(e) => {\n                    setSearch(e.currentTarget.value);\n                    combobox.openDropdown();\n                    combobox.updateSelectedOptionIndex();\n                  }}\n                  onFocus={() => !isArchived && combobox.openDropdown()}\n                />\n              )}\n            </Pill.Group>\n          </PillsInput>\n        </Combobox.DropdownTarget>\n\n        <Combobox.Dropdown>\n          <ScrollArea.Autosize mah={300} type=\"scroll\">\n            <Combobox.Options>\n              {filteredGroups.length === 0 && (\n                <Combobox.Empty>\n                  <Trans>No results found</Trans>\n                </Combobox.Empty>\n              )}\n              {filteredGroups.map((group) => {\n                const allGroupSelected = group.accounts.every((acc) =>\n                  selectedAccountIds.has(acc.accountId),\n                );\n                const someGroupSelected =\n                  !allGroupSelected &&\n                  group.accounts.some((acc) =>\n                    selectedAccountIds.has(acc.accountId),\n                  );\n\n                return (\n                  <Combobox.Group\n                    key={group.websiteId}\n                    label={\n                      <WebsiteGroupHeader\n                        displayName={group.websiteDisplayName}\n                        allSelected={allGroupSelected}\n                        someSelected={someGroupSelected}\n                        selectedCount={\n                          group.accounts.filter((acc) =>\n                            selectedAccountIds.has(acc.accountId),\n                          ).length\n                        }\n                        totalCount={group.accounts.length}\n                        onToggle={() => handleGroupToggle(group)}\n                      />\n                    }\n                  >\n                    {group.accounts.map((acc) => {\n                      const isSelected = selectedAccountIds.has(acc.accountId);\n                      return (\n                        <Combobox.Option\n                          key={acc.accountId}\n                          value={acc.accountId}\n                          active={isSelected}\n                          pl=\"xl\"\n                        >\n                          <AccountOptionItem\n                            account={acc}\n                            isSelected={isSelected}\n                          />\n                        </Combobox.Option>\n                      );\n                    })}\n                  </Combobox.Group>\n                );\n              })}\n            </Combobox.Options>\n          </ScrollArea.Autosize>\n        </Combobox.Dropdown>\n      </Combobox>\n    </Stack>\n  );\n}\n\ninterface WebsiteGroupHeaderProps {\n  displayName: string;\n  allSelected: boolean;\n  someSelected: boolean;\n  selectedCount: number;\n  totalCount: number;\n  onToggle: () => void;\n}\n\n/**\n * Clickable website group header that toggles all accounts in the group.\n */\nfunction WebsiteGroupHeader({\n  displayName,\n  allSelected,\n  someSelected,\n  selectedCount,\n  totalCount,\n  onToggle,\n}: WebsiteGroupHeaderProps) {\n  return (\n    <Group\n      gap=\"xs\"\n      wrap=\"nowrap\"\n      onClick={(e) => {\n        e.preventDefault();\n        e.stopPropagation();\n        onToggle();\n      }}\n      style={{ cursor: 'pointer', userSelect: 'none' }}\n      py={2}\n    >\n      <Checkbox\n        size=\"xs\"\n        checked={allSelected}\n        indeterminate={someSelected}\n        onChange={() => {}} // Handled by group click\n        tabIndex={-1}\n        style={{ pointerEvents: 'none' }}\n      />\n      <Text size=\"xs\" fw={600} style={{ flex: 1 }} tt=\"uppercase\">\n        {displayName}\n      </Text>\n      <Badge size=\"xs\" variant=\"light\" color=\"gray\">\n        {selectedCount}/{totalCount}\n      </Badge>\n    </Group>\n  );\n}\n\ninterface AccountOptionItemProps {\n  account: AccountRecord;\n  isSelected: boolean;\n}\n\n/**\n * Single account option in the dropdown with login state color indicator.\n */\nfunction AccountOptionItem({ account, isSelected }: AccountOptionItemProps) {\n  const loginColor = account.isLoggedIn\n    ? 'green'\n    : account.isPending\n      ? 'yellow'\n      : 'red';\n\n  return (\n    <Group gap=\"xs\" wrap=\"nowrap\">\n      <Checkbox\n        size=\"xs\"\n        checked={isSelected}\n        onChange={() => {}} // Handled by Combobox onOptionSubmit\n        tabIndex={-1}\n        style={{ pointerEvents: 'none' }}\n      />\n      <IconCircleFilled\n        size={8}\n        style={{ flexShrink: 0 }}\n        color={`var(--mantine-color-${loginColor}-filled)`}\n      />\n      <Box style={{ flex: 1, minWidth: 0 }}>\n        <Text size=\"sm\" truncate>\n          {account.name}\n        </Text>\n      </Box>\n      {account.username && (\n        <Text size=\"xs\" c=\"dimmed\" truncate>\n          {account.username}\n        </Text>\n      )}\n    </Group>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/account-selection/account-selection-form.tsx",
    "content": "/**\n * AccountSelectionForm - Form for selecting accounts grouped by website.\n * Each account checkbox expands to show website-specific form fields when selected.\n * API calls are made directly when accounts are checked/unchecked.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport {\n  Badge,\n  Box,\n  Button,\n  Checkbox,\n  Collapse,\n  Group,\n  Paper,\n  Stack,\n  Text,\n  UnstyledButton,\n} from '@mantine/core';\nimport { useDisclosure } from '@mantine/hooks';\nimport { SubmissionType, type WebsiteOptionsDto } from '@postybirb/types';\nimport {\n  IconChevronDown,\n  IconChevronRight,\n  IconSquare,\n  IconSquareCheck,\n} from '@tabler/icons-react';\nimport { useCallback, useMemo, useState } from 'react';\nimport websiteOptionsApi from '../../../../../api/website-options.api';\nimport { useAccounts } from '../../../../../stores/entity/account-store';\nimport {\n  useFileWebsites,\n  useMessageWebsites,\n} from '../../../../../stores/entity/website-store';\nimport type {\n  AccountRecord,\n  WebsiteRecord,\n} from '../../../../../stores/records';\nimport { useSubmissionEditCardContext } from '../context';\nimport { AccountOptionRow } from './account-option-row';\nimport './account-selection.css';\n\ninterface WebsiteAccountGroupProps {\n  website: WebsiteRecord;\n  accounts: AccountRecord[];\n  /** Map of accountId -> WebsiteOptionsDto for quick lookup */\n  optionsByAccount: Map<string, WebsiteOptionsDto>;\n  /** Map of websiteOptionId -> validation result */\n  validationsByOptionId: Map<\n    string,\n    { hasErrors: boolean; hasWarnings: boolean }\n  >;\n}\n\n/**\n * A single website group with expandable account list.\n */\nfunction WebsiteAccountGroup({\n  website,\n  accounts,\n  optionsByAccount,\n  validationsByOptionId,\n}: WebsiteAccountGroupProps) {\n  const [expanded, { toggle, open }] = useDisclosure(false);\n\n  // Count how many accounts are selected (have website options)\n  const selectedCount = useMemo(\n    () => accounts.filter((acc) => optionsByAccount.has(acc.accountId)).length,\n    [accounts, optionsByAccount],\n  );\n\n  // Count logged in accounts\n  const loggedInCount = useMemo(\n    () => accounts.filter((acc) => acc.isLoggedIn).length,\n    [accounts],\n  );\n\n  // Count errors and warnings in this website group\n  const { errorCount, warningCount } = useMemo(() => {\n    let errors = 0;\n    let warnings = 0;\n    accounts.forEach((acc) => {\n      const option = optionsByAccount.get(acc.accountId);\n      if (option) {\n        const validation = validationsByOptionId.get(option.id);\n        if (validation?.hasErrors) errors++;\n        if (validation?.hasWarnings) warnings++;\n      }\n    });\n    return { errorCount: errors, warningCount: warnings };\n  }, [accounts, optionsByAccount, validationsByOptionId]);\n\n  return (\n    <Paper withBorder radius=\"sm\" p={0}>\n      <UnstyledButton\n        onClick={toggle}\n        className=\"postybirb__website_group_header\"\n      >\n        <Group\n          gap=\"xs\"\n          px=\"sm\"\n          py=\"xs\"\n          wrap=\"nowrap\"\n          style={{\n            backgroundColor:\n              selectedCount > 0 ? 'var(--mantine-primary-color-light)' : '',\n          }}\n        >\n          {expanded ? (\n            <IconChevronDown size={14} style={{ flexShrink: 0 }} />\n          ) : (\n            <IconChevronRight size={14} style={{ flexShrink: 0 }} />\n          )}\n          <Text size=\"sm\" fw={500} style={{ flex: 1 }} truncate>\n            {website.displayName}\n          </Text>\n          <Group gap={4}>\n            {errorCount > 0 && (\n              <Badge size=\"xs\" variant=\"light\" color=\"red\">\n                {errorCount} {errorCount === 1 ? 'error' : 'errors'}\n              </Badge>\n            )}\n            {warningCount > 0 && (\n              <Badge size=\"xs\" variant=\"light\" color=\"yellow\">\n                {warningCount} {warningCount === 1 ? 'warning' : 'warnings'}\n              </Badge>\n            )}\n            <Badge size=\"xs\" variant=\"light\">\n              {selectedCount}/{accounts.length}\n            </Badge>\n          </Group>\n        </Group>\n      </UnstyledButton>\n\n      <Collapse in={expanded}>\n        <Box pb=\"xs\">\n          {accounts.map((account) => {\n            const option = optionsByAccount.get(account.accountId);\n            const validation = option\n              ? validationsByOptionId.get(option.id)\n              : undefined;\n            return (\n              <AccountOptionRow\n                key={account.id}\n                account={account}\n                websiteOption={option ?? null}\n                hasErrors={validation?.hasErrors ?? false}\n                hasWarnings={validation?.hasWarnings ?? false}\n              />\n            );\n          })}\n        </Box>\n      </Collapse>\n    </Paper>\n  );\n}\n\n/**\n * Account selection form with websites grouped and expandable.\n * Each account selection triggers an API call to add/remove website options.\n */\nexport function AccountSelectionForm() {\n  const { submission } = useSubmissionEditCardContext();\n  const accounts = useAccounts();\n  const fileWebsites = useFileWebsites();\n  const messageWebsites = useMessageWebsites();\n  const [isSelectingAll, setIsSelectingAll] = useState(false);\n  const [isDeselectingAll, setIsDeselectingAll] = useState(false);\n  const [hideUnselected, setHideUnselected] = useState(false);\n\n  // Build a map of accountId -> WebsiteOptionsDto for quick lookup\n  const optionsByAccount = useMemo(() => {\n    const map = new Map<string, WebsiteOptionsDto>();\n    submission.options.forEach((opt) => {\n      if (!opt.isDefault) {\n        map.set(opt.accountId, opt);\n      }\n    });\n    return map;\n  }, [submission.options]);\n\n  // Build a map of websiteOptionId -> validation status\n  const validationsByOptionId = useMemo(() => {\n    const map = new Map<string, { hasErrors: boolean; hasWarnings: boolean }>();\n    submission.validations.forEach((validation) => {\n      map.set(validation.id, {\n        hasErrors: Boolean(validation.errors && validation.errors.length > 0),\n        hasWarnings: Boolean(\n          validation.warnings && validation.warnings.length > 0,\n        ),\n      });\n    });\n    return map;\n  }, [submission.validations]);\n\n  // Group accounts by website\n  const accountsByWebsite = useMemo(() => {\n    const grouped = new Map<string, AccountRecord[]>();\n    accounts.forEach((account) => {\n      const existing = grouped.get(account.website) ?? [];\n      existing.push(account);\n      grouped.set(account.website, existing);\n    });\n    return grouped;\n  }, [accounts]);\n\n  // Filter websites based on submission type\n  const websites = useMemo(\n    () =>\n      submission.type === SubmissionType.FILE ? fileWebsites : messageWebsites,\n    [submission.type, fileWebsites, messageWebsites],\n  );\n\n  // Get all accounts for websites that support this submission type\n  const eligibleAccounts = useMemo(() => {\n    const websiteIds = new Set(websites.map((w) => w.id));\n    return accounts.filter((acc) => websiteIds.has(acc.website));\n  }, [accounts, websites]);\n\n  // Accounts that are not yet selected\n  const unselectedAccounts = useMemo(\n    () =>\n      eligibleAccounts.filter((acc) => !optionsByAccount.has(acc.accountId)),\n    [eligibleAccounts, optionsByAccount],\n  );\n\n  // Select all accounts\n  const handleSelectAll = useCallback(async () => {\n    if (unselectedAccounts.length === 0) return;\n\n    setIsSelectingAll(true);\n    try {\n      // Rating is intentionally omitted so each option defaults to\n      // \"inherit from default\" mode on the server.\n      await Promise.all(\n        unselectedAccounts.map((account) =>\n          websiteOptionsApi.create({\n            submissionId: submission.id,\n            accountId: account.accountId,\n            data: {},\n          }),\n        ),\n      );\n    } catch (error) {\n      // eslint-disable-next-line no-console, lingui/no-unlocalized-strings\n      console.error('Failed to select all accounts:', error);\n    } finally {\n      setIsSelectingAll(false);\n    }\n  }, [unselectedAccounts, submission.id]);\n\n  // Deselect all accounts\n  const handleDeselectAll = useCallback(async () => {\n    const selectedOptions = Array.from(optionsByAccount.values());\n    if (selectedOptions.length === 0) return;\n\n    setIsDeselectingAll(true);\n    try {\n      await websiteOptionsApi.remove(selectedOptions.map((opt) => opt.id));\n    } catch (error) {\n      // eslint-disable-next-line no-console, lingui/no-unlocalized-strings\n      console.error('Failed to deselect all accounts:', error);\n    } finally {\n      setIsDeselectingAll(false);\n    }\n  }, [optionsByAccount]);\n\n  const hasSelectedAccounts = optionsByAccount.size > 0;\n  const hasUnselectedAccounts = unselectedAccounts.length > 0;\n\n  return (\n    <Stack gap=\"xs\">\n      <Group justify=\"space-between\" align=\"center\">\n        <Text fw={600} size=\"sm\">\n          <Trans>Websites</Trans>\n        </Text>\n        <Group gap=\"xs\">\n          <Checkbox\n            size=\"xs\"\n            label={<Trans>Hide unselected</Trans>}\n            checked={hideUnselected}\n            onChange={(e) => setHideUnselected(e.currentTarget.checked)}\n          />\n          {hasUnselectedAccounts && (\n            <Button\n              size=\"xs\"\n              variant=\"subtle\"\n              leftSection={<IconSquareCheck size={14} />}\n              onClick={handleSelectAll}\n              loading={isSelectingAll}\n              disabled={isDeselectingAll}\n            >\n              <Trans>Select all</Trans>\n            </Button>\n          )}\n          {hasSelectedAccounts && (\n            <Button\n              size=\"xs\"\n              variant=\"subtle\"\n              leftSection={<IconSquare size={14} />}\n              onClick={handleDeselectAll}\n              loading={isDeselectingAll}\n              disabled={isSelectingAll}\n            >\n              <Trans>Deselect all</Trans>\n            </Button>\n          )}\n        </Group>\n      </Group>\n      {websites.map((website) => {\n        const websiteAccounts = accountsByWebsite.get(website.id) ?? [];\n        if (websiteAccounts.length === 0) return null;\n\n        // Check if any account in this website group is selected\n        const hasSelectedInGroup = websiteAccounts.some((acc) =>\n          optionsByAccount.has(acc.accountId),\n        );\n\n        // Hide this website group if hideUnselected is enabled and no accounts are selected\n        if (hideUnselected && !hasSelectedInGroup) return null;\n\n        return (\n          <WebsiteAccountGroup\n            key={website.id}\n            website={website}\n            accounts={websiteAccounts}\n            optionsByAccount={optionsByAccount}\n            validationsByOptionId={validationsByOptionId}\n          />\n        );\n      })}\n    </Stack>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/account-selection/account-selection.css",
    "content": "/**\n * CSS for AccountSelection and SelectedAccountsForms components.\n */\n\n.postybirb__account_option_row:hover {\n  background-color: var(--mantine-color-gray-light-hover);\n}\n\n.postybirb__account_option_form {\n  background-color: var(--mantine-color-gray-light);\n}\n\n.postybirb__website_group_header {\n  width: 100%;\n  display: block;\n}\n\n.postybirb__website_group_header:hover {\n  background-color: var(--mantine-color-gray-light-hover);\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/boolean-field.tsx",
    "content": "/**\n * BooleanField - Checkbox field for boolean values.\n */\n\nimport { useLingui } from '@lingui/react/macro';\nimport { Checkbox } from '@mantine/core';\nimport {\n    BooleanFieldType,\n    FieldAggregateType,\n    FieldType,\n} from '@postybirb/form-builder';\nimport { useFormFieldsContext } from '../form-fields-context';\nimport { useValidations } from '../hooks/use-validations';\nimport { FieldLabel, getTranslatedLabel } from './field-label';\nimport { FormFieldProps } from './form-field.type';\n\nexport function BooleanField({\n  fieldName,\n  field,\n}: FormFieldProps<BooleanFieldType>) {\n  const { t } = useLingui();\n  const { getValue, setValue, submission } = useFormFieldsContext();\n  const validations = useValidations(fieldName);\n\n  const value = Boolean(\n    getValue<boolean>(fieldName) ?? field.defaultValue ?? false,\n  );\n\n  // Create a label-less field for the wrapper (checkbox has its own label)\n  const labelLessField = {\n    ...field,\n    label: undefined,\n  } as unknown as FieldType<boolean, string>;\n\n  return (\n    <FieldLabel\n      field={labelLessField as FieldAggregateType}\n      fieldName={fieldName}\n      validationState={validations}\n    >\n      <Checkbox\n        checked={value}\n        disabled={submission.isArchived}\n        onChange={(event) => setValue(fieldName, event.currentTarget.checked)}\n        label={getTranslatedLabel(field, t)}\n      />\n    </FieldLabel>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/datetime-field.tsx",
    "content": "/**\n * DateTimeField - Date/time picker field.\n */\n\nimport { DateTimePicker } from '@mantine/dates';\nimport { DateTimeFieldType } from '@postybirb/form-builder';\nimport { IconCalendar } from '@tabler/icons-react';\nimport moment from 'moment';\nimport { useFormFieldsContext } from '../form-fields-context';\nimport { useDefaultOption } from '../hooks/use-default-option';\nimport { useValidations } from '../hooks/use-validations';\nimport { FieldLabel } from './field-label';\nimport { FormFieldProps } from './form-field.type';\n\nfunction DateTimePickerField({\n  fieldName,\n  field,\n  defaultValue,\n}: FormFieldProps<DateTimeFieldType> & { defaultValue: string | undefined }) {\n  const { getValue, setValue, submission } = useFormFieldsContext();\n\n  const value = getValue<string>(fieldName) ?? field.defaultValue ?? '';\n  const dateValue = value ? moment(value).toDate() : null;\n\n  const minDate = field.min ? moment(field.min).toDate() : undefined;\n  const maxDate = field.max ? moment(field.max).toDate() : undefined;\n\n  return (\n    <DateTimePicker\n      rightSection={<IconCalendar />}\n      value={dateValue}\n      disabled={submission.isArchived}\n      onChange={(date) => {\n        if (date) {\n          setValue(fieldName, moment(date).toISOString());\n        } else {\n          setValue(fieldName, '');\n        }\n      }}\n      placeholder={\n        defaultValue\n          ? // eslint-disable-next-line lingui/no-unlocalized-strings\n            moment(defaultValue).format(field.format || 'YYYY-MM-DD HH:mm')\n          : undefined\n      }\n      // eslint-disable-next-line lingui/no-unlocalized-strings\n      valueFormat={field.format || 'YYYY-MM-DD HH:mm'}\n      withSeconds={false}\n      minDate={minDate}\n      maxDate={maxDate}\n      clearable\n      w=\"100%\"\n      required={field.required}\n    />\n  );\n}\n\nexport function DateTimeField({\n  fieldName,\n  field,\n}: FormFieldProps<DateTimeFieldType>) {\n  const defaultValue = useDefaultOption<string>(fieldName);\n  const validations = useValidations(fieldName);\n\n  return (\n    <FieldLabel\n      field={field}\n      fieldName={fieldName}\n      validationState={validations}\n    >\n      <DateTimePickerField\n        fieldName={fieldName}\n        field={field}\n        defaultValue={defaultValue}\n      />\n    </FieldLabel>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-field.tsx",
    "content": "/* eslint-disable lingui/text-restrictions */\n/**\n * DescriptionField - Rich text editor for descriptions.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport { Alert, Box, Checkbox } from '@mantine/core';\nimport { useDebouncedCallback, useDisclosure } from '@mantine/hooks';\nimport { DescriptionFieldType } from '@postybirb/form-builder';\nimport {\n    DefaultDescription,\n    DefaultDescriptionValue,\n    DescriptionValue,\n} from '@postybirb/types';\nimport { IconAlertTriangle } from '@tabler/icons-react';\nimport { useMemo } from 'react';\nimport { DescriptionEditor } from '../../../../../../shared';\nimport { useFormFieldsContext } from '../form-fields-context';\nimport { useDefaultOption } from '../hooks/use-default-option';\nimport { useValidations } from '../hooks/use-validations';\nimport { DescriptionPreviewPanel } from './description-preview-panel';\nimport { FieldLabel } from './field-label';\nimport { FormFieldProps } from './form-field.type';\n\n/**\n * Recursively checks if a description contains a specific inline content type.\n */\nfunction hasInlineContentType(\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  blocks: any[],\n  type: string,\n): boolean {\n  for (const block of blocks) {\n    if (Array.isArray(block?.content)) {\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n      if (block.content.some((inline: any) => inline?.type === type)) {\n        return true;\n      }\n    }\n    if (\n      Array.isArray(block?.children) &&\n      hasInlineContentType(block.children, type)\n    ) {\n      return true;\n    }\n  }\n  return false;\n}\n\n/**\n * Pattern to detect legacy shortcuts like {title}, {tags}, {cw}, {fa:username}, etc.\n */\nconst LEGACY_SHORTCUT_PATTERN = /\\{[a-zA-Z0-9]+(?:\\[[^\\]]+\\])?(?::[^}]+)?\\}/;\n\n/**\n * Recursively checks if a description contains legacy shortcut syntax in text nodes.\n */\nfunction hasLegacyShortcuts(\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  blocks: any[],\n): boolean {\n  for (const block of blocks) {\n    if (Array.isArray(block?.content)) {\n      for (const inline of block.content) {\n        if (\n          inline?.type === 'text' &&\n          typeof inline?.text === 'string' &&\n          LEGACY_SHORTCUT_PATTERN.test(inline.text)\n        ) {\n          return true;\n        }\n      }\n    }\n    if (Array.isArray(block?.children) && hasLegacyShortcuts(block.children)) {\n      return true;\n    }\n  }\n  return false;\n}\n\nexport function DescriptionField({\n  fieldName,\n  field,\n}: FormFieldProps<DescriptionFieldType>) {\n  const { getValue, setValue, option, submission } = useFormFieldsContext();\n  const defaultOption = useDefaultOption<DescriptionValue>(fieldName);\n  const validations = useValidations(fieldName);\n  const [previewOpened, { toggle: togglePreview }] = useDisclosure(false);\n\n  const fieldValue =\n    getValue<DescriptionValue>(fieldName) ??\n    field.defaultValue ??\n    DefaultDescriptionValue();\n  const overrideDefault = fieldValue.overrideDefault || false;\n\n  // Derive effective values - if default has it enabled, it's always enabled (locked)\n  const defaultInsertTitle = defaultOption?.insertTitle || false;\n  const defaultInsertTags = defaultOption?.insertTags || false;\n  const insertTags = fieldValue.insertTags || defaultInsertTags;\n  const insertTitle = fieldValue.insertTitle || defaultInsertTitle;\n  const description = useMemo(\n    () => fieldValue.description || DefaultDescription(),\n    [fieldValue.description],\n  );\n\n  const hasTagsShortcut = useMemo(\n    () => hasInlineContentType(description.content || [], 'tagsShortcut'),\n    [description],\n  );\n\n  const hasTitleShortcut = useMemo(\n    () => hasInlineContentType(description.content || [], 'titleShortcut'),\n    [description],\n  );\n\n  const containsLegacyShortcuts = useMemo(\n    () => hasLegacyShortcuts(description.content || []),\n    [description],\n  );\n\n  const descriptionChangeEvent = useMemo(() => new EventTarget(), []);\n\n  const debouncedDispatchDescriptionChange = useDebouncedCallback(\n    () => descriptionChangeEvent.dispatchEvent(new Event('change')),\n    { delay: 1000, flushOnUnmount: false },\n  );\n\n  return (\n    <Box data-tour-id=\"edit-card-description\">\n      {containsLegacyShortcuts && (\n        <Alert\n          icon={<IconAlertTriangle size={16} />}\n          title={<Trans>Legacy Shortcuts Detected</Trans>}\n          color=\"yellow\"\n          mb=\"sm\"\n        >\n          <Trans>\n            Your description contains legacy shortcut syntax. These are no\n            longer supported. Please use the new shortcut menu (type @, &lbrace;\n            or &#96;) to insert shortcuts.\n          </Trans>\n        </Alert>\n      )}\n      <FieldLabel\n        field={field}\n        fieldName={fieldName}\n        validationState={validations}\n      >\n        {defaultOption === undefined ? null : (\n          <Checkbox\n            mb=\"4\"\n            disabled={submission.isArchived}\n            checked={overrideDefault}\n            onChange={(e) => {\n              setValue(fieldName, {\n                ...fieldValue,\n                overrideDefault: e.target.checked,\n              });\n              debouncedDispatchDescriptionChange();\n            }}\n            label={<Trans>Use custom description</Trans>}\n          />\n        )}\n        <Checkbox\n          mb=\"4\"\n          disabled={\n            submission.isArchived ||\n            (overrideDefault && hasTitleShortcut) ||\n            defaultInsertTitle\n          }\n          checked={insertTitle}\n          onChange={(e) => {\n            setValue(fieldName, {\n              ...fieldValue,\n              insertTitle: e.target.checked,\n            });\n            debouncedDispatchDescriptionChange();\n          }}\n          label={<Trans>Insert title at start</Trans>}\n        />\n        <Checkbox\n          mb=\"4\"\n          disabled={\n            submission.isArchived ||\n            (overrideDefault && hasTagsShortcut) ||\n            defaultInsertTags\n          }\n          checked={insertTags}\n          onChange={(e) => {\n            setValue(fieldName, {\n              ...fieldValue,\n              insertTags: e.target.checked,\n            });\n            debouncedDispatchDescriptionChange();\n          }}\n          label={<Trans>Insert tags at end</Trans>}\n        />\n        {(overrideDefault || option.isDefault) && (\n          <>\n            <DescriptionEditor\n              id={option.id}\n              value={description}\n              minHeight={35}\n              showCustomShortcuts\n              isDefaultEditor={option.isDefault}\n              onPreview={togglePreview}\n              readOnly={submission.isArchived}\n              onChange={(value) => {\n                setValue(fieldName, {\n                  ...fieldValue,\n                  description: value,\n                });\n                debouncedDispatchDescriptionChange();\n              }}\n            />\n            {previewOpened && (\n              <DescriptionPreviewPanel\n                submissionId={submission.id}\n                options={submission.options}\n                isDefaultEditor={option.isDefault}\n                currentOptionId={option.isDefault ? undefined : option.id}\n                changeEvent={descriptionChangeEvent}\n              />\n            )}\n          </>\n        )}\n      </FieldLabel>\n    </Box>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-preview-panel.tsx",
    "content": "/* eslint-disable lingui/text-restrictions */\n/* eslint-disable lingui/no-unlocalized-strings */\n/**\n * DescriptionPreviewPanel - Shows parsed description output per website.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport {\n  ActionIcon,\n  Alert,\n  Box,\n  CopyButton,\n  Group,\n  Loader,\n  ScrollArea,\n  Tabs,\n  Text,\n  Textarea,\n  Tooltip,\n  Typography,\n} from '@mantine/core';\nimport {\n  DescriptionType,\n  IDescriptionPreviewResult,\n  SubmissionId,\n  WebsiteOptionsDto,\n} from '@postybirb/types';\nimport { IconCheck, IconCopy } from '@tabler/icons-react';\nimport { useCallback, useEffect, useMemo, useState } from 'react';\nimport websiteOptionsApi from '../../../../../../../api/website-options.api';\nimport { useWebsitesMap } from '../../../../../../../stores/entity/website-store';\nimport { ComponentErrorBoundary } from '../../../../../../error-boundary';\n\ninterface DescriptionPreviewPanelProps {\n  /** The submission ID */\n  submissionId: SubmissionId;\n  /** All website options for this submission */\n  options: WebsiteOptionsDto[];\n  /** Whether the current option being edited is the default */\n  isDefaultEditor: boolean;\n  /** The current (non-default) option ID, if applicable */\n  currentOptionId?: string;\n  changeEvent: EventTarget;\n}\n\ninterface PreviewState {\n  loading: boolean;\n  error?: string;\n  result?: IDescriptionPreviewResult;\n}\n\nfunction formatDescriptionType(type: DescriptionType): string {\n  switch (type) {\n    case DescriptionType.HTML:\n      return 'HTML';\n    case DescriptionType.MARKDOWN:\n      return 'Markdown';\n    case DescriptionType.BBCODE:\n      return 'BBCode';\n    case DescriptionType.PLAINTEXT:\n      return 'Plain Text';\n    case DescriptionType.CUSTOM:\n      return 'Custom';\n    default:\n      return type;\n  }\n}\n\nfunction PreviewContent({\n  preview,\n  websiteId,\n}: {\n  preview: PreviewState;\n  websiteId: string;\n}) {\n  if (preview.loading) {\n    return (\n      <Box p=\"md\" style={{ display: 'flex', justifyContent: 'center' }}>\n        <Loader size=\"sm\" />\n      </Box>\n    );\n  }\n\n  if (preview.error) {\n    return (\n      <Alert color=\"red\" p=\"xs\">\n        {preview.error}\n      </Alert>\n    );\n  }\n\n  if (!preview.result) {\n    return (\n      <Text c=\"dimmed\" size=\"sm\" p=\"md\">\n        <Trans>No preview available</Trans>\n      </Text>\n    );\n  }\n\n  const { descriptionType, description } = preview.result;\n\n  const Renderer =\n    descriptionPreviewRendererByWebsite.get(websiteId) ??\n    descriptionPreviewRendererByType.get(descriptionType);\n\n  return (\n    <Box>\n      <Group justify=\"space-between\" mb=\"xs\">\n        <Text size=\"xs\" c=\"dimmed\">\n          Format: {formatDescriptionType(descriptionType)}\n        </Text>\n        <CopyButton value={description} timeout={2000}>\n          {({ copied, copy }) => (\n            <Tooltip label={copied ? 'Copied' : 'Copy'} withArrow>\n              <ActionIcon\n                size=\"xs\"\n                variant=\"subtle\"\n                color={copied ? 'teal' : 'gray'}\n                onClick={copy}\n              >\n                {copied ? <IconCheck size={14} /> : <IconCopy size={14} />}\n              </ActionIcon>\n            </Tooltip>\n          )}\n        </CopyButton>\n      </Group>\n\n      {/* Raw output */}\n      <Textarea\n        value={description}\n        readOnly\n        autosize\n        minRows={3}\n        maxRows={12}\n        styles={{\n          input: {\n            fontFamily: 'monospace',\n            fontSize: '12px',\n          },\n        }}\n      />\n\n      {/* HTML rendered preview */}\n      {description && Renderer && (\n        <Box mt=\"xs\">\n          <Text size=\"xs\" c=\"dimmed\" mb={4}>\n            Rendered:\n          </Text>\n          <ScrollArea.Autosize mah={300}>\n            <Typography>\n              <Box\n                style={{\n                  border: '1px solid var(--mantine-color-default-border)',\n                  borderRadius: 'var(--mantine-radius-sm)',\n                  padding: 'var(--mantine-spacing-xs)',\n                  fontSize: '13px',\n                }}\n              >\n                <ComponentErrorBoundary>\n                  <Renderer description={description} />\n                </ComponentErrorBoundary>\n              </Box>\n            </Typography>\n          </ScrollArea.Autosize>\n        </Box>\n      )}\n    </Box>\n  );\n}\n\nexport const descriptionPreviewRendererByType = new Map<\n  string,\n  (props: { description: string }) => React.ReactNode\n>();\n\nexport const descriptionPreviewRendererByWebsite = new Map<\n  string,\n  (props: { description: string }) => React.ReactNode\n>();\n\ndescriptionPreviewRendererByType.set(\n  DescriptionType.HTML,\n  ({ description }) => (\n    <Box dangerouslySetInnerHTML={{ __html: description }} />\n  ),\n);\n\ndescriptionPreviewRendererByType.set(\n  DescriptionType.PLAINTEXT,\n  ({ description }) =>\n    description.split('\\n').map((e) => <Box key={e}>{e}</Box>),\n);\n\nexport function DescriptionPreviewPanel({\n  submissionId,\n  options,\n  isDefaultEditor,\n  currentOptionId,\n  changeEvent,\n}: DescriptionPreviewPanelProps) {\n  const websitesMap = useWebsitesMap();\n  const [previews, setPreviews] = useState<Record<string, PreviewState>>({});\n\n  // For default editor: show all non-default options as tabs (one per website)\n  // For website-specific editor: show only the current option\n  const previewableOptions = useMemo(() => {\n    const candidates = isDefaultEditor\n      ? options.filter((o) => !o.isDefault)\n      : options.filter((o) => o.id === currentOptionId);\n    // Deduplicate by website — keep only the first option per unique website\n    const seen = new Set<string>();\n    return candidates.filter((o) => {\n      const websiteId = o.account?.website ?? o.id;\n      if (seen.has(websiteId)) return false;\n      seen.add(websiteId);\n      return true;\n    });\n  }, [isDefaultEditor, options, currentOptionId]);\n\n  /** Resolve human-readable website display name for an option */\n  const getDisplayName = useCallback(\n    (opt: WebsiteOptionsDto) =>\n      websitesMap.get(opt.account?.website)?.displayName ??\n      opt.account?.website ??\n      'Unknown',\n    [websitesMap],\n  );\n\n  const loadPreview = useCallback(\n    async (optionId: string, setLoading = true) => {\n      if (setLoading) {\n        setPreviews((prev) => ({\n          ...prev,\n          [optionId]: { loading: true },\n        }));\n      }\n\n      try {\n        const response = await websiteOptionsApi.previewDescription({\n          submissionId,\n          websiteOptionId: optionId,\n        });\n        setPreviews((prev) => ({\n          ...prev,\n          [optionId]: { loading: false, result: response.body },\n        }));\n      } catch (err) {\n        setPreviews((prev) => ({\n          ...prev,\n          [optionId]: {\n            loading: false,\n            error:\n              err instanceof Error ? err.message : 'Failed to load preview',\n          },\n        }));\n      }\n    },\n    [submissionId],\n  );\n\n  // Auto-load first tab\n  useEffect(() => {\n    if (previewableOptions.length > 0) {\n      const firstId = previewableOptions[0].id;\n      if (!previews[firstId]) {\n        loadPreview(firstId);\n      }\n    }\n    // Only run on mount and when previewableOptions changes\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [previewableOptions.map((o) => o.id).join(',')]);\n\n  useEffect(() => {\n    const listener = () => {\n      for (const option of previewableOptions) {\n        // Setting loading makes the whole description section flicker on type because the size of the\n        // preview component changs\n        loadPreview(option.id, false);\n      }\n    };\n    changeEvent.addEventListener('change', listener);\n\n    return () => changeEvent.removeEventListener('change', listener);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [changeEvent, loadPreview, previewableOptions.map((o) => o.id).join(',')]);\n\n  if (previewableOptions.length === 0) {\n    return (\n      <Alert color=\"yellow\" p=\"xs\" mt=\"xs\">\n        <Trans>Add website accounts to preview the description output.</Trans>\n      </Alert>\n    );\n  }\n\n  // Single option — no tabs needed\n  if (previewableOptions.length === 1) {\n    const opt = previewableOptions[0];\n    const preview = previews[opt.id] ?? { loading: true };\n    return (\n      <Box\n        mt=\"xs\"\n        p=\"xs\"\n        style={{\n          border: '1px solid var(--mantine-color-default-border)',\n          borderRadius: 'var(--mantine-radius-sm)',\n        }}\n      >\n        <Group justify=\"space-between\" mb=\"xs\">\n          <Text size=\"sm\" fw={500}>\n            <Trans>Preview</Trans>: {getDisplayName(opt)}\n          </Text>\n          <ActionIcon\n            size=\"xs\"\n            variant=\"subtle\"\n            onClick={() => loadPreview(opt.id)}\n          >\n            <Text size=\"xs\">↻</Text>\n          </ActionIcon>\n        </Group>\n        <PreviewContent preview={preview} websiteId={opt.account.website} />\n      </Box>\n    );\n  }\n\n  // Multiple options — tabbed view\n  return (\n    <Box\n      mt=\"xs\"\n      style={{\n        border: '1px solid var(--mantine-color-default-border)',\n        borderRadius: 'var(--mantine-radius-sm)',\n      }}\n    >\n      <Tabs\n        defaultValue={previewableOptions[0].id}\n        onChange={(tabId) => {\n          if (tabId && !previews[tabId]) {\n            loadPreview(tabId);\n          }\n        }}\n      >\n        <Tabs.List>\n          {previewableOptions.map((opt) => (\n            <Tabs.Tab key={opt.id} value={opt.id}>\n              {getDisplayName(opt)}\n            </Tabs.Tab>\n          ))}\n        </Tabs.List>\n\n        {previewableOptions.map((opt) => {\n          const preview = previews[opt.id] ?? { loading: true };\n          return (\n            <Tabs.Panel key={opt.id} value={opt.id} p=\"xs\">\n              <Group justify=\"flex-end\" mb=\"xs\">\n                <ActionIcon\n                  size=\"xs\"\n                  variant=\"subtle\"\n                  onClick={() => loadPreview(opt.id)}\n                >\n                  <Text size=\"xs\">↻</Text>\n                </ActionIcon>\n              </Group>\n              <ComponentErrorBoundary>\n                <PreviewContent\n                  preview={preview}\n                  websiteId={opt.account.website}\n                />\n              </ComponentErrorBoundary>\n            </Tabs.Panel>\n          );\n        })}\n      </Tabs>\n    </Box>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/field-copy-button.tsx",
    "content": "/**\n * FieldCopyButton - Copy to clipboard button for text fields.\n * Re-exports CopyToClipboard for backwards compatibility.\n */\n\nimport { CopyToClipboard } from '../../../../../../shared/copy-to-clipboard';\n\nexport function FieldCopyButton({ value }: { value: string | undefined }) {\n  return <CopyToClipboard value={value} variant=\"icon\" size=\"sm\" />;\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/field-label.tsx",
    "content": "/**\n * FieldLabel - Wrapper component that displays field label and validation messages.\n */\n\nimport { MessageDescriptor } from '@lingui/core';\nimport { useLingui } from '@lingui/react/macro';\nimport { Input } from '@mantine/core';\nimport type { FieldAggregateType } from '@postybirb/form-builder';\nimport { FieldLabelTranslations } from '@postybirb/translations';\nimport { PropsWithChildren } from 'react';\nimport { ValidationTranslation } from '../../../../../../../i18n/validation-translation';\nimport { UseValidationResult } from '../hooks/use-validations';\n\ninterface FieldLabelProps {\n  field: FieldAggregateType;\n  fieldName: string;\n  validationState: UseValidationResult;\n}\n\nexport function getTranslatedLabel(\n  field: FieldAggregateType,\n  converter: (msg: MessageDescriptor) => string,\n): string {\n  if (typeof field.label !== 'string') return field.label.untranslated;\n\n  const translationLabel =\n    FieldLabelTranslations[field.label as keyof typeof FieldLabelTranslations];\n\n  if (!translationLabel) {\n    // eslint-disable-next-line lingui/no-unlocalized-strings, no-console\n    console.warn('Missing translation for field', field);\n    return field.label;\n  }\n\n  return converter(translationLabel);\n}\n\nexport function FieldLabel(\n  props: PropsWithChildren<FieldLabelProps>,\n): JSX.Element {\n  const { field, children, fieldName, validationState } = props;\n  const { errors, warnings } = validationState;\n  const { t } = useLingui();\n\n  const label = field.label ? getTranslatedLabel(field, t) : undefined;\n\n  return (\n    <Input.Wrapper required={field.required} label={label}>\n      {children}\n      {errors.map((error) => (\n        <Input.Error\n          key={`${fieldName}-${error.id}-${JSON.stringify(error.values)}`}\n          pb={4}\n        >\n          <ValidationTranslation id={error.id} values={error.values} />\n        </Input.Error>\n      ))}\n      {warnings.map((warning) => (\n        <Input.Error\n          key={`${fieldName}-${warning.id}-${JSON.stringify(warning.values)}`}\n          c=\"orange\"\n          pb={4}\n        >\n          <ValidationTranslation id={warning.id} values={warning.values} />\n        </Input.Error>\n      ))}\n    </Input.Wrapper>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/field.css",
    "content": ".postybirb-field-error {\n  background-color: rgba(200, 0, 0, 0.1);\n  border-radius: 4px;\n}\n\n.postybirb-field-warning {\n  background-color: rgba(200, 153, 0, 0.1);\n  border-radius: 4px;\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/form-field.type.ts",
    "content": "/**\n * Form field types for the remake form system.\n */\n\nimport type { FieldAggregateType } from '@postybirb/form-builder';\n\nexport interface FormFieldProps<\n  T extends FieldAggregateType = FieldAggregateType,\n> {\n  /** The name/key of the field in the form data */\n  fieldName: string;\n  /** The field metadata from form-builder */\n  field: T;\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/index.ts",
    "content": "export { BooleanField } from './boolean-field';\nexport { DateTimeField } from './datetime-field';\nexport { DescriptionField } from './description-field';\nexport { FieldCopyButton } from './field-copy-button';\nexport { FieldLabel, getTranslatedLabel } from './field-label';\nexport type { FormFieldProps } from './form-field.type';\nexport { InputField } from './input-field';\nexport { RadioField } from './radio-field';\nexport { SelectField } from './select-field';\nexport { TagField } from './tag-field';\n\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/input-field.tsx",
    "content": "/**\n * InputField - Text input and textarea field.\n */\n\nimport { Textarea, TextInput } from '@mantine/core';\nimport { TextFieldType } from '@postybirb/form-builder';\nimport { useFormFieldsContext } from '../form-fields-context';\nimport { useDefaultOption } from '../hooks/use-default-option';\nimport { useValidations } from '../hooks/use-validations';\nimport { FieldCopyButton } from './field-copy-button';\nimport { FieldLabel } from './field-label';\nimport { FormFieldProps } from './form-field.type';\n\nfunction TextField({\n  fieldName,\n  field,\n  defaultValue,\n}: FormFieldProps<TextFieldType> & { defaultValue: string | undefined }) {\n  const { getValue, setValue, submission } = useFormFieldsContext();\n  const value = getValue<string>(fieldName) ?? field.defaultValue ?? '';\n\n  return (\n    <TextInput\n      value={value}\n      required={field.required}\n      placeholder={defaultValue}\n      w=\"100%\"\n      maxLength={field.maxLength}\n      disabled={submission.isArchived}\n      description={\n        field.maxLength\n          ? `${value?.length ?? 0} / ${field.maxLength}`\n          : undefined\n      }\n      rightSection={<FieldCopyButton value={value} />}\n      onChange={(e) => setValue(fieldName, e.currentTarget.value)}\n    />\n  );\n}\n\nfunction TextAreaField({\n  fieldName,\n  field,\n  defaultValue,\n}: FormFieldProps<TextFieldType> & { defaultValue: string | undefined }) {\n  const { getValue, setValue, submission } = useFormFieldsContext();\n  const value = getValue<string>(fieldName) ?? field.defaultValue ?? '';\n\n  return (\n    <Textarea\n      value={value}\n      required={field.required}\n      placeholder={defaultValue}\n      w=\"100%\"\n      maxLength={field.maxLength}\n      disabled={submission.isArchived}\n      description={\n        field.maxLength\n          ? `${value?.length ?? 0} / ${field.maxLength}`\n          : undefined\n      }\n      rightSection={<FieldCopyButton value={value} />}\n      onChange={(e) => setValue(fieldName, e.currentTarget.value)}\n    />\n  );\n}\n\nexport function InputField({\n  fieldName,\n  field,\n}: FormFieldProps<TextFieldType>) {\n  const defaultValue = useDefaultOption<string>(fieldName);\n  const validations = useValidations(fieldName);\n\n  return (\n    <FieldLabel\n      field={field}\n      fieldName={fieldName}\n      validationState={validations}\n    >\n      {field.formField === 'input' ? (\n        <TextField\n          fieldName={fieldName}\n          field={field}\n          defaultValue={defaultValue}\n        />\n      ) : (\n        <TextAreaField\n          fieldName={fieldName}\n          field={field}\n          defaultValue={defaultValue}\n        />\n      )}\n    </FieldLabel>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/radio-field.tsx",
    "content": "/**\n * RadioField - Segmented control for radio/rating fields.\n */\n\nimport { useLingui } from '@lingui/react/macro';\nimport { Box, SegmentedControl } from '@mantine/core';\nimport { RadioFieldType, RatingFieldType } from '@postybirb/form-builder';\nimport { useFormFieldsContext } from '../form-fields-context';\nimport { useDefaultOption } from '../hooks/use-default-option';\nimport { useValidations } from '../hooks/use-validations';\nimport { FieldLabel } from './field-label';\n\ninterface RadioFieldProps {\n  fieldName: string;\n  field: RadioFieldType | RatingFieldType;\n}\n\ninterface RatingFieldControlProps {\n  fieldName: string;\n  field: RatingFieldType;\n  defaultValue: string | undefined;\n}\n\ninterface InnerRadioFieldProps {\n  fieldName: string;\n  field: RadioFieldType;\n}\n\nfunction RatingFieldControl({\n  fieldName,\n  field,\n  defaultValue,\n}: RatingFieldControlProps) {\n  const { getValue, setValue, option, submission } = useFormFieldsContext();\n  const { t } = useLingui();\n\n  const baseOptions = field.options;\n  \n  const options =\n    field.formField === 'rating' && !option.isDefault\n      ? [{ label: t`Default`, value: '' }, ...baseOptions]\n      : baseOptions;\n  const rawValue = getValue<string>(fieldName);\n  const value =\n    !option.isDefault && (!rawValue || rawValue === defaultValue)\n      ? ''\n      : (rawValue ?? field.defaultValue ?? '');\n\n  return (\n    <SegmentedControl\n      value={value}\n      orientation={field.layout}\n      size=\"xs\"\n      disabled={submission.isArchived}\n      data={options.map((o) => ({\n        label: `${o.label}${\n          defaultValue !== undefined &&\n          o.value &&\n          o.value.toString() === defaultValue\n            ? ' *'\n            : ''\n        }`,\n        value: o.value ? o.value.toString() : '',\n      }))}\n      onChange={(e) => setValue(fieldName, e || undefined)}\n    />\n  );\n}\n\nfunction InnerRadioField({ fieldName, field }: InnerRadioFieldProps) {\n  const { getValue, setValue, submission } = useFormFieldsContext();\n  const value = getValue<string>(fieldName) ?? field.defaultValue ?? '';\n\n  return (\n    <SegmentedControl\n      value={value}\n      size=\"xs\"\n      disabled={submission.isArchived}\n      data={field.options.map((o) => ({\n        label: `${o.label}${\n          field.defaultValue !== undefined &&\n          o.value &&\n          o.value.toString() === field.defaultValue\n            ? ' *'\n            : ''\n        }`,\n        value: o.value ? o.value.toString() : '',\n      }))}\n      onChange={(e) => setValue(fieldName, e)}\n    />\n  );\n}\n\nexport function RadioField({ fieldName, field }: RadioFieldProps) {\n  const defaultValue = useDefaultOption<string>(fieldName);\n  const validations = useValidations(fieldName);\n\n  return (\n    <FieldLabel\n      field={field}\n      fieldName={fieldName}\n      validationState={validations}\n    >\n      <Box>\n        {field.formField === 'rating' ? (\n          <RatingFieldControl\n            fieldName={fieldName}\n            field={field as RatingFieldType}\n            defaultValue={defaultValue}\n          />\n        ) : (\n          <InnerRadioField\n            fieldName={fieldName}\n            field={field as RadioFieldType}\n          />\n        )}\n      </Box>\n    </FieldLabel>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/select-field.tsx",
    "content": "/**\n * SelectField - Hybrid select/multi-select dropdown field.\n * Uses Mantine Select for flat options, TreeSelect for hierarchical groups.\n */\n\nimport { Select as MantineSelect, MultiSelect } from '@mantine/core';\nimport { SelectFieldType, SelectOption } from '@postybirb/form-builder';\nimport { getFileType } from '@postybirb/utils/file-type';\nimport { uniqBy } from 'lodash';\nimport { useCallback, useMemo } from 'react';\nimport { useFormFieldsContext } from '../form-fields-context';\nimport { useValidations } from '../hooks/use-validations';\nimport { FieldLabel } from './field-label';\nimport { FormFieldProps } from './form-field.type';\nimport {\n  flattenSelectableOptions,\n  handleMutuallyExclusiveSelection,\n  hasNestedGroups,\n} from './select-utils';\nimport { TreeSelect } from './tree-select';\n\nfunction getSelectOptions(\n  options: SelectFieldType['options'],\n  submission: {\n    isMultiSubmission: boolean;\n    isTemplate: boolean;\n    files: { fileName: string }[];\n  },\n): SelectOption[] {\n  if (!options) return [];\n\n  if (Array.isArray(options)) return options;\n\n  // Discriminator based select options\n  const { options: allOptions, discriminator } = options;\n  if (submission.isMultiSubmission || submission.isTemplate) {\n    const groupedOptions: SelectOption[] = [];\n    Object.entries(allOptions).forEach(([, opts]) => {\n      groupedOptions.push(...opts);\n    });\n    return groupedOptions;\n  }\n\n  if (discriminator === 'overallFileType') {\n    const fileType = getFileType(submission.files[0]?.fileName || '');\n    return allOptions[fileType] || [];\n  }\n\n  return [];\n}\n\n/**\n * Flattens options for Mantine's native Select/MultiSelect\n * (loses hierarchy but works for flat option lists)\n */\nfunction flattenForMantine(\n  options: SelectOption[],\n): { value: string; label: string }[] {\n  const result: { value: string; label: string }[] = [];\n\n  for (const option of options) {\n    if ('items' in option) {\n      // Group - include group if selectable, then flatten children\n      if (option.value !== undefined) {\n        result.push({\n          value: option.value,\n          label: option.label,\n        });\n      }\n      result.push(...flattenForMantine(option.items));\n    } else {\n      result.push({\n        value:\n          typeof option.value === 'string'\n            ? option.value\n            : JSON.stringify(option.value),\n        label: option.label,\n      });\n    }\n  }\n\n  return result;\n}\n\nexport function SelectField({\n  fieldName,\n  field,\n}: FormFieldProps<SelectFieldType>) {\n  const { getValue, setValue, submission } = useFormFieldsContext();\n  const validations = useValidations(fieldName);\n\n  const value =\n    getValue<string | string[]>(fieldName) ?? field.defaultValue ?? '';\n  const selectOptions = useMemo(\n    () => getSelectOptions(field.options, submission),\n    [field.options, submission],\n  );\n\n  // Detect if we need the tree select (hierarchical structure)\n  const useTreeSelect = useMemo(\n    () => hasNestedGroups(selectOptions),\n    [selectOptions],\n  );\n\n  // Stable callback for TreeSelect onChange\n  const handleTreeChange = useCallback(\n    (newValue: string | string[]) => setValue(fieldName, newValue),\n    [fieldName, setValue],\n  );\n\n  // Handle multi-select with mutually exclusive logic\n  const handleMultiChange = useCallback(\n    (newValues: string[]) => {\n      const currentValues = Array.isArray(value) ? value : [];\n      const addedValues = newValues.filter((v) => !currentValues.includes(v));\n\n      if (addedValues.length > 0) {\n        // Find the added option and apply mutually exclusive logic\n        const flatOptions = flattenSelectableOptions(selectOptions);\n        const addedOption = flatOptions.find((o) => o.value === addedValues[0]);\n        const processedValues = handleMutuallyExclusiveSelection(\n          currentValues,\n          addedOption ?? null,\n          selectOptions,\n        );\n        setValue(fieldName, processedValues);\n      } else {\n        // Removal - just set the new values\n        setValue(fieldName, newValues);\n      }\n    },\n    [value, selectOptions, fieldName, setValue],\n  );\n\n  // Use TreeSelect for hierarchical options\n  if (useTreeSelect) {\n    const treeValue = field.allowMultiple\n      ? (Array.isArray(value) ? value : [])\n      : (typeof value === 'string' ? value : '');\n\n    return (\n      <FieldLabel\n        field={field}\n        fieldName={fieldName}\n        validationState={validations}\n      >\n        <TreeSelect\n          options={selectOptions}\n          value={treeValue}\n          onChange={handleTreeChange}\n          multiple={field.allowMultiple}\n          disabled={submission.isArchived}\n          error={validations.isInvalid}\n        />\n      </FieldLabel>\n    );\n  }\n\n  // Use Mantine native select for flat options\n  const flatOptions = uniqBy(flattenForMantine(selectOptions), 'value');\n\n  if (field.allowMultiple) {\n    const multiValue = Array.isArray(value) ? value : [];\n\n    return (\n      <FieldLabel\n        field={field}\n        fieldName={fieldName}\n        validationState={validations}\n      >\n        <MultiSelect\n          data={flatOptions}\n          value={multiValue}\n          onChange={handleMultiChange}\n          disabled={submission.isArchived}\n          clearable\n          searchable\n        />\n      </FieldLabel>\n    );\n  }\n\n  const singleValue = typeof value === 'string' ? value : '';\n\n  return (\n    <FieldLabel\n      field={field}\n      fieldName={fieldName}\n      validationState={validations}\n    >\n      <MantineSelect\n        data={flatOptions}\n        value={singleValue}\n        onChange={(val) => setValue(fieldName, val)}\n        disabled={submission.isArchived}\n        clearable\n        searchable\n      />\n    </FieldLabel>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/select-utils.ts",
    "content": "/**\n * Select field utilities - type guards, option helpers, and selection logic\n */\n\nimport {\n    SelectOption,\n    SelectOptionGroup,\n    SelectOptionSingle,\n} from '@postybirb/form-builder';\n\n/**\n * Type guard: checks if option is a group with items\n */\nexport function isOptionGroup(option: SelectOption): option is SelectOptionGroup {\n  return 'items' in option;\n}\n\n/**\n * Type guard: checks if option is a single selectable option\n */\nexport function isOptionSingle(option: SelectOption): option is SelectOptionSingle {\n  return 'value' in option && !('items' in option);\n}\n\n/**\n * Checks if options contain any nested groups (hierarchical structure)\n * Returns true if any option has items array with children\n */\nexport function hasNestedGroups(options: SelectOption[]): boolean {\n  for (const option of options) {\n    if (isOptionGroup(option) && option.items.length > 0) {\n      return true;\n    }\n  }\n  return false;\n}\n\n/**\n * Flattens all selectable options from a hierarchical structure.\n * Groups with a value are included as selectable options.\n */\nexport function flattenSelectableOptions(\n  options: SelectOption[],\n): SelectOptionSingle[] {\n  const flattened: SelectOptionSingle[] = [];\n\n  for (const option of options) {\n    if (isOptionGroup(option)) {\n      // Add the group itself if it has a value (making it selectable)\n      if (option.value !== undefined) {\n        flattened.push({\n          label: option.label,\n          value: option.value,\n          data: option.data,\n          mutuallyExclusive: option.mutuallyExclusive,\n        });\n      }\n      // Recursively add child items\n      flattened.push(...flattenSelectableOptions(option.items));\n    } else if (isOptionSingle(option)) {\n      flattened.push(option);\n    }\n  }\n\n  return flattened;\n}\n\n/**\n * Gets selected options from values\n */\nexport function getSelectedOptions(\n  value: string | string[] | null,\n  options: SelectOption[],\n): SelectOptionSingle[] {\n  if (!value) return [];\n\n  const flatOptions = flattenSelectableOptions(options);\n  const values = Array.isArray(value) ? value : [value];\n\n  return flatOptions.filter((option) => values.includes(option.value));\n}\n\n/**\n * Handles mutually exclusive option selection logic.\n * When a mutually exclusive option is selected, all others are deselected.\n * When a regular option is selected while an exclusive option is selected,\n * the exclusive option is deselected.\n *\n * @param currentValues - Current selected values\n * @param addedOption - The option being added (or null if removing)\n * @param options - All available options (for looking up mutually exclusive flag)\n * @returns New array of selected values\n */\nexport function handleMutuallyExclusiveSelection(\n  currentValues: string[],\n  addedOption: SelectOptionSingle | null,\n  options: SelectOption[],\n): string[] {\n  if (!addedOption) {\n    return currentValues;\n  }\n\n  const flatOptions = flattenSelectableOptions(options);\n\n  // If the added option is mutually exclusive, only keep it\n  if (addedOption.mutuallyExclusive) {\n    return [addedOption.value];\n  }\n\n  // Check if any currently selected option is mutually exclusive\n  const hasExclusiveSelected = currentValues.some((val) => {\n    const opt = flatOptions.find((o) => o.value === val);\n    return opt?.mutuallyExclusive;\n  });\n\n  if (hasExclusiveSelected) {\n    // Remove exclusive options, keep the new non-exclusive one\n    const nonExclusiveValues = currentValues.filter((val) => {\n      const opt = flatOptions.find((o) => o.value === val);\n      return !opt?.mutuallyExclusive;\n    });\n    return [...nonExclusiveValues, addedOption.value];\n  }\n\n  // Normal case: add the option\n  return [...currentValues, addedOption.value];\n}\n\n/**\n * Filters options based on search query while preserving parent structure.\n * If a child matches, its parent group is included.\n * If a group label matches, all its children are included.\n */\nexport function filterOptions(\n  options: SelectOption[],\n  searchQuery: string,\n): SelectOption[] {\n  if (!searchQuery.trim()) return options;\n\n  const query = searchQuery.toLowerCase().trim();\n\n  const filterOption = (option: SelectOption): SelectOption | null => {\n    if (isOptionGroup(option)) {\n      const filteredItems = option.items\n        .map((item) => filterOption(item))\n        .filter(Boolean) as SelectOption[];\n\n      // Check if group label matches\n      const groupMatches =\n        option.label.toLowerCase().includes(query) ||\n        query.split(' ').every((word) => option.label.toLowerCase().includes(word));\n\n      if (groupMatches) {\n        // Include group with all children if group label matches\n        return option;\n      }\n\n      if (filteredItems.length > 0) {\n        // Include group with only matching children\n        return {\n          ...option,\n          items: filteredItems,\n        };\n      }\n\n      return null;\n    }\n\n    // Single option: fuzzy match\n    const optionMatches =\n      option.label.toLowerCase().includes(query) ||\n      query.split(' ').every((word) => option.label.toLowerCase().includes(word)) ||\n      option.label\n        .toLowerCase()\n        .split(' ')\n        .some((word) => word.startsWith(query));\n\n    return optionMatches ? option : null;\n  };\n\n  return options.map((option) => filterOption(option)).filter(Boolean) as SelectOption[];\n}\n\n/**\n * Counts total selectable options (for search threshold)\n */\nexport function countSelectableOptions(options: SelectOption[]): number {\n  return flattenSelectableOptions(options).length;\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/tag-field.tsx",
    "content": "/**\n * TagField - Tag input with groups, conversion display, and search provider support.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport {\n  Badge,\n  Box,\n  Checkbox,\n  Group,\n  Pill,\n  TagsInput,\n  Text,\n} from '@mantine/core';\nimport { TagFieldType } from '@postybirb/form-builder';\nimport { DefaultTagValue, Tag, TagValue } from '@postybirb/types';\nimport { IconArrowRight } from '@tabler/icons-react';\nimport { flatten, uniq } from 'lodash';\nimport { useMemo } from 'react';\nimport { useTagSearch } from '../../../../../../../hooks';\nimport {\n  TagConverterRecord,\n  TagGroupRecord,\n  useAccount,\n  useNonEmptyTagGroups,\n  useSettings,\n  useTagConverters,\n} from '../../../../../../../stores';\nimport { useFormFieldsContext } from '../form-fields-context';\nimport { useDefaultOption } from '../hooks/use-default-option';\nimport { useValidations } from '../hooks/use-validations';\nimport { FieldCopyButton } from './field-copy-button';\nimport { FieldLabel } from './field-label';\nimport { FormFieldProps } from './form-field.type';\n\nconst TAG_GROUP_LABEL = 'GROUP:';\n\n/**\n * Check if all tags in a group are already in the current tag list.\n */\nfunction containsAllTagsInGroup(tags: Tag[], group: TagGroupRecord): boolean {\n  return group.tags.every((tag) => tags.includes(tag));\n}\n\n/**\n * Get the converted tag for a specific website.\n */\nfunction getTagConversion(\n  website: string,\n  tagConverters: TagConverterRecord[],\n  tag: Tag,\n): Tag {\n  const matchingConverter = tagConverters.find(\n    (converter) => converter.tag === tag,\n  );\n  if (!matchingConverter) {\n    return tag;\n  }\n\n  return (\n    matchingConverter.convertTo[website] ??\n    matchingConverter.convertTo.default ??\n    tag\n  );\n}\n\nexport function TagField({\n  fieldName,\n  field,\n}: FormFieldProps<TagFieldType>): JSX.Element {\n  const { getValue, setValue, option, submission } = useFormFieldsContext();\n  const defaultOption = useDefaultOption<TagValue>(fieldName);\n  const validations = useValidations(fieldName);\n\n  // Get tag groups and converters\n  const tagGroups = useNonEmptyTagGroups();\n  const tagConverters = useTagConverters();\n  const account = useAccount(option.accountId);\n  const settings = useSettings();\n\n  // Tag search provider\n  const search = useTagSearch(field.searchProviderId);\n\n  const fieldValue =\n    getValue<TagValue>(fieldName) ?? field.defaultValue ?? DefaultTagValue();\n  const overrideDefault = fieldValue.overrideDefault || false;\n  const tagValue = useMemo(() => fieldValue.tags || [], [fieldValue.tags]);\n  const allTags = useMemo(\n    () => [...tagValue, ...(defaultOption?.tags || [])],\n    [tagValue, defaultOption?.tags],\n  );\n\n  // Calculate tag conversions for display\n  const convertedTags = useMemo(() => {\n    if (!account) return [];\n    return allTags\n      .map((tag) => {\n        const converted = getTagConversion(account.website, tagConverters, tag);\n        return [tag, converted] as [Tag, Tag];\n      })\n      .filter(([tag, converted]) => converted !== tag);\n  }, [account, allTags, tagConverters]);\n\n  // Build tag group options for dropdown\n  const tagGroupsOptions = useMemo(\n    () =>\n      tagGroups.map((tagGroup) => ({\n        label: `${TAG_GROUP_LABEL}${JSON.stringify({ name: tagGroup.name, tags: tagGroup.tags })}`,\n        value: `${TAG_GROUP_LABEL}${JSON.stringify({ name: tagGroup.name, tags: tagGroup.tags })}`,\n        disabled: containsAllTagsInGroup(tagValue, tagGroup),\n      })),\n    [tagGroups, tagValue],\n  );\n\n  const updateTags = (tags: Tag[]) => {\n    if (defaultOption && !overrideDefault) {\n      const defaultTags = defaultOption.tags || [];\n      setValue(fieldName, {\n        ...fieldValue,\n        tags: uniq(tags.filter((tag) => !defaultTags.includes(tag))),\n      });\n    } else {\n      setValue(fieldName, { ...fieldValue, tags: uniq(tags) });\n    }\n  };\n\n  const totalTags = overrideDefault ? tagValue.length : allTags.length;\n\n  return (\n    <Box>\n      <FieldLabel\n        field={field}\n        fieldName={fieldName}\n        validationState={validations}\n      >\n        {option.isDefault ? null : (\n          <Checkbox\n            mb=\"4\"\n            disabled={submission.isArchived}\n            checked={overrideDefault}\n            onChange={(e) => {\n              setValue(fieldName, {\n                ...fieldValue,\n                overrideDefault: e.target.checked,\n              });\n            }}\n            label={\n              <Trans context=\"override-default\">Ignore default tags</Trans>\n            }\n          />\n        )}\n\n        {/* Display tag conversions */}\n        {convertedTags.length > 0 && (\n          <Box mb=\"xs\">\n            <Group gap=\"xs\">\n              {convertedTags.map(([tag, convertedTag]) => (\n                <Badge key={tag} size=\"xs\" variant=\"light\">\n                  <Group gap=\"4\">\n                    {tag}\n                    <IconArrowRight\n                      size=\"0.75rem\"\n                      style={{ verticalAlign: 'middle' }}\n                    />\n                    {convertedTag}\n                  </Group>\n                </Badge>\n              ))}\n            </Group>\n          </Box>\n        )}\n\n        <TagsInput\n          inputWrapperOrder={['label', 'input', 'description', 'error']}\n          clearable\n          disabled={submission.isArchived}\n          required={field.required}\n          value={tagValue}\n          data={[...search.data, ...tagGroupsOptions]}\n          searchValue={search.searchValue}\n          onSearchChange={search.onSearchChange}\n          onClear={() => {\n            setValue(fieldName, { ...fieldValue, tags: [] });\n          }}\n          description={\n            field.maxTags ? `${totalTags ?? 0} / ${field.maxTags}` : undefined\n          }\n          onChange={(tags) => {\n            // Handle tag groups - expand group to individual tags\n            const newTags = flatten(\n              tags.map((tag) => {\n                if (tag.startsWith(TAG_GROUP_LABEL)) {\n                  const group: { name: string; tags: Tag[] } = JSON.parse(\n                    tag.slice(TAG_GROUP_LABEL.length),\n                  );\n                  return group.tags;\n                }\n                return tag;\n              }),\n            );\n            updateTags([...newTags]);\n          }}\n          renderOption={(tagOption) => {\n            const { value } = tagOption.option;\n\n            // Render tag group option\n            if (value.startsWith(TAG_GROUP_LABEL)) {\n              const group: { name: string; tags: Tag[] } = JSON.parse(\n                value.slice(TAG_GROUP_LABEL.length),\n              );\n              return (\n                <Box>\n                  <Pill c=\"teal\" mr=\"xs\">\n                    <strong>{group.name}</strong>\n                  </Pill>\n                  {group.tags.map((tag) => (\n                    <Pill key={tag} c=\"gray\" ml=\"4\">\n                      {tag}\n                    </Pill>\n                  ))}\n                </Box>\n              );\n            }\n\n            // Render search provider item\n            if (search.provider && settings) {\n              const view = search.provider.renderSearchItem(\n                value,\n                settings.tagSearchProvider ?? { id: '' },\n              );\n              if (view) return view;\n            }\n\n            return <Text inherit>{value}</Text>;\n          }}\n          rightSection={<FieldCopyButton value={tagValue.join(', ')} />}\n        />\n      </FieldLabel>\n    </Box>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/tree-select.tsx",
    "content": "/**\n * TreeSelect - A dropdown select with hierarchical option list.\n *\n * Custom implementation that renders the option tree directly instead of\n * using Mantine's Tree/useTree, which avoids the infinite re-render loop\n * caused by rebuilding tree data on every state change.\n *\n * Supports single/multi-select, search filtering, keyboard navigation,\n * and mutually exclusive options.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport {\n    ActionIcon,\n    Box,\n    Checkbox,\n    Group,\n    InputBase,\n    Popover,\n    ScrollArea,\n    Text,\n    TextInput,\n} from '@mantine/core';\nimport { SelectOption } from '@postybirb/form-builder';\nimport { IconChevronDown, IconSearch, IconX } from '@tabler/icons-react';\nimport React, {\n    useCallback,\n    useEffect,\n    useMemo,\n    useRef,\n    useState,\n} from 'react';\nimport {\n    countSelectableOptions,\n    filterOptions,\n    flattenSelectableOptions,\n    getSelectedOptions,\n    handleMutuallyExclusiveSelection,\n    isOptionGroup,\n} from './select-utils';\n\nconst SEARCH_THRESHOLD = 7;\n\n// ── TreeNode: memoised leaf/group renderer ──────────────────────────────\n\ninterface TreeNodeProps {\n  option: SelectOption;\n  depth: number;\n  multiple: boolean;\n  selectedValues: string[];\n  focusedValue: string | null;\n  onSelect: (value: string) => void;\n}\n\nconst TreeNode = React.memo(function TreeNode({\n  option,\n  depth,\n  multiple,\n  selectedValues,\n  focusedValue,\n  onSelect,\n}: TreeNodeProps) {\n  if (isOptionGroup(option)) {\n    const groupValue = option.value;\n    const isSelectable = groupValue !== undefined;\n    const isSelected = isSelectable && selectedValues.includes(groupValue);\n    const isFocused = isSelectable && focusedValue === groupValue;\n\n    return (\n      <Box>\n        {/* Group header row */}\n        <Group\n          gap=\"xs\"\n          wrap=\"nowrap\"\n          py={4}\n          px=\"xs\"\n          pl={depth * 16 + 8}\n          bg={isFocused ? 'var(--mantine-color-blue-light)' : undefined}\n          style={{ cursor: isSelectable ? 'pointer' : 'default' }}\n          data-tree-node-value={isSelectable ? groupValue : undefined}\n          onClick={isSelectable ? () => onSelect(groupValue) : undefined}\n        >\n          {isSelectable && multiple && (\n            <Checkbox\n              checked={isSelected}\n              onChange={() => onSelect(groupValue)}\n              onClick={(e) => e.stopPropagation()}\n              size=\"xs\"\n            />\n          )}\n          <Text size=\"sm\" fw={500} style={{ flex: 1 }}>\n            {option.label}\n          </Text>\n        </Group>\n\n        {/* Children (always expanded) */}\n        {option.items.map((child) => (\n          <TreeNode\n            key={\n              isOptionGroup(child) ? (child.value ?? child.label) : child.value\n            }\n            option={child}\n            depth={depth + 1}\n            multiple={multiple}\n            selectedValues={selectedValues}\n            focusedValue={focusedValue}\n            onSelect={onSelect}\n          />\n        ))}\n      </Box>\n    );\n  }\n\n  // Leaf option\n  const isSelected = selectedValues.includes(option.value);\n  const isFocused = focusedValue === option.value;\n\n  return (\n    <Group\n      gap=\"xs\"\n      wrap=\"nowrap\"\n      py={4}\n      px=\"xs\"\n      pl={depth * 16 + 8}\n      bg={isFocused ? 'var(--mantine-color-blue-light)' : undefined}\n      style={{ cursor: 'pointer' }}\n      data-tree-node-value={option.value}\n      onClick={() => onSelect(option.value)}\n    >\n      {multiple ? (\n        <Checkbox\n          checked={isSelected}\n          onChange={() => onSelect(option.value)}\n          onClick={(e) => e.stopPropagation()}\n          size=\"xs\"\n        />\n      ) : null}\n      <Text\n        size=\"sm\"\n        style={{ flex: 1 }}\n        fw={isSelected && !multiple ? 500 : undefined}\n        c={isSelected && !multiple ? 'blue' : undefined}\n      >\n        {option.label}\n      </Text>\n    </Group>\n  );\n});\n\n// ── Main component ──────────────────────────────────────────────────────\n\ninterface TreeSelectProps {\n  options: SelectOption[];\n  value: string | string[];\n  onChange: (value: string | string[]) => void;\n  multiple?: boolean;\n  placeholder?: React.ReactNode;\n  disabled?: boolean;\n  clearable?: boolean;\n  error?: boolean;\n}\n\nexport function TreeSelect({\n  options,\n  value,\n  onChange,\n  multiple = false,\n  placeholder,\n  disabled = false,\n  clearable = true,\n  error = false,\n}: TreeSelectProps) {\n  const [opened, setOpened] = useState(false);\n  const [searchQuery, setSearchQuery] = useState('');\n  const [focusedValue, setFocusedValue] = useState<string | null>(null);\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const searchRef = useRef<HTMLInputElement>(null);\n  const dropdownRef = useRef<HTMLDivElement>(null);\n  const scrollAreaRef = useRef<HTMLDivElement>(null);\n\n  const defaultPlaceholder = <Trans>Select...</Trans>;\n  const displayPlaceholder = placeholder ?? defaultPlaceholder;\n\n  // ── Derived data (all stable memos – no JSX in deps) ───────────────\n\n  const selectedValues = useMemo(\n    () => (Array.isArray(value) ? value : value ? [value] : []),\n    [value],\n  );\n\n  const selectedOptions = useMemo(\n    () => getSelectedOptions(value, options),\n    [value, options],\n  );\n\n  const enableSearch = countSelectableOptions(options) >= SEARCH_THRESHOLD;\n\n  const filteredOptions = useMemo(\n    () => (searchQuery.trim() ? filterOptions(options, searchQuery) : options),\n    [options, searchQuery],\n  );\n\n  const flatSelectableOptions = useMemo(\n    () => flattenSelectableOptions(filteredOptions),\n    [filteredOptions],\n  );\n\n  const displayText = useMemo(() => {\n    if (selectedOptions.length === 0) return displayPlaceholder;\n    if (multiple && selectedOptions.length > 2) {\n      return <Trans>{selectedOptions.length} selected</Trans>;\n    }\n    return selectedOptions.map((opt) => opt.label).join(', ');\n  }, [selectedOptions, displayPlaceholder, multiple]);\n\n  // ── Callbacks ──────────────────────────────────────────────────────\n\n  const handleOptionClick = useCallback(\n    (optionValue: string) => {\n      if (disabled) return;\n\n      const option = flattenSelectableOptions(options).find(\n        (o) => o.value === optionValue,\n      );\n      if (!option) return;\n\n      if (multiple) {\n        const isSelected = selectedValues.includes(optionValue);\n        let newValues: string[];\n\n        if (isSelected) {\n          newValues = selectedValues.filter((v) => v !== optionValue);\n        } else {\n          newValues = handleMutuallyExclusiveSelection(\n            selectedValues,\n            option,\n            options,\n          );\n        }\n        onChange(newValues);\n      } else {\n        const newValue = selectedValues.includes(optionValue)\n          ? ''\n          : optionValue;\n        onChange(newValue);\n        setOpened(false);\n        setSearchQuery('');\n      }\n    },\n    [disabled, multiple, selectedValues, options, onChange],\n  );\n\n  // Ref-stable wrapper: keeps the same function identity across renders\n  // so that React.memo on TreeNode isn't invalidated by parent re-renders.\n  const handleOptionClickRef = useRef(handleOptionClick);\n  handleOptionClickRef.current = handleOptionClick;\n  const stableOnSelect = useCallback(\n    (optionValue: string) => handleOptionClickRef.current(optionValue),\n    [],\n  );\n\n  const handleClear = useCallback(\n    (e: React.MouseEvent) => {\n      e.stopPropagation();\n      if (disabled) return;\n      onChange(multiple ? [] : '');\n    },\n    [disabled, multiple, onChange],\n  );\n\n  // ── Shared keyboard helpers ────────────────────────────────────────\n\n  const moveFocus = useCallback(\n    (direction: 'up' | 'down') => {\n      if (flatSelectableOptions.length === 0) return;\n      const currentIndex = flatSelectableOptions.findIndex(\n        (o) => o.value === focusedValue,\n      );\n      let nextIndex: number;\n      if (direction === 'down') {\n        nextIndex =\n          currentIndex < flatSelectableOptions.length - 1\n            ? currentIndex + 1\n            : 0;\n      } else {\n        nextIndex =\n          currentIndex > 0\n            ? currentIndex - 1\n            : flatSelectableOptions.length - 1;\n      }\n      setFocusedValue(flatSelectableOptions[nextIndex]?.value ?? null);\n    },\n    [flatSelectableOptions, focusedValue],\n  );\n\n  const closeDropdown = useCallback(() => {\n    setOpened(false);\n    setSearchQuery('');\n    setFocusedValue(null);\n    triggerRef.current?.focus();\n  }, []);\n\n  // ── Keyboard: dropdown list (shared by search input + non-search dropdown) ─\n\n  const handleListKeyDown = useCallback(\n    (event: React.KeyboardEvent) => {\n      switch (event.key) {\n        case 'ArrowDown':\n          event.preventDefault();\n          moveFocus('down');\n          break;\n        case 'ArrowUp':\n          event.preventDefault();\n          moveFocus('up');\n          break;\n        case 'Enter':\n        case ' ':\n          event.preventDefault();\n          if (focusedValue) handleOptionClick(focusedValue);\n          break;\n        case 'Escape':\n          event.preventDefault();\n          closeDropdown();\n          break;\n        case 'Home':\n          event.preventDefault();\n          if (flatSelectableOptions.length > 0)\n            setFocusedValue(flatSelectableOptions[0].value);\n          break;\n        case 'End':\n          event.preventDefault();\n          if (flatSelectableOptions.length > 0)\n            setFocusedValue(\n              flatSelectableOptions[flatSelectableOptions.length - 1].value,\n            );\n          break;\n        default:\n          break;\n      }\n    },\n    [\n      moveFocus,\n      focusedValue,\n      handleOptionClick,\n      closeDropdown,\n      flatSelectableOptions,\n    ],\n  );\n\n  // ── Keyboard: trigger button ───────────────────────────────────────\n\n  const handleTriggerKeyDown = useCallback(\n    (event: React.KeyboardEvent) => {\n      if (disabled) return;\n\n      switch (event.key) {\n        case 'Enter':\n        case ' ':\n          event.preventDefault();\n          if (opened && focusedValue) {\n            handleOptionClick(focusedValue);\n          } else if (!opened) {\n            setOpened(true);\n          }\n          break;\n        case 'ArrowDown':\n          event.preventDefault();\n          if (!opened) {\n            setOpened(true);\n          } else {\n            moveFocus('down');\n          }\n          break;\n        case 'ArrowUp':\n          event.preventDefault();\n          if (opened) moveFocus('up');\n          break;\n        case 'Escape':\n          closeDropdown();\n          break;\n        case 'Tab':\n          setOpened(false);\n          setSearchQuery('');\n          setFocusedValue(null);\n          break;\n        case 'Home':\n          event.preventDefault();\n          if (opened && flatSelectableOptions.length > 0)\n            setFocusedValue(flatSelectableOptions[0].value);\n          break;\n        case 'End':\n          event.preventDefault();\n          if (opened && flatSelectableOptions.length > 0)\n            setFocusedValue(\n              flatSelectableOptions[flatSelectableOptions.length - 1].value,\n            );\n          break;\n        default:\n          // Type-ahead: open and start searching\n          if (\n            !opened &&\n            enableSearch &&\n            event.key.length === 1 &&\n            !event.ctrlKey &&\n            !event.metaKey &&\n            !event.altKey\n          ) {\n            setOpened(true);\n            setSearchQuery(event.key);\n            setTimeout(() => searchRef.current?.focus(), 0);\n          }\n      }\n    },\n    [\n      disabled,\n      opened,\n      focusedValue,\n      flatSelectableOptions,\n      enableSearch,\n      handleOptionClick,\n      moveFocus,\n      closeDropdown,\n    ],\n  );\n\n  // ── Effects ────────────────────────────────────────────────────────\n\n  // Auto-focus the search input or dropdown when opened\n  useEffect(() => {\n    if (opened) {\n      const target = enableSearch ? searchRef : dropdownRef;\n      setTimeout(() => target.current?.focus(), 0);\n    }\n  }, [opened, enableSearch]);\n\n  // Scroll the focused item into view\n  useEffect(() => {\n    if (focusedValue && opened && scrollAreaRef.current) {\n      const el = scrollAreaRef.current.querySelector(\n        `[data-tree-node-value=\"${CSS.escape(focusedValue)}\"]`,\n      );\n      if (el) {\n        el.scrollIntoView({ block: 'nearest' });\n      }\n    }\n  }, [focusedValue, opened]);\n\n  // ── Render ─────────────────────────────────────────────────────────\n\n  const showClear = clearable && selectedOptions.length > 0;\n\n  const chevronStyles: React.CSSProperties = {\n    color: 'var(--mantine-color-dimmed)',\n    transform: opened ? 'rotate(180deg)' : 'rotate(0deg)',\n    // eslint-disable-next-line lingui/no-unlocalized-strings\n    transition: 'transform 200ms ease',\n  };\n\n  const rightSection = (\n    <Group gap={4} wrap=\"nowrap\" pr=\"xs\">\n      {showClear && (\n        <ActionIcon\n          size=\"sm\"\n          variant=\"subtle\"\n          color=\"gray\"\n          onClick={handleClear}\n        >\n          <IconX size={14} />\n        </ActionIcon>\n      )}\n      <IconChevronDown size={16} style={chevronStyles} />\n    </Group>\n  );\n\n  return (\n    <Popover\n      opened={opened}\n      onChange={setOpened}\n      width=\"target\"\n      position=\"bottom-start\"\n      shadow=\"md\"\n      withinPortal\n    >\n      <Popover.Target>\n        <InputBase\n          ref={triggerRef}\n          component=\"button\"\n          type=\"button\"\n          pointer\n          role=\"combobox\"\n          aria-expanded={opened}\n          aria-haspopup=\"listbox\"\n          disabled={disabled}\n          error={error}\n          onClick={() => !disabled && setOpened(!opened)}\n          onKeyDown={handleTriggerKeyDown}\n          rightSection={rightSection}\n          rightSectionWidth={showClear ? 56 : 32}\n          rightSectionPointerEvents=\"auto\"\n        >\n          <Text\n            size=\"sm\"\n            c={selectedOptions.length === 0 ? 'dimmed' : undefined}\n            style={{\n              overflow: 'hidden',\n              textOverflow: 'ellipsis',\n              whiteSpace: 'nowrap',\n            }}\n          >\n            {displayText}\n          </Text>\n        </InputBase>\n      </Popover.Target>\n\n      <Popover.Dropdown p={0}>\n        <Box\n          ref={dropdownRef}\n          tabIndex={enableSearch ? -1 : 0}\n          onKeyDown={enableSearch ? undefined : handleListKeyDown}\n          style={{ outline: 'none' }}\n        >\n          {enableSearch && (\n            <Box p=\"xs\" pb={0} mb=\"xs\">\n              <TextInput\n                ref={searchRef}\n                value={searchQuery}\n                onChange={(e) => {\n                  setSearchQuery(e.currentTarget.value);\n                  setFocusedValue(null);\n                }}\n                onKeyDown={handleListKeyDown}\n                size=\"xs\"\n                leftSection={<IconSearch size={14} />}\n              />\n            </Box>\n          )}\n\n          <ScrollArea.Autosize\n            mah={250}\n            type=\"auto\"\n            viewportRef={scrollAreaRef}\n          >\n            {opened && filteredOptions.length > 0 ? (\n              <Box py={4}>\n                {filteredOptions.map((option) => (\n                  <TreeNode\n                    key={\n                      isOptionGroup(option)\n                        ? (option.value ?? option.label)\n                        : option.value\n                    }\n                    option={option}\n                    depth={0}\n                    multiple={multiple}\n                    selectedValues={selectedValues}\n                    focusedValue={focusedValue}\n                    onSelect={stableOnSelect}\n                  />\n                ))}\n              </Box>\n            ) : opened ? (\n              <Text size=\"sm\" c=\"dimmed\" ta=\"center\" py=\"md\">\n                {searchQuery ? (\n                  <Trans>No results found</Trans>\n                ) : (\n                  <Trans>No options available</Trans>\n                )}\n              </Text>\n            ) : null}\n          </ScrollArea.Autosize>\n        </Box>\n      </Popover.Dropdown>\n    </Popover>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/account-selection/form/form-field.tsx",
    "content": "/**\n * FormField - Routes to the appropriate field component based on field type.\n */\n\nimport { Box } from '@mantine/core';\nimport type {\n  BooleanFieldType,\n  DateTimeFieldType,\n  DescriptionFieldType,\n  FieldAggregateType,\n  RadioFieldType,\n  RatingFieldType,\n  SelectFieldType,\n  TagFieldType,\n  TextFieldType,\n} from '@postybirb/form-builder';\nimport { BooleanField } from './fields/boolean-field';\nimport { DateTimeField } from './fields/datetime-field';\nimport { DescriptionField } from './fields/description-field';\nimport './fields/field.css';\nimport { InputField } from './fields/input-field';\nimport { RadioField } from './fields/radio-field';\nimport { SelectField } from './fields/select-field';\nimport { TagField } from './fields/tag-field';\nimport { useFormFieldsContext } from './form-fields-context';\nimport { useValidations } from './hooks/use-validations';\n\ninterface FormFieldProps {\n  fieldName: string;\n  field: FieldAggregateType;\n}\n\nexport function FormField({ fieldName, field }: FormFieldProps) {\n  const { getValue, option, submission } = useFormFieldsContext();\n  const validations = useValidations(fieldName);\n\n  // Evaluate visibility: showWhen can override hidden\n  // If showWhen exists and evaluates to true, field is shown regardless of hidden\n  // If showWhen exists and evaluates to false, field is hidden\n  // If no showWhen and hidden is true, field is hidden\n  if (field.showWhen) {\n    const shouldShow = evaluateShowWhen(field, getValue, option, submission);\n    if (!shouldShow) {\n      return null;\n    }\n  } else if (field.hidden) {\n    return null;\n  }\n\n  let formField: JSX.Element | null = null;\n  switch (field.formField) {\n    case 'description':\n      formField = (\n        <DescriptionField\n          fieldName={fieldName}\n          field={field as DescriptionFieldType}\n        />\n      );\n      break;\n    case 'input':\n    case 'textarea':\n      formField = (\n        <InputField fieldName={fieldName} field={field as TextFieldType} />\n      );\n      break;\n    case 'radio':\n    case 'rating':\n      formField = (\n        <RadioField\n          fieldName={fieldName}\n          field={field as RadioFieldType | RatingFieldType}\n        />\n      );\n      break;\n    case 'checkbox':\n      formField = (\n        <BooleanField fieldName={fieldName} field={field as BooleanFieldType} />\n      );\n      break;\n    case 'tag':\n      formField = (\n        <TagField fieldName={fieldName} field={field as TagFieldType} />\n      );\n      break;\n    case 'select':\n      formField = (\n        <SelectField fieldName={fieldName} field={field as SelectFieldType} />\n      );\n      break;\n    case 'datetime':\n      formField = (\n        <DateTimeField\n          fieldName={fieldName}\n          field={field as DateTimeFieldType}\n        />\n      );\n      break;\n    default:\n      formField = (\n        // eslint-disable-next-line lingui/no-unlocalized-strings\n        <div>Unknown field type: {(field as FieldAggregateType).formField}</div>\n      );\n  }\n\n  const hasErrors = validations.errors?.length;\n  const hasWarnings = validations.warnings?.length;\n\n  if (hasErrors || hasWarnings) {\n    return (\n      <Box\n        pr={6}\n        pb={3}\n        pl={6}\n        className={\n          hasErrors ? 'postybirb-field-error' : 'postybirb-field-warning'\n        }\n      >\n        {formField}\n      </Box>\n    );\n  }\n\n  return <Box>{formField}</Box>;\n}\n\n/**\n * Evaluates showWhen conditions to determine if field should be visible.\n */\nfunction evaluateShowWhen(\n  field: FieldAggregateType,\n  getValue: <T>(name: string) => T,\n  option: { data: unknown; isDefault?: boolean },\n  submission: {\n    options: Array<{ isDefault?: boolean; data: unknown }>;\n  },\n): boolean {\n  if (!field.showWhen || field.showWhen.length === 0) return true;\n\n  const defaultOption = submission.options.find((opt) => opt.isDefault);\n\n  for (const [fieldName, allowedValues] of field.showWhen) {\n    let currentValue = getValue(fieldName as string);\n\n    // For rating field, fall back to default option value\n    if (option !== defaultOption && fieldName === 'rating' && !currentValue) {\n      const defaultData = defaultOption?.data as Record<string, unknown>;\n      currentValue = defaultData?.rating as typeof currentValue;\n    }\n\n    if (!allowedValues.includes(currentValue)) {\n      return false;\n    }\n  }\n\n  return true;\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/account-selection/form/form-fields-context.tsx",
    "content": "/**\n * FormFieldsContext - Provides form field metadata and state for website option forms.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport {\n  FieldAggregateType,\n  FormBuilderMetadata,\n} from '@postybirb/form-builder';\nimport { SubmissionType, WebsiteOptionsDto } from '@postybirb/types';\nimport {\n  createContext,\n  PropsWithChildren,\n  useCallback,\n  useContext,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from 'react';\nimport { useQuery } from 'react-query';\nimport formGeneratorApi from '../../../../../../api/form-generator.api';\nimport websiteOptionsApi from '../../../../../../api/website-options.api';\nimport type { SubmissionRecord } from '../../../../../../stores/records';\nimport { showErrorWithContext } from '../../../../../../utils/notifications';\n\ninterface FormFieldsContextValue {\n  /** Form field metadata from API */\n  formFields: FormBuilderMetadata | undefined;\n  /** Loading state for form fields */\n  isLoading: boolean;\n  /** Error state for form fields */\n  isError: boolean;\n  /** The website option being edited */\n  option: WebsiteOptionsDto;\n  /** The submission containing this option */\n  submission: SubmissionRecord;\n  /** Get a field by name */\n  getField: (name: string) => FieldAggregateType | undefined;\n  /** Get current value for a field */\n  getValue: <T = unknown>(name: string) => T;\n  /** Update a field value */\n  setValue: (name: string, value: unknown) => void;\n}\n\nconst FormFieldsContext = createContext<FormFieldsContextValue | null>(null);\n\ninterface FormFieldsProviderProps extends PropsWithChildren {\n  option: WebsiteOptionsDto;\n  submission: SubmissionRecord;\n}\n\nexport function FormFieldsProvider({\n  option,\n  submission,\n  children,\n}: FormFieldsProviderProps) {\n  // Local state for optimistic updates - provides instant UI feedback\n  const [localValues, setLocalValues] = useState<Record<string, unknown>>({});\n\n  // Debounce timer ref for save\n  const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  // Track the option.id to reset local state when switching options\n  const optionIdRef = useRef(option.id);\n\n  // Sync local state when option changes (server confirmed update or option switch)\n  useEffect(() => {\n    if (optionIdRef.current !== option.id) {\n      // Switching to different option - reset local values\n      optionIdRef.current = option.id;\n      setLocalValues({});\n    } else {\n      // Server confirmed update - clear local overrides for synced values\n      setLocalValues((prev) => {\n        const newLocal: Record<string, unknown> = {};\n        for (const [key, value] of Object.entries(prev)) {\n          // Keep local value only if it differs from server (pending update)\n          // eslint-disable-next-line @typescript-eslint/no-explicit-any\n          if ((option.data as any)[key] !== value) {\n            newLocal[key] = value;\n          }\n        }\n        return Object.keys(newLocal).length > 0 ? newLocal : {};\n      });\n    }\n  }, [option.id, option.data]);\n\n  // Fetch form metadata from API\n  const {\n    data: formFields,\n    isLoading,\n    isError,\n  } = useQuery({\n    queryKey: [\n      'form-fields',\n      option.accountId,\n      submission.type,\n      submission.isMultiSubmission,\n    ],\n    queryFn: async () => {\n      const response = await formGeneratorApi.getForm({\n        accountId: option.accountId,\n        type: submission.type as SubmissionType,\n        isMultiSubmission: submission.isMultiSubmission,\n      });\n      return response.body;\n    },\n    staleTime: 5 * 60 * 1000, // 5 minutes\n  });\n\n  // Get a field by name\n  const getField = useCallback(\n    (name: string): FieldAggregateType | undefined => {\n      if (!formFields) return undefined;\n      return formFields[name];\n    },\n    [formFields],\n  );\n\n  // Get current value for a field - prefers local optimistic value over server value\n  const getValue = useCallback(\n    <T = unknown,>(name: string): T => {\n      // Local value takes precedence (optimistic update)\n      // by checking that current option is the one passed in the props we ensure that we don't use\n      // cached values from the different entity (e.g. description from one template does not render in another)\n      if (optionIdRef.current === option.id && name in localValues) {\n        return localValues[name] as T;\n      }\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n      return (option.data as any)[name] as T;\n    },\n    [option.data, option.id, localValues],\n  );\n\n  // Update a field value with immediate local update and debounced server save\n  const setValue = useCallback(\n    (name: string, value: unknown) => {\n      // IMMEDIATE local update for UI responsiveness\n      setLocalValues((prev) => ({ ...prev, [name]: value }));\n\n      // Clear any pending save\n      if (saveTimerRef.current) {\n        clearTimeout(saveTimerRef.current);\n      }\n\n      // Determine debounce time based on value type\n      // Booleans/selects: 150ms (quick confirmation)\n      // Text fields: 500ms (wait for typing to stop)\n      const debounceTime = typeof value === 'string' ? 500 : 150;\n\n      // Schedule the save with debounce\n      saveTimerRef.current = setTimeout(async () => {\n        try {\n          // Merge all local values with option data for the update\n          const updatedData = {\n            ...option.data,\n            ...localValues,\n            [name]: value,\n          };\n\n          await websiteOptionsApi.update(option.id, { data: updatedData });\n        } catch (error) {\n          // Show error toast on API failure\n          showErrorWithContext(error, <Trans>Failed to save changes</Trans>);\n        }\n      }, debounceTime);\n    },\n    [option.id, option.data, localValues],\n  );\n\n  const contextValue = useMemo<FormFieldsContextValue>(\n    () => ({\n      formFields,\n      isLoading,\n      isError,\n      option,\n      submission,\n      getField,\n      getValue,\n      setValue,\n    }),\n    [\n      formFields,\n      isLoading,\n      isError,\n      option,\n      submission,\n      getField,\n      getValue,\n      setValue,\n    ],\n  );\n\n  return (\n    <FormFieldsContext.Provider value={contextValue}>\n      {children}\n    </FormFieldsContext.Provider>\n  );\n}\n\nexport function useFormFieldsContext(): FormFieldsContextValue {\n  const context = useContext(FormFieldsContext);\n  if (!context) {\n    throw new Error(\n      // eslint-disable-next-line lingui/no-unlocalized-strings\n      'useFormFieldsContext must be used within a FormFieldsProvider',\n    );\n  }\n  return context;\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/account-selection/form/hooks/index.ts",
    "content": "export { useDefaultOption } from './use-default-option';\nexport { useValidations, type UseValidationResult } from './use-validations';\n\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/account-selection/form/hooks/use-default-option.tsx",
    "content": "/**\n * Hook to get the default option value for a field.\n */\n\nimport { IWebsiteFormFields } from '@postybirb/types';\nimport { useFormFieldsContext } from '../form-fields-context';\n\nexport function useDefaultOption<T>(fieldName: string): T | undefined {\n  const { option, submission } = useFormFieldsContext();\n\n  // Find the default option\n  const defaultOption = submission.options.find((opt) => opt.isDefault);\n\n  if (!defaultOption || defaultOption === option) {\n    return undefined;\n  }\n\n  const defaultValue =\n    defaultOption.data[fieldName as keyof IWebsiteFormFields];\n  return defaultValue as T | undefined;\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/account-selection/form/hooks/use-validations.tsx",
    "content": "/**\n * Hook to get validation messages for a specific field.\n */\n\nimport { ValidationMessage } from '@postybirb/types';\nimport { useMemo } from 'react';\nimport { useFormFieldsContext } from '../form-fields-context';\n\nexport interface UseValidationResult {\n  warnings: ValidationMessage[];\n  errors: ValidationMessage[];\n  isInvalid: boolean;\n}\n\nexport function useValidations(fieldName: string): UseValidationResult {\n  const { option, submission } = useFormFieldsContext();\n\n  const validationMsgs = useMemo(() => {\n    // Find validation result for this option\n    const validations = submission.validations.find((v) => v.id === option.id);\n    const warnings: ValidationMessage[] = (validations?.warnings || []).filter(\n      (warning: ValidationMessage) => warning.field === fieldName,\n    );\n    const errors: ValidationMessage[] = (validations?.errors || []).filter(\n      (error: ValidationMessage) => error.field === fieldName,\n    );\n\n    return {\n      warnings,\n      errors,\n      isInvalid: !!errors.length,\n    };\n  }, [option.id, submission.validations, fieldName]);\n\n  return validationMsgs;\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/account-selection/form/index.ts",
    "content": "export * from './fields';\nexport { FormField } from './form-field';\nexport {\n    FormFieldsProvider,\n    useFormFieldsContext\n} from './form-fields-context';\nexport * from './hooks';\nexport { SaveDefaultsPopover } from './save-defaults-popover';\nexport { SectionLayout } from './section-layout';\nexport { ValidationAlerts } from './validation-alerts';\n\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx",
    "content": "/**\n * SaveDefaultsPopover - Popover for selecting which field values to save as account defaults.\n * Users can choose which fields to include when saving current values as future defaults.\n */\n\nimport { Trans, useLingui } from '@lingui/react/macro';\nimport {\n    Button,\n    Checkbox,\n    Group,\n    Popover,\n    ScrollArea,\n    Stack,\n    Text,\n} from '@mantine/core';\nimport { useDisclosure } from '@mantine/hooks';\nimport {\n    FieldAggregateType,\n    FormBuilderMetadata,\n} from '@postybirb/form-builder';\nimport { DynamicObject } from '@postybirb/types';\nimport { IconDeviceFloppy } from '@tabler/icons-react';\nimport { useCallback, useState } from 'react';\nimport userSpecifiedWebsiteOptionsApi from '../../../../../../api/user-specified-website-options.api';\nimport {\n    showSaveErrorNotification,\n    showSuccessNotification,\n} from '../../../../../../utils/notifications';\nimport { getTranslatedLabel } from './fields/field-label';\nimport { useFormFieldsContext } from './form-fields-context';\n\n/**\n * Get a displayable label string from a field label for sorting purposes.\n */\nfunction getLabelString(\n  field: FieldAggregateType,\n  t: ReturnType<typeof useLingui>['t'],\n): string {\n  return getTranslatedLabel(field, t);\n}\n\n/**\n * Popover for selecting and saving field values as account defaults.\n */\nexport function SaveDefaultsPopover() {\n  const { t } = useLingui();\n  const { formFields, option, submission } = useFormFieldsContext();\n  const [opened, { toggle, close }] = useDisclosure(false);\n  const [selectedFields, setSelectedFields] = useState<Record<string, boolean>>(\n    {},\n  );\n  const [isSaving, setIsSaving] = useState(false);\n\n  // Handle checkbox toggle\n  const handleFieldToggle = useCallback(\n    (fieldKey: string, checked: boolean) => {\n      setSelectedFields((prev) => ({\n        ...prev,\n        [fieldKey]: checked,\n      }));\n    },\n    [],\n  );\n\n  // Select all fields\n  const handleSelectAll = useCallback(() => {\n    if (!formFields) return;\n    const allSelected: Record<string, boolean> = {};\n    Object.keys(formFields).forEach((key) => {\n      allSelected[key] = true;\n    });\n    setSelectedFields(allSelected);\n  }, [formFields]);\n\n  // Deselect all fields\n  const handleSelectNone = useCallback(() => {\n    setSelectedFields({});\n  }, []);\n\n  // Handle save\n  const handleSave = useCallback(async () => {\n    if (!formFields) return;\n\n    setIsSaving(true);\n    try {\n      // Create a copy of current values, filtered by selected fields\n      const values = option.data as unknown as Record<string, unknown>;\n      const filteredOptions: DynamicObject = {};\n\n      for (const [key, isSelected] of Object.entries(selectedFields)) {\n        if (isSelected && key in values) {\n          filteredOptions[key] = values[key];\n        }\n      }\n\n      await userSpecifiedWebsiteOptionsApi.create({\n        accountId: option.accountId,\n        type: submission.type,\n        options: filteredOptions,\n      });\n\n      showSuccessNotification(<Trans>Defaults saved successfully</Trans>);\n\n      // Reset selections and close\n      setSelectedFields({});\n      close();\n    } catch (error) {\n      showSaveErrorNotification(\n        error instanceof Error ? error.message : undefined,\n      );\n    } finally {\n      setIsSaving(false);\n    }\n  }, [\n    formFields,\n    option.data,\n    option.accountId,\n    submission.type,\n    selectedFields,\n    close,\n  ]);\n\n  // Check if any fields are selected\n  const hasSelectedFields = Object.values(selectedFields).some(Boolean);\n\n  // Sort form fields alphabetically by label\n  const sortedFields = formFields\n    ? Object.entries(formFields as FormBuilderMetadata).sort((a, b) =>\n        getLabelString(a[1], t).localeCompare(getLabelString(b[1], t)),\n      )\n    : [];\n\n  // Don't show if no form fields\n  if (!formFields || sortedFields.length === 0) {\n    return null;\n  }\n\n  // Don't show for archived submissions\n  if (submission.isArchived) {\n    return null;\n  }\n\n  return (\n    <Popover\n      trapFocus\n      returnFocus\n      opened={opened}\n      onChange={(isOpen) => {\n        if (!isOpen) close();\n      }}\n      position=\"bottom-end\"\n      withArrow\n      shadow=\"md\"\n      width={300}\n    >\n      <Popover.Target>\n        <Button\n          variant=\"subtle\"\n          size=\"xs\"\n          onClick={toggle}\n          leftSection={<IconDeviceFloppy size={14} />}\n        >\n          <Trans>Save as default</Trans>\n        </Button>\n      </Popover.Target>\n\n      <Popover.Dropdown>\n        <Stack gap=\"sm\">\n          <Text size=\"sm\" fw={500}>\n            <Trans>Save as default</Trans>\n          </Text>\n\n          <Text size=\"xs\" c=\"dimmed\">\n            <Trans>Select fields to save as defaults for this account.</Trans>\n            <br />\n            <br />\n            <Trans>\n              These defaults apply to future submissions.\n            </Trans>\n          </Text>\n\n          {/* Select All / None buttons */}\n          <Group gap=\"xs\">\n            <Button size=\"compact-xs\" variant=\"light\" onClick={handleSelectAll}>\n              <Trans>All</Trans>\n            </Button>\n            <Button\n              size=\"compact-xs\"\n              variant=\"light\"\n              onClick={handleSelectNone}\n            >\n              <Trans>None</Trans>\n            </Button>\n          </Group>\n\n          {/* Field checkboxes in scrollable area */}\n          <ScrollArea.Autosize mah={250}>\n            <Stack gap=\"xs\">\n              {sortedFields.map(([key, field]) => (\n                <Checkbox\n                  key={key}\n                  size=\"xs\"\n                  label={getTranslatedLabel(field, t)}\n                  checked={selectedFields[key] ?? false}\n                  onChange={(e) =>\n                    handleFieldToggle(key, e.currentTarget.checked)\n                  }\n                />\n              ))}\n            </Stack>\n          </ScrollArea.Autosize>\n\n          {/* Save button */}\n          <Group gap=\"xs\">\n            <Button size=\"sm\" variant=\"default\" onClick={close} flex={1}>\n              <Trans>Cancel</Trans>\n            </Button>\n            <Button\n              size=\"sm\"\n              loading={isSaving}\n              disabled={!hasSelectedFields}\n              onClick={handleSave}\n              flex={1}\n            >\n              <Trans>Save Selected</Trans>\n            </Button>\n          </Group>\n        </Stack>\n      </Popover.Dropdown>\n    </Popover>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/account-selection/form/section-layout.css",
    "content": "/* Section Layout - 12-column grid system for form fields */\n\n.section-layout {\n  display: flex;\n  flex-direction: column;\n  gap: 1rem;\n}\n\n.section-group {\n  display: flex;\n  flex-direction: column;\n  gap: 0.5rem;\n}\n\n.section-title {\n  font-weight: 600;\n  font-size: 0.875rem;\n  color: var(--mantine-color-dimmed);\n  text-transform: uppercase;\n  letter-spacing: 0.5px;\n  padding-bottom: 0.25rem;\n  border-bottom: 1px solid var(--mantine-color-gray-3);\n}\n\n.section-grid {\n  display: flex;\n  flex-wrap: wrap;\n  margin: -4px;\n}\n\n.grid-item {\n  box-sizing: border-box;\n  padding: 4px;\n  flex-shrink: 0; /* Prevent shrinking - forces wrap instead */\n  overflow: hidden; /* Contain children that exceed width */\n  max-width: 100%; /* Prevent blowout */\n}\n\n/* Ensure Mantine SegmentedControl respects container width */\n.grid-item .mantine-SegmentedControl-root {\n  flex-wrap: wrap;\n  max-width: 100%;\n}\n\n/* Column spans (12-column grid) */\n.grid-item.span-1 { width: 8.333%; min-width: 80px; }\n.grid-item.span-2 { width: 16.666%; min-width: 100px; }\n.grid-item.span-3 { width: 25%; min-width: 120px; }\n.grid-item.span-4 { width: 33.333%; min-width: 140px; }\n.grid-item.span-5 { width: 41.666%; min-width: 160px; }\n.grid-item.span-6 { width: 50%; min-width: 180px; }\n.grid-item.span-7 { width: 58.333%; min-width: 200px; }\n.grid-item.span-8 { width: 66.666%; min-width: 220px; }\n.grid-item.span-9 { width: 75%; min-width: 240px; }\n.grid-item.span-10 { width: 83.333%; min-width: 260px; }\n.grid-item.span-11 { width: 91.666%; min-width: 280px; }\n.grid-item.span-12 { width: 100%; min-width: 200px; }\n\n/* Column offsets */\n.grid-item.offset-1 { margin-left: 8.333%; }\n.grid-item.offset-2 { margin-left: 16.666%; }\n.grid-item.offset-3 { margin-left: 25%; }\n.grid-item.offset-4 { margin-left: 33.333%; }\n.grid-item.offset-5 { margin-left: 41.666%; }\n.grid-item.offset-6 { margin-left: 50%; }\n.grid-item.offset-7 { margin-left: 58.333%; }\n.grid-item.offset-8 { margin-left: 66.666%; }\n.grid-item.offset-9 { margin-left: 75%; }\n.grid-item.offset-10 { margin-left: 83.333%; }\n.grid-item.offset-11 { margin-left: 91.666%; }\n\n/* Responsive breakpoints */\n@media (max-width: 768px) {\n  .grid-item {\n    width: 100% !important;\n    min-width: 0 !important;\n    margin-left: 0 !important;\n  }\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/account-selection/form/section-layout.tsx",
    "content": "/**\n * SectionLayout - Groups form fields by section and renders them in a 12-column grid.\n */\n\nimport { Box, Skeleton, Stack, Text } from '@mantine/core';\nimport { FieldAggregateType } from '@postybirb/form-builder';\nimport { useMemo } from 'react';\nimport { ComponentErrorBoundary } from '../../../../../error-boundary';\nimport { FormField } from './form-field';\nimport { useFormFieldsContext } from './form-fields-context';\nimport { SaveDefaultsPopover } from './save-defaults-popover';\nimport './section-layout.css';\nimport { ValidationAlerts } from './validation-alerts';\n\nconst COMMON_SECTION = 'common';\n\ninterface SectionGroup {\n  name: string;\n  fields: Array<{ fieldName: string; field: FieldAggregateType }>;\n}\n\n/**\n * Groups fields by their section property, orders by field.order\n */\nfunction groupFieldsBySection(\n  formFields: Record<string, FieldAggregateType>,\n): SectionGroup[] {\n  const sectionMap = new Map<\n    string,\n    Array<{ fieldName: string; field: FieldAggregateType }>\n  >();\n\n  // Group fields by section\n  Object.entries(formFields).forEach(([fieldName, field]) => {\n    const sectionName = field.section ?? COMMON_SECTION;\n\n    if (!sectionMap.has(sectionName)) {\n      sectionMap.set(sectionName, []);\n    }\n    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion\n    sectionMap.get(sectionName)!.push({ fieldName, field });\n  });\n\n  // Sort fields within each section by order\n  sectionMap.forEach((fields) => {\n    fields.sort((a, b) => (a.field.order ?? 0) - (b.field.order ?? 0));\n  });\n\n  // Convert to array, putting common first\n  const sections: SectionGroup[] = [];\n\n  if (sectionMap.has(COMMON_SECTION)) {\n    sections.push({\n      name: COMMON_SECTION,\n      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion\n      fields: sectionMap.get(COMMON_SECTION)!,\n    });\n    sectionMap.delete(COMMON_SECTION);\n  }\n\n  // Add remaining sections alphabetically\n  Array.from(sectionMap.keys())\n    .sort()\n    .forEach((sectionName) => {\n      sections.push({\n        name: sectionName,\n        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion\n        fields: sectionMap.get(sectionName)!,\n      });\n    });\n\n  return sections;\n}\n\ninterface GridItemProps {\n  field: FieldAggregateType;\n  fieldName: string;\n}\n\nfunction GridItem({ field, fieldName }: GridItemProps) {\n  const span = field.span ?? 12;\n  const offset = field.offset ?? 0;\n\n  const classes = [`grid-item`, `span-${span}`];\n  if (offset > 0) {\n    classes.push(`offset-${offset}`);\n  }\n\n  return (\n    <Box className={classes.join(' ')}>\n      <ComponentErrorBoundary>\n        <FormField fieldName={fieldName} field={field} />\n      </ComponentErrorBoundary>\n    </Box>\n  );\n}\n\ninterface SectionGroupComponentProps {\n  section: SectionGroup;\n}\n\nfunction SectionGroupComponent({ section }: SectionGroupComponentProps) {\n  return (\n    <Box className=\"section-group\">\n      <Box className=\"section-grid\">\n        {section.fields.map(({ fieldName, field }) => (\n          <GridItem key={fieldName} fieldName={fieldName} field={field} />\n        ))}\n      </Box>\n    </Box>\n  );\n}\n\nexport function SectionLayout() {\n  const { formFields, isLoading, isError, option } = useFormFieldsContext();\n  const sections = useMemo(() => {\n    if (!formFields) return [];\n    return groupFieldsBySection(formFields);\n  }, [formFields]);\n\n  if (isLoading) {\n    return (\n      <Stack gap=\"sm\">\n        <Skeleton height={40} radius=\"sm\" />\n        <Skeleton height={40} radius=\"sm\" />\n        <Skeleton height={40} radius=\"sm\" />\n      </Stack>\n    );\n  }\n\n  if (isError) {\n    // eslint-disable-next-line lingui/no-unlocalized-strings\n    return <Text c=\"red\">Failed to load form fields</Text>;\n  }\n\n  if (sections.length === 0) {\n    // eslint-disable-next-line lingui/no-unlocalized-strings\n    return <Text c=\"dimmed\">No form fields available</Text>;\n  }\n\n  return (\n    <Box className=\"section-layout\" pos=\"relative\">\n      {/* Header with save defaults action */}\n      <Box pos=\"absolute\" mb=\"md\" top={0} right={0}>\n        <SaveDefaultsPopover />\n      </Box>\n\n      {/* Non-field-specific validation alerts */}\n      <ValidationAlerts />\n\n      {sections.map((section) => (\n        <SectionGroupComponent key={section.name} section={section} />\n      ))}\n    </Box>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/account-selection/form/validation-alerts.tsx",
    "content": "/**\n * ValidationAlerts - Displays non-field-specific validation messages.\n * Shows errors and warnings that don't belong to a specific form field.\n */\n\nimport { Alert, Stack } from '@mantine/core';\nimport { IconAlertCircle, IconAlertTriangle } from '@tabler/icons-react';\nimport { useMemo } from 'react';\nimport { ValidationTranslation } from '../../../../../../i18n/validation-translation';\nimport { useFormFieldsContext } from './form-fields-context';\n\n/**\n * Component to display validation alerts for non-field-specific messages.\n */\nexport function ValidationAlerts() {\n  const { option, submission, formFields } = useFormFieldsContext();\n\n  const { errors, warnings } = useMemo(() => {\n    // Find validation result for this option\n    const validations = submission.validations.find((v) => v.id === option.id);\n\n    // Filter out field-specific messages - we only want messages that:\n    // 1. Have no field property, OR\n    // 2. Have a field property that doesn't correspond to an actual form field\n    const isNonFormField = (\n      fieldName: string | number | symbol | undefined,\n    ) => {\n      if (!fieldName) return true; // No field specified\n      if (!formFields) return true; // No form fields loaded yet\n      if (typeof fieldName !== 'string') return true; // Only string fields are valid\n      return !formFields[fieldName]; // Field doesn't exist in form\n    };\n\n    const nonFieldErrors = (validations?.errors || []).filter((error) =>\n      isNonFormField(error.field),\n    );\n    const nonFieldWarnings = (validations?.warnings || []).filter((warning) =>\n      isNonFormField(warning.field),\n    );\n\n    return {\n      errors: nonFieldErrors,\n      warnings: nonFieldWarnings,\n    };\n  }, [option.id, submission.validations, formFields]);\n\n  // Don't render anything if there are no non-field messages\n  if (errors.length === 0 && warnings.length === 0) {\n    return null;\n  }\n\n  return (\n    <Stack gap=\"xs\">\n      {errors.map((error, index) => (\n        <Alert\n          // eslint-disable-next-line react/no-array-index-key\n          key={`error-${error.id}-${index}`}\n          variant=\"light\"\n          color=\"red\"\n          p=\"xs\"\n          styles={{ title: { fontSize: 'sm' }, message: { fontSize: 'sm' } }}\n          icon={<IconAlertCircle size={16} />}\n        >\n          <ValidationTranslation id={error.id} values={error.values} />\n        </Alert>\n      ))}\n      {warnings.map((warning, index) => (\n        <Alert\n          // eslint-disable-next-line react/no-array-index-key\n          key={`warning-${warning.id}-${index}`}\n          variant=\"light\"\n          color=\"yellow\"\n          p=\"xs\"\n          styles={{ title: { fontSize: 'sm' }, message: { fontSize: 'sm' } }}\n          icon={<IconAlertTriangle size={16} />}\n        >\n          <ValidationTranslation id={warning.id} values={warning.values} />\n        </Alert>\n      ))}\n    </Stack>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/account-selection/index.ts",
    "content": "export { AccountOptionRow } from './account-option-row';\nexport { AccountSelect } from './account-select';\nexport { AccountSelectionForm } from './account-selection-form';\nexport { SelectedAccountsForms } from './selected-accounts-forms';\n\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/account-selection/selected-accounts-forms.tsx",
    "content": "/**\n * SelectedAccountsForms - Displays website-specific option forms for all selected accounts.\n * Groups selected accounts by website, each in a collapsible section (collapsed by default).\n * Each account within a group shows its form fields via FormFieldsProvider + SectionLayout.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport {\n  Badge,\n  Box,\n  Collapse,\n  Divider,\n  Group,\n  Paper,\n  Stack,\n  Text,\n  Tooltip,\n  UnstyledButton,\n} from '@mantine/core';\nimport { useDisclosure } from '@mantine/hooks';\nimport type { EntityId, WebsiteOptionsDto } from '@postybirb/types';\nimport {\n  IconCheck,\n  IconChevronDown,\n  IconChevronRight,\n  IconCircleFilled,\n  IconLoader,\n  IconX,\n} from '@tabler/icons-react';\nimport { useMemo } from 'react';\nimport { useAccounts } from '../../../../../stores/entity/account-store';\nimport { useWebsites } from '../../../../../stores/entity/website-store';\nimport type {\n  AccountRecord,\n  WebsiteRecord,\n} from '../../../../../stores/records';\nimport { ComponentErrorBoundary } from '../../../../error-boundary';\nimport {\n  type AccountPostStatus,\n  type AccountPostStatusEntry,\n  getAccountPostStatusMap,\n} from '../../submission-history';\nimport { useSubmissionEditCardContext } from '../context';\nimport './account-selection.css';\nimport { FormFieldsProvider, SectionLayout } from './form';\n\ninterface WebsiteFormGroupProps {\n  website: WebsiteRecord;\n  options: Array<{\n    option: WebsiteOptionsDto;\n    account: AccountRecord;\n    hasErrors: boolean;\n    hasWarnings: boolean;\n  }>;\n  accountStatusMap: Map<EntityId, AccountPostStatusEntry>;\n}\n\n/**\n * Get the icon for an individual account post status.\n */\nfunction AccountStatusIcon({ status, errors }: { status: AccountPostStatus; errors: string[] }) {\n  switch (status) {\n    case 'success':\n      return (\n        <Tooltip label={<Trans>Posted successfully</Trans>} withArrow>\n          <IconCheck size={14} color=\"var(--mantine-color-green-6)\" style={{ flexShrink: 0 }} />\n        </Tooltip>\n      );\n    case 'failed':\n      return (\n        <Tooltip\n          label={errors.length > 0 ? errors.join(' | ') : <Trans>Post failed</Trans>}\n          multiline\n          w={300}\n          withArrow\n        >\n          <IconX size={14} color=\"var(--mantine-color-red-6)\" style={{ flexShrink: 0 }} />\n        </Tooltip>\n      );\n    case 'running':\n      return (\n        <Tooltip label={<Trans>Posting in progress</Trans>} withArrow>\n          <IconLoader size={14} color=\"var(--mantine-color-blue-6)\" style={{ flexShrink: 0 }} />\n        </Tooltip>\n      );\n    default:\n      return null;\n  }\n}\n\n/**\n * Compute the aggregate status icon for a website group.\n */\nfunction GroupStatusIcon({ options, accountStatusMap }: {\n  options: WebsiteFormGroupProps['options'];\n  accountStatusMap: Map<EntityId, AccountPostStatusEntry>;\n}) {\n  const statuses = options\n    .map(({ option }) => accountStatusMap.get(option.accountId))\n    .filter((s): s is AccountPostStatusEntry => s != null);\n\n  if (statuses.length === 0) return null;\n\n  const hasAnyFailed = statuses.some((s) => s.status === 'failed');\n  const hasAnyRunning = statuses.some((s) => s.status === 'running');\n  const allSuccess = statuses.every((s) => s.status === 'success');\n\n  if (allSuccess) {\n    return (\n      <Tooltip label={<Trans>All posted successfully</Trans>} withArrow>\n        <IconCheck size={14} color=\"var(--mantine-color-green-6)\" style={{ flexShrink: 0 }} />\n      </Tooltip>\n    );\n  }\n\n  if (hasAnyFailed) {\n    const failedErrors = statuses\n      .filter((s) => s.status === 'failed')\n      .flatMap((s) => s.errors);\n    return (\n      <Tooltip\n        label={failedErrors.length > 0 ? failedErrors.join(' | ') : <Trans>Some posts failed</Trans>}\n        multiline\n        w={300}\n        withArrow\n      >\n        <IconX size={14} color=\"var(--mantine-color-red-6)\" style={{ flexShrink: 0 }} />\n      </Tooltip>\n    );\n  }\n\n  if (hasAnyRunning) {\n    return (\n      <Tooltip label={<Trans>Posting in progress</Trans>} withArrow>\n        <IconLoader size={14} color=\"var(--mantine-color-blue-6)\" style={{ flexShrink: 0 }} />\n      </Tooltip>\n    );\n  }\n\n  return null;\n}\n\n/**\n * A single website group with collapsible form sections.\n * Collapsed by default. Shows error/warning counts in header.\n */\nfunction WebsiteFormGroup({ website, options, accountStatusMap }: WebsiteFormGroupProps) {\n  const { submission } = useSubmissionEditCardContext();\n  const [expanded, { toggle }] = useDisclosure(false);\n\n  const { errorCount, warningCount } = useMemo(() => {\n    let errors = 0;\n    let warnings = 0;\n    options.forEach(({ hasErrors, hasWarnings }) => {\n      if (hasErrors) errors++;\n      if (hasWarnings) warnings++;\n    });\n    return { errorCount: errors, warningCount: warnings };\n  }, [options]);\n\n  return (\n    <Paper withBorder radius=\"sm\" p={0}>\n      <UnstyledButton\n        onClick={toggle}\n        className=\"postybirb__website_group_header\"\n      >\n        <Group\n          gap=\"xs\"\n          px=\"sm\"\n          py=\"xs\"\n          wrap=\"nowrap\"\n          style={{\n            backgroundColor: 'var(--mantine-primary-color-light)',\n          }}\n        >\n          {expanded ? (\n            <IconChevronDown size={14} style={{ flexShrink: 0 }} />\n          ) : (\n            <IconChevronRight size={14} style={{ flexShrink: 0 }} />\n          )}\n          <Text size=\"sm\" fw={500} style={{ flex: 1 }} truncate>\n            {website.displayName}\n          </Text>\n          <Group gap={4}>\n            <GroupStatusIcon options={options} accountStatusMap={accountStatusMap} />\n            {errorCount > 0 && (\n              <Badge size=\"xs\" variant=\"light\" color=\"red\">\n                {errorCount} {errorCount === 1 ? 'error' : 'errors'}\n              </Badge>\n            )}\n            {warningCount > 0 && (\n              <Badge size=\"xs\" variant=\"light\" color=\"yellow\">\n                {warningCount} {warningCount === 1 ? 'warning' : 'warnings'}\n              </Badge>\n            )}\n            <Badge size=\"xs\" variant=\"light\">\n              {options.length}{' '}\n              {options.length === 1 ? (\n                <Trans>account</Trans>\n              ) : (\n                <Trans>accounts</Trans>\n              )}\n            </Badge>\n          </Group>\n        </Group>\n      </UnstyledButton>\n\n      <Collapse in={expanded}>\n        <Stack gap=\"xs\" p=\"sm\">\n          {options.map(({ option, account, hasErrors, hasWarnings }, index) => (\n            <Box key={option.id}>\n              {/* Divider between accounts in same group */}\n              {index > 0 && <Divider mb=\"xs\" />}\n\n              {/* Account sub-header */}\n              <Group gap=\"xs\" mb=\"xs\">\n                <IconCircleFilled\n                  size={8}\n                  color={\n                    account.isLoggedIn\n                      ? 'var(--mantine-color-green-filled)'\n                      : account.isPending\n                        ? 'var(--mantine-color-yellow-filled)'\n                        : 'var(--mantine-color-red-filled)'\n                  }\n                />\n                <Text size=\"sm\" fw={500}>\n                  {account.name}\n                </Text>\n                {account.username && (\n                  <Text size=\"xs\" c=\"dimmed\">\n                    ({account.username})\n                  </Text>\n                )}\n                {(() => {\n                  const accountStatus = accountStatusMap.get(option.accountId);\n                  if (accountStatus) {\n                    return (\n                      <AccountStatusIcon\n                        status={accountStatus.status}\n                        errors={accountStatus.errors}\n                      />\n                    );\n                  }\n                  return null;\n                })()}\n                {hasErrors && (\n                  <Badge size=\"xs\" variant=\"light\" color=\"red\">\n                    <Trans>Error</Trans>\n                  </Badge>\n                )}\n                {hasWarnings && (\n                  <Badge size=\"xs\" variant=\"light\" color=\"yellow\">\n                    <Trans>Warning</Trans>\n                  </Badge>\n                )}\n              </Group>\n\n              {/* Website-specific form */}\n              <ComponentErrorBoundary>\n                <FormFieldsProvider option={option} submission={submission}>\n                  <SectionLayout key={option.id} />\n                </FormFieldsProvider>\n              </ComponentErrorBoundary>\n            </Box>\n          ))}\n        </Stack>\n      </Collapse>\n    </Paper>\n  );\n}\n\n/**\n * Renders all selected accounts' forms, grouped by website in collapsible sections.\n */\nexport function SelectedAccountsForms() {\n  const { submission } = useSubmissionEditCardContext();\n  const accounts = useAccounts();\n  const websites = useWebsites();\n\n  // Compute per-account post status from latest post record\n  // Skip for templates and multi-edit cards (they never have post history)\n  const accountStatusMap = useMemo(() => {\n    if (submission.isTemplate || submission.isMultiSubmission) {\n      return new Map<EntityId, AccountPostStatusEntry>();\n    }\n    return getAccountPostStatusMap(submission);\n  }, [submission]);\n\n  // Map accountId -> AccountRecord\n  const accountById = useMemo(() => {\n    const map = new Map<string, AccountRecord>();\n    accounts.forEach((acc) => map.set(acc.accountId, acc));\n    return map;\n  }, [accounts]);\n\n  // Map websiteId -> WebsiteRecord\n  const websiteById = useMemo(() => {\n    const map = new Map<string, WebsiteRecord>();\n    websites.forEach((w) => map.set(w.id, w));\n    return map;\n  }, [websites]);\n\n  // Build validation lookup: optionId -> { hasErrors, hasWarnings }\n  const validationsByOptionId = useMemo(() => {\n    const map = new Map<string, { hasErrors: boolean; hasWarnings: boolean }>();\n    submission.validations.forEach((validation) => {\n      map.set(validation.id, {\n        hasErrors: Boolean(validation.errors && validation.errors.length > 0),\n        hasWarnings: Boolean(\n          validation.warnings && validation.warnings.length > 0,\n        ),\n      });\n    });\n    return map;\n  }, [submission.validations]);\n\n  // Get non-default options and group by website\n  const websiteGroups = useMemo(() => {\n    const nonDefaultOptions = submission.options.filter(\n      (opt) => !opt.isDefault,\n    );\n\n    // Group by website (via the account's website field)\n    const grouped = new Map<\n      string,\n      Array<{\n        option: WebsiteOptionsDto;\n        account: AccountRecord;\n        hasErrors: boolean;\n        hasWarnings: boolean;\n      }>\n    >();\n\n    nonDefaultOptions.forEach((option) => {\n      const account = accountById.get(option.accountId);\n      if (!account) return;\n\n      const websiteId = account.website;\n      const validation = validationsByOptionId.get(option.id);\n      const entry = {\n        option,\n        account,\n        hasErrors: validation?.hasErrors ?? false,\n        hasWarnings: validation?.hasWarnings ?? false,\n      };\n\n      const existing = grouped.get(websiteId) ?? [];\n      existing.push(entry);\n      grouped.set(websiteId, existing);\n    });\n\n    // Convert to array with website records, preserving website display order\n    const groups: Array<{\n      website: WebsiteRecord;\n      options: Array<{\n        option: WebsiteOptionsDto;\n        account: AccountRecord;\n        hasErrors: boolean;\n        hasWarnings: boolean;\n      }>;\n    }> = [];\n\n    grouped.forEach((options, websiteId) => {\n      const website = websiteById.get(websiteId);\n      if (website) {\n        groups.push({ website, options });\n      }\n    });\n\n    // Sort by website display name\n    groups.sort((a, b) =>\n      a.website.displayName.localeCompare(b.website.displayName),\n    );\n\n    return groups;\n  }, [submission.options, accountById, websiteById, validationsByOptionId]);\n\n  if (websiteGroups.length === 0) {\n    return null;\n  }\n\n  return (\n    <Stack gap=\"xs\">\n      <Text fw={600} size=\"sm\">\n        <Trans>Website Options</Trans>\n      </Text>\n      {websiteGroups.map(({ website, options }) => (\n        <WebsiteFormGroup\n          key={website.id}\n          website={website}\n          options={options}\n          accountStatusMap={accountStatusMap}\n        />\n      ))}\n    </Stack>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/actions/apply-template-action.tsx",
    "content": "/**\n * ApplyTemplateAction - Button to open template picker modal for a submission.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport { ActionIcon, Tooltip } from '@mantine/core';\nimport { IconTemplate } from '@tabler/icons-react';\nimport { useState } from 'react';\nimport { TemplatePickerModal } from '../../../../shared/template-picker';\nimport { useSubmissionEditCardContext } from '../context/submission-edit-card-context';\n\n/**\n * Action button to apply a template to the current submission.\n */\nexport function ApplyTemplateAction() {\n  const { submission, targetSubmissionIds } = useSubmissionEditCardContext();\n  const [isModalOpen, setIsModalOpen] = useState(false);\n\n  const handleClick = (e: React.MouseEvent) => {\n    e.stopPropagation();\n    setIsModalOpen(true);\n  };\n\n  // Determine which submissions to apply to\n  // In mass edit mode, use targetSubmissionIds; otherwise just this submission\n  const targetIds = targetSubmissionIds?.length\n    ? targetSubmissionIds\n    : [submission.submissionId];\n\n  return (\n    <>\n      <Tooltip label={<Trans>Apply template</Trans>}>\n        <ActionIcon\n          variant=\"subtle\"\n          size=\"sm\"\n          color=\"grape\"\n          onClick={handleClick}\n        >\n          <IconTemplate size={16} />\n        </ActionIcon>\n      </Tooltip>\n\n      {isModalOpen && (\n        <TemplatePickerModal\n          targetSubmissionIds={targetIds}\n          type={submission.type}\n          onClose={() => setIsModalOpen(false)}\n        />\n      )}\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/actions/index.ts",
    "content": "export { SubmissionEditCardActions } from './submission-edit-card-actions';\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/actions/save-to-many-action.tsx",
    "content": "/**\n * SaveToManyAction - Button to apply multi-submission changes to multiple submissions.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport { ActionIcon, Tooltip } from '@mantine/core';\nimport { SubmissionId } from '@postybirb/types';\nimport { IconDeviceFloppy } from '@tabler/icons-react';\nimport { useState } from 'react';\nimport submissionApi from '../../../../../api/submission.api';\nimport {\n  showSuccessNotification,\n  showUpdateErrorNotification,\n} from '../../../../../utils/notifications';\nimport { SubmissionPickerModal } from '../../../../shared/submission-picker';\nimport { useSubmissionEditCardContext } from '../context';\n\n/**\n * Action button that opens a modal to apply the current multi-submission\n * settings to multiple target submissions.\n */\nexport function SaveToManyAction() {\n  const { submission, targetSubmissionIds = [] } = useSubmissionEditCardContext();\n  const [modalOpened, setModalOpened] = useState(false);\n\n  const handleConfirm = async (submissionIds: string[], merge: boolean) => {\n    try {\n      await submissionApi.applyToMultipleSubmissions({\n        submissionToApply: submission.id as SubmissionId,\n        submissionIds: submissionIds as SubmissionId[],\n        merge,\n      });\n      showSuccessNotification(\n        <Trans>Applied to {submissionIds.length} submission(s)</Trans>\n      );\n      setModalOpened(false);\n    } catch {\n      showUpdateErrorNotification();\n    }\n  };\n\n  return (\n    <>\n      <Tooltip label={<Trans>Save to submissions</Trans>}>\n        <ActionIcon\n          variant=\"subtle\"\n          size=\"sm\"\n          color=\"blue\"\n          onClick={(e) => {\n            e.stopPropagation();\n            setModalOpened(true);\n          }}\n        >\n          <IconDeviceFloppy size={16} />\n        </ActionIcon>\n      </Tooltip>\n\n      <SubmissionPickerModal\n        opened={modalOpened}\n        onClose={() => setModalOpened(false)}\n        onConfirm={handleConfirm}\n        type={submission.type}\n        excludeIds={[submission.id]}\n        initialSelectedIds={targetSubmissionIds}\n        title={<Trans>Save to submissions</Trans>}\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx",
    "content": "/**\n * SubmissionEditCardActions - Action buttons for the submission edit card header.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport { ActionIcon, Group, Tooltip } from '@mantine/core';\nimport { useDisclosure } from '@mantine/hooks';\nimport {\n    IconArchiveOff,\n    IconCancel,\n    IconHelp,\n    IconHistory,\n    IconSend,\n    IconTrash,\n} from '@tabler/icons-react';\nimport postManagerApi from '../../../../../api/post-manager.api';\nimport postQueueApi from '../../../../../api/post-queue.api';\nimport submissionApi from '../../../../../api/submission.api';\nimport { useTourActions } from '../../../../../stores/ui/tour-store';\nimport {\n    showDeletedNotification,\n    showDeleteErrorNotification,\n    showPostErrorNotification,\n    showRestoredNotification,\n    showRestoreErrorNotification,\n} from '../../../../../utils/notifications';\nimport { HoldToConfirmButton } from '../../../../hold-to-confirm';\nimport { SUBMISSION_EDIT_TOUR_ID } from '../../../../onboarding-tour/tours/submission-edit-tour';\nimport { SubmissionHistoryDrawer } from '../../submission-history-drawer';\nimport { useSubmissionEditCardContext } from '../context';\nimport { ApplyTemplateAction } from './apply-template-action';\nimport { SaveToManyAction } from './save-to-many-action';\n\n/**\n * Actions for the submission edit card header - visible buttons, not in a menu.\n */\nexport function SubmissionEditCardActions() {\n  const { submission } = useSubmissionEditCardContext();\n  const { startTour } = useTourActions();\n  // Drawer is only used for archived submissions (list-level context)\n  const [historyOpened, historyDrawer] = useDisclosure(false);\n\n  const handlePost = async () => {\n    try {\n      await postQueueApi.enqueue([submission.id]);\n    } catch {\n      showPostErrorNotification();\n    }\n  };\n\n  const handleCancel = async () => {\n    try {\n      await postManagerApi.cancelIfRunning(submission.id);\n    } catch {\n      // Silently handle if not running\n    }\n  };\n\n  const handleDelete = async () => {\n    try {\n      await submissionApi.remove([submission.id]);\n      showDeletedNotification(1);\n    } catch {\n      showDeleteErrorNotification();\n    }\n  };\n\n  const handleUnarchive = async () => {\n    try {\n      await submissionApi.unarchive(submission.submissionId);\n      showRestoredNotification();\n    } catch {\n      showRestoreErrorNotification();\n    }\n  };\n\n  if (submission.isMultiSubmission) {\n    return (\n      <Group gap={4} wrap=\"nowrap\" onClick={(e) => e.stopPropagation()}>\n        <ApplyTemplateAction />\n        <SaveToManyAction />\n      </Group>\n    );\n  }\n\n  // Archived submissions: show history (if available), unarchive and delete\n  if (submission.isArchived) {\n    const hasHistory = submission.posts.length > 0;\n    return (\n      <>\n        <Group gap={4} wrap=\"nowrap\" onClick={(e) => e.stopPropagation()}>\n          {hasHistory && (\n            <Tooltip label={<Trans>View history</Trans>}>\n              <ActionIcon\n                variant=\"subtle\"\n                size=\"sm\"\n                onClick={historyDrawer.open}\n              >\n                <IconHistory size={16} />\n              </ActionIcon>\n            </Tooltip>\n          )}\n          <Tooltip label={<Trans>Restore</Trans>}>\n            <ActionIcon\n              variant=\"subtle\"\n              size=\"sm\"\n              color=\"blue\"\n              onClick={handleUnarchive}\n            >\n              <IconArchiveOff size={16} />\n            </ActionIcon>\n          </Tooltip>\n          <Tooltip label={<Trans>Hold to delete permanently</Trans>}>\n            <HoldToConfirmButton\n              variant=\"subtle\"\n              size=\"sm\"\n              color=\"red\"\n              onConfirm={handleDelete}\n            >\n              <IconTrash size={16} />\n            </HoldToConfirmButton>\n          </Tooltip>\n        </Group>\n\n        {/* History drawer */}\n        <SubmissionHistoryDrawer\n          opened={historyOpened}\n          onClose={historyDrawer.close}\n          submission={submission}\n        />\n      </>\n    );\n  }\n\n  if (submission.isTemplate) {\n    // Templates need delete only\n    return (\n      <Group gap={4} wrap=\"nowrap\" onClick={(e) => e.stopPropagation()}>\n        <Tooltip label={<Trans>Hold to delete</Trans>}>\n          <HoldToConfirmButton\n            variant=\"subtle\"\n            size=\"sm\"\n            color=\"red\"\n            onConfirm={handleDelete}\n          >\n            <IconTrash size={16} />\n          </HoldToConfirmButton>\n        </Tooltip>\n      </Group>\n    );\n  }\n\n  // If currently posting, show cancel button\n  if (submission.isPosting) {\n    return (\n      <Group gap={4} wrap=\"nowrap\" onClick={(e) => e.stopPropagation()}>\n        <Tooltip label={<Trans>Cancel posting</Trans>}>\n          <ActionIcon\n            variant=\"subtle\"\n            size=\"sm\"\n            color=\"orange\"\n            onClick={handleCancel}\n          >\n            <IconCancel size={16} />\n          </ActionIcon>\n        </Tooltip>\n        <Tooltip label={<Trans>Hold to delete</Trans>}>\n          <HoldToConfirmButton\n            variant=\"subtle\"\n            size=\"sm\"\n            color=\"red\"\n            onConfirm={handleDelete}\n          >\n            <IconTrash size={16} />\n          </HoldToConfirmButton>\n        </Tooltip>\n      </Group>\n    );\n  }\n\n  // Check if submission can be posted\n  const canPost = !submission.hasErrors && submission.hasWebsiteOptions;\n\n  // Determine tooltip message for disabled post button\n  let postTooltip: React.ReactNode = <Trans>Hold to post</Trans>;\n  if (submission.hasErrors) {\n    postTooltip = <Trans>Submission has validation errors</Trans>;\n  } else if (!submission.hasWebsiteOptions) {\n    postTooltip = <Trans>No websites selected</Trans>;\n  }\n\n  // Normal state: show template, post and delete\n  return (\n    <Group gap={4} wrap=\"nowrap\" onClick={(e) => e.stopPropagation()}>\n      <Tooltip label={<Trans>Editor Tour</Trans>}>\n        <ActionIcon variant=\"subtle\" size=\"sm\" onClick={() => startTour(SUBMISSION_EDIT_TOUR_ID)}>\n          <IconHelp size={16} />\n        </ActionIcon>\n      </Tooltip>\n      <ApplyTemplateAction />\n      <Tooltip label={postTooltip}>\n        <HoldToConfirmButton\n          variant=\"subtle\"\n          size=\"sm\"\n          color=\"blue\"\n          onConfirm={handlePost}\n          disabled={!canPost}\n        >\n          <IconSend size={16} />\n        </HoldToConfirmButton>\n      </Tooltip>\n      <Tooltip label={<Trans>Hold to delete</Trans>}>\n        <HoldToConfirmButton\n          variant=\"subtle\"\n          size=\"sm\"\n          color=\"red\"\n          onConfirm={handleDelete}\n        >\n          <IconTrash size={16} />\n        </HoldToConfirmButton>\n      </Tooltip>\n    </Group>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/body/index.ts",
    "content": "export { SubmissionEditCardBody } from './submission-edit-card-body';\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/body/submission-edit-card-body.tsx",
    "content": "/**\n * SubmissionEditCardBody - Body content of the submission edit card.\n */\n\nimport { Box, Stack } from '@mantine/core';\nimport { ISubmissionScheduleInfo, SubmissionType } from '@postybirb/types';\nimport { useCallback } from 'react';\nimport submissionApi from '../../../../../api/submission.api';\nimport { showUpdateErrorNotification } from '../../../../../utils/notifications';\nimport { ComponentErrorBoundary } from '../../../../error-boundary';\nimport { AccountSelect, SelectedAccountsForms } from '../account-selection';\nimport { useSubmissionEditCardContext } from '../context';\nimport { DefaultsForm } from '../defaults-form';\nimport { SubmissionFileManager } from '../file-management';\nimport { ScheduleForm } from '../schedule-form';\n\n/**\n * Body content of the submission edit card.\n * Contains file management (conditional), schedule form, defaults form, and account selection form.\n */\nexport function SubmissionEditCardBody() {\n  const { submission } = useSubmissionEditCardContext();\n\n  const handleScheduleChange = useCallback(\n    async (schedule: ISubmissionScheduleInfo, isScheduled: boolean) => {\n      try {\n        await submissionApi.update(submission.id, {\n          isScheduled,\n          ...schedule,\n        });\n      } catch {\n        showUpdateErrorNotification();\n      }\n    },\n    [submission.id],\n  );\n\n  const showFileManagement =\n    submission.type === SubmissionType.FILE &&\n    !submission.isMultiSubmission &&\n    !submission.isTemplate;\n\n  // Don't show schedule form for templates or multi-submissions\n  const showScheduleForm = !submission.isTemplate && !submission.isMultiSubmission;\n\n  return (\n    <Stack gap=\"md\" p=\"md\">\n      {/* File Management Section (conditional) */}\n      {showFileManagement && (\n        <ComponentErrorBoundary>\n          <Box data-tour-id=\"edit-card-files\">\n            <SubmissionFileManager />\n          </Box>\n        </ComponentErrorBoundary>\n      )}\n\n      {/* Schedule Form - configure when submission should be posted */}\n      {showScheduleForm && (\n        <ComponentErrorBoundary>\n          <Box data-tour-id=\"edit-card-schedule\">\n            <ScheduleForm\n              schedule={submission.schedule}\n              isScheduled={submission.isScheduled}\n              disabled={submission.isArchived}\n              onChange={handleScheduleChange}\n            />\n          </Box>\n        </ComponentErrorBoundary>\n      )}\n\n      {/* Defaults Form - global options like title, description, tags */}\n      <ComponentErrorBoundary>\n        <Box data-tour-id=\"edit-card-defaults\">\n          <DefaultsForm />\n        </Box>\n      </ComponentErrorBoundary>\n\n      {/* Account Selection - dropdown for selecting accounts */}\n      <ComponentErrorBoundary>\n        <Box data-tour-id=\"edit-card-accounts\">\n          <AccountSelect />\n        </Box>\n      </ComponentErrorBoundary>\n\n      {/* Website Options - per-website forms for selected accounts */}\n      <ComponentErrorBoundary>\n        <Box data-tour-id=\"edit-card-website-forms\">\n          <SelectedAccountsForms />\n        </Box>\n      </ComponentErrorBoundary>\n    </Stack>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/context/index.ts",
    "content": "export {\n    SubmissionEditCardProvider,\n    useSubmissionEditCardContext,\n    useSubmissionEditCardContextOptional,\n    type SubmissionEditCardContextValue,\n    type SubmissionEditCardViewMode\n} from './submission-edit-card-context';\n\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/context/submission-edit-card-context.tsx",
    "content": "/**\n * SubmissionEditCardContext - Context provider for individual submission edit cards.\n * Each card has its own context to manage its submission state and actions.\n */\n\nimport {\n    createContext,\n    useContext,\n    useMemo,\n    type Dispatch,\n    type ReactNode,\n    type SetStateAction,\n} from 'react';\nimport type { SubmissionRecord } from '../../../../../stores/records';\n\nexport type SubmissionEditCardViewMode = 'edit' | 'history';\n\n/**\n * Context value for a submission edit card.\n */\nexport interface SubmissionEditCardContextValue {\n  /** The submission being edited */\n  submission: SubmissionRecord;\n  /** Whether the card can be collapsed (multiple selections, not mass edit) */\n  isCollapsible: boolean;\n  /** Whether the card should be expanded by default */\n  defaultExpanded: boolean;\n  /** Target submission IDs for mass edit mode (to pre-populate Save To Many) */\n  targetSubmissionIds?: string[];\n  /** Current view mode: edit form or post history */\n  viewMode: SubmissionEditCardViewMode;\n  /** Set the current view mode */\n  setViewMode: Dispatch<SetStateAction<SubmissionEditCardViewMode>>;\n}\n\nconst SubmissionEditCardContext =\n  createContext<SubmissionEditCardContextValue | null>(null);\n\n/**\n * Props for the SubmissionEditCardProvider.\n */\ninterface SubmissionEditCardProviderProps {\n  children: ReactNode;\n  submission: SubmissionRecord;\n  isCollapsible: boolean;\n  defaultExpanded?: boolean;\n  targetSubmissionIds?: string[];\n  viewMode: SubmissionEditCardViewMode;\n  setViewMode: Dispatch<SetStateAction<SubmissionEditCardViewMode>>;\n}\n\n/**\n * Provider component for submission edit card context.\n */\nexport function SubmissionEditCardProvider({\n  children,\n  submission,\n  isCollapsible,\n  defaultExpanded = true,\n  targetSubmissionIds,\n  viewMode,\n  setViewMode,\n}: SubmissionEditCardProviderProps) {\n  const value = useMemo<SubmissionEditCardContextValue>(\n    () => ({\n      submission,\n      isCollapsible,\n      defaultExpanded,\n      targetSubmissionIds,\n      viewMode,\n      setViewMode,\n    }),\n    [submission, isCollapsible, defaultExpanded, targetSubmissionIds, viewMode, setViewMode],\n  );\n\n  return (\n    <SubmissionEditCardContext.Provider value={value}>\n      {children}\n    </SubmissionEditCardContext.Provider>\n  );\n}\n\n/**\n * Hook to access the submission edit card context.\n * Throws if used outside of a SubmissionEditCardProvider.\n */\nexport function useSubmissionEditCardContext(): SubmissionEditCardContextValue {\n  const context = useContext(SubmissionEditCardContext);\n  if (!context) {\n    throw new Error(\n      // eslint-disable-next-line lingui/no-unlocalized-strings\n      'useSubmissionEditCardContext must be used within a SubmissionEditCardProvider',\n    );\n  }\n  return context;\n}\n\n/**\n * Hook to access the submission edit card context (optional).\n * Returns null if used outside of a SubmissionEditCardProvider.\n */\nexport function useSubmissionEditCardContextOptional(): SubmissionEditCardContextValue | null {\n  return useContext(SubmissionEditCardContext);\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/defaults-form/defaults-form.css",
    "content": "/**\n * Defaults form section styles.\n */\n\n.postybirb__defaults_form_header {\n  width: 100%;\n  cursor: pointer;\n  transition: background-color 150ms ease;\n}\n\n.postybirb__defaults_form_header:hover {\n  background-color: var(--mantine-color-default-hover);\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/defaults-form/defaults-form.tsx",
    "content": "/**\n * DefaultsForm - Form for editing default submission options (title, description, tags, etc.).\n * These defaults are inherited by all website-specific options unless overridden.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport {\n    Box,\n    Collapse,\n    Group,\n    Paper,\n    Stack,\n    Text,\n    UnstyledButton,\n} from '@mantine/core';\nimport { useDisclosure } from '@mantine/hooks';\nimport { IconChevronDown, IconChevronRight } from '@tabler/icons-react';\nimport { ComponentErrorBoundary } from '../../../../error-boundary';\nimport {\n    FormFieldsProvider,\n    SectionLayout,\n} from '../account-selection/form';\nimport { useSubmissionEditCardContext } from '../context';\nimport './defaults-form.css';\n\n/**\n * Collapsible form section for editing default submission options.\n * The default options contain global settings like title, description, tags, and rating\n * that are inherited by all website-specific options.\n */\nexport function DefaultsForm() {\n  const { submission } = useSubmissionEditCardContext();\n  const [expanded, { toggle }] = useDisclosure(true);\n\n  const defaultOption = submission.getDefaultOptions();\n\n  if (!defaultOption) {\n    return (\n      <Text c=\"dimmed\" size=\"sm\">\n        <Trans>No default options available</Trans>\n      </Text>\n    );\n  }\n\n  return (\n    <Stack gap=\"xs\">\n      <Paper withBorder radius=\"sm\" p={0}>\n        <UnstyledButton\n          onClick={toggle}\n          className=\"postybirb__defaults_form_header\"\n        >\n          <Group gap=\"xs\" px=\"sm\" py=\"xs\" wrap=\"nowrap\">\n            {expanded ? (\n              <IconChevronDown size={14} style={{ flexShrink: 0 }} />\n            ) : (\n              <IconChevronRight size={14} style={{ flexShrink: 0 }} />\n            )}\n            <Text size=\"sm\" fw={500} style={{ flex: 1 }}>\n              <Trans>Defaults</Trans>\n            </Text>\n          </Group>\n        </UnstyledButton>\n\n        <Collapse in={expanded}>\n          <Box p=\"sm\" pt={0}>\n            <ComponentErrorBoundary>\n              <FormFieldsProvider\n                option={defaultOption}\n                submission={submission}\n              >\n                <SectionLayout />\n              </FormFieldsProvider>\n            </ComponentErrorBoundary>\n          </Box>\n        </Collapse>\n      </Paper>\n    </Stack>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/defaults-form/index.ts",
    "content": "export { DefaultsForm } from './defaults-form';\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/file-management/bulk-file-editor.tsx",
    "content": "/* eslint-disable no-param-reassign */\n/**\n * BulkFileEditor - Inline panel for applying metadata to multiple files at once.\n * Supports: Skip Accounts (overwrite), Source URLs (append), Dimension scale % (default dims).\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport {\n    ActionIcon,\n    Box,\n    Button,\n    Divider,\n    Group,\n    MultiSelect,\n    NumberInput,\n    Stack,\n    Text,\n    TextInput,\n    Tooltip,\n} from '@mantine/core';\nimport {\n    AccountId,\n    EntityId,\n    FileType,\n    ISubmissionFileDto,\n} from '@postybirb/types';\nimport { getFileType } from '@postybirb/utils/file-type';\nimport { IconTrash } from '@tabler/icons-react';\nimport { useCallback, useMemo, useState } from 'react';\nimport fileSubmissionApi from '../../../../../api/file-submission.api';\nimport {\n    showErrorWithContext,\n    showSuccessNotification,\n} from '../../../../../utils/notifications';\nimport { BasicWebsiteSelect } from '../../../../shared';\n\ninterface BulkFileEditorProps {\n  files: ISubmissionFileDto[];\n}\n\nexport function BulkFileEditor({ files }: BulkFileEditorProps) {\n  const [selectedFileIds, setSelectedFileIds] = useState<EntityId[]>([]);\n  const [ignoredWebsites, setIgnoredWebsites] = useState<AccountId[]>([]);\n  const [sourceUrls, setSourceUrls] = useState<string[]>(['']);\n  const [scalePercent, setScalePercent] = useState<number | null>(null);\n  const [isApplying, setIsApplying] = useState(false);\n\n  const hasImages = files.some(\n    (f) => getFileType(f.fileName) === FileType.IMAGE,\n  );\n\n  const fileOptions = useMemo(\n    () =>\n      files.map((f) => ({\n        value: f.id,\n        label: f.fileName,\n      })),\n    [files],\n  );\n\n  const selectAll = () => setSelectedFileIds(files.map((f) => f.id));\n\n  const updateUrl = (index: number, value: string) => {\n    const newUrls = [...sourceUrls];\n    newUrls[index] = value;\n    if (index === newUrls.length - 1 && value.trim()) {\n      newUrls.push('');\n    }\n    setSourceUrls(newUrls);\n  };\n\n  const removeUrl = (index: number) => {\n    const newUrls = sourceUrls.filter((_, i) => i !== index);\n    if (newUrls.length === 0 || newUrls[newUrls.length - 1] !== '') {\n      newUrls.push('');\n    }\n    setSourceUrls(newUrls);\n  };\n\n  const getValidUrls = useCallback(\n    (): string[] =>\n      sourceUrls.filter((url) => {\n        const trimmed = url.trim();\n        if (!trimmed) return false;\n        try {\n          const parsed = new URL(trimmed);\n          return (\n            ['http:', 'https:'].includes(parsed.protocol) &&\n            parsed.hostname.includes('.')\n          );\n        } catch {\n          return false;\n        }\n      }),\n    [sourceUrls],\n  );\n\n  const hasChanges =\n    ignoredWebsites.length > 0 ||\n    getValidUrls().length > 0 ||\n    scalePercent !== null;\n\n  const canApply = selectedFileIds.length > 0 && hasChanges;\n\n  const handleApply = async () => {\n    if (!canApply) return;\n\n    setIsApplying(true);\n    const validUrls = getValidUrls();\n\n    try {\n      const selectedFiles = files.filter((f) =>\n        selectedFileIds.includes(f.id),\n      );\n\n      await Promise.all(\n        selectedFiles.map((file) => {\n          const updatedMetadata = { ...file.metadata };\n\n          // Skip Accounts — overwrite\n          if (ignoredWebsites.length > 0) {\n            updatedMetadata.ignoredWebsites = [...ignoredWebsites];\n          }\n\n          // Source URLs — append + deduplicate\n          if (validUrls.length > 0) {\n            const existing = updatedMetadata.sourceUrls ?? [];\n            const merged = [...existing, ...validUrls];\n            updatedMetadata.sourceUrls = [...new Set(merged)];\n          }\n\n          // Dimensions — percentage of each file's original size (default only)\n          if (\n            scalePercent !== null &&\n            getFileType(file.fileName) === FileType.IMAGE &&\n            file.width > 0 &&\n            file.height > 0\n          ) {\n            const newHeight = Math.max(\n              1,\n              Math.round(file.height * (scalePercent / 100)),\n            );\n            const newWidth = Math.max(\n              1,\n              Math.round(file.width * (scalePercent / 100)),\n            );\n\n            if (!updatedMetadata.dimensions) {\n              updatedMetadata.dimensions = {};\n            }\n            updatedMetadata.dimensions.default = {\n              height: newHeight,\n              width: newWidth,\n            };\n          }\n\n          return fileSubmissionApi.updateMetadata(file.id, updatedMetadata);\n        }),\n      );\n\n      showSuccessNotification(\n        <Trans>Applied settings to {selectedFiles.length} file(s)</Trans>,\n      );\n\n      // Reset form\n      setIgnoredWebsites([]);\n      setSourceUrls(['']);\n      setScalePercent(null);\n    } catch (error) {\n      showErrorWithContext(\n        error,\n        <Trans>Failed to apply bulk settings</Trans>,\n      );\n    } finally {\n      setIsApplying(false);\n    }\n  };\n\n  return (\n    <Box p=\"md\" pt=\"xs\">\n      <Stack gap=\"sm\">\n        {/* File selector */}\n        <Group gap=\"xs\" align=\"end\">\n          <Box style={{ flex: 1 }}>\n            <MultiSelect\n              size=\"xs\"\n              label={<Trans>Apply to files</Trans>}\n              data={fileOptions}\n              value={selectedFileIds}\n              onChange={setSelectedFileIds}\n              searchable\n              clearable\n            />\n          </Box>\n          <Button size=\"xs\" variant=\"light\" color=\"gray\" onClick={selectAll}>\n            <Trans>Select All</Trans>\n          </Button>\n        </Group>\n\n        <Divider variant=\"dashed\" />\n\n        {/* Skip Accounts */}\n        <BasicWebsiteSelect\n          label={<Trans>Skip Accounts</Trans>}\n          size=\"xs\"\n          selected={ignoredWebsites}\n          onSelect={(selectedAccounts) => {\n            setIgnoredWebsites(selectedAccounts.map((acc) => acc.id));\n          }}\n        />\n\n        {/* Source URLs */}\n        <Box>\n          <Text size=\"xs\" fw={500} mb={4}>\n            <Trans>Source URLs (append)</Trans>\n          </Text>\n          {sourceUrls.map((url, index) => (\n            // eslint-disable-next-line react/no-array-index-key\n            <Group key={index} gap=\"xs\" mb={4}>\n              <TextInput\n                placeholder=\"https://...\"\n                value={url}\n                size=\"xs\"\n                style={{ flex: 1 }}\n                onChange={(e) => updateUrl(index, e.target.value)}\n              />\n              {url.trim() && (\n                <ActionIcon\n                  size=\"sm\"\n                  variant=\"subtle\"\n                  color=\"red\"\n                  onClick={() => removeUrl(index)}\n                >\n                  <IconTrash size={14} />\n                </ActionIcon>\n              )}\n            </Group>\n          ))}\n        </Box>\n\n        {/* Dimension scale % — only for image submissions */}\n        {hasImages && (\n          <Box>\n            <Text size=\"xs\" fw={500} mb={4}>\n              <Trans>Dimension Scale</Trans>\n            </Text>\n            <Group gap=\"xs\" align=\"center\">\n              <NumberInput\n                size=\"xs\"\n                value={scalePercent ?? ''}\n                min={1}\n                max={100}\n                step={5}\n                suffix=\"%\"\n                placeholder=\"%\"\n                onChange={(val) =>\n                  setScalePercent(val === '' ? null : Number(val))\n                }\n                styles={{ input: { width: 80 } }}\n              />\n              {([100, 75, 50, 25] as const).map((p) => (\n                <Tooltip key={p} label={`${p}%`}>\n                  <Button\n                    size=\"compact-xs\"\n                    variant={scalePercent === p ? 'filled' : 'light'}\n                    color={scalePercent === p ? 'blue' : 'gray'}\n                    onClick={() => setScalePercent(p)}\n                  >\n                    {p}%\n                  </Button>\n                </Tooltip>\n              ))}\n            </Group>\n          </Box>\n        )}\n\n        <Button\n          size=\"xs\"\n          onClick={handleApply}\n          disabled={!canApply}\n          loading={isApplying}\n          fullWidth\n        >\n          <Trans>Apply to Selected Files</Trans>\n        </Button>\n      </Stack>\n    </Box>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx",
    "content": "/**\n * FileActions - Primary file and thumbnail management with replace/upload/crop actions.\n * Horizontal layout for compact display.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport {\n  ActionIcon,\n  Badge,\n  Box,\n  Divider,\n  FileButton,\n  Group,\n  Image,\n  Stack,\n  Text,\n  Tooltip,\n} from '@mantine/core';\nimport { FileWithPath } from '@mantine/dropzone';\nimport { FileType, ISubmissionFileDto, SubmissionId } from '@postybirb/types';\nimport { getFileType } from '@postybirb/utils/file-type';\nimport { IconCrop, IconFileUpload, IconPencil, IconReplace } from '@tabler/icons-react';\nimport { useState } from 'react';\nimport fileSubmissionApi, {\n  FileUpdateTarget,\n} from '../../../../../api/file-submission.api';\nimport { defaultTargetProvider } from '../../../../../transports/http-client';\nimport {\n  showErrorNotification,\n  showErrorWithContext,\n  showErrorWithTitleNotification,\n} from '../../../../../utils/notifications';\nimport { ImageEditor } from '../../file-submission-modal/image-editor';\nimport { useSubmissionEditCardContext } from '../context';\nimport { FilePreview } from './file-preview';\n\ninterface FileActionsProps {\n  file: ISubmissionFileDto;\n  submissionId: SubmissionId;\n}\n\n/**\n * File actions panel with horizontal layout - primary file and thumbnail management.\n */\nexport function FileActions({ file, submissionId }: FileActionsProps) {\n  const { submission } = useSubmissionEditCardContext();\n  const fileType = getFileType(file.fileName);\n  const { isArchived } = submission;\n\n  // Editor modal states - stores the file to edit and which target to update\n  const [editorFile, setEditorFile] = useState<FileWithPath | null>(null);\n  const [editorTarget, setEditorTarget] = useState<FileUpdateTarget>('file');\n  const [isLoadingPrimary, setIsLoadingPrimary] = useState(false);\n\n  const handleReplaceFile = async (\n    target: FileUpdateTarget,\n    blob: Blob,\n    filename?: string,\n  ) => {\n    try {\n      await fileSubmissionApi.replaceFile(\n        submissionId,\n        file.id,\n        target,\n        blob,\n        filename,\n      );\n    } catch (error) {\n      showErrorWithContext(error, <Trans>Failed to replace file</Trans>);\n    }\n  };\n\n  const handlePrimaryReplace = (payload: File | null) => {\n    if (!payload) return;\n\n    const newFileType = getFileType(payload.name);\n    // Only allow replacing files with the same type\n    if (fileType !== newFileType) {\n      showErrorWithTitleNotification(\n        <Trans>Update Failed</Trans>,\n        <Trans>\n          File types do not match. Please upload a file of the same type.\n        </Trans>,\n      );\n      return;\n    }\n\n    // For images, open editor modal first\n    if (\n      fileType === FileType.IMAGE &&\n      (payload.type === 'image/png' || payload.type === 'image/jpeg')\n    ) {\n      setEditorTarget('file');\n      setEditorFile(payload as FileWithPath);\n    } else {\n      handleReplaceFile('file', payload, payload.name);\n    }\n  };\n\n  const handleThumbnailUpload = (payload: File | null) => {\n    if (!payload) return;\n\n    if (getFileType(payload.name) !== FileType.IMAGE) {\n      showErrorNotification(<Trans>Thumbnail must be an image file.</Trans>);\n      return;\n    }\n\n    // Open editor for thumbnail\n    setEditorTarget('thumbnail');\n    setEditorFile(payload as FileWithPath);\n  };\n\n  const fetchPrimaryAsFile = async (): Promise<FileWithPath> => {\n    const response = await fetch(\n      `${defaultTargetProvider()}/api/file/file/${file.id}?${file.hash}`,\n    );\n    const blob = await response.blob();\n    return new File([blob], file.fileName, {\n      type: file.mimeType,\n    }) as FileWithPath;\n  };\n\n  const handleEditPrimary = async () => {\n    setIsLoadingPrimary(true);\n    try {\n      const primaryFile = await fetchPrimaryAsFile();\n      setEditorTarget('file');\n      setEditorFile(primaryFile);\n    } catch {\n      showErrorNotification(\n        <Trans>Failed to load file for editing.</Trans>\n      );\n    } finally {\n      setIsLoadingPrimary(false);\n    }\n  };\n\n  const handleCropFromPrimary = async () => {\n    // Fetch the primary file as blob and convert to File\n    setIsLoadingPrimary(true);\n    try {\n      const primaryFile = await fetchPrimaryAsFile();\n      setEditorTarget('thumbnail');\n      setEditorFile(primaryFile);\n    } catch {\n      showErrorNotification(\n        <Trans>Failed to load primary file for cropping.</Trans>,\n      );\n    } finally {\n      setIsLoadingPrimary(false);\n    }\n  };\n\n  const handleEditorClose = () => {\n    setEditorFile(null);\n  };\n\n  const handleEditorApply = (originalFile: FileWithPath, editedBlob: Blob) => {\n    handleReplaceFile(editorTarget, editedBlob, originalFile.name);\n    setEditorFile(null);\n  };\n\n  return (\n    <>\n      {/* Image Editor Modal */}\n      {editorFile && (\n        <ImageEditor\n          file={editorFile}\n          opened={!!editorFile}\n          onClose={handleEditorClose}\n          onApply={handleEditorApply}\n        />\n      )}\n\n      {/* Horizontal Layout */}\n      <Group gap=\"sm\" align=\"flex-start\" wrap=\"nowrap\">\n        {/* Primary File Section */}\n        <Stack gap={4} align=\"center\">\n          <Badge\n            variant=\"outline\"\n            radius=\"sm\"\n            size=\"xs\"\n            style={{ textTransform: 'none' }}\n          >\n            <Trans>Primary</Trans>\n          </Badge>\n\n          <Box\n            style={{\n              borderRadius: 6,\n              overflow: 'hidden',\n              // eslint-disable-next-line lingui/no-unlocalized-strings\n              border: '1px solid var(--mantine-color-dark-7)',\n              width: 80,\n              height: 80,\n              display: 'flex',\n              alignItems: 'center',\n              justifyContent: 'center',\n            }}\n          >\n            <FilePreview file={file} size={70} />\n          </Box>\n\n          <Group gap={4}>\n            {/* Edit primary - only for editable images */}\n            {fileType === FileType.IMAGE &&\n              (file.mimeType === 'image/png' ||\n                file.mimeType === 'image/jpeg') && (\n                <Tooltip label={<Trans>Edit file</Trans>} withArrow>\n                  <ActionIcon\n                    disabled={isArchived}\n                    variant=\"light\"\n                    color=\"grape\"\n                    size=\"xs\"\n                    onClick={handleEditPrimary}\n                    loading={isLoadingPrimary}\n                  >\n                    <IconPencil size={12} />\n                  </ActionIcon>\n                </Tooltip>\n              )}\n\n            <FileButton onChange={handlePrimaryReplace} disabled={isArchived}>\n              {(buttonProps) => (\n                <Tooltip label={<Trans>Replace file</Trans>} withArrow>\n                  <ActionIcon\n                    {...buttonProps}\n                    variant=\"light\"\n                    color=\"blue\"\n                    size=\"xs\"\n                    disabled={isArchived}\n                  >\n                    <IconReplace size={12} />\n                  </ActionIcon>\n                </Tooltip>\n              )}\n            </FileButton>\n          </Group>\n        </Stack>\n\n        <Divider orientation=\"vertical\" />\n\n        {/* Thumbnail Section */}\n        <Stack gap={4} align=\"center\">\n          <Badge\n            variant=\"outline\"\n            color=\"gray\"\n            radius=\"sm\"\n            size=\"xs\"\n            style={{ textTransform: 'none' }}\n          >\n            <Trans>Thumbnail</Trans>\n          </Badge>\n\n          <Box\n            style={{\n              borderRadius: 6,\n              overflow: 'hidden',\n              // eslint-disable-next-line lingui/no-unlocalized-strings\n              border: '1px solid var(--mantine-color-dark-7)',\n              width: 80,\n              height: 80,\n              display: 'flex',\n              alignItems: 'center',\n              justifyContent: 'center',\n            }}\n          >\n            <ThumbnailDisplay file={file} />\n          </Box>\n\n          <Group gap={4}>\n            {/* Crop from primary - only for images */}\n            {fileType === FileType.IMAGE && (\n              <Tooltip label={<Trans>Crop from primary</Trans>} withArrow>\n                <ActionIcon\n                  variant=\"light\"\n                  color=\"teal\"\n                  size=\"xs\"\n                  disabled={isArchived}\n                  onClick={handleCropFromPrimary}\n                  loading={isLoadingPrimary}\n                >\n                  <IconCrop size={12} />\n                </ActionIcon>\n              </Tooltip>\n            )}\n\n            {/* Upload custom thumbnail */}\n            <FileButton\n              accept=\"image/*\"\n              onChange={handleThumbnailUpload}\n              disabled={isArchived}\n            >\n              {(buttonProps) => (\n                <Tooltip label={<Trans>Upload thumbnail</Trans>} withArrow>\n                  <ActionIcon\n                    {...buttonProps}\n                    variant=\"light\"\n                    color=\"indigo\"\n                    size=\"xs\"\n                    disabled={isArchived}\n                  >\n                    <IconFileUpload size={12} />\n                  </ActionIcon>\n                </Tooltip>\n              )}\n            </FileButton>\n          </Group>\n        </Stack>\n      </Group>\n    </>\n  );\n}\n\n/**\n * ThumbnailDisplay - Shows thumbnail or placeholder.\n */\nfunction ThumbnailDisplay({ file }: { file: ISubmissionFileDto }) {\n  if (!file.hasThumbnail) {\n    return (\n      <Text size=\"xs\" c=\"dimmed\" ta=\"center\">\n        <Trans>None</Trans>\n      </Text>\n    );\n  }\n\n  // Use file hash for cache-busting — stable across renders, changes when content updates\n  const src = `${defaultTargetProvider()}/api/file/thumbnail/${file.id}?${file.hash}`;\n\n  return (\n    <Image\n      radius={0}\n      height={70}\n      width={70}\n      fit=\"contain\"\n      loading=\"lazy\"\n      alt={file.fileName}\n      src={src}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/file-management/file-alt-text-editor.tsx",
    "content": "/**\n * FileAltTextEditor - Plain text editor for TEXT file fallback content.\n * Allows editing the auto-generated text content of DOCX, RTF, and TXT files.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport { Input, Loader, Textarea } from '@mantine/core';\nimport { ISubmissionFileDto } from '@postybirb/types';\nimport { debounce } from 'lodash';\nimport { useCallback, useEffect, useState } from 'react';\nimport { useQuery } from 'react-query';\nimport fileSubmissionApi from '../../../../../api/file-submission.api';\nimport { showErrorNotification } from '../../../../../utils/notifications';\n\ninterface FileAltTextEditorProps {\n  file: ISubmissionFileDto;\n}\n\n/**\n * Plain textarea editor for editing TEXT file fallback content.\n */\nexport function FileAltTextEditor({ file }: FileAltTextEditorProps) {\n  const [text, setText] = useState('');\n\n  // Fetch the alt text content\n  const {\n    data: initialText,\n    isLoading,\n    isFetching,\n  } = useQuery(\n    ['alt-text', file.altFileId],\n    () =>\n      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion\n      fileSubmissionApi.getAltText(file.altFileId!).then((res) => res.body),\n    { enabled: !!file.altFileId },\n  );\n\n  // Seed local state when fetched data arrives\n  useEffect(() => {\n    if (initialText != null) {\n      setText(initialText);\n    }\n  }, [initialText]);\n\n  // Debounced save function with error notification\n  // eslint-disable-next-line react-hooks/exhaustive-deps\n  const save = useCallback(\n    debounce(async (value: string) => {\n      try {\n        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion\n        await fileSubmissionApi.updateAltText(file.altFileId!, value);\n      } catch {\n        showErrorNotification(<Trans>Failed to save fallback text</Trans>);\n      }\n    }, 500),\n    [file.altFileId],\n  );\n\n  if (isLoading || isFetching) {\n    return <Loader size=\"sm\" />;\n  }\n\n  return (\n    <>\n      <Input.Label>\n        <Trans>Fallback Text</Trans>\n      </Input.Label>\n      <Textarea\n        value={text}\n        onChange={(e) => {\n          const val = e.currentTarget.value;\n          setText(val);\n          save(val);\n        }}\n        autosize\n        minRows={3}\n        maxRows={10}\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/file-management/file-management.css",
    "content": "/**\n * File management section styles.\n */\n\n.postybirb-sortable-file {\n  transition:\n    box-shadow 150ms ease,\n    transform 150ms ease;\n}\n\n.postybirb-sortable-file:hover {\n  box-shadow: var(--mantine-shadow-sm);\n}\n\n.postybirb-sortable-file.sortable-ghost {\n  opacity: 0.5;\n}\n\n.postybirb-sortable-file.sortable-chosen {\n  box-shadow: var(--mantine-shadow-md);\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx",
    "content": "/* eslint-disable react/no-array-index-key */\n/* eslint-disable no-param-reassign */\n/**\n * FileMetadata - Form for editing file metadata (alt text, spoiler, sources, skip accounts, dimensions).\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport {\n  ActionIcon,\n  Badge,\n  Box,\n  Button,\n  Divider,\n  Grid,\n  Group,\n  NumberInput,\n  Select,\n  Text,\n  TextInput,\n  Tooltip,\n} from '@mantine/core';\nimport {\n  AccountId,\n  FileType,\n  IAccountDto,\n  ISubmissionFileDto,\n  ModifiedFileDimension,\n} from '@postybirb/types';\nimport { getFileType } from '@postybirb/utils/file-type';\nimport {\n  IconInfoCircle,\n  IconPlus,\n  IconRestore,\n  IconTrash,\n} from '@tabler/icons-react';\nimport { debounce } from 'lodash';\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport fileSubmissionApi from '../../../../../api/file-submission.api';\nimport { showErrorWithContext } from '../../../../../utils/notifications';\nimport { BasicWebsiteSelect } from '../../../../shared';\nimport { FileAltTextEditor } from './file-alt-text-editor';\n\ninterface FileMetadataProps {\n  file: ISubmissionFileDto;\n  accounts: IAccountDto[];\n}\n\nexport function FileMetadata({ file, accounts }: FileMetadataProps) {\n  const { metadata } = file;\n  const fileType = getFileType(file.fileName);\n  const [ignoredWebsites, setIgnoredWebsites] = useState<AccountId[]>(\n    metadata.ignoredWebsites ?? [],\n  );\n\n  // Sync local state when file prop changes (e.g. after bulk edit)\n  useEffect(() => {\n    setIgnoredWebsites(metadata.ignoredWebsites ?? []);\n  }, [metadata.ignoredWebsites]);\n\n  // Create a save function that updates metadata on the server\n  const save = useCallback(() => {\n    fileSubmissionApi.updateMetadata(file.id, metadata).catch((error) => {\n      showErrorWithContext(error, <Trans>Failed to save metadata</Trans>);\n    });\n  }, [file.id, metadata]);\n\n  return (\n    <Box>\n      {/* Skip Accounts */}\n      <Grid gutter=\"xs\">\n        <Grid.Col span={12}>\n          <BasicWebsiteSelect\n            label={<Trans>Skip Accounts</Trans>}\n            size=\"xs\"\n            selected={ignoredWebsites}\n            onSelect={(selectedAccounts) => {\n              const ids = selectedAccounts.map((acc) => acc.id);\n              setIgnoredWebsites(ids);\n              metadata.ignoredWebsites = ids;\n              save();\n            }}\n          />\n        </Grid.Col>\n\n        {/* Alt Text */}\n        <Grid.Col span={6}>\n          <TextInput\n            key={`alt-${file.id}-${file.updatedAt}`}\n            label={<Trans>Alt Text</Trans>}\n            defaultValue={metadata.altText}\n            size=\"xs\"\n            onBlur={(event) => {\n              metadata.altText = event.target.value.trim();\n              save();\n            }}\n          />\n        </Grid.Col>\n\n        {/* Spoiler Text */}\n        <Grid.Col span={6}>\n          <TextInput\n            key={`spoiler-${file.id}-${file.updatedAt}`}\n            label={<Trans>Spoiler Text</Trans>}\n            defaultValue={metadata.spoilerText}\n            size=\"xs\"\n            onBlur={(event) => {\n              metadata.spoilerText = event.target.value.trim();\n              save();\n            }}\n          />\n        </Grid.Col>\n      </Grid>\n\n      {/* Dimensions (for images only) */}\n      {fileType === FileType.IMAGE && (\n        <FileDimensions file={file} accounts={accounts} save={save} />\n      )}\n\n      {/* Source URLs (for non-text files) */}\n      {fileType !== FileType.TEXT && (\n        <FileSourceUrls metadata={metadata} save={save} />\n      )}\n\n      {/* Fallback Text Editor (for TEXT files only - excludes PDF) */}\n      {fileType === FileType.TEXT && (\n        <>\n          <Divider my=\"sm\" variant=\"dashed\" />\n          <FileAltTextEditor file={file} />\n        </>\n      )}\n    </Box>\n  );\n}\n\n/**\n * FileDimensions - Dimension controls with aspect ratio lock.\n */\ninterface FileDimensionsProps {\n  file: ISubmissionFileDto;\n  accounts: IAccountDto[];\n  save: () => void;\n}\n\nfunction FileDimensions({ file, accounts, save }: FileDimensionsProps) {\n  const { metadata } = file;\n  const { width: providedWidth, height: providedHeight } =\n    metadata.dimensions?.default ?? file;\n\n  const [height, setHeight] = useState<number>(providedHeight || 1);\n  const [width, setWidth] = useState<number>(providedWidth || 1);\n  const aspectRef = useRef(file.width / file.height);\n\n  // Sync local state when file dimensions change (e.g. after bulk edit)\n  useEffect(() => {\n    const { width: pw, height: ph } = metadata.dimensions?.default ?? file;\n    setHeight(ph || 1);\n    setWidth(pw || 1);\n  }, [file, metadata.dimensions]);\n\n  const original = { h: file.height, w: file.width };\n  const scale = Math.round((height / original.h) * 100);\n\n  // Debounced save\n  const debouncedSave = useMemo(() => debounce(() => save(), 400), [save]);\n\n  useEffect(() => () => debouncedSave.cancel(), [debouncedSave]);\n\n  const applyDimensions = (nextH: number, nextW: number) => {\n    const safeH = nextH || 1;\n    const safeW = nextW || 1;\n    setHeight(safeH);\n    setWidth(safeW);\n\n    // Update metadata\n    if (!metadata.dimensions) {\n      metadata.dimensions = { default: { height: safeH, width: safeW } };\n    } else {\n      metadata.dimensions.default = { height: safeH, width: safeW };\n    }\n\n    debouncedSave();\n  };\n\n  const setHeightLocked = (h: number) => {\n    const ratio = aspectRef.current;\n    const clampedH = Math.min(h, original.h);\n    const newW = Math.min(Math.round(clampedH * ratio), original.w);\n    applyDimensions(clampedH || 1, newW || 1);\n  };\n\n  const setWidthLocked = (w: number) => {\n    const ratio = aspectRef.current;\n    const clampedW = Math.min(w, original.w);\n    const newH = Math.min(Math.round(clampedW / ratio), original.h);\n    applyDimensions(newH || 1, clampedW || 1);\n  };\n\n  const reset = () => applyDimensions(original.h, original.w);\n\n  return (\n    <Box pt=\"md\">\n      <Group justify=\"space-between\" mb=\"xs\" wrap=\"nowrap\">\n        <Group gap={6}>\n          <Text size=\"sm\" fw={600}>\n            <Trans>Dimensions</Trans>\n          </Text>\n          <Tooltip\n            label={\n              <Trans>Adjust dimensions while maintaining aspect ratio</Trans>\n            }\n            withArrow\n          >\n            <IconInfoCircle size={14} style={{ opacity: 0.7 }} />\n          </Tooltip>\n          <Badge\n            size=\"xs\"\n            variant=\"light\"\n            color={scale === 100 ? 'gray' : 'blue'}\n          >\n            {scale}%\n          </Badge>\n        </Group>\n        <Tooltip label={<Trans>Reset</Trans>}>\n          <ActionIcon size=\"sm\" variant=\"subtle\" onClick={reset}>\n            <IconRestore size={14} />\n          </ActionIcon>\n        </Tooltip>\n      </Group>\n\n      <Group gap=\"xs\" align=\"end\" wrap=\"nowrap\" mb=\"sm\">\n        <NumberInput\n          label={<Trans>Height</Trans>}\n          value={height}\n          max={original.h}\n          min={1}\n          size=\"xs\"\n          step={10}\n          onChange={(val) => setHeightLocked(Number(val) || 1)}\n          styles={{ input: { width: 90 } }}\n        />\n        <Text px={4} pb={4}>\n          ×\n        </Text>\n        <NumberInput\n          label={<Trans>Width</Trans>}\n          value={width}\n          max={original.w}\n          min={1}\n          size=\"xs\"\n          step={10}\n          onChange={(val) => setWidthLocked(Number(val) || 1)}\n          styles={{ input: { width: 90 } }}\n        />\n        <Group gap={4} mb={6}>\n          {([100, 75, 50, 25] as const).map((p) => {\n            const active = scale === p;\n            return (\n              <Button\n                key={p}\n                size=\"compact-xs\"\n                variant={active ? 'filled' : 'light'}\n                color={active ? 'blue' : 'gray'}\n                onClick={() => {\n                  const targetH = Math.max(\n                    1,\n                    Math.round(original.h * (p / 100)),\n                  );\n                  setHeightLocked(targetH);\n                }}\n              >\n                {p}%\n              </Button>\n            );\n          })}\n        </Group>\n      </Group>\n\n      {/* Per-account dimensions */}\n      <Divider my=\"xs\" variant=\"dashed\" />\n      <CustomAccountDimensions\n        accounts={accounts}\n        file={file}\n        metadata={metadata}\n        save={save}\n      />\n    </Box>\n  );\n}\n\n/**\n * CustomAccountDimensions - Per-account dimension overrides.\n */\ninterface CustomAccountDimensionsProps {\n  accounts: IAccountDto[];\n  file: ISubmissionFileDto;\n  metadata: ISubmissionFileDto['metadata'];\n  save: () => void;\n}\n\nfunction CustomAccountDimensions({\n  accounts,\n  file,\n  metadata,\n  save,\n}: CustomAccountDimensionsProps) {\n  const [selectedAccountId, setSelectedAccountId] = useState<string | null>(\n    null,\n  );\n\n  const customDimensions = Object.entries(metadata.dimensions ?? {}).filter(\n    ([key]) => key !== 'default',\n  ) as [string, ModifiedFileDimension][];\n\n  const availableAccounts = accounts.filter(\n    (acc) => !customDimensions.some(([key]) => key === acc.id),\n  );\n\n  const addAccountDimension = () => {\n    if (!selectedAccountId) return;\n\n    if (!metadata.dimensions) {\n      metadata.dimensions = {\n        default: { height: file.height, width: file.width },\n      };\n    }\n    metadata.dimensions[selectedAccountId] = {\n      height: file.height,\n      width: file.width,\n    };\n    setSelectedAccountId(null);\n    save();\n  };\n\n  const removeAccountDimension = (accountId: string) => {\n    if (metadata.dimensions) {\n      delete metadata.dimensions[accountId];\n      save();\n    }\n  };\n\n  const updateAccountDimension = (\n    accountId: string,\n    height: number,\n    width: number,\n  ) => {\n    if (metadata.dimensions) {\n      metadata.dimensions[accountId] = { height, width };\n      save();\n    }\n  };\n\n  // Build select options with [WebsiteName] AccountName format\n  const accountOptions = availableAccounts.map((acc) => ({\n    value: acc.id,\n    label: `[${acc.websiteInfo.websiteDisplayName}] ${acc.name}`,\n  }));\n\n  return (\n    <Box>\n      <Group justify=\"space-between\" mb=\"xs\">\n        <Text size=\"xs\" fw={500}>\n          <Trans>Per-Account Dimensions</Trans>\n        </Text>\n      </Group>\n\n      {/* Account selector for adding new dimension override */}\n      {availableAccounts.length > 0 && (\n        <Group gap=\"xs\" mb=\"xs\" wrap=\"nowrap\">\n          <Select\n            size=\"xs\"\n            data={accountOptions}\n            value={selectedAccountId}\n            onChange={setSelectedAccountId}\n            searchable\n            clearable\n            style={{ flex: 1 }}\n          />\n          <ActionIcon\n            size=\"sm\"\n            variant=\"light\"\n            disabled={!selectedAccountId}\n            onClick={addAccountDimension}\n          >\n            <IconPlus size={14} />\n          </ActionIcon>\n        </Group>\n      )}\n\n      {customDimensions.length === 0 ? (\n        <Text size=\"xs\" c=\"dimmed\">\n          <Trans>No custom dimensions set</Trans>\n        </Text>\n      ) : (\n        customDimensions.map(([accountId, dims]) => {\n          const account = accounts.find((acc) => acc.id === accountId);\n          if (!account) return null;\n\n          return (\n            <Group key={accountId} gap=\"xs\" mb=\"xs\" wrap=\"nowrap\">\n              <Badge size=\"xs\" variant=\"light\" color=\"gray\">\n                {account.websiteInfo.websiteDisplayName}\n              </Badge>\n              <Text size=\"xs\" style={{ minWidth: 80 }} truncate>\n                {account.name}\n              </Text>\n              <NumberInput\n                value={dims.height}\n                min={1}\n                max={file.height}\n                size=\"xs\"\n                styles={{ input: { width: 60 } }}\n                onChange={(val) =>\n                  updateAccountDimension(\n                    accountId,\n                    Number(val) || 1,\n                    dims.width,\n                  )\n                }\n              />\n              <Text size=\"xs\">×</Text>\n              <NumberInput\n                value={dims.width}\n                min={1}\n                max={file.width}\n                size=\"xs\"\n                styles={{ input: { width: 60 } }}\n                onChange={(val) =>\n                  updateAccountDimension(\n                    accountId,\n                    dims.height,\n                    Number(val) || 1,\n                  )\n                }\n              />\n              <ActionIcon\n                size=\"xs\"\n                variant=\"subtle\"\n                color=\"red\"\n                onClick={() => removeAccountDimension(accountId)}\n              >\n                <IconTrash size={12} />\n              </ActionIcon>\n            </Group>\n          );\n        })\n      )}\n    </Box>\n  );\n}\n\n/**\n * FileSourceUrls - List of source URLs for the file.\n */\ninterface FileSourceUrlsProps {\n  metadata: ISubmissionFileDto['metadata'];\n  save: () => void;\n}\n\nfunction FileSourceUrls({ metadata, save }: FileSourceUrlsProps) {\n  const [urls, setUrls] = useState<string[]>(() => [\n    ...(metadata.sourceUrls || []),\n    '', // Always have one empty slot\n  ]);\n\n  // Sync local state when source URLs change (e.g. after bulk edit)\n  useEffect(() => {\n    setUrls([...(metadata.sourceUrls || []), '']);\n  }, [metadata.sourceUrls]);\n\n  const updateUrl = (index: number, value: string) => {\n    const newUrls = [...urls];\n    newUrls[index] = value;\n\n    // Add empty slot if last one is filled\n    if (index === newUrls.length - 1 && value.trim()) {\n      newUrls.push('');\n    }\n\n    setUrls(newUrls);\n  };\n\n  const commitUrls = () => {\n    const validUrls = urls.filter(\n      (url) => url.trim() && isValidUrl(url.trim()),\n    );\n    metadata.sourceUrls = validUrls;\n    save();\n  };\n\n  const removeUrl = (index: number) => {\n    const newUrls = urls.filter((_, i) => i !== index);\n    if (newUrls.length === 0 || newUrls[newUrls.length - 1] !== '') {\n      newUrls.push('');\n    }\n    setUrls(newUrls);\n\n    // Commit immediately on remove\n    const validUrls = newUrls.filter(\n      (url) => url.trim() && isValidUrl(url.trim()),\n    );\n    metadata.sourceUrls = validUrls;\n    save();\n  };\n\n  return (\n    <Box pt=\"md\">\n      <Text size=\"sm\" fw={600} mb=\"xs\">\n        <Trans>Source URLs</Trans>\n      </Text>\n      {urls.map((url, index) => (\n        <Group key={index} gap=\"xs\" mb=\"xs\">\n          <TextInput\n            placeholder=\"https://...\"\n            value={url}\n            size=\"xs\"\n            style={{ flex: 1 }}\n            error={url.trim() && !isValidUrl(url.trim())}\n            onChange={(e) => updateUrl(index, e.target.value)}\n            onBlur={commitUrls}\n          />\n          {url.trim() && (\n            <ActionIcon\n              size=\"sm\"\n              variant=\"subtle\"\n              color=\"red\"\n              onClick={() => removeUrl(index)}\n            >\n              <IconTrash size={14} />\n            </ActionIcon>\n          )}\n        </Group>\n      ))}\n    </Box>\n  );\n}\n\n// URL validation helper\nfunction isValidUrl(url: string): boolean {\n  if (!url.trim()) return true;\n\n  try {\n    const parsed = new URL(url.trim());\n    return (\n      ['http:', 'https:'].includes(parsed.protocol) &&\n      parsed.hostname.includes('.')\n    );\n  } catch {\n    return false;\n  }\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/file-management/file-preview.tsx",
    "content": "/**\n * FilePreview - Renders appropriate preview based on file type.\n */\n\nimport { Image } from '@mantine/core';\nimport { FileType, ISubmissionFileDto } from '@postybirb/types';\nimport { getFileType } from '@postybirb/utils/file-type';\nimport { IconFileText, IconFileUnknown } from '@tabler/icons-react';\nimport { memo } from 'react';\nimport { defaultTargetProvider } from '../../../../../transports/http-client';\n\ninterface FilePreviewProps {\n  file: ISubmissionFileDto;\n  size?: number;\n}\n\nexport const FilePreview = memo(({ file, size = 80 }: FilePreviewProps) => {\n  const { fileName, id, hash } = file;\n  const fileType = getFileType(fileName);\n  const src = `${defaultTargetProvider()}/api/file/file/${id}?${hash}`;\n\n  switch (fileType) {\n    case FileType.AUDIO:\n      return (\n        // eslint-disable-next-line jsx-a11y/media-has-caption\n        <audio controls style={{ height: size, width: size }}>\n          <source src={src} type=\"audio/ogg\" />\n          <source src={src} type=\"audio/mpeg\" />\n          <source src={src} type=\"audio/mp3\" />\n          <source src={src} type=\"audio/wav\" />\n          <source src={src} type=\"audio/webm\" />\n        </audio>\n      );\n\n    case FileType.TEXT:\n      return (\n        <IconFileText\n          style={{ display: 'block' }}\n          height={size}\n          width={size * 0.6}\n          color=\"var(--mantine-color-teal-5)\"\n        />\n      );\n\n    case FileType.VIDEO:\n      return (\n        // eslint-disable-next-line jsx-a11y/media-has-caption\n        <video\n          width={size * 1.5}\n          height={size}\n          controls\n          style={{ borderRadius: 4 }}\n        >\n          <source src={src} type=\"video/mp4\" />\n          <source src={src} type=\"video/ogg\" />\n          <source src={src} type=\"video/webm\" />\n        </video>\n      );\n\n    case FileType.IMAGE:\n      return (\n        <Image\n          radius={4}\n          loading=\"lazy\"\n          h={size}\n          w={size}\n          fit=\"contain\"\n          alt={fileName}\n          src={src}\n        />\n      );\n\n    case FileType.UNKNOWN:\n    default:\n      return (\n        <IconFileUnknown\n          style={{ display: 'block' }}\n          height={size}\n          width={size * 0.6}\n          color=\"var(--mantine-color-gray-5)\"\n        />\n      );\n  }\n});\n\n/**\n * ThumbnailPreview - Preview for thumbnail image.\n */\ninterface ThumbnailPreviewProps {\n  file: ISubmissionFileDto;\n  size?: number;\n}\n\nexport const ThumbnailPreview = memo(({ file, size = 60 }: ThumbnailPreviewProps) => {\n  if (!file.hasThumbnail || !file.thumbnailId) {\n    return null;\n  }\n\n  const src = `${defaultTargetProvider()}/api/file/file/${file.thumbnailId}?${file.hash}`;\n\n  return (\n    <Image\n      radius={4}\n      loading=\"lazy\"\n      h={size}\n      w={size}\n      fit=\"contain\"\n      // eslint-disable-next-line lingui/no-unlocalized-strings\n      alt=\"Thumbnail\"\n      src={src}\n    />\n  );\n});\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/file-management/file-uploader.tsx",
    "content": "/**\n * FileUploader - Dropzone for uploading additional files to submission.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport { Box, Group, Text, rem } from '@mantine/core';\nimport {\n    Dropzone,\n    FileWithPath,\n    IMAGE_MIME_TYPE,\n    MS_WORD_MIME_TYPE,\n    PDF_MIME_TYPE,\n} from '@mantine/dropzone';\nimport { FileType } from '@postybirb/types';\nimport { getFileType } from '@postybirb/utils/file-type';\nimport { IconPhoto, IconUpload, IconX } from '@tabler/icons-react';\nimport { useState } from 'react';\nimport fileSubmissionApi from '../../../../../api/file-submission.api';\nimport {\n    showErrorNotification,\n    showUploadErrorNotification,\n    showWarningNotification,\n} from '../../../../../utils/notifications';\nimport { useSubmissionEditCardContext } from '../context';\n\n// Supported MIME types\nconst TEXT_MIME_TYPES = [\n  'text/plain',\n  'text/html',\n  'application/json',\n  'application/xml',\n  'application/rtf',\n  ...MS_WORD_MIME_TYPE,\n  ...PDF_MIME_TYPE,\n];\n\nconst VIDEO_MIME_TYPES = ['video/mp4', 'video/x-m4v', 'video/*'];\nconst AUDIO_MIME_TYPES = ['audio/*'];\n\nconst MAX_SIZE = 100 * 1024 * 1024; // 100MB\n\n/**\n * Gets a human-readable label for a FileType.\n */\nfunction getFileTypeLabel(fileType: FileType): string {\n  switch (fileType) {\n    case FileType.IMAGE:\n      return 'image';\n    case FileType.VIDEO:\n      return 'video';\n    case FileType.AUDIO:\n      return 'audio';\n    case FileType.TEXT:\n      return 'text';\n    default:\n      return 'unknown';\n  }\n}\n\nexport function FileUploader() {\n  const { submission } = useSubmissionEditCardContext();\n  const [uploading, setUploading] = useState(false);\n\n  // Get the existing file type if there are files\n  const existingFileType =\n    submission.files.length > 0\n      ? getFileType(submission.files[0].fileName)\n      : null;\n\n  const handleDrop = async (files: FileWithPath[]) => {\n    if (files.length === 0) return;\n\n    // Check file type compatibility before uploading\n    if (existingFileType) {\n      const incompatibleFiles = files.filter(\n        (file) => getFileType(file.name) !== existingFileType\n      );\n\n      if (incompatibleFiles.length > 0) {\n        const newFileType = getFileType(incompatibleFiles[0].name);\n        showErrorNotification(\n          <Trans>\n            Cannot add {getFileTypeLabel(newFileType)} files to a submission\n            containing {getFileTypeLabel(existingFileType)} files. All files in\n            a submission must be of the same type.\n          </Trans>\n        );\n        return;\n      }\n    }\n\n    setUploading(true);\n    try {\n      await fileSubmissionApi.appendFiles(submission.id, 'file', files);\n    } catch (error) {\n      showUploadErrorNotification(\n        error instanceof Error ? error.message : undefined\n      );\n    } finally {\n      setUploading(false);\n    }\n  };\n\n  const handleReject = () => {\n    showWarningNotification(\n      <Trans>Some files were rejected. Check file type and size (max 100MB).</Trans>\n    );\n  };\n\n  return (\n    <Dropzone\n      onDrop={handleDrop}\n      onReject={handleReject}\n      maxSize={MAX_SIZE}\n      accept={[\n        ...IMAGE_MIME_TYPE,\n        ...VIDEO_MIME_TYPES,\n        ...AUDIO_MIME_TYPES,\n        ...TEXT_MIME_TYPES,\n      ]}\n      useFsAccessApi={false}\n      loading={uploading}\n      disabled={submission.isArchived}\n      multiple\n    >\n      <Group\n        justify=\"center\"\n        gap=\"xl\"\n        mih={80}\n        style={{ pointerEvents: 'none' }}\n      >\n        <Dropzone.Accept>\n          <IconUpload\n            style={{\n              width: rem(40),\n              height: rem(40),\n              color: 'var(--mantine-color-blue-6)',\n            }}\n            stroke={1.5}\n          />\n        </Dropzone.Accept>\n        <Dropzone.Reject>\n          <IconX\n            style={{\n              width: rem(40),\n              height: rem(40),\n              color: 'var(--mantine-color-red-6)',\n            }}\n            stroke={1.5}\n          />\n        </Dropzone.Reject>\n        <Dropzone.Idle>\n          <IconPhoto\n            style={{\n              width: rem(40),\n              height: rem(40),\n              color: 'var(--mantine-color-dimmed)',\n            }}\n            stroke={1.5}\n          />\n        </Dropzone.Idle>\n\n        <Box>\n          <Text size=\"sm\" inline>\n            <Trans>Drop files here or click to browse</Trans>\n          </Text>\n          <Text size=\"xs\" c=\"dimmed\" inline mt={4}>\n            <Trans>Images, videos, audio, and text files up to 100MB</Trans>\n          </Text>\n        </Box>\n      </Group>\n    </Dropzone>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/file-management/index.ts",
    "content": "/**\n * File management components for submission file handling.\n */\n\nexport { BulkFileEditor } from './bulk-file-editor';\nexport { FileActions } from './file-actions';\nexport { FileAltTextEditor } from './file-alt-text-editor';\nexport { FileMetadata } from './file-metadata';\nexport { FilePreview, ThumbnailPreview } from './file-preview';\nexport { SubmissionFileCard } from './submission-file-card';\nexport { SubmissionFileManager } from './submission-file-manager';\nexport { useSubmissionAccounts } from './use-submission-accounts';\n\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/file-management/submission-file-card.tsx",
    "content": "/**\n * SubmissionFileCard - Individual file card with preview, metadata, and actions.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport {\n    ActionIcon,\n    Badge,\n    Box,\n    Collapse,\n    Divider,\n    Flex,\n    Group,\n    Paper,\n    Text,\n    Tooltip,\n} from '@mantine/core';\nimport { useDisclosure } from '@mantine/hooks';\nimport {\n    FileType,\n    ISubmissionFileDto,\n} from '@postybirb/types';\nimport { getFileType } from '@postybirb/utils/file-type';\nimport {\n    IconChevronDown,\n    IconGripVertical,\n    IconPencil,\n    IconTrash\n} from '@tabler/icons-react';\nimport { memo } from 'react';\nimport fileSubmissionApi from '../../../../../api/file-submission.api';\nimport { showErrorWithContext } from '../../../../../utils/notifications';\nimport { useSubmissionEditCardContext } from '../context';\nimport { FileActions } from './file-actions';\nimport { FileMetadata } from './file-metadata';\nimport { DRAGGABLE_FILE_CLASS } from './submission-file-manager';\nimport { useSubmissionAccounts } from './use-submission-accounts';\n\ninterface SubmissionFileCardProps {\n  file: ISubmissionFileDto;\n  draggable: boolean;\n  totalFiles: number;\n}\n\nexport const SubmissionFileCard = memo(({\n  file,\n  draggable,\n  totalFiles,\n}: SubmissionFileCardProps) => {\n  const { submission } = useSubmissionEditCardContext();\n  const accounts = useSubmissionAccounts();\n  const [expanded, { toggle }] = useDisclosure(false);\n  const fileType = getFileType(file.fileName);\n\n  const canDelete = totalFiles > 1 && !submission.isArchived;\n\n  const handleDelete = async () => {\n    if (!canDelete) return;\n\n    try {\n      await fileSubmissionApi.removeFile(submission.id, file.id, 'file');\n    } catch (error) {\n      showErrorWithContext(error, <Trans>Failed to delete file</Trans>);\n    }\n  };\n\n  return (\n    <Paper\n      p=\"sm\"\n      shadow=\"xs\"\n      radius=\"md\"\n      withBorder\n      className={DRAGGABLE_FILE_CLASS}\n      style={{\n        cursor: draggable ? 'grab' : undefined,\n        position: 'relative',\n      }}\n    >\n      {/* Drag handle */}\n      {draggable && (\n        <Box\n          style={{\n            position: 'absolute',\n            left: 8,\n            top: '50%',\n            transform: 'translateY(-50%)',\n            opacity: 0.5,\n            cursor: 'grab',\n          }}\n        >\n          <IconGripVertical size={16} />\n        </Box>\n      )}\n\n      <Flex gap=\"md\" align=\"flex-start\" ml={draggable ? 'md' : 0}>\n        {/* File Preview with Actions */}\n        <Box\n          style={{\n            // eslint-disable-next-line lingui/no-unlocalized-strings\n            flex: '0 0 auto',\n            padding: 4,\n            borderRadius: 8,\n          }}\n        >\n          <FileActions file={file} submissionId={submission.id} />\n        </Box>\n\n        {/* File Info */}\n        <Box style={{ flex: 1, minWidth: 0 }}>\n          <Group justify=\"space-between\" wrap=\"nowrap\" mb={4}>\n            <Group gap=\"xs\" wrap=\"nowrap\" style={{ minWidth: 0 }}>\n              <Text size=\"sm\" fw={600} truncate style={{ maxWidth: 200 }}>\n                {file.fileName}\n              </Text>\n              <Badge\n                size=\"xs\"\n                variant=\"light\"\n                color={getFileTypeColor(fileType)}\n              >\n                {fileType}\n              </Badge>\n            </Group>\n\n            <Group gap=\"xs\">\n              {/* Edit metadata button - more discoverable */}\n              {!submission.isArchived && (\n                <Tooltip label={expanded ? <Trans>Collapse</Trans> : <Trans>Edit metadata</Trans>}>\n                  <ActionIcon \n                    size=\"sm\" \n                    variant={expanded ? \"filled\" : \"light\"}\n                    color=\"blue\"\n                    onClick={toggle}\n                  >\n                    {expanded ? (\n                      <IconChevronDown size={14} />\n                    ) : (\n                      <IconPencil size={14} />\n                    )}\n                  </ActionIcon>\n                </Tooltip>\n              )}\n\n              {/* Delete button */}\n              <Tooltip\n                label={\n                  canDelete ? (\n                    <Trans>Delete file</Trans>\n                  ) : (\n                    <Trans>Cannot delete the only file</Trans>\n                  )\n                }\n              >\n                <ActionIcon\n                  size=\"sm\"\n                  variant=\"subtle\"\n                  color=\"red\"\n                  disabled={!canDelete}\n                  onClick={handleDelete}\n                >\n                  <IconTrash size={14} />\n                </ActionIcon>\n              </Tooltip>\n            </Group>\n          </Group>\n\n          {/* File size */}\n          <Text size=\"xs\" c=\"dimmed\">\n            {formatFileSize(file.size)} • {file.width}×{file.height}\n          </Text>\n        </Box>\n      </Flex>\n\n      {/* Expandable metadata section */}\n      <Collapse in={expanded} ml=\"lg\">\n        <Divider my=\"sm\" variant=\"dashed\" />\n        <FileMetadata file={file} accounts={accounts} />\n      </Collapse>\n    </Paper>\n  );\n});\n\nfunction getFileTypeColor(fileType: FileType): string {\n  switch (fileType) {\n    case FileType.IMAGE:\n      return 'blue';\n    case FileType.TEXT:\n      return 'green';\n    case FileType.VIDEO:\n      return 'purple';\n    case FileType.AUDIO:\n      return 'orange';\n    default:\n      return 'gray';\n  }\n}\n\nfunction formatFileSize(bytes: number): string {\n  if (bytes < 1024) return `${bytes} B`;\n  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;\n  return `${(bytes / 1024 / 1024).toFixed(2)} MB`;\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/file-management/submission-file-manager.tsx",
    "content": "/**\n * SubmissionFileManager - Container for file cards with drag-and-drop reordering.\n * Uses dnd-kit for React-native drag-and-drop (consolidated from sortablejs).\n */\n\nimport {\n  closestCenter,\n  DndContext,\n  DragEndEvent,\n  KeyboardSensor,\n  PointerSensor,\n  useSensor,\n  useSensors,\n} from '@dnd-kit/core';\nimport {\n  arrayMove,\n  SortableContext,\n  sortableKeyboardCoordinates,\n  useSortable,\n  verticalListSortingStrategy,\n} from '@dnd-kit/sortable';\nimport { CSS } from '@dnd-kit/utilities';\nimport { Trans } from '@lingui/react/macro';\nimport {\n  Box,\n  Button,\n  Collapse,\n  Divider,\n  Group,\n  Paper,\n  ScrollArea,\n  Stack,\n  Text,\n} from '@mantine/core';\nimport { useDisclosure } from '@mantine/hooks';\nimport { ISubmissionFileDto } from '@postybirb/types';\nimport { IconArrowsSort, IconListDetails } from '@tabler/icons-react';\nimport {\n  CSSProperties,\n  useCallback,\n  useEffect,\n  useMemo,\n  useState,\n} from 'react';\nimport fileSubmissionApi from '../../../../../api/file-submission.api';\nimport { useSubmissionEditCardContext } from '../context';\nimport { BulkFileEditor } from './bulk-file-editor';\nimport './file-management.css';\nimport { FileUploader } from './file-uploader';\nimport { SubmissionFileCard } from './submission-file-card';\n\nexport const DRAGGABLE_FILE_CLASS = 'postybirb-sortable-file';\n\nfunction orderFiles(files: ISubmissionFileDto[]): ISubmissionFileDto[] {\n  return [...files]\n    .filter((f) => !!f)\n    .sort((a, b) => {\n      const aOrder = a.order ?? Number.MAX_SAFE_INTEGER;\n      const bOrder = b.order ?? Number.MAX_SAFE_INTEGER;\n      return aOrder - bOrder;\n    });\n}\n\n/** Wrapper that makes a SubmissionFileCard sortable via dnd-kit */\nfunction SortableFileCard({\n  file,\n  isDraggable,\n  totalFiles,\n}: {\n  file: ISubmissionFileDto;\n  isDraggable: boolean;\n  totalFiles: number;\n}) {\n  const {\n    attributes,\n    listeners,\n    setNodeRef,\n    transform,\n    transition,\n    isDragging,\n  } = useSortable({\n    id: file.id,\n    disabled: !isDraggable,\n  });\n\n  const style: CSSProperties = {\n    transform: CSS.Transform.toString(transform),\n    transition,\n    opacity: isDragging ? 0.5 : 1,\n  };\n\n  return (\n    <div ref={setNodeRef} style={style} {...attributes} {...listeners}>\n      <SubmissionFileCard\n        file={file}\n        draggable={isDraggable}\n        totalFiles={totalFiles}\n      />\n    </div>\n  );\n}\n\nexport function SubmissionFileManager() {\n  const { submission } = useSubmissionEditCardContext();\n  const [orderedFiles, setOrderedFiles] = useState(() =>\n    orderFiles(submission.files),\n  );\n  const [bulkOpen, { toggle: toggleBulk }] = useDisclosure(false);\n\n  // Track file IDs to ensure reactivity when files are added/removed\n  const fileIds = submission.files.map((f) => f.id).join(',');\n\n  useEffect(() => {\n    setOrderedFiles(orderFiles(submission.files));\n  }, [submission.files, fileIds]);\n\n  const sensors = useSensors(\n    useSensor(PointerSensor, {\n      activationConstraint: { distance: 8 },\n    }),\n    useSensor(KeyboardSensor, {\n      coordinateGetter: sortableKeyboardCoordinates,\n    }),\n  );\n\n  const fileIdList = useMemo(\n    () => orderedFiles.map((f) => f.id),\n    [orderedFiles],\n  );\n\n  const handleDragEnd = useCallback(\n    (event: DragEndEvent) => {\n      const { active, over } = event;\n      if (!over || active.id === over.id) return;\n\n      const oldIndex = orderedFiles.findIndex((f) => f.id === active.id);\n      const newIndex = orderedFiles.findIndex((f) => f.id === over.id);\n      if (oldIndex === -1 || newIndex === -1) return;\n\n      const newOrderedFiles = arrayMove(orderedFiles, oldIndex, newIndex);\n\n      // Update order property for all files\n      const baseOrder = Date.now();\n      newOrderedFiles.forEach((file, index) => {\n        // eslint-disable-next-line no-param-reassign\n        file.order = baseOrder + index;\n      });\n\n      setOrderedFiles(newOrderedFiles);\n\n      // Persist to backend\n      fileSubmissionApi.reorder({\n        order: newOrderedFiles.reduce((acc: Record<string, number>, file) => {\n          acc[file.id] = file.order ?? 0;\n          return acc;\n        }, {}),\n      });\n    },\n    [orderedFiles],\n  );\n\n  const isDraggable = orderedFiles.length > 1 && !submission.isArchived;\n\n  return (\n    <Paper withBorder p={0} radius=\"md\">\n      <Box p=\"xs\">\n        <Group justify=\"space-between\">\n          <Text size=\"sm\" fw={600}>\n            <Trans>Files</Trans> ({orderedFiles.length})\n          </Text>\n          <Group gap=\"xs\">\n            {orderedFiles.length > 1 && (\n              <Button\n                size=\"xs\"\n                variant={bulkOpen ? 'filled' : 'subtle'}\n                color=\"violet\"\n                onClick={toggleBulk}\n                leftSection={<IconListDetails size={12} />}\n              >\n                <Trans>Bulk edit files</Trans>\n              </Button>\n            )}\n            {isDraggable && (\n              <>\n                <IconArrowsSort size={14} />\n                <Text size=\"xs\" c=\"dimmed\">\n                  <Trans>Drag to reorder</Trans>\n                </Text>\n              </>\n            )}\n          </Group>\n        </Group>\n      </Box>\n\n      {/* Bulk edit panel */}\n      <Collapse in={bulkOpen && orderedFiles.length > 1}>\n        <Divider />\n        <BulkFileEditor files={orderedFiles} />\n        <Divider />\n      </Collapse>\n\n      <ScrollArea\n        h={orderedFiles.length === 1 ? 'auto' : 350}\n        p=\"md\"\n        offsetScrollbars\n        scrollbarSize={6}\n        type=\"auto\"\n      >\n        <DndContext\n          sensors={sensors}\n          collisionDetection={closestCenter}\n          onDragEnd={handleDragEnd}\n        >\n          <SortableContext\n            items={fileIdList}\n            strategy={verticalListSortingStrategy}\n          >\n            <Stack gap=\"md\">\n              {orderedFiles.map((file) => (\n                <SortableFileCard\n                  key={`${file.id}:${file.hash}`}\n                  file={file}\n                  isDraggable={isDraggable}\n                  totalFiles={orderedFiles.length}\n                />\n              ))}\n            </Stack>\n          </SortableContext>\n        </DndContext>\n      </ScrollArea>\n\n      <Box p=\"md\" pt={0}>\n        <FileUploader />\n      </Box>\n    </Paper>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/file-management/use-submission-accounts.ts",
    "content": "/**\n * Hook to derive IAccountDto[] from submission website options.\n * Excludes the default account (NULL_ACCOUNT_ID) and maps AccountRecords to DTOs.\n */\n\nimport { IAccountDto, NULL_ACCOUNT_ID } from '@postybirb/types';\nimport { useMemo } from 'react';\nimport { useAccountsMap } from '../../../../../stores/entity/account-store';\nimport { useSubmissionEditCardContext } from '../context';\n\nexport function useSubmissionAccounts(): IAccountDto[] {\n  const { submission } = useSubmissionEditCardContext();\n  const accountsMap = useAccountsMap();\n\n  return useMemo(() => {\n    const accountIds = submission.options\n      .map((option) => option.account?.id)\n      .filter((id): id is string => !!id && id !== NULL_ACCOUNT_ID);\n\n    return accountIds\n      .map((id) => {\n        const record = accountsMap.get(id);\n        if (!record) return null;\n        return {\n          id: record.id,\n          name: record.name,\n          website: record.website,\n          groups: record.groups,\n          state: record.state,\n          data: record.data,\n          websiteInfo: record.websiteInfo,\n          createdAt: record.createdAt.toISOString(),\n          updatedAt: record.updatedAt.toISOString(),\n        } as IAccountDto;\n      })\n      .filter((acc): acc is IAccountDto => acc !== null);\n  }, [submission.options, accountsMap]);\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/header/index.ts",
    "content": "export { SubmissionEditCardHeader } from './submission-edit-card-header';\nexport type { SubmissionEditCardHeaderProps } from './submission-edit-card-header';\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/header/submission-edit-card-header.tsx",
    "content": "/**\n * SubmissionEditCardHeader - Header component for the submission edit card.\n * Displays title, archived badge, and expand/collapse chevron.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport { Badge, Box, Group, SegmentedControl, Text } from '@mantine/core';\nimport { IconChevronDown, IconChevronRight } from '@tabler/icons-react';\nimport { useSubmissionEditCardContext, type SubmissionEditCardViewMode } from '../context';\n\nexport interface SubmissionEditCardHeaderProps {\n  /** Whether the card is expanded (controls chevron direction) */\n  isExpanded?: boolean;\n}\n\n/**\n * Header component for the submission edit card.\n * Shows expand/collapse chevron, title, and archived badge.\n */\nexport function SubmissionEditCardHeader({\n  isExpanded = true,\n}: SubmissionEditCardHeaderProps) {\n  const { submission, isCollapsible, viewMode, setViewMode } =\n    useSubmissionEditCardContext();\n\n  const ChevronIcon = isExpanded ? IconChevronDown : IconChevronRight;\n\n  return (\n    <Group gap=\"xs\" wrap=\"nowrap\" style={{ flex: 1 }}>\n      {/* Expand/Collapse chevron - only shown if collapsible */}\n      {isCollapsible && (\n        <Box style={{ flexShrink: 0 }}>\n          <ChevronIcon size={16} className=\"postybirb__edit_card_chevron\" />\n        </Box>\n      )}\n\n      {/* Title */}\n      <Text fw={600} size=\"sm\" truncate style={{ flex: 1 }}>\n        {submission.title || <Trans>Untitled</Trans>}\n      </Text>\n\n      {/* Edit / History toggle - shown when post history exists */}\n      {!submission.isTemplate &&\n        !submission.isMultiSubmission &&\n        submission.posts.length > 0 && (\n          <Box onClick={(e) => e.stopPropagation()} style={{ flexShrink: 0 }}>\n            <SegmentedControl\n              size=\"xs\"\n              value={viewMode}\n              onChange={(value) => setViewMode(value as SubmissionEditCardViewMode)}\n              data={[\n                { label: <Trans>Edit</Trans>, value: 'edit' },\n                { label: <Trans>History</Trans>, value: 'history' },\n              ]}\n            />\n          </Box>\n        )}\n\n      {/* Archived badge */}\n      {submission.isArchived && (\n        <Badge color=\"grape\" size=\"sm\" variant=\"light\">\n          <Trans>Archived</Trans>\n        </Badge>\n      )}\n      {submission.isMultiSubmission && (\n        <Badge color=\"grape\" size=\"sm\" variant=\"light\">\n          <Trans>Multi Edit</Trans>\n        </Badge>\n      )}\n    </Group>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/index.ts",
    "content": "export {\n    SubmissionEditCardProvider,\n    useSubmissionEditCardContext,\n    useSubmissionEditCardContextOptional,\n    type SubmissionEditCardContextValue,\n    type SubmissionEditCardViewMode\n} from './context';\nexport { SubmissionEditCard } from './submission-edit-card';\nexport type { SubmissionEditCardProps } from './submission-edit-card';\n\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/schedule-form/index.ts",
    "content": "export { ScheduleForm } from './schedule-form';\nexport type { ScheduleFormProps } from './schedule-form';\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx",
    "content": "/**\n * ScheduleForm - Inline schedule editor for submission edit card.\n * Supports None/Once/Recurring schedule types with date picker and CRON builder.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport { Box, Group, Paper, Stack, Switch, Tabs, Text, Title } from '@mantine/core';\nimport { DateTimePicker, DateValue } from '@mantine/dates';\nimport { ISubmissionScheduleInfo, ScheduleType } from '@postybirb/types';\nimport { IconCalendar, IconCalendarOff, IconRepeat } from '@tabler/icons-react';\nimport { Cron } from 'croner';\nimport moment from 'moment';\nimport { useCallback, useEffect, useState } from 'react';\nimport { useLocalStorage } from 'react-use';\nimport { useLocale } from '../../../../../hooks';\nimport { CronPicker } from '../../../../shared/schedule-popover/cron-picker';\n\nexport interface ScheduleFormProps {\n  /** Current schedule info */\n  schedule: ISubmissionScheduleInfo;\n  /** Whether the submission is currently scheduled */\n  isScheduled: boolean;\n  /** Callback when schedule changes */\n  onChange: (schedule: ISubmissionScheduleInfo, isScheduled: boolean) => void;\n  /** Whether all schedule controls should be disabled (e.g., archived submission) */\n  disabled?: boolean;\n}\n\nconst SCHEDULE_GLOBAL_KEY = 'postybirb-last-schedule';\nconst DEFAULT_CRON = '0 9 * * 5'; // Friday at 9 AM\n\n/**\n * Inline schedule form with None/Once/Recurring options.\n */\nexport function ScheduleForm({\n  schedule,\n  isScheduled,\n  onChange,\n  disabled = false,\n}: ScheduleFormProps) {\n  const { formatRelativeTime } = useLocale();\n  const [internalSchedule, setInternalSchedule] =\n    useState<ISubmissionScheduleInfo>(schedule);\n  const [internalIsScheduled, setInternalIsScheduled] =\n    useState<boolean>(isScheduled);\n\n  // Persist last used schedule date\n  const [lastUsedDate, setLastUsedDate] = useLocalStorage<string | undefined>(\n    SCHEDULE_GLOBAL_KEY,\n    undefined,\n  );\n\n  // Sync internal state with props\n  useEffect(() => {\n    setInternalSchedule(schedule);\n    setInternalIsScheduled(isScheduled);\n  }, [schedule, isScheduled]);\n\n  // Handle schedule type change\n  const handleTypeChange = useCallback(\n    (type: string | null) => {\n      const scheduleType = type as ScheduleType;\n      let newSchedule: ISubmissionScheduleInfo;\n      let newIsScheduled = internalIsScheduled;\n\n      switch (scheduleType) {\n        case ScheduleType.SINGLE: {\n          // Use last used date if valid, otherwise tomorrow\n          let scheduledFor: string;\n          if (lastUsedDate && new Date(lastUsedDate) > new Date()) {\n            scheduledFor = lastUsedDate;\n          } else {\n            scheduledFor = moment()\n              .add(1, 'day')\n              .hour(9)\n              .minute(0)\n              .toISOString();\n          }\n          newSchedule = {\n            scheduleType,\n            scheduledFor,\n            cron: undefined,\n          };\n          break;\n        }\n        case ScheduleType.RECURRING: {\n          const nextRun = Cron(DEFAULT_CRON)?.nextRun()?.toISOString();\n          newSchedule = {\n            scheduleType,\n            cron: DEFAULT_CRON,\n            scheduledFor: nextRun,\n          };\n          break;\n        }\n        case ScheduleType.NONE:\n        default:\n          newSchedule = {\n            scheduleType: ScheduleType.NONE,\n            scheduledFor: undefined,\n            cron: undefined,\n          };\n          newIsScheduled = false;\n          break;\n      }\n\n      setInternalSchedule(newSchedule);\n      setInternalIsScheduled(newIsScheduled);\n      onChange(newSchedule, newIsScheduled);\n    },\n    [lastUsedDate, internalIsScheduled, onChange],\n  );\n\n  // Handle date/time change for single schedule\n  const handleDateTimeChange = useCallback(\n    (date: DateValue) => {\n      if (!date) return;\n      // DateValue can be Date or string, convert to ISO string\n      const scheduledFor = date instanceof Date ? date.toISOString() : new Date(date).toISOString();\n      const newSchedule: ISubmissionScheduleInfo = {\n        ...internalSchedule,\n        scheduledFor,\n      };\n      setInternalSchedule(newSchedule);\n      setLastUsedDate(scheduledFor);\n      onChange(newSchedule, internalIsScheduled);\n    },\n    [internalSchedule, internalIsScheduled, setLastUsedDate, onChange],\n  );\n\n  // Handle CRON change for recurring schedule\n  const handleCronChange = useCallback(\n    (cron: string) => {\n      let scheduledFor: string | undefined;\n      try {\n        scheduledFor = Cron(cron)?.nextRun()?.toISOString();\n      } catch {\n        // Invalid cron\n      }\n      const newSchedule: ISubmissionScheduleInfo = {\n        ...internalSchedule,\n        cron,\n        scheduledFor,\n      };\n      setInternalSchedule(newSchedule);\n      onChange(newSchedule, internalIsScheduled);\n    },\n    [internalSchedule, internalIsScheduled, onChange],\n  );\n\n  // Handle toggling schedule active state\n  const handleToggleActive = useCallback(\n    (checked: boolean) => {\n      setInternalIsScheduled(checked);\n      onChange(internalSchedule, checked);\n    },\n    [internalSchedule, onChange],\n  );\n\n  // Parse date for picker\n  const scheduledDate = internalSchedule.scheduledFor\n    ? new Date(internalSchedule.scheduledFor)\n    : null;\n\n  const isDateInPast = scheduledDate ? scheduledDate < new Date() : false;\n\n  return (\n    <Paper withBorder p=\"md\" radius=\"sm\">\n      <Stack gap=\"md\">\n        <Group justify=\"space-between\" align=\"center\">\n          <Title order={6}>\n            <Trans>Schedule</Trans>\n          </Title>\n          {/* Activation toggle - only show when schedule is configured */}\n          {internalSchedule.scheduleType !== ScheduleType.NONE && (\n            <Switch\n              label={\n                internalIsScheduled ? (\n                  <Trans>Active</Trans>\n                ) : (\n                  <Trans>Inactive</Trans>\n                )\n              }\n              checked={internalIsScheduled}\n              disabled={disabled}\n              onChange={(e) => handleToggleActive(e.currentTarget.checked)}\n              size=\"sm\"\n            />\n          )}\n        </Group>\n\n        {/* Vertical tabs layout */}\n        <Tabs\n          value={internalSchedule.scheduleType}\n          onChange={disabled ? undefined : handleTypeChange}\n          orientation=\"vertical\"\n          variant=\"pills\"\n        >\n          <Tabs.List>\n            <Tabs.Tab\n              value={ScheduleType.NONE}\n              leftSection={<IconCalendarOff size={16} />}\n              disabled={disabled}\n            >\n              <Trans>None</Trans>\n            </Tabs.Tab>\n            <Tabs.Tab\n              value={ScheduleType.SINGLE}\n              leftSection={<IconCalendar size={16} />}\n              disabled={disabled}\n            >\n              <Trans>Once</Trans>\n            </Tabs.Tab>\n            <Tabs.Tab\n              value={ScheduleType.RECURRING}\n              leftSection={<IconRepeat size={16} />}\n              disabled={disabled}\n            >\n              <Trans>Recurring</Trans>\n            </Tabs.Tab>\n          </Tabs.List>\n\n          <Tabs.Panel value={ScheduleType.NONE} pl=\"md\">\n            <Stack gap=\"sm\">\n              <Text size=\"sm\" c=\"dimmed\">\n                <Trans>No schedule configured</Trans>\n              </Text>\n            </Stack>\n          </Tabs.Panel>\n\n          <Tabs.Panel value={ScheduleType.SINGLE} pl=\"md\">\n            <Stack gap=\"sm\">\n              <DateTimePicker\n                label={<Trans>Date and Time</Trans>}\n                size=\"sm\"\n                clearable={false}\n                // eslint-disable-next-line lingui/no-unlocalized-strings\n                valueFormat=\"YYYY-MM-DD HH:mm\"\n                highlightToday\n                minDate={new Date()}\n                value={scheduledDate}\n                disabled={disabled}\n                onChange={handleDateTimeChange}\n                error={isDateInPast ? <Trans>Date is in the past</Trans> : null}\n              />\n              {scheduledDate && !isDateInPast && (\n                <Text size=\"xs\" c=\"dimmed\">\n                  {formatRelativeTime(scheduledDate)}\n                </Text>\n              )}\n              <Text size=\"sm\" c={internalIsScheduled ? 'blue' : 'dimmed'}>\n                {internalIsScheduled ? (\n                  <Trans>Submission will be posted automatically</Trans>\n                ) : (\n                  <Trans>Schedule configured (inactive)</Trans>\n                )}\n              </Text>\n            </Stack>\n          </Tabs.Panel>\n\n          <Tabs.Panel value={ScheduleType.RECURRING} pl=\"md\">\n            <Stack gap=\"sm\">\n              <Box\n                // CronPicker does not support a disabled prop, so we use\n                // pointer-events to prevent interaction when archived.\n                style={disabled ? { pointerEvents: 'none', opacity: 0.6 } : undefined}\n              >\n                <CronPicker\n                  value={internalSchedule.cron || DEFAULT_CRON}\n                  onChange={handleCronChange}\n                />\n              </Box>\n              <Text size=\"sm\" c={internalIsScheduled ? 'blue' : 'dimmed'}>\n                {internalIsScheduled ? (\n                  <Trans>Submission will be posted automatically</Trans>\n                ) : (\n                  <Trans>Schedule configured (inactive)</Trans>\n                )}\n              </Text>\n            </Stack>\n          </Tabs.Panel>\n        </Tabs>\n      </Stack>\n    </Paper>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/submission-edit-card.css",
    "content": "/**\n * SubmissionEditCard styles.\n */\n\n.postybirb__edit_card {\n  overflow: hidden;\n}\n\n.postybirb__edit_card_header {\n  width: 100%;\n  cursor: pointer;\n  transition: background-color 150ms ease;\n}\n\n.postybirb__edit_card_header:hover {\n  background-color: var(--mantine-color-gray-light-hover);\n}\n\n.postybirb__edit_card_header_static {\n  width: 100%;\n}\n\n.postybirb__edit_card_chevron {\n  color: var(--mantine-color-dimmed);\n  transition: transform 150ms ease;\n}"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-edit-card/submission-edit-card.tsx",
    "content": "/**\n * SubmissionEditCard - Collapsible card for editing a single submission.\n * Uses its own context provider for state management.\n */\n\nimport { Box, Collapse, Group, Paper, UnstyledButton } from '@mantine/core';\nimport { useDisclosure } from '@mantine/hooks';\nimport { useEffect, useState } from 'react';\nimport type { SubmissionRecord } from '../../../../stores/records';\nimport { ComponentErrorBoundary } from '../../../error-boundary';\nimport { PostHistoryContent } from '../submission-history';\nimport { SubmissionEditCardActions } from './actions';\nimport { SubmissionEditCardBody } from './body';\nimport {\n    SubmissionEditCardProvider,\n    useSubmissionEditCardContext,\n    type SubmissionEditCardViewMode,\n} from './context';\nimport { SubmissionEditCardHeader } from './header';\nimport './submission-edit-card.css';\n\nexport interface SubmissionEditCardProps {\n  /** The submission to edit */\n  submission: SubmissionRecord;\n  /** Whether the card can be collapsed */\n  isCollapsible: boolean;\n  /** Whether the card should be expanded by default (only applies if collapsible) */\n  defaultExpanded?: boolean;\n  /** Target submission IDs for mass edit mode (to pre-populate Save To Many) */\n  targetSubmissionIds?: string[];\n}\n\n/**\n * Inner card component that uses context.\n */\nfunction SubmissionEditCardInner() {\n  const { isCollapsible, defaultExpanded, viewMode, submission } =\n    useSubmissionEditCardContext();\n  const [expanded, { toggle, open }] = useDisclosure(defaultExpanded);\n\n  // If not collapsible, always show expanded\n  const isExpanded = isCollapsible ? expanded : true;\n\n  const [renderBody, setRenderBody] = useState(isExpanded);\n\n  useEffect(() => {\n    if (isExpanded) {\n      setRenderBody(true);\n    }\n  }, [isExpanded]);\n\n  // Auto-expand when switching to history mode on a collapsed card\n  useEffect(() => {\n    if (viewMode === 'history' && isCollapsible && !expanded) {\n      open();\n    }\n  }, [viewMode, isCollapsible, expanded, open]);\n\n  const onTransitionEnd = () => {\n    if (!isExpanded) {\n      setRenderBody(false);\n    }\n  };\n\n  return (\n    <Paper withBorder radius=\"sm\" p={0} className=\"postybirb__edit_card\">\n      {/* Header - clickable only if collapsible */}\n      {isCollapsible ? (\n        <UnstyledButton\n          onClick={toggle}\n          className=\"postybirb__edit_card_header\"\n        >\n          <Group gap=\"xs\" px=\"sm\" py=\"xs\" wrap=\"nowrap\">\n            <SubmissionEditCardHeader isExpanded={isExpanded} />\n            <SubmissionEditCardActions />\n          </Group>\n        </UnstyledButton>\n      ) : (\n        <Box data-tour-id=\"edit-card-header\" className=\"postybirb__edit_card_header_static\">\n          <ComponentErrorBoundary>\n            <Group gap=\"xs\" px=\"sm\" py=\"xs\" wrap=\"nowrap\">\n              <SubmissionEditCardHeader />\n              <SubmissionEditCardActions />\n            </Group>\n          </ComponentErrorBoundary>\n        </Box>\n      )}\n\n      {/* Collapsible Body */}\n      <Collapse in={isExpanded} onTransitionEnd={onTransitionEnd}>\n        <ComponentErrorBoundary>\n          {renderBody &&\n            (viewMode === 'history' ? (\n              <Box p=\"md\">\n                <PostHistoryContent submission={submission} />\n              </Box>\n            ) : (\n              <SubmissionEditCardBody />\n            ))}\n        </ComponentErrorBoundary>\n      </Collapse>\n    </Paper>\n  );\n}\n\n/**\n * Submission edit card with its own context provider.\n */\nexport function SubmissionEditCard({\n  submission,\n  isCollapsible,\n  defaultExpanded = true,\n  targetSubmissionIds,\n}: SubmissionEditCardProps) {\n  const [viewMode, setViewMode] = useState<SubmissionEditCardViewMode>('edit');\n\n  return (\n    <ComponentErrorBoundary>\n      <SubmissionEditCardProvider\n        submission={submission}\n        isCollapsible={isCollapsible}\n        defaultExpanded={defaultExpanded}\n        targetSubmissionIds={targetSubmissionIds}\n        viewMode={viewMode}\n        setViewMode={setViewMode}\n      >\n        <SubmissionEditCardInner />\n      </SubmissionEditCardProvider>\n    </ComponentErrorBoundary>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-history/history-utils.ts",
    "content": "/**\n * Shared utilities for submission post history display and status derivation.\n */\n\nimport {\n    EntityId,\n    PostEventDto,\n    PostEventType,\n    PostRecordDto,\n    PostRecordState,\n} from '@postybirb/types';\nimport type { SubmissionRecord } from '../../../../stores/records';\n\n/**\n * Derived website post information from events.\n */\nexport interface DerivedWebsitePost {\n  accountId: EntityId;\n  accountName: string;\n  websiteName: string;\n  isSuccess: boolean;\n  sourceUrls: string[];\n  errors: string[];\n}\n\n/**\n * Extract website post results from post events.\n * Aggregates events per account to determine success/failure and source URLs.\n */\nexport function extractWebsitePostsFromEvents(\n  events: PostEventDto[] | undefined,\n): DerivedWebsitePost[] {\n  if (!events || events.length === 0) return [];\n\n  const postsByAccount = new Map<EntityId, DerivedWebsitePost>();\n\n  for (const event of events) {\n    if (!event.accountId) continue;\n\n    // Get or create post entry for this account\n    let post = postsByAccount.get(event.accountId);\n    if (!post) {\n      const accountSnapshot = event.metadata?.accountSnapshot;\n      post = {\n        accountId: event.accountId,\n        // eslint-disable-next-line lingui/no-unlocalized-strings\n        accountName: accountSnapshot?.name ?? 'Unknown',\n        // eslint-disable-next-line lingui/no-unlocalized-strings\n        websiteName: accountSnapshot?.website ?? '?',\n        isSuccess: false,\n        sourceUrls: [],\n        errors: [],\n      };\n      postsByAccount.set(event.accountId, post);\n    }\n\n    // Process event based on type\n    switch (event.eventType) {\n      case PostEventType.POST_ATTEMPT_COMPLETED:\n        post.isSuccess = true;\n        break;\n\n      case PostEventType.POST_ATTEMPT_FAILED:\n        post.isSuccess = false;\n        if (event.error?.message) {\n          post.errors.push(event.error.message);\n        }\n        break;\n\n      case PostEventType.MESSAGE_POSTED:\n      case PostEventType.FILE_POSTED:\n        if (event.sourceUrl) {\n          post.sourceUrls.push(event.sourceUrl);\n        }\n        break;\n\n      case PostEventType.MESSAGE_FAILED:\n      case PostEventType.FILE_FAILED:\n        if (event.error?.message) {\n          post.errors.push(event.error.message);\n        }\n        break;\n\n      default:\n        break;\n    }\n  }\n\n  return Array.from(postsByAccount.values());\n}\n\n/**\n * Export post record to a JSON file (browser download).\n */\nexport function exportPostRecordToFile(record: PostRecordDto): string {\n  const jsonString = JSON.stringify(record, null, 2);\n  const blob = new Blob([jsonString], { type: 'application/json' });\n  const url = URL.createObjectURL(blob);\n  const link = document.createElement('a');\n\n  const formattedDate = new Date(record.createdAt).toISOString().split('T')[0];\n  const filename = `post-record-${record.id}-${formattedDate}.json`;\n\n  link.href = url;\n  link.download = filename;\n  document.body.appendChild(link);\n  link.click();\n  document.body.removeChild(link);\n  URL.revokeObjectURL(url);\n\n  return filename;\n}\n\n/**\n * Format duration in human-readable format.\n */\nexport function formatDuration(ms: number): string {\n  const seconds = Math.floor(ms / 1000);\n  const minutes = Math.floor(seconds / 60);\n  const hours = Math.floor(minutes / 60);\n\n  if (hours > 0) {\n    // eslint-disable-next-line lingui/no-unlocalized-strings\n    return `${hours}h ${minutes % 60}m`;\n  }\n  if (minutes > 0) {\n    // eslint-disable-next-line lingui/no-unlocalized-strings\n    return `${minutes}m ${seconds % 60}s`;\n  }\n  if (seconds < 1) {\n    // eslint-disable-next-line lingui/no-unlocalized-strings\n    return '< 1s';\n  }\n  // eslint-disable-next-line lingui/no-unlocalized-strings\n  return `${seconds}s`;\n}\n\n/**\n * Get icon for post record state (used in PostRecordCard).\n * Returns a React element — import from React consumers only.\n */\nexport function getPostRecordStateInfo(state: PostRecordState): {\n  color: string;\n  label: string;\n} {\n  switch (state) {\n    case PostRecordState.DONE:\n      // eslint-disable-next-line lingui/no-unlocalized-strings\n      return { color: 'var(--mantine-color-green-5)', label: 'Done' };\n    case PostRecordState.FAILED:\n      // eslint-disable-next-line lingui/no-unlocalized-strings\n      return { color: 'var(--mantine-color-red-5)', label: 'Failed' };\n    case PostRecordState.RUNNING:\n      // eslint-disable-next-line lingui/no-unlocalized-strings\n      return { color: 'var(--mantine-color-blue-5)', label: 'Running' };\n    case PostRecordState.PENDING:\n    default:\n      // eslint-disable-next-line lingui/no-unlocalized-strings\n      return { color: 'var(--mantine-color-gray-5)', label: 'Pending' };\n  }\n}\n\n// =============================================================================\n// Per-account post status derivation (Phase 3)\n// =============================================================================\n\n/**\n * Possible per-account post status values.\n */\nexport type AccountPostStatus =\n  | 'success'\n  | 'failed'\n  | 'running'\n  | 'waiting'\n  | null;\n\n/**\n * Per-account post status entry.\n */\nexport interface AccountPostStatusEntry {\n  status: AccountPostStatus;\n  errors: string[];\n}\n\n/**\n * Derive a per-account post status map from the submission's latest post record.\n *\n * Uses `latestPost` (not `latestCompletedPost`) so that RUNNING and PENDING\n * states are surfaced. Status is derived purely from the event lifecycle:\n *\n * | Events for account                                  | Status    |\n * |-----------------------------------------------------|-----------|\n * | Post record is PENDING (no events)                  | 'waiting' |\n * | POST_ATTEMPT_STARTED only (no terminal event)       | 'running' |\n * | POST_ATTEMPT_COMPLETED present                      | 'success' |\n * | POST_ATTEMPT_FAILED present                         | 'failed'  |\n * | Account not in events at all (later batch)          | 'waiting' |\n * | No latestPost exists                                | empty map |\n */\nexport function getAccountPostStatusMap(\n  submission: SubmissionRecord,\n): Map<EntityId, AccountPostStatusEntry> {\n  const result = new Map<EntityId, AccountPostStatusEntry>();\n  const { latestPost } = submission;\n\n  if (!latestPost) return result;\n\n  // If the post record is PENDING, no events have been created yet.\n  // All accounts that have options in this submission are \"waiting\".\n  if (latestPost.state === PostRecordState.PENDING) {\n    for (const option of submission.options) {\n      if (!option.isDefault) {\n        result.set(option.accountId, { status: 'waiting', errors: [] });\n      }\n    }\n    return result;\n  }\n\n  // For RUNNING, DONE, or FAILED records — inspect events\n  const { events } = latestPost;\n  if (!events || events.length === 0) {\n    // Record exists but no events yet — treat all accounts as waiting\n    for (const option of submission.options) {\n      if (!option.isDefault) {\n        result.set(option.accountId, { status: 'waiting', errors: [] });\n      }\n    }\n    return result;\n  }\n\n  // Build per-account event state\n  const accountEvents = new Map<\n    EntityId,\n    { started: boolean; completed: boolean; failed: boolean; errors: string[] }\n  >();\n\n  for (const event of events) {\n    if (!event.accountId) continue;\n\n    let entry = accountEvents.get(event.accountId);\n    if (!entry) {\n      entry = { started: false, completed: false, failed: false, errors: [] };\n      accountEvents.set(event.accountId, entry);\n    }\n\n    switch (event.eventType) {\n      case PostEventType.POST_ATTEMPT_STARTED:\n        entry.started = true;\n        break;\n      case PostEventType.POST_ATTEMPT_COMPLETED:\n        entry.completed = true;\n        break;\n      case PostEventType.POST_ATTEMPT_FAILED:\n        entry.failed = true;\n        if (event.error?.message) {\n          entry.errors.push(event.error.message);\n        }\n        break;\n      case PostEventType.MESSAGE_FAILED:\n      case PostEventType.FILE_FAILED:\n        if (event.error?.message) {\n          entry.errors.push(event.error.message);\n        }\n        break;\n      default:\n        break;\n    }\n  }\n\n  // Classify each account that has events\n  for (const [accountId, entry] of accountEvents) {\n    if (entry.completed) {\n      result.set(accountId, { status: 'success', errors: [] });\n    } else if (entry.failed) {\n      result.set(accountId, { status: 'failed', errors: entry.errors });\n    } else if (entry.started) {\n      result.set(accountId, { status: 'running', errors: [] });\n    } else {\n      result.set(accountId, { status: 'waiting', errors: [] });\n    }\n  }\n\n  // Accounts with options but no events yet are \"waiting\" (later batch)\n  for (const option of submission.options) {\n    if (!option.isDefault && !result.has(option.accountId)) {\n      result.set(option.accountId, { status: 'waiting', errors: [] });\n    }\n  }\n\n  return result;\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-history/index.ts",
    "content": "export {\n    exportPostRecordToFile, extractWebsitePostsFromEvents, formatDuration,\n    getAccountPostStatusMap,\n    getPostRecordStateInfo,\n    type AccountPostStatus,\n    type AccountPostStatusEntry,\n    type DerivedWebsitePost\n} from './history-utils';\nexport { PostHistoryContent } from './post-history-content';\nexport { PostRecordCard } from './post-record-card';\n\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-history/post-history-content.tsx",
    "content": "/**\n * PostHistoryContent - Reusable post history display (stats + post record accordion).\n * Used both inline in the submission edit card and in the history drawer.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport { Accordion, Card, Group, Stack, Text } from '@mantine/core';\nimport { useMemo } from 'react';\nimport { SubmissionRecord, useAccountsMap } from '../../../../stores';\nimport { EmptyState } from '../../../empty-state';\nimport { PostRecordCard } from './post-record-card';\n\ninterface PostHistoryContentProps {\n  submission: SubmissionRecord;\n}\n\n/**\n * Displays submission post history: stats summary card and accordion of post records.\n * Does not include any Drawer/ScrollArea wrapper — caller is responsible for layout.\n */\nexport function PostHistoryContent({ submission }: PostHistoryContentProps) {\n  const accountsMap = useAccountsMap();\n\n  const descendingRecords = useMemo(\n    () => submission.sortedPostsDescending,\n    [submission],\n  );\n\n  const stats = submission.postingStats;\n\n  return (\n    <Stack gap=\"md\">\n      {/* Stats Summary */}\n      {stats && stats.totalAttempts > 0 && (\n        <Card withBorder p=\"sm\">\n          <Group justify=\"space-around\">\n            <Stack gap={0} align=\"center\">\n              <Text size=\"xl\" fw={700}>\n                {stats.totalAttempts}\n              </Text>\n              <Text size=\"xs\" c=\"dimmed\">\n                <Trans>Total</Trans>\n              </Text>\n            </Stack>\n            <Stack gap={0} align=\"center\">\n              <Text size=\"xl\" fw={700} c=\"green\">\n                {stats.successfulAttempts}\n              </Text>\n              <Text size=\"xs\" c=\"dimmed\">\n                <Trans>Successful</Trans>\n              </Text>\n            </Stack>\n            <Stack gap={0} align=\"center\">\n              <Text size=\"xl\" fw={700} c=\"red\">\n                {stats.failedAttempts}\n              </Text>\n              <Text size=\"xs\" c=\"dimmed\">\n                <Trans>Failed</Trans>\n              </Text>\n            </Stack>\n            {stats.runningAttempts > 0 && (\n              <Stack gap={0} align=\"center\">\n                <Text size=\"xl\" fw={700} c=\"blue\">\n                  {stats.runningAttempts}\n                </Text>\n                <Text size=\"xs\" c=\"dimmed\">\n                  <Trans>Running</Trans>\n                </Text>\n              </Stack>\n            )}\n          </Group>\n        </Card>\n      )}\n\n      {descendingRecords.length === 0 ? (\n        <EmptyState preset=\"no-records\" size=\"sm\" />\n      ) : (\n        <Accordion variant=\"separated\">\n          {descendingRecords.map((record) => (\n            <PostRecordCard\n              key={record.id}\n              record={record}\n              accountsMap={accountsMap}\n            />\n          ))}\n        </Accordion>\n      )}\n    </Stack>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-history/post-record-card.tsx",
    "content": "/**\n * PostRecordCard - Accordion item displaying a single post record with\n * per-website results table and exportable JSON data.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport {\n    Accordion,\n    ActionIcon,\n    Badge,\n    Button,\n    Card,\n    Divider,\n    Group,\n    Stack,\n    Table,\n    Text,\n    Textarea,\n    Tooltip,\n} from '@mantine/core';\nimport { EntityId, PostRecordDto, PostRecordState } from '@postybirb/types';\nimport {\n    IconCheck,\n    IconDeviceFloppy,\n    IconExternalLink,\n    IconInfoCircle,\n    IconLoader,\n    IconX,\n} from '@tabler/icons-react';\nimport { useLocale } from '../../../../hooks';\nimport type { AccountRecord } from '../../../../stores/records';\nimport { CopyToClipboard } from '../../../shared/copy-to-clipboard';\nimport { ExternalLink } from '../../../shared/external-link';\nimport {\n    exportPostRecordToFile,\n    extractWebsitePostsFromEvents,\n    formatDuration,\n} from './history-utils';\n\nfunction getStateIcon(state: PostRecordState): React.ReactNode {\n  switch (state) {\n    case PostRecordState.DONE:\n      return <IconCheck size={16} color=\"var(--mantine-color-green-5)\" />;\n    case PostRecordState.FAILED:\n      return <IconX size={16} color=\"var(--mantine-color-red-5)\" />;\n    case PostRecordState.RUNNING:\n      return <IconLoader size={16} color=\"var(--mantine-color-blue-5)\" />;\n    case PostRecordState.PENDING:\n    default:\n      return null;\n  }\n}\n\ninterface PostRecordCardProps {\n  record: PostRecordDto;\n  accountsMap: Map<EntityId, AccountRecord>;\n}\n\n/**\n * Displays an individual post record as an Accordion.Item.\n */\nexport function PostRecordCard({ record, accountsMap }: PostRecordCardProps) {\n  const { formatDateTime } = useLocale();\n  const formattedJson = JSON.stringify(record, null, 2);\n\n  const handleSaveToFile = () => {\n    exportPostRecordToFile(record);\n  };\n\n  // Extract website posts from events\n  const websitePosts = extractWebsitePostsFromEvents(record.events);\n  const successCount = websitePosts.filter((p) => p.isSuccess).length;\n  const failedCount = websitePosts.length - successCount;\n\n  // Calculate duration if completed\n  const startedAt = new Date(record.createdAt);\n  const completedAt = record.completedAt ? new Date(record.completedAt) : null;\n  const duration = completedAt\n    ? completedAt.getTime() - startedAt.getTime()\n    : null;\n\n  return (\n    <Accordion.Item value={record.id}>\n      <Accordion.Control>\n        <Group justify=\"space-between\" wrap=\"nowrap\" pr=\"xs\">\n          <Group gap=\"xs\">\n            {getStateIcon(record.state)}\n            <Text size=\"sm\" fw={500}>\n              {formatDateTime(startedAt)}\n            </Text>\n          </Group>\n          <Group gap=\"xs\">\n            {successCount > 0 && (\n              <Badge size=\"sm\" color=\"green\" variant=\"light\">\n                {successCount} <Trans>success</Trans>\n              </Badge>\n            )}\n            {failedCount > 0 && (\n              <Badge size=\"sm\" color=\"red\" variant=\"light\">\n                {failedCount} <Trans>failed</Trans>\n              </Badge>\n            )}\n            {duration && (\n              <Badge size=\"sm\" variant=\"outline\" color=\"gray\">\n                {formatDuration(duration)}\n              </Badge>\n            )}\n          </Group>\n        </Group>\n      </Accordion.Control>\n      <Accordion.Panel>\n        <Stack gap=\"md\">\n          {/* Posts table */}\n          {websitePosts.length > 0 && (\n            <Table striped highlightOnHover withTableBorder>\n              <Table.Thead>\n                <Table.Tr>\n                  <Table.Th>\n                    <Trans>Website</Trans>\n                  </Table.Th>\n                  <Table.Th>\n                    <Trans>Account</Trans>\n                  </Table.Th>\n                  <Table.Th>\n                    <Trans>Status</Trans>\n                  </Table.Th>\n                  <Table.Th>\n                    <Trans>Source URL</Trans>\n                  </Table.Th>\n                </Table.Tr>\n              </Table.Thead>\n              <Table.Tbody>\n                {websitePosts.map((post) => {\n                  const account = accountsMap.get(post.accountId);\n                  return (\n                    <Table.Tr key={post.accountId}>\n                      <Table.Td>\n                        <Text size=\"sm\">\n                          {account?.websiteInfo?.websiteDisplayName ??\n                            post.websiteName}\n                        </Text>\n                      </Table.Td>\n                      <Table.Td>\n                        <Text size=\"sm\" fw={500}>\n                          {account?.name ?? post.accountName}\n                        </Text>\n                      </Table.Td>\n                      <Table.Td>\n                        {post.isSuccess ? (\n                          <Group gap=\"xs\">\n                            <IconCheck\n                              size={16}\n                              color=\"var(--mantine-color-green-6)\"\n                            />\n                            <Text size=\"sm\" c=\"green.7\">\n                              <Trans>Success</Trans>\n                            </Text>\n                          </Group>\n                        ) : (\n                          <Group gap=\"xs\">\n                            <IconX\n                              size={16}\n                              color=\"var(--mantine-color-red-6)\"\n                            />\n                            <Text size=\"sm\" c=\"red.7\">\n                              <Trans>Failed</Trans>\n                            </Text>\n                            {post.errors.length > 0 && (\n                              <Tooltip\n                                label={post.errors.join(' | ')}\n                                multiline\n                                w={300}\n                                withArrow\n                              >\n                                <ActionIcon\n                                  size=\"xs\"\n                                  variant=\"subtle\"\n                                  color=\"red\"\n                                >\n                                  <IconInfoCircle size={14} />\n                                </ActionIcon>\n                              </Tooltip>\n                            )}\n                          </Group>\n                        )}\n                      </Table.Td>\n                      <Table.Td>\n                        {post.sourceUrls.length > 0 ? (\n                          <Stack gap=\"xs\">\n                            {post.sourceUrls.map((url) => (\n                              <ExternalLink href={url} key={url}>\n                                <Group gap={4}>\n                                  <IconExternalLink size=\"0.75rem\" />\n                                  <Text size=\"xs\" c=\"blue.6\" td=\"underline\">\n                                    <Trans>View</Trans>\n                                  </Text>\n                                </Group>\n                              </ExternalLink>\n                            ))}\n                          </Stack>\n                        ) : (\n                          <Text size=\"xs\" c=\"dimmed\">\n                            -\n                          </Text>\n                        )}\n                      </Table.Td>\n                    </Table.Tr>\n                  );\n                })}\n              </Table.Tbody>\n            </Table>\n          )}\n\n          {/* No posts message */}\n          {websitePosts.length === 0 && (\n            <Card withBorder p=\"sm\" bg=\"yellow.0\">\n              <Stack gap=\"xs\">\n                <Text size=\"sm\" fw={600}>\n                  <Trans>No website posts found</Trans>\n                </Text>\n                <Text size=\"xs\" c=\"dimmed\">\n                  <Trans>\n                    This post record has no events or is still processing. Check\n                    the JSON data below for more details.\n                  </Trans>\n                </Text>\n              </Stack>\n            </Card>\n          )}\n\n          <Divider />\n\n          {/* JSON Export */}\n          <Accordion variant=\"contained\">\n            <Accordion.Item value=\"json-data\">\n              <Accordion.Control>\n                <Group gap=\"xs\">\n                  <IconDeviceFloppy size={16} />\n                  <Text fw={500}>\n                    <Trans>Post Data (JSON)</Trans>\n                  </Text>\n                </Group>\n              </Accordion.Control>\n              <Accordion.Panel>\n                <Stack>\n                  <Group justify=\"flex-end\">\n                    <CopyToClipboard\n                      value={formattedJson}\n                      variant=\"button\"\n                      size=\"xs\"\n                      color=\"blue\"\n                    />\n                    <Button\n                      onClick={handleSaveToFile}\n                      leftSection={<IconDeviceFloppy size={16} />}\n                      color=\"green\"\n                      size=\"xs\"\n                    >\n                      <Trans>Save to file</Trans>\n                    </Button>\n                  </Group>\n                  <Textarea\n                    readOnly\n                    autosize\n                    minRows={5}\n                    maxRows={15}\n                    value={formattedJson}\n                    styles={{\n                      input: { fontFamily: 'monospace', fontSize: '0.85rem' },\n                    }}\n                  />\n                </Stack>\n              </Accordion.Panel>\n            </Accordion.Item>\n          </Accordion>\n        </Stack>\n      </Accordion.Panel>\n    </Accordion.Item>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-history-drawer.tsx",
    "content": "/**\n * SubmissionHistoryDrawer - Thin drawer wrapper around PostHistoryContent.\n * Used for list-level history triggers (submissions-section, archived list).\n */\n\nimport { Drawer, Group, ScrollArea, Text } from '@mantine/core';\nimport { IconHistory } from '@tabler/icons-react';\nimport type { SubmissionRecord } from '../../../stores/records';\nimport { PostHistoryContent } from './submission-history';\n\ninterface SubmissionHistoryDrawerProps {\n  /** Whether the drawer is open */\n  opened: boolean;\n  /** Handler to close the drawer */\n  onClose: () => void;\n  /** The submission to display history for */\n  submission: SubmissionRecord | null;\n}\n\n/**\n * Drawer component for viewing submission post history.\n */\nexport function SubmissionHistoryDrawer({\n  opened,\n  onClose,\n  submission,\n}: SubmissionHistoryDrawerProps) {\n  if (!submission) {\n    return null;\n  }\n\n  return (\n    <Drawer\n      opened={opened}\n      onClose={onClose}\n      title={\n        <Group gap=\"xs\">\n          <IconHistory size={20} />\n          <Text fw={500}>{submission.title}</Text>\n        </Group>\n      }\n      position=\"right\"\n      size=\"lg\"\n      padding=\"md\"\n    >\n      {/* eslint-disable-next-line lingui/no-unlocalized-strings */}\n      <ScrollArea h=\"calc(100vh - 80px)\">\n        <PostHistoryContent submission={submission} />\n      </ScrollArea>\n    </Drawer>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-list.tsx",
    "content": "/**\n * SubmissionList - Renders the virtualized, sortable list of submissions.\n * Uses TanStack Virtual for performance with large lists.\n * Uses dnd-kit for drag-and-drop reordering.\n * Provides scroll container context for thumbnail lazy-loading.\n */\n\nimport {\n    closestCenter,\n    DndContext,\n    DragEndEvent,\n    DragOverlay,\n    DragStartEvent,\n    KeyboardSensor,\n    PointerSensor,\n    useSensor,\n    useSensors,\n} from '@dnd-kit/core';\nimport {\n    arrayMove,\n    SortableContext,\n    sortableKeyboardCoordinates,\n    verticalListSortingStrategy,\n} from '@dnd-kit/sortable';\nimport { Box, Loader, ScrollArea } from '@mantine/core';\nimport { useVirtualizer } from '@tanstack/react-virtual';\nimport { useCallback, useEffect, useRef, useState } from 'react';\nimport submissionApi from '../../../api/submission.api';\nimport type { SubmissionRecord } from '../../../stores/records';\nimport { useIsCompactView } from '../../../stores/ui/appearance-store';\nimport { EmptyState } from '../../empty-state';\nimport { useSubmissionsData } from './context';\nimport { SortableSubmissionCard, SubmissionCard } from './submission-card';\nimport './submissions-section.css';\nimport { DRAGGABLE_SUBMISSION_CLASS } from './types';\n\n/** Card height for virtualization in normal mode */\nconst NORMAL_CARD_HEIGHT = 102;\n/** Card height for virtualization in compact mode */\nconst COMPACT_CARD_HEIGHT = 89;\n/** Number of items to render outside visible area */\nconst OVERSCAN_COUNT = 5;\n\ninterface SubmissionListProps {\n  /** Whether submissions are loading */\n  isLoading: boolean;\n  /** Ordered list of submissions to display */\n  submissions: SubmissionRecord[];\n  /** Callback to update ordered submissions after drag */\n  onReorder: (submissions: SubmissionRecord[]) => void;\n}\n\n/**\n * Virtualized, sortable list of submission cards.\n * Actions and selection are provided via SubmissionsContext.\n */\nexport function SubmissionList({\n  isLoading,\n  submissions,\n  onReorder,\n}: SubmissionListProps) {\n  const { submissionType, selectedIds, isDragEnabled } =\n    useSubmissionsData();\n  const isCompact = useIsCompactView();\n\n  // Ref for the Mantine ScrollArea viewport - used for virtualization\n  const viewportRef = useRef<HTMLDivElement>(null);\n\n  // Track active drag item for DragOverlay\n  const [activeId, setActiveId] = useState<string | null>(null);\n  const activeSubmission = activeId\n    ? submissions.find((s) => s.id === activeId)\n    : null;\n\n  // dnd-kit sensors with keyboard accessibility\n  const sensors = useSensors(\n    useSensor(PointerSensor, {\n      activationConstraint: {\n        distance: 8, // Require 8px movement before starting drag\n      },\n    }),\n    useSensor(KeyboardSensor, {\n      coordinateGetter: sortableKeyboardCoordinates,\n    }),\n  );\n\n  // TanStack Virtual virtualizer - uses the ScrollArea viewport as scroll element\n  const virtualizer = useVirtualizer({\n    count: submissions.length,\n    getScrollElement: () => viewportRef.current,\n    estimateSize: () => (isCompact ? COMPACT_CARD_HEIGHT : NORMAL_CARD_HEIGHT),\n    overscan: OVERSCAN_COUNT,\n  });\n\n  // Invalidate measurements when compact mode changes (card heights change)\n  useEffect(() => {\n    virtualizer.measure();\n  }, [isCompact, virtualizer]);\n\n  // Handle drag start - set active item for overlay\n  const handleDragStart = useCallback((event: DragStartEvent) => {\n    setActiveId(event.active.id as string);\n  }, []);\n\n  // Handle drag end - reorder submissions\n  const handleDragEnd = useCallback(\n    (event: DragEndEvent) => {\n      const { active, over } = event;\n      setActiveId(null);\n\n      if (over && active.id !== over.id) {\n        const oldIndex = submissions.findIndex((s) => s.id === active.id);\n        const newIndex = submissions.findIndex((s) => s.id === over.id);\n\n        if (oldIndex !== -1 && newIndex !== -1) {\n          const newOrder = arrayMove(submissions, oldIndex, newIndex);\n          onReorder(newOrder);\n\n          // Determine position relative to target\n          const position = newIndex > oldIndex ? 'after' : 'before';\n          const targetId = over.id as string;\n\n          // Persist the new order to the server\n          submissionApi.reorder(active.id as string, targetId, position);\n        }\n      }\n    },\n    [submissions, onReorder],\n  );\n\n  // Handle drag cancel\n  const handleDragCancel = useCallback(() => {\n    setActiveId(null);\n  }, []);\n\n  // Get submission IDs for SortableContext\n  const submissionIds = submissions.map((s) => s.id);\n\n  if (isLoading) {\n    return (\n      <Box className=\"postybirb__submission__list_loading\">\n        <Loader size=\"sm\" />\n      </Box>\n    );\n  }\n\n  if (submissions.length === 0) {\n    return <EmptyState preset=\"no-results\" />;\n  }\n\n  const virtualItems = virtualizer.getVirtualItems();\n\n  return (\n    <DndContext\n      sensors={sensors}\n      collisionDetection={closestCenter}\n      onDragStart={handleDragStart}\n      onDragEnd={handleDragEnd}\n      onDragCancel={handleDragCancel}\n    >\n      <SortableContext\n        items={submissionIds}\n        strategy={verticalListSortingStrategy}\n      >\n        <ScrollArea\n          viewportRef={viewportRef}\n          className=\"postybirb__submission__list_scroll\"\n          style={{ flex: 1 }}\n          type=\"hover\"\n          scrollbarSize={8}\n        >\n          <div\n            className=\"postybirb__submission__list\"\n            style={{\n              height: `${virtualizer.getTotalSize()}px`,\n              width: '100%',\n              position: 'relative',\n            }}\n          >\n            {virtualItems.map((virtualRow) => {\n              const submission = submissions[virtualRow.index];\n              return (\n                <div\n                  key={submission.id}\n                  data-index={virtualRow.index}\n                  ref={virtualizer.measureElement}\n                  style={{\n                    position: 'absolute',\n                    top: 0,\n                    left: 0,\n                    width: '100%',\n                    transform: `translateY(${virtualRow.start}px)`,\n                  }}\n                >\n                  <SortableSubmissionCard\n                    id={submission.id}\n                    virtualIndex={virtualRow.index}\n                    submission={submission}\n                    submissionType={submissionType}\n                    isSelected={selectedIds.includes(submission.id)}\n                    draggable={isDragEnabled}\n                    isCompact={isCompact}\n                    className={DRAGGABLE_SUBMISSION_CLASS}\n                  />\n                </div>\n              );\n            })}\n          </div>\n        </ScrollArea>\n      </SortableContext>\n\n      {/* DragOverlay renders the dragged item in a portal - critical for virtualization */}\n      <DragOverlay>\n        {activeSubmission ? (\n          <SubmissionCard\n            submission={activeSubmission}\n            submissionType={submissionType}\n            isSelected={selectedIds.includes(activeSubmission.id)}\n            draggable={false}\n            isCompact={isCompact}\n            className={DRAGGABLE_SUBMISSION_CLASS}\n          />\n        ) : null}\n      </DragOverlay>\n    </DndContext>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submission-section-header.tsx",
    "content": "/**\n * SubmissionSectionHeader - Sticky header for submissions section panel.\n * Contains title, create button, and search/filter controls.\n */\n\nimport { Trans, useLingui } from '@lingui/react/macro';\nimport {\n    ActionIcon,\n    Box,\n    Button,\n    Checkbox,\n    Group,\n    Kbd,\n    Popover,\n    SegmentedControl,\n    Stack,\n    Text,\n    TextInput,\n    Tooltip,\n} from '@mantine/core';\nimport { Dropzone, FileWithPath, IMAGE_MIME_TYPE, MS_WORD_MIME_TYPE, PDF_MIME_TYPE } from '@mantine/dropzone';\nimport { useDisclosure } from '@mantine/hooks';\nimport { SubmissionId, SubmissionType } from '@postybirb/types';\nimport {\n    IconCalendarEvent,\n    IconHelp,\n    IconPlus,\n    IconSend,\n    IconTemplate,\n    IconTrash,\n    IconUpload,\n    IconViewportShort,\n    IconViewportTall,\n} from '@tabler/icons-react';\nimport { useCallback, useState } from 'react';\nimport { DeleteSelectedKeybinding, formatKeybindingDisplay } from '../../../config/keybindings';\nimport type { SubmissionRecord } from '../../../stores/records';\nimport { useSubmissionViewMode } from '../../../stores/ui/appearance-store';\nimport { type SubmissionFilter, useSubmissionsFilter } from '../../../stores/ui/submissions-ui-store';\nimport { useTourActions } from '../../../stores/ui/tour-store';\nimport { SUBMISSIONS_TOUR_ID } from '../../onboarding-tour/tours/submissions-tour';\nimport { MultiSchedulerModal, SearchInput } from '../../shared';\nimport { TemplatePickerModal } from '../../shared/template-picker';\nimport './submissions-section.css';\n\n/** Selection state for the checkbox */\nexport type SelectionState = 'none' | 'partial' | 'all';\n\ninterface SubmissionSectionHeaderProps {\n  /** Type of submissions (FILE or MESSAGE) */\n  submissionType: SubmissionType;\n  /** Handler for creating a new file submission (opens file picker) */\n  onCreateSubmission?: () => void;\n  /** Handler for creating a new message submission with title */\n  onCreateMessageSubmission?: (title: string) => void;\n  /** Current selection state */\n  selectionState?: SelectionState;\n  /** Handler for toggling select all/none */\n  onToggleSelectAll?: () => void;\n  /** Currently selected submission IDs */\n  selectedIds?: SubmissionId[];\n  /** Currently selected submission records */\n  selectedSubmissions?: SubmissionRecord[];\n  /** Number of selected items */\n  selectedCount?: number;\n  /** Total number of items */\n  totalCount?: number;\n  /** Handler for deleting selected submissions */\n  onDeleteSelected?: () => void;\n  /** Handler for posting selected submissions */\n  onPostSelected?: () => void;\n  /** Handler for files dropped into the dropzone (FILE type only) */\n  onFileDrop?: (files: FileWithPath[]) => void;\n}\n\n/**\n * Sticky header for the submissions section panel.\n * Provides title, create button, and search/filter functionality.\n */\nexport function SubmissionSectionHeader({\n  submissionType,\n  onCreateSubmission,\n  onCreateMessageSubmission,\n  selectionState = 'none',\n  onToggleSelectAll,\n  selectedIds = [],\n  selectedSubmissions = [],\n  selectedCount = 0,\n  totalCount = 0,\n  onDeleteSelected,\n  onPostSelected,\n  onFileDrop,\n}: SubmissionSectionHeaderProps) {\n  const { filter, searchQuery, setFilter, setSearchQuery } =\n    useSubmissionsFilter(submissionType);\n  const { viewMode, toggleViewMode } = useSubmissionViewMode();\n  const { startTour } = useTourActions();\n  const { t } = useLingui();\n\n  // Popover state for message submission creation\n  const [popoverOpened, popover] = useDisclosure(false);\n  const [messageTitle, setMessageTitle] = useState('');\n\n  // Template picker modal state\n  const [isTemplateModalOpen, setIsTemplateModalOpen] = useState(false);\n\n  // Multi-scheduler modal state\n  const [isSchedulerModalOpen, setIsSchedulerModalOpen] = useState(false);\n\n  // Handle creating a message submission\n  const handleCreateMessage = useCallback(() => {\n    if (messageTitle.trim()) {\n      onCreateMessageSubmission?.(messageTitle.trim());\n      setMessageTitle('');\n      popover.close();\n    }\n  }, [messageTitle, onCreateMessageSubmission, popover]);\n\n  // Handle key press in message title input\n  const handleKeyDown = useCallback(\n    (e: React.KeyboardEvent) => {\n      if (e.key === 'Enter') {\n        e.preventDefault();\n        handleCreateMessage();\n      }\n      if (e.key === 'Escape') {\n        popover.close();\n        setMessageTitle('');\n      }\n    },\n    [handleCreateMessage, popover],\n  );\n\n  const isFileType = submissionType === SubmissionType.FILE;\n  const headerTitle = isFileType ? (\n    <Trans>File Submissions</Trans>\n  ) : (\n    <Trans>Message Submissions</Trans>\n  );\n\n  return (\n    <Box p=\"sm\" className=\"postybirb__submission__header\">\n      <Stack gap=\"xs\">\n        {/* Title row with select all checkbox and create button */}\n        <Group justify=\"space-between\" align=\"center\">\n          <Group gap=\"xs\">\n            <Tooltip\n              label={\n                selectionState === 'all' ? (\n                  <Trans>Deselect all</Trans>\n                ) : (\n                  <Trans>Select all</Trans>\n                )\n              }\n            >\n              <Checkbox\n                data-tour-id=\"submissions-select-all\"\n                size=\"xs\"\n                checked={selectionState === 'all'}\n                indeterminate={selectionState === 'partial'}\n                onChange={onToggleSelectAll}\n                // eslint-disable-next-line lingui/no-unlocalized-strings\n                aria-label=\"Select all submissions\"\n              />\n            </Tooltip>\n            <Text fw={600} size=\"sm\">\n              {selectedCount > 0 ? (\n                <Trans>\n                  {selectedCount} of {totalCount} selected\n                </Trans>\n              ) : (\n                headerTitle\n              )}\n            </Text>\n            <Tooltip label={<Trans>Submissions Tour</Trans>}>\n              <ActionIcon\n                variant=\"subtle\"\n                size=\"xs\"\n                onClick={() => startTour(SUBMISSIONS_TOUR_ID)}\n              >\n                <IconHelp size={16} />\n              </ActionIcon>\n            </Tooltip>\n          </Group>\n          <Group gap=\"xs\">\n            {/* Apply template button - only show when items are selected */}\n            {selectedCount > 0 && (\n              <Tooltip label={<Trans>Apply template</Trans>}>\n                <ActionIcon\n                  onClick={() => setIsTemplateModalOpen(true)}\n                  variant=\"light\"\n                  size=\"sm\"\n                  color=\"grape\"\n                  // eslint-disable-next-line lingui/no-unlocalized-strings\n                  aria-label=\"Apply template\"\n                >\n                  <IconTemplate size={16} />\n                </ActionIcon>\n              </Tooltip>\n            )}\n            {/* Schedule button - only show when items are selected */}\n            {selectedCount > 0 && (\n              <Tooltip label={<Trans>Schedule</Trans>}>\n                <ActionIcon\n                  onClick={() => setIsSchedulerModalOpen(true)}\n                  variant=\"light\"\n                  size=\"sm\"\n                  color=\"violet\"\n                  // eslint-disable-next-line lingui/no-unlocalized-strings\n                  aria-label=\"Schedule\"\n                >\n                  <IconCalendarEvent size={16} />\n                </ActionIcon>\n              </Tooltip>\n            )}\n            {/* Mass post button - only show when items are selected */}\n            {selectedCount > 0 && onPostSelected && (\n              <Tooltip label={<Trans>Post</Trans>}>\n                <ActionIcon\n                  onClick={onPostSelected}\n                  variant=\"light\"\n                  size=\"sm\"\n                  // eslint-disable-next-line lingui/no-unlocalized-strings\n                  aria-label=\"Post\"\n                >\n                  <IconSend size={16} />\n                </ActionIcon>\n              </Tooltip>\n            )}\n            {/* Mass delete button - only show when items are selected */}\n            {selectedCount > 0 && onDeleteSelected && (\n              <Tooltip\n                label={\n                  <Group gap=\"xs\">\n                    <Trans>Delete</Trans>\n                    <Kbd size=\"xs\">{formatKeybindingDisplay(DeleteSelectedKeybinding)}</Kbd>\n                  </Group>\n                }\n              >\n                <ActionIcon\n                  onClick={onDeleteSelected}\n                  variant=\"light\"\n                  size=\"sm\"\n                  color=\"red\"\n                  // eslint-disable-next-line lingui/no-unlocalized-strings\n                  aria-label=\"Delete\"\n                >\n                  <IconTrash size={16} />\n                </ActionIcon>\n              </Tooltip>\n            )}\n\n            {/* Create button - different behavior for FILE vs MESSAGE */}\n            {isFileType ? (\n              // FILE type: opens file picker\n              onCreateSubmission && (\n                <Tooltip label={<Trans>Create Submission</Trans>}>\n                  <ActionIcon\n                    data-tour-id=\"submissions-create\"\n                    variant=\"light\"\n                    size=\"sm\"\n                    onClick={onCreateSubmission}\n                    // eslint-disable-next-line lingui/no-unlocalized-strings\n                    aria-label=\"Create submission\"\n                  >\n                    <IconPlus size={16} />\n                  </ActionIcon>\n                </Tooltip>\n              )\n            ) : (\n              // MESSAGE type: opens popover with title input\n              <Popover\n                opened={popoverOpened}\n                onChange={(opened) => {\n                  if (!opened) popover.close();\n                }}\n                position=\"bottom-end\"\n                withArrow\n              >\n                <Popover.Target>\n                  <Tooltip label={<Trans>Create Message</Trans>}>\n                    <ActionIcon\n                      data-tour-id=\"submissions-create\"\n                      variant=\"light\"\n                      size=\"sm\"\n                      onClick={popover.toggle}\n                      // eslint-disable-next-line lingui/no-unlocalized-strings\n                      aria-label=\"Create message submission\"\n                    >\n                      <IconPlus size={16} />\n                    </ActionIcon>\n                  </Tooltip>\n                </Popover.Target>\n                <Popover.Dropdown>\n                  <Stack gap=\"xs\">\n                    <Text size=\"sm\" fw={500}>\n                      <Trans>New Message</Trans>\n                    </Text>\n                    <TextInput\n                      size=\"xs\"\n                      placeholder={t`Enter title...`}\n                      value={messageTitle}\n                      onChange={(e) => setMessageTitle(e.currentTarget.value)}\n                      onKeyDown={handleKeyDown}\n                      autoFocus\n                    />\n                    <Button\n                      size=\"xs\"\n                      onClick={handleCreateMessage}\n                      disabled={!messageTitle.trim()}\n                    >\n                      <Trans>Create</Trans>\n                    </Button>\n                  </Stack>\n                </Popover.Dropdown>\n              </Popover>\n            )}\n          </Group>\n        </Group>\n\n        {/* Compact dropzone for file submissions */}\n        {isFileType && onFileDrop && (\n          <Dropzone\n            data-tour-id=\"submissions-dropzone\"\n            onDrop={onFileDrop}\n            accept={[\n              ...IMAGE_MIME_TYPE,\n              'video/*',\n              'audio/*',\n              ...PDF_MIME_TYPE,\n              ...MS_WORD_MIME_TYPE,\n            ]}\n            useFsAccessApi={false}\n            py={6}\n            style={{ cursor: 'pointer' }}\n          >\n            <Group gap=\"xs\" justify=\"center\">\n              <Dropzone.Idle>\n                <IconUpload size={14} style={{ opacity: 0.5 }} />\n              </Dropzone.Idle>\n              <Text size=\"xs\" c=\"dimmed\">\n                <Trans>Drop files here or click to browse</Trans>\n              </Text>\n            </Group>\n          </Dropzone>\n        )}\n\n        {/* Search input */}\n        <Box data-tour-id=\"submissions-search\">\n          <SearchInput\n            size=\"xs\"\n            value={searchQuery}\n            onChange={setSearchQuery}\n            onClear={() => setSearchQuery('')}\n          />\n        </Box>\n\n        {/* Status filter and view mode toggle */}\n        <Group gap=\"xs\" wrap=\"nowrap\" data-tour-id=\"submissions-filter\">\n          <SegmentedControl\n            size=\"xs\"\n            style={{ flex: 1 }}\n            value={filter}\n            onChange={(value) => setFilter(value as SubmissionFilter)}\n            data={[\n              { value: 'all', label: t`All` },\n              { value: 'queued', label: t`Queued` },\n              { value: 'scheduled', label: t`Scheduled` },\n            ]}\n          />\n          <Tooltip\n            label={\n              viewMode === 'compact' ? (\n                <Trans>Detailed view</Trans>\n              ) : (\n                <Trans>Compact view</Trans>\n              )\n            }\n          >\n            <ActionIcon\n              variant=\"subtle\"\n              size=\"sm\"\n              onClick={toggleViewMode}\n              // eslint-disable-next-line lingui/no-unlocalized-strings\n              aria-label=\"Toggle view mode\"\n            >\n              {viewMode === 'compact' ? (\n                <IconViewportTall size={16} />\n              ) : (\n                <IconViewportShort size={16} />\n              )}\n            </ActionIcon>\n          </Tooltip>\n        </Group>\n      </Stack>\n\n      {/* Template picker modal for mass apply */}\n      {isTemplateModalOpen && selectedIds.length > 0 && (\n        <TemplatePickerModal\n          targetSubmissionIds={selectedIds}\n          type={submissionType}\n          onClose={() => setIsTemplateModalOpen(false)}\n        />\n      )}\n\n      {/* Multi-scheduler modal */}\n      <MultiSchedulerModal\n        opened={isSchedulerModalOpen}\n        onClose={() => setIsSchedulerModalOpen(false)}\n        submissions={selectedSubmissions}\n      />\n    </Box>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submissions-content.tsx",
    "content": "/**\n * SubmissionsContent - Primary content area for submissions view.\n * Displays submission details when submissions are selected.\n * Works for both FILE and MESSAGE submission types.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport {\n    ActionIcon,\n    Box,\n    Card,\n    Center,\n    Container,\n    Divider,\n    Group,\n    ScrollArea,\n    Stack,\n    Switch,\n    Text,\n    Title,\n} from '@mantine/core';\nimport type { SubmissionId } from '@postybirb/types';\nimport { SubmissionType } from '@postybirb/types';\nimport {\n    IconInbox,\n    IconLayoutSidebarLeftCollapse,\n    IconLayoutSidebarLeftExpand,\n} from '@tabler/icons-react';\nimport { useMemo } from 'react';\nimport {\n    useSubmissionsByType,\n    useSubmissionsMap,\n} from '../../../stores/entity/submission-store';\nimport type { SubmissionRecord } from '../../../stores/records';\nimport {\n    useSubNavVisible,\n    useSubmissionsContentPreferences,\n    useToggleSectionPanel,\n} from '../../../stores/ui/submissions-ui-store';\nimport {\n    isFileSubmissionsViewState,\n    isMessageSubmissionsViewState,\n    type ViewState,\n} from '../../../types/view-state';\nimport { SubmissionEditCard } from './submission-edit-card';\n\ninterface SubmissionsContentProps {\n  /** Current view state */\n  viewState: ViewState;\n  /** Type of submissions (FILE or MESSAGE) */\n  submissionType: SubmissionType;\n}\n\n/**\n * Empty state when no submission is selected.\n */\nfunction EmptySubmissionSelection() {\n  return (\n    <Center h=\"100%\">\n      <Stack align=\"center\" gap=\"md\">\n        <IconInbox size={64} stroke={1.5} opacity={0.3} />\n        <Text size=\"sm\" c=\"dimmed\" ta=\"center\">\n          <Trans>Select a submission from the list to view details</Trans>\n        </Text>\n      </Stack>\n    </Center>\n  );\n}\n\ninterface SubmissionsContentHeaderProps {\n  submissionType: SubmissionType;\n  selectedCount: number;\n  hasArchived: boolean;\n}\n\nfunction SubmissionsContentHeader({\n  submissionType,\n  selectedCount,\n  hasArchived,\n}: SubmissionsContentHeaderProps) {\n  const { visible: isSectionPanelVisible } = useSubNavVisible();\n  const toggleSectionPanel = useToggleSectionPanel();\n  const { preferMultiEdit, setPreferMultiEdit } =\n    useSubmissionsContentPreferences();\n\n  return (\n    <Box\n      p=\"md\"\n      style={{ flexShrink: 0, backgroundColor: 'var(--mantine-color-body)' }}\n    >\n      <Group justify=\"space-between\" align=\"center\">\n        <Group gap=\"sm\">\n          <ActionIcon\n            c=\"dimmed\"\n            variant=\"transparent\"\n            onClick={toggleSectionPanel}\n            // eslint-disable-next-line lingui/no-unlocalized-strings\n            aria-label=\"Toggle section panel\"\n          >\n            {isSectionPanelVisible ? (\n              <IconLayoutSidebarLeftCollapse size={18} />\n            ) : (\n              <IconLayoutSidebarLeftExpand size={18} />\n            )}\n          </ActionIcon>\n          <Box>\n            <Title order={4} lh={1.2}>\n              {submissionType === SubmissionType.FILE ? (\n                <Trans>File Submissions</Trans>\n              ) : (\n                <Trans>Message Submissions</Trans>\n              )}\n            </Title>\n          </Box>\n        </Group>\n\n        <Switch\n          size=\"sm\"\n          disabled={hasArchived}\n          checked={preferMultiEdit}\n          onChange={(e) => setPreferMultiEdit(e.currentTarget.checked)}\n          label={<Trans>Mass Edit</Trans>}\n        />\n      </Group>\n    </Box>\n  );\n}\n\n/**\n * Primary content for the submissions view.\n * Shows submission details when submissions are selected.\n */\nexport function SubmissionsContent({\n  viewState,\n  submissionType,\n}: SubmissionsContentProps) {\n  const { selectedIds, mode } = useMemo(() => {\n    if (\n      submissionType === SubmissionType.FILE &&\n      isFileSubmissionsViewState(viewState)\n    ) {\n      return {\n        selectedIds: viewState.params.selectedIds,\n        mode: viewState.params.mode,\n      };\n    }\n    if (\n      submissionType === SubmissionType.MESSAGE &&\n      isMessageSubmissionsViewState(viewState)\n    ) {\n      return {\n        selectedIds: viewState.params.selectedIds,\n        mode: viewState.params.mode,\n      };\n    }\n    return { selectedIds: [] as string[], mode: 'single' as const };\n  }, [submissionType, viewState]);\n\n  const submissionsMap = useSubmissionsMap();\n  const selectedSubmissions = useMemo(\n    () =>\n      selectedIds\n        .map((id) => submissionsMap.get(id as SubmissionId))\n        .filter((s): s is SubmissionRecord => Boolean(s)),\n    [selectedIds, submissionsMap],\n  );\n\n  const allOfType = useSubmissionsByType(submissionType);\n  const multiSubmission = useMemo(\n    () => allOfType.find((s) => s.isMultiSubmission),\n    [allOfType],\n  );\n  const hasArchived = useMemo(\n    () => allOfType.some((s) => s.isArchived),\n    [allOfType],\n  );\n\n  const { preferMultiEdit } = useSubmissionsContentPreferences();\n  const effectiveMultiEdit = preferMultiEdit && !hasArchived;\n\n  // Cards are collapsible only when multiple selected and not in mass edit mode\n  const isCollapsible = selectedIds.length > 1 && !effectiveMultiEdit;\n\n  return (\n    <Box h=\"100%\" style={{ display: 'flex', flexDirection: 'column' }}>\n      <SubmissionsContentHeader\n        submissionType={submissionType}\n        selectedCount={selectedIds.length}\n        hasArchived={hasArchived}\n      />\n      <Divider />\n      <Box data-tour-id=\"submissions-editor\" style={{ flex: 1, minHeight: 0 }}>\n        {selectedIds.length === 0 && !preferMultiEdit ? (\n          <EmptySubmissionSelection />\n        ) : (\n          <ScrollArea style={{ height: '100%' }} type=\"hover\" scrollbarSize={6}>\n            <Container size=\"xxl\">\n              <Box p=\"md\">\n                <Stack gap=\"md\">\n                  {effectiveMultiEdit ? (\n                    multiSubmission ? (\n                      <SubmissionEditCard\n                        submission={multiSubmission}\n                        isCollapsible={false}\n                        targetSubmissionIds={selectedIds}\n                      />\n                    ) : (\n                      <Card withBorder radius=\"sm\" p=\"md\">\n                        {/* eslint-disable-next-line lingui/no-unlocalized-strings */}\n                        <Text size=\"sm\" c=\"dimmed\">\n                          Multi-edit record not found for this submission type.\n                        </Text>\n                      </Card>\n                    )\n                  ) : (\n                    selectedSubmissions.map((submission, index) => (\n                      <SubmissionEditCard\n                        key={submission.id}\n                        submission={submission}\n                        isCollapsible={isCollapsible}\n                        defaultExpanded={index === 0}\n                      />\n                    ))\n                  )}\n                </Stack>\n              </Box>\n            </Container>\n          </ScrollArea>\n        )}\n      </Box>\n    </Box>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submissions-section.css",
    "content": "/**\n * Submissions Section styles.\n * Naming convention: postybirb__submission__element_modifier\n */\n\n/* =============================================================================\n   Section Header\n   ============================================================================= */\n\n.postybirb__submission__header {\n  position: sticky;\n  top: 0;\n  z-index: var(--z-sticky);\n  padding: var(--mantine-spacing-sm);\n  background-color: var(--mantine-color-body);\n  border-bottom: 1px solid var(--mantine-color-default-border);\n}\n\n/* =============================================================================\n   Section Container\n   ============================================================================= */\n\n.postybirb__submission__section {\n  display: flex;\n  flex-direction: column;\n  height: 100%;\n}\n\n/* Hidden file input for creating submissions */\n.postybirb__submission__file_input {\n  display: none;\n}\n\n/* =============================================================================\n   Submission List\n   ============================================================================= */\n\n.postybirb__submission__list_scroll {\n  flex: 1;\n}\n\n.postybirb__submission__list_loading {\n  padding: var(--mantine-spacing-md);\n  text-align: center;\n}\n\n.postybirb__submission__list_empty {\n  padding: var(--mantine-spacing-md);\n  text-align: center;\n}\n\n/* =============================================================================\n   Submission Card\n   ============================================================================= */\n\n.postybirb__submission__card {\n  cursor: pointer;\n  user-select: none;\n  border-left: none;\n  border-right: none;\n  border-top: none;\n}\n\n.postybirb__submission__card:focus-visible {\n  outline: 2px solid var(--mantine-color-blue-6);\n  outline-offset: -2px;\n  background-color: var(--mantine-color-blue-light);\n}\n\n.postybirb__submission__card--selected {\n  background-color: var(--mantine-primary-color-light);\n  border-color: var(--mantine-primary-color-filled);\n  border-left: 3px solid var(--mantine-primary-color-filled) !important;\n}\n\n.postybirb__submission__card--scheduled {\n  background-color: var(--mantine-color-blue-light);\n}\n\n.postybirb__submission__card--scheduled.postybirb__submission__card--selected {\n  background-color: color-mix(\n    in srgb,\n    var(--mantine-color-blue-light) 50%,\n    var(--mantine-primary-color-light) 50%\n  );\n}\n\n.postybirb__submission__card--has-errors {\n  background-color: var(--mantine-color-red-light);\n  border-left: 3px solid var(--mantine-color-red-6) !important;\n}\n\n.postybirb__submission__card--has-errors.postybirb__submission__card--selected {\n  background-color: color-mix(\n    in srgb,\n    var(--mantine-color-red-light) 60%,\n    var(--mantine-primary-color-light) 40%\n  );\n  border-left: 3px solid var(--mantine-color-red-6) !important;\n}\n\n/* Compact mode */\n.postybirb__submission__card--compact {\n  padding: var(--mantine-spacing-xs);\n}\n\n/* Card actions column - checkbox + drag handle */\n.postybirb__submission__card_actions_column {\n  padding: var(--mantine-spacing-xs) 0;\n  border-right: 1px solid var(--mantine-color-default-border);\n  padding-right: var(--mantine-spacing-xs);\n}\n\n/* Drag handle */\n.postybirb__submission__drag_handle {\n  cursor: grab;\n  display: flex;\n  align-items: center;\n  opacity: 0.5;\n}\n\n.postybirb__submission__drag_handle:active {\n  cursor: grabbing;\n}\n\n/* Card content wrapper - fills remaining space */\n.postybirb__submission__card_content {\n  flex: 1;\n  min-width: 0;\n}\n\n/* Last modified timestamp row */\n.postybirb__submission__timestamp_icon {\n  opacity: 0.5;\n}\n\n/* =============================================================================\n   Thumbnail\n   ============================================================================= */\n\n.postybirb__submission__thumbnail {\n  flex-shrink: 0;\n  width: 40px;\n  height: 40px;\n  border-radius: var(--mantine-radius-sm);\n  overflow: hidden;\n  background-color: var(--mantine-color-default);\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n\n.postybirb__submission__thumbnail_placeholder {\n  opacity: 0.5;\n}\n\n/* =============================================================================\n   Tabs\n   ============================================================================= */\n\n.postybirb__submission__tabs {\n  flex-shrink: 0;\n}\n\n.postybirb__submission__tabs_panel {\n  flex: 1;\n  display: flex;\n  flex-direction: column;\n  min-height: 0;\n}\n\n/* =============================================================================\n   Archived List Placeholder\n   ============================================================================= */\n\n.postybirb__submission__archived_placeholder {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: center;\n  gap: var(--mantine-spacing-md);\n  padding: var(--mantine-spacing-xl);\n  flex: 1;\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/submissions-section.tsx",
    "content": "/**\n * SubmissionsSection - Section panel content for submissions view.\n * Displays a scrollable list of submissions with filtering.\n * Works for both FILE and MESSAGE submission types.\n */\n\nimport { Trans, useLingui } from '@lingui/react/macro';\nimport { Box, Tabs } from '@mantine/core';\nimport { FileWithPath } from '@mantine/dropzone';\nimport { useDisclosure } from '@mantine/hooks';\nimport { SubmissionType } from '@postybirb/types';\nimport { IconArchive, IconFiles, IconMessage } from '@tabler/icons-react';\nimport { useCallback, useEffect, useMemo, useState } from 'react';\nimport { tinykeys } from 'tinykeys';\nimport {\n  DeleteSelectedKeybinding,\n  toTinykeysFormat,\n} from '../../../config/keybindings';\nimport { SubmissionRecord } from '../../../stores';\nimport { useSubmissionsLoading } from '../../../stores/entity/submission-store';\nimport { ConfirmActionModal } from '../../confirm-action-modal';\nimport { ArchivedSubmissionList } from './archived-submission-list';\nimport { SubmissionsProvider } from './context';\nimport { FileSubmissionModal } from './file-submission-modal';\nimport {\n  useGlobalDropzone,\n  useSubmissionHandlers,\n  useSubmissions,\n  useSubmissionSelection,\n} from './hooks';\nimport { PostConfirmModal } from './post-confirm-modal';\nimport { ResumeModeModal } from './resume-mode-modal';\nimport { SubmissionHistoryDrawer } from './submission-history-drawer';\nimport { SubmissionList } from './submission-list';\nimport { SubmissionSectionHeader } from './submission-section-header';\nimport './submissions-section.css';\nimport type { SubmissionsSectionProps } from './types';\n\n/** Tab values for submissions view */\ntype SubmissionTab = 'submissions' | 'archived';\n\n/**\n * Section panel content for the submissions view.\n * Displays a scrollable list of submissions with search and filter.\n */\nexport function SubmissionsSection({\n  viewState,\n  submissionType,\n}: SubmissionsSectionProps) {\n  const { isLoading } = useSubmissionsLoading();\n  const { t } = useLingui();\n\n  // Current tab state\n  const [activeTab, setActiveTab] = useState<SubmissionTab>('submissions');\n\n  // Get filtered and ordered submissions\n  const {\n    allSubmissions,\n    orderedSubmissions,\n    setOrderedSubmissions,\n    isDragEnabled,\n  } = useSubmissions({ submissionType });\n\n  // Selection management\n  const { selectedIds, selectionState, handleSelect, handleToggleSelectAll } =\n    useSubmissionSelection({\n      viewState,\n      orderedSubmissions,\n    });\n\n  // Get selected submission records for bulk actions\n  const selectedSubmissions = useMemo(\n    () => orderedSubmissions.filter((s) => selectedIds.includes(s.id)),\n    [orderedSubmissions, selectedIds]\n  );\n\n  // Action handlers\n  const {\n    fileInputRef,\n    isFileModalOpen,\n    openFileModal,\n    closeFileModal,\n    handleFileUpload,\n    handleCreateSubmission,\n    handleCreateMessageSubmission,\n    handleFileChange,\n    handleDelete,\n    handleDeleteSelected,\n    handleDuplicate,\n    handleArchive,\n    handleEdit,\n    handleDefaultOptionChange,\n    handlePost,\n    handleCancel,\n    handlePostSelected,\n    handleScheduleChange,\n    pendingResumeSubmissionId,\n    cancelResume,\n    confirmResume,\n  } = useSubmissionHandlers({\n    submissionType,\n  });\n\n  // Global dropzone - opens modal when files are dragged into the section panel\n  useGlobalDropzone({\n    isOpen: isFileModalOpen,\n    onOpen: openFileModal,\n    onClose: closeFileModal,\n    enabled: submissionType === SubmissionType.FILE,\n    targetElementId: 'postybirb-section-panel',\n  });\n\n  // Delete confirmation modal\n  const [deleteModalOpened, deleteModal] = useDisclosure(false);\n\n  // Initial files for the modal (from header dropzone)\n  const [initialFiles, setInitialFiles] = useState<FileWithPath[]>([]);\n\n  // Post confirmation modal\n  const [postModalOpened, postModal] = useDisclosure(false);\n\n  // History drawer state\n  const [historySubmission, setHistorySubmission] =\n    useState<SubmissionRecord | null>(null);\n\n  // Handle delete with confirmation\n  const handleDeleteWithConfirm = useCallback(() => {\n    if (selectedIds.length > 0) {\n      deleteModal.open();\n    }\n  }, [selectedIds.length, deleteModal]);\n\n  // Handle post with confirmation (only if items are selected)\n  const handlePostWithConfirm = useCallback(() => {\n    if (selectedIds.length > 0) {\n      postModal.open();\n    }\n  }, [selectedIds.length, postModal]);\n\n  // Delete key handler\n  useEffect(() => {\n    const unsubscribe = tinykeys(window, {\n      [toTinykeysFormat(DeleteSelectedKeybinding)]: (event: KeyboardEvent) => {\n        // Don't trigger if user is typing in an input\n        const { activeElement } = document;\n        if (\n          activeElement?.tagName === 'INPUT' ||\n          activeElement?.tagName === 'TEXTAREA' ||\n          (activeElement as HTMLElement)?.isContentEditable\n        ) {\n          return;\n        }\n\n        // Only trigger if items are selected\n        if (selectedIds.length > 0) {\n          event.preventDefault();\n          deleteModal.open();\n        }\n      },\n    });\n\n    return unsubscribe;\n  }, [selectedIds.length, deleteModal]);\n\n  // Handle view history - find submission by id and open drawer\n  const handleViewHistory = useCallback(\n    (id: string) => {\n      const submission = allSubmissions.find((s) => s.id === id);\n      if (submission) {\n        setHistorySubmission(submission);\n      }\n    },\n    [allSubmissions],\n  );\n\n  const handleCloseHistory = useCallback(() => {\n    setHistorySubmission(null);\n  }, []);\n\n  // Get the appropriate icon for the submissions tab\n  const SubmissionsIcon =\n    submissionType === SubmissionType.FILE ? IconFiles : IconMessage;\n\n  return (\n    <Box h=\"100%\" className=\"postybirb__submission__section\">\n      {/* Delete confirmation modal */}\n      <ConfirmActionModal\n        opened={deleteModalOpened}\n        onClose={deleteModal.close}\n        onConfirm={handleDeleteSelected}\n        title={<Trans>Delete Submissions</Trans>}\n        message={\n          <Trans>\n            Are you sure you want to delete {selectedIds.length} submission(s)?\n            This action cannot be undone.\n          </Trans>\n        }\n        confirmLabel={<Trans>Delete</Trans>}\n        confirmColor=\"red\"\n      />\n\n      {/* Post confirmation modal with reorderable list */}\n      <PostConfirmModal\n        opened={postModalOpened}\n        onClose={postModal.close}\n        onConfirm={handlePostSelected}\n        selectedSubmissions={selectedSubmissions}\n        totalSelectedCount={selectedIds.length}\n      />\n\n      {/* File submission modal */}\n      {submissionType === SubmissionType.FILE && (\n        <FileSubmissionModal\n          opened={isFileModalOpen}\n          onClose={() => {\n            closeFileModal();\n            setInitialFiles([]);\n          }}\n          onUpload={handleFileUpload}\n          type={submissionType}\n          initialFiles={initialFiles}\n        />\n      )}\n\n      {/* Hidden file input for creating file submissions (legacy fallback) */}\n      {submissionType === SubmissionType.FILE && (\n        <input\n          ref={fileInputRef}\n          type=\"file\"\n          multiple\n          className=\"postybirb__submission__file_input\"\n          onChange={handleFileChange}\n          accept=\"image/*,video/*,audio/*,.gif,.webp,.png,.jpg,.jpeg,.pdf,.txt,.doc,.docx\"\n        />\n      )}\n\n      {/* Sticky header */}\n      <SubmissionSectionHeader\n        submissionType={submissionType}\n        onCreateSubmission={handleCreateSubmission}\n        onCreateMessageSubmission={handleCreateMessageSubmission}\n        selectionState={selectionState}\n        onToggleSelectAll={handleToggleSelectAll}\n        selectedIds={selectedIds}\n        selectedSubmissions={selectedSubmissions}\n        selectedCount={selectedIds.length}\n        totalCount={orderedSubmissions.length}\n        onDeleteSelected={handleDeleteWithConfirm}\n        onPostSelected={handlePostWithConfirm}\n        onFileDrop={(files) => {\n          setInitialFiles(files);\n          openFileModal();\n        }}\n      />\n\n      {/* Tabs for Submissions / Archived */}\n      <Tabs\n        value={activeTab}\n        onChange={(value) => setActiveTab(value as SubmissionTab)}\n        className=\"postybirb__submission__tabs\"\n      >\n        <Tabs.List grow>\n          <Tabs.Tab\n            value=\"submissions\"\n            leftSection={<SubmissionsIcon size={14} />}\n          >\n            {t`Submissions`}\n          </Tabs.Tab>\n          <Tabs.Tab value=\"archived\" leftSection={<IconArchive size={14} />}>\n            {t`Archived`}\n          </Tabs.Tab>\n        </Tabs.List>\n      </Tabs>\n\n      {/* Tab content - wrap both in SubmissionsProvider for shared selection */}\n      <SubmissionsProvider\n        submissionType={submissionType}\n        selectedIds={selectedIds}\n        isDragEnabled={isDragEnabled}\n        onSelect={handleSelect}\n        onDelete={handleDelete}\n        onDuplicate={handleDuplicate}\n        onEdit={handleEdit}\n        onPost={handlePost}\n        onCancel={handleCancel}\n        onArchive={handleArchive}\n        onViewHistory={handleViewHistory}\n        onDefaultOptionChange={handleDefaultOptionChange}\n        onScheduleChange={handleScheduleChange}\n      >\n        {activeTab === 'submissions' ? (\n          <SubmissionList\n            isLoading={isLoading}\n            submissions={orderedSubmissions}\n            onReorder={setOrderedSubmissions}\n          />\n        ) : (\n          <ArchivedSubmissionList submissionType={submissionType} />\n        )}\n      </SubmissionsProvider>\n\n      {/* History drawer */}\n      <SubmissionHistoryDrawer\n        opened={historySubmission !== null}\n        onClose={handleCloseHistory}\n        submission={historySubmission}\n      />\n\n      {/* Resume mode modal */}\n      <ResumeModeModal\n        opened={pendingResumeSubmissionId !== null}\n        onClose={cancelResume}\n        onConfirm={confirmResume}\n      />\n    </Box>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/submissions-section/types.ts",
    "content": "/**\n * Shared types for SubmissionsSection components.\n */\n\nimport { SubmissionType } from '@postybirb/types';\nimport type {\n    FileSubmissionsViewState,\n    MessageSubmissionsViewState,\n    ViewState,\n} from '../../../types/view-state';\nimport {\n    isFileSubmissionsViewState,\n    isMessageSubmissionsViewState,\n} from '../../../types/view-state';\n\n/**\n * Props for the SubmissionsSection component.\n */\nexport interface SubmissionsSectionProps {\n  /** Current view state */\n  viewState: ViewState;\n  /** Type of submissions to display (FILE or MESSAGE) */\n  submissionType: SubmissionType;\n}\n\n/** Class name for draggable submission cards */\nexport const DRAGGABLE_SUBMISSION_CLASS = 'draggable-submission-card';\n\n// ============================================================================\n// View State Type Guards\n// ============================================================================\n\n/**\n * Union type for submissions view states (file or message)\n */\nexport type SubmissionsViewState =\n  | FileSubmissionsViewState\n  | MessageSubmissionsViewState;\n\n/**\n * Type guard to check if a view state is a submissions view state.\n * Use this to narrow ViewState to SubmissionsViewState.\n */\nexport function isSubmissionsViewState(\n  viewState: ViewState\n): viewState is SubmissionsViewState {\n  return (\n    isFileSubmissionsViewState(viewState) ||\n    isMessageSubmissionsViewState(viewState)\n  );\n}\n\n// ============================================================================\n// Filter Constants\n// ============================================================================\n\n/**\n * Submission filter values as constants.\n * Use these instead of magic strings for type safety.\n */\nexport const SUBMISSION_FILTERS = {\n  ALL: 'all',\n  DRAFTS: 'drafts',\n  SCHEDULED: 'scheduled',\n  POSTED: 'posted',\n  FAILED: 'failed',\n} as const;\n\n/**\n * Type for submission filter values\n */\nexport type SubmissionFilterValue =\n  (typeof SUBMISSION_FILTERS)[keyof typeof SUBMISSION_FILTERS];\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/templates-section/index.ts",
    "content": "/**\n * TemplatesSection exports.\n */\n\nexport { TemplatesContent } from './templates-content';\nexport { TemplatesSection } from './templates-section';\n\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/templates-section/template-card.tsx",
    "content": "/**\n * TemplateCard - Card component for displaying a template in the list.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport {\n    ActionIcon,\n    Card,\n    Group,\n    Text,\n    TextInput,\n    ThemeIcon,\n    Tooltip,\n} from '@mantine/core';\nimport { SubmissionType } from '@postybirb/types';\nimport {\n    IconCheck,\n    IconCopy,\n    IconFile,\n    IconMessage,\n    IconPencil,\n    IconTrash,\n    IconX,\n} from '@tabler/icons-react';\nimport { useCallback, useEffect, useState } from 'react';\nimport submissionApi from '../../../api/submission.api';\nimport type { SubmissionRecord } from '../../../stores/records';\nimport {\n    showDeletedNotification,\n    showDeleteErrorNotification,\n    showDuplicatedNotification,\n    showDuplicateErrorNotification,\n    showErrorNotification,\n    showUpdatedNotification,\n} from '../../../utils/notifications';\nimport { HoldToConfirmButton } from '../../hold-to-confirm';\nimport './templates-section.css';\n\ninterface TemplateCardProps {\n  template: SubmissionRecord;\n  isSelected: boolean;\n  onSelect: (id: string) => void;\n}\n\n/**\n * Card displaying a template with edit/delete actions.\n */\nexport function TemplateCard({\n  template,\n  isSelected,\n  onSelect,\n}: TemplateCardProps) {\n  const [isEditing, setIsEditing] = useState(false);\n  const [editedName, setEditedName] = useState(template.title ?? '');\n  const [isSaving, setIsSaving] = useState(false);\n\n  // Sync editedName when template changes (e.g., after save)\n  useEffect(() => {\n    if (!isEditing) {\n      setEditedName(template.title ?? '');\n    }\n  }, [template.title, isEditing]);\n\n  const handleSave = useCallback(async () => {\n    if (!editedName.trim()) return;\n\n    setIsSaving(true);\n    try {\n      await submissionApi.updateTemplateName(template.id, {\n        name: editedName.trim(),\n      });\n      setIsEditing(false);\n      showUpdatedNotification(editedName.trim());\n    } catch {\n      showErrorNotification(<Trans>Failed to update template name</Trans>);\n    } finally {\n      setIsSaving(false);\n    }\n  }, [editedName, template.id]);\n\n  const handleCancel = useCallback(() => {\n    setEditedName(template.title ?? '');\n    setIsEditing(false);\n  }, [template.title]);\n\n  const handleDelete = useCallback(async () => {\n    try {\n      await submissionApi.remove([template.id]);\n      showDeletedNotification(1);\n    } catch {\n      showDeleteErrorNotification();\n    }\n  }, [template.id]);\n\n  const [isDuplicating, setIsDuplicating] = useState(false);\n\n  const handleDuplicate = useCallback(async () => {\n    setIsDuplicating(true);\n    try {\n      await submissionApi.duplicate(template.id);\n      showDuplicatedNotification(template.title);\n    } catch {\n      showDuplicateErrorNotification();\n    } finally {\n      setIsDuplicating(false);\n    }\n  }, [template.id, template.title]);\n\n  const handleKeyDown = useCallback(\n    (e: React.KeyboardEvent) => {\n      if (e.key === 'Enter') {\n        handleSave();\n      } else if (e.key === 'Escape') {\n        handleCancel();\n      }\n    },\n    [handleSave, handleCancel]\n  );\n\n  return (\n    <Card\n      withBorder\n      p=\"xs\"\n      radius=\"sm\"\n      data-tour-id=\"templates-card\"\n      className={`postybirb__templates__card ${isSelected ? 'postybirb__templates__card--selected' : ''}`}\n      onClick={() => !isEditing && onSelect(template.id)}\n      style={{ cursor: isEditing ? 'default' : 'pointer' }}\n    >\n      <Group gap=\"xs\" wrap=\"nowrap\">\n        <ThemeIcon\n          size=\"sm\"\n          variant=\"light\"\n          color={template.type === SubmissionType.FILE ? 'blue' : 'grape'}\n        >\n          {template.type === SubmissionType.FILE ? (\n            <IconFile size={12} />\n          ) : (\n            <IconMessage size={12} />\n          )}\n        </ThemeIcon>\n\n        {isEditing ? (\n          <TextInput\n            size=\"xs\"\n            value={editedName}\n            onChange={(e) => setEditedName(e.target.value)}\n            onKeyDown={handleKeyDown}\n            autoFocus\n            style={{ flex: 1 }}\n            disabled={isSaving}\n          />\n        ) : (\n          <Text size=\"sm\" lineClamp={1} style={{ flex: 1 }}>\n            {template.title || <Trans>Untitled</Trans>}\n          </Text>\n        )}\n\n        <Group gap={4} wrap=\"nowrap\">\n          {isEditing ? (\n            <>\n              <Tooltip label={<Trans>Save</Trans>}>\n                <ActionIcon\n                  size=\"xs\"\n                  variant=\"subtle\"\n                  color=\"green\"\n                  onClick={(e) => {\n                    e.stopPropagation();\n                    handleSave();\n                  }}\n                  disabled={!editedName.trim() || isSaving}\n                  loading={isSaving}\n                >\n                  <IconCheck size={12} />\n                </ActionIcon>\n              </Tooltip>\n              <Tooltip label={<Trans>Cancel</Trans>}>\n                <ActionIcon\n                  size=\"xs\"\n                  variant=\"subtle\"\n                  color=\"gray\"\n                  onClick={(e) => {\n                    e.stopPropagation();\n                    handleCancel();\n                  }}\n                  disabled={isSaving}\n                >\n                  <IconX size={12} />\n                </ActionIcon>\n              </Tooltip>\n            </>\n          ) : (\n            <>\n              <Tooltip label={<Trans>Rename</Trans>}>\n                <ActionIcon\n                  size=\"xs\"\n                  variant=\"subtle\"\n                  onClick={(e) => {\n                    e.stopPropagation();\n                    setIsEditing(true);\n                  }}\n                >\n                  <IconPencil size={12} />\n                </ActionIcon>\n              </Tooltip>\n              <Tooltip label={<Trans>Duplicate</Trans>}>\n                <ActionIcon\n                  size=\"xs\"\n                  variant=\"subtle\"\n                  onClick={(e) => {\n                    e.stopPropagation();\n                    handleDuplicate();\n                  }}\n                  loading={isDuplicating}\n                  disabled={isDuplicating}\n                >\n                  <IconCopy size={12} />\n                </ActionIcon>\n              </Tooltip>\n              <Tooltip label={<Trans>Hold to delete</Trans>}>\n                {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */}\n                <span\n                  onMouseDown={(e) => e.stopPropagation()}\n                  onClick={(e) => e.stopPropagation()}\n                >\n                  <HoldToConfirmButton\n                    size=\"xs\"\n                    variant=\"subtle\"\n                    color=\"red\"\n                    onConfirm={handleDelete}\n                  >\n                    <IconTrash size={12} />\n                  </HoldToConfirmButton>\n                </span>\n              </Tooltip>\n            </>\n          )}\n        </Group>\n      </Group>\n    </Card>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/templates-section/templates-content.tsx",
    "content": "/**\n * TemplatesContent - Main content area for templates view.\n * Displays template editor when a template is selected.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport {\n    ActionIcon,\n    Box,\n    Center,\n    Container,\n    Divider,\n    Group,\n    ScrollArea,\n    Stack,\n    Text,\n    Title,\n} from '@mantine/core';\nimport type { SubmissionId } from '@postybirb/types';\nimport {\n    IconInbox,\n    IconLayoutSidebarLeftCollapse,\n    IconLayoutSidebarLeftExpand,\n} from '@tabler/icons-react';\nimport { useMemo } from 'react';\nimport { useSubmissionsMap } from '../../../stores/entity/submission-store';\nimport type { SubmissionRecord } from '../../../stores/records';\nimport {\n    useSubNavVisible,\n    useToggleSectionPanel,\n} from '../../../stores/ui/submissions-ui-store';\nimport { isTemplatesViewState, type ViewState } from '../../../types/view-state';\nimport { SubmissionEditCard } from '../submissions-section/submission-edit-card';\n\ninterface TemplatesContentProps {\n  viewState: ViewState;\n}\n\n/**\n * Empty state when no template is selected.\n */\nfunction EmptyTemplateSelection() {\n  return (\n    <Center h=\"100%\">\n      <Stack align=\"center\" gap=\"md\">\n        <IconInbox size={64} stroke={1.5} opacity={0.3} />\n        <Text size=\"sm\" c=\"dimmed\" ta=\"center\">\n          <Trans>Select a template from the list to view or edit it</Trans>\n        </Text>\n      </Stack>\n    </Center>\n  );\n}\n\n/**\n * Header for templates content area.\n */\nfunction TemplatesContentHeader() {\n  const { visible: isSectionPanelVisible } = useSubNavVisible();\n  const toggleSectionPanel = useToggleSectionPanel();\n\n  return (\n    <Box\n      p=\"md\"\n      style={{ flexShrink: 0, backgroundColor: 'var(--mantine-color-body)' }}\n    >\n      <Group justify=\"space-between\" align=\"center\">\n        <Group gap=\"sm\">\n          <ActionIcon\n            c=\"dimmed\"\n            variant=\"transparent\"\n            onClick={toggleSectionPanel}\n            // eslint-disable-next-line lingui/no-unlocalized-strings\n            aria-label=\"Toggle section panel\"\n          >\n            {isSectionPanelVisible ? (\n              <IconLayoutSidebarLeftCollapse size={18} />\n            ) : (\n              <IconLayoutSidebarLeftExpand size={18} />\n            )}\n          </ActionIcon>\n          <Box>\n            <Title order={4} lh={1.2}>\n              <Trans>Templates</Trans>\n            </Title>\n            <Text size=\"sm\" c=\"dimmed\">\n              <Trans>Template Editor</Trans>\n            </Text>\n          </Box>\n        </Group>\n      </Group>\n    </Box>\n  );\n}\n\n/**\n * Content area for the templates view.\n * Shows selected template editor or empty state.\n */\nexport function TemplatesContent({ viewState }: TemplatesContentProps) {\n  const selectedId = isTemplatesViewState(viewState)\n    ? viewState.params.selectedId\n    : null;\n\n  const submissionsMap = useSubmissionsMap();\n  const selectedTemplate = useMemo(\n    () =>\n      selectedId\n        ? (submissionsMap.get(selectedId as SubmissionId) as\n            | SubmissionRecord\n            | undefined)\n        : null,\n    [selectedId, submissionsMap],\n  );\n\n  return (\n    <Box h=\"100%\" style={{ display: 'flex', flexDirection: 'column' }}>\n      <TemplatesContentHeader />\n      <Divider />\n      <Box data-tour-id=\"templates-editor\" style={{ flex: 1, minHeight: 0 }}>\n        {!selectedTemplate ? (\n          <EmptyTemplateSelection />\n        ) : (\n          <ScrollArea style={{ height: '100%' }} type=\"hover\" scrollbarSize={6}>\n            <Container size=\"xxl\">\n              <Box p=\"md\">\n                <SubmissionEditCard\n                  submission={selectedTemplate}\n                  isCollapsible={false}\n                />\n              </Box>\n            </Container>\n          </ScrollArea>\n        )}\n      </Box>\n    </Box>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/templates-section/templates-section.css",
    "content": "/**\n * TemplatesSection styles.\n */\n\n.postybirb__templates__section {\n  display: flex;\n  flex-direction: column;\n}\n\n.postybirb__templates__header {\n  flex-shrink: 0;\n}\n\n.postybirb__templates__card {\n  transition: background-color 0.15s ease;\n}\n\n.postybirb__templates__card:hover {\n  background-color: var(--mantine-color-dark-5);\n}\n\n.postybirb__templates__card--selected {\n  border-color: var(--mantine-primary-color-filled);\n  background-color: var(--mantine-color-dark-6);\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/sections/templates-section/templates-section.tsx",
    "content": "/**\n * TemplatesSection - Section panel for managing submission templates.\n */\n\nimport { Trans, useLingui } from '@lingui/react/macro';\nimport {\n    ActionIcon,\n    Box,\n    Button,\n    Divider,\n    Group,\n    Loader,\n    Modal,\n    ScrollArea,\n    SegmentedControl,\n    Stack,\n    Text,\n    TextInput,\n    ThemeIcon,\n    Tooltip,\n} from '@mantine/core';\nimport { useDisclosure } from '@mantine/hooks';\nimport { SubmissionType } from '@postybirb/types';\nimport {\n    IconFile,\n    IconHelp,\n    IconMessage,\n    IconPlus,\n    IconTemplate,\n} from '@tabler/icons-react';\nimport { useCallback, useMemo, useState } from 'react';\nimport submissionApi from '../../../api/submission.api';\nimport {\n    useSubmissionsLoading,\n    useTemplateSubmissions,\n} from '../../../stores/entity/submission-store';\nimport { useNavigationStore } from '../../../stores/ui/navigation-store';\nimport { useTemplatesFilter } from '../../../stores/ui/templates-ui-store';\nimport { useTourActions } from '../../../stores/ui/tour-store';\nimport { isTemplatesViewState, type ViewState } from '../../../types/view-state';\nimport {\n    showErrorNotification,\n    showSuccessNotification,\n} from '../../../utils/notifications';\nimport { EmptyState } from '../../empty-state';\nimport { TEMPLATES_TOUR_ID } from '../../onboarding-tour/tours/templates-tour';\nimport { SearchInput } from '../../shared';\nimport { TemplateCard } from './template-card';\nimport './templates-section.css';\n\ninterface TemplatesSectionProps {\n  viewState: ViewState;\n}\n\n/**\n * Templates section panel with search, tabs, and template list.\n */\nexport function TemplatesSection({ viewState }: TemplatesSectionProps) {\n  const { t } = useLingui();\n  const templates = useTemplateSubmissions();\n  const { isLoading } = useSubmissionsLoading();\n  const { tabType, searchQuery, setTabType, setSearchQuery } =\n    useTemplatesFilter();\n  const { startTour } = useTourActions();\n  const setViewState = useNavigationStore((state) => state.setViewState);\n\n  // Create template modal\n  const [modalOpened, { open: openModal, close: closeModal }] =\n    useDisclosure(false);\n  const [newTemplateName, setNewTemplateName] = useState('');\n  const [isCreating, setIsCreating] = useState(false);\n\n  // Get selected template ID from view state\n  const selectedTemplateId = isTemplatesViewState(viewState)\n    ? viewState.params.selectedId\n    : null;\n\n  // Handle selecting a template\n  const handleSelectTemplate = useCallback(\n    (templateId: string) => {\n      if (isTemplatesViewState(viewState)) {\n        setViewState({\n          ...viewState,\n          params: {\n            ...viewState.params,\n            selectedId: templateId,\n          },\n        });\n      }\n    },\n    [viewState, setViewState]\n  );\n\n  // Handle creating a new template\n  const handleCreateTemplate = useCallback(async () => {\n    if (!newTemplateName.trim()) return;\n\n    setIsCreating(true);\n    try {\n      await submissionApi.create({\n        name: newTemplateName.trim(),\n        type: tabType,\n        isTemplate: true,\n      });\n      showSuccessNotification(<Trans>Template created</Trans>);\n      setNewTemplateName('');\n      closeModal();\n    } catch {\n      showErrorNotification(<Trans>Failed to create template</Trans>);\n    } finally {\n      setIsCreating(false);\n    }\n  }, [newTemplateName, tabType, closeModal]);\n\n  // Handle key press in input\n  const handleKeyDown = useCallback(\n    (e: React.KeyboardEvent) => {\n      if (e.key === 'Enter') {\n        handleCreateTemplate();\n      }\n    },\n    [handleCreateTemplate]\n  );\n\n  // Filter templates by type and search query\n  const filteredTemplates = useMemo(\n    () =>\n      templates.filter((template) => {\n        // Filter by type\n        if (template.type !== tabType) return false;\n\n        // Filter by search query\n        if (searchQuery) {\n          const query = searchQuery.toLowerCase();\n          const name = template.title?.toLowerCase() ?? '';\n          if (!name.includes(query)) return false;\n        }\n\n        return true;\n      }),\n    [templates, tabType, searchQuery]\n  );\n\n  return (\n    <Box h=\"100%\" className=\"postybirb__templates__section\">\n      {/* Header */}\n      <Stack gap=\"xs\" p=\"xs\" className=\"postybirb__templates__header\">\n        <Group gap=\"xs\" justify=\"space-between\">\n          <Group gap=\"xs\">\n            <ThemeIcon size=\"sm\" variant=\"light\">\n              <IconTemplate size={14} />\n            </ThemeIcon>\n            <Text size=\"sm\" fw={500}>\n              <Trans>Templates</Trans>\n            </Text>\n          </Group>\n          <Tooltip label={<Trans>Templates Tour</Trans>}>\n            <ActionIcon\n              variant=\"subtle\"\n              size=\"sm\"\n              onClick={() => startTour(TEMPLATES_TOUR_ID)}\n            >\n              <IconHelp size={18} />\n            </ActionIcon>\n          </Tooltip>\n        </Group>\n\n        {/* Search */}\n        <Box data-tour-id=\"templates-search\">\n          <SearchInput\n            size=\"xs\"\n            value={searchQuery}\n            onChange={setSearchQuery}\n            onClear={() => setSearchQuery('')}\n          />\n        </Box>\n\n        {/* Tabs */}\n        <SegmentedControl\n          data-tour-id=\"templates-type-tabs\"\n          size=\"xs\"\n          fullWidth\n          value={tabType}\n          onChange={(value) => setTabType(value as SubmissionType)}\n          data={[\n            {\n              value: SubmissionType.FILE,\n              label: (\n                <Group gap={4} justify=\"center\">\n                  <IconFile size={14} />\n                  <Trans>File</Trans>\n                </Group>\n              ),\n            },\n            {\n              value: SubmissionType.MESSAGE,\n              label: (\n                <Group gap={4} justify=\"center\">\n                  <IconMessage size={14} />\n                  <Trans>Message</Trans>\n                </Group>\n              ),\n            },\n          ]}\n        />\n\n        {/* Create new template */}\n        <Button\n          data-tour-id=\"templates-create\"\n          size=\"xs\"\n          variant=\"light\"\n          leftSection={<IconPlus size={14} />}\n          onClick={openModal}\n          fullWidth\n        >\n          <Trans>Create New Template</Trans>\n        </Button>\n      </Stack>\n\n      {/* Create template modal */}\n      <Modal\n        opened={modalOpened}\n        onClose={closeModal}\n        title={<Trans>Create New Template</Trans>}\n        size=\"sm\"\n        centered\n      >\n        <Stack gap=\"md\">\n          <TextInput\n            label={<Trans>Template Name</Trans>}\n            value={newTemplateName}\n            onChange={(e) => setNewTemplateName(e.target.value)}\n            onKeyDown={handleKeyDown}\n            disabled={isCreating}\n            data-autofocus\n          />\n          <Group justify=\"flex-end\">\n            <Button variant=\"default\" onClick={closeModal} disabled={isCreating}>\n              <Trans>Cancel</Trans>\n            </Button>\n            <Button\n              onClick={handleCreateTemplate}\n              disabled={!newTemplateName.trim()}\n              loading={isCreating}\n            >\n              <Trans>Create</Trans>\n            </Button>\n          </Group>\n        </Stack>\n      </Modal>\n\n      <Divider />\n\n      {/* Template list */}\n      <ScrollArea style={{ flex: 1 }} type=\"hover\" scrollbarSize={6}>\n        <Stack gap=\"xs\" p=\"xs\">\n          {isLoading ? (\n            <Box ta=\"center\" py=\"xl\">\n              <Loader size=\"sm\" />\n            </Box>\n          ) : filteredTemplates.length === 0 ? (\n            <EmptyState preset=\"no-results\" size=\"sm\" />\n          ) : (\n            filteredTemplates.map((template) => (\n              <TemplateCard\n                key={template.id}\n                template={template}\n                isSelected={template.id === selectedTemplateId}\n                onSelect={handleSelectTemplate}\n              />\n            ))\n          )}\n        </Stack>\n      </ScrollArea>\n    </Box>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/account-picker/account-picker.css",
    "content": "/**\n * AccountPicker styles.\n */\n\n.postybirb__account_picker_group_header {\n  width: 100%;\n  cursor: pointer;\n  transition: background-color 150ms ease;\n}\n\n.postybirb__account_picker_group_header:hover {\n  background-color: var(--mantine-color-gray-light-hover);\n}\n\n.postybirb__account_picker_account_row {\n  border-radius: var(--mantine-radius-xs);\n  transition: background-color 150ms ease;\n}\n\n.postybirb__account_picker_account_row:hover {\n  background-color: var(--mantine-color-gray-light-hover);\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/account-picker/account-picker.tsx",
    "content": "/**\n * AccountPicker - Shared component for selecting accounts grouped by website.\n * Displays websites as collapsible sections with checkboxes for each account.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport {\n    Badge,\n    Box,\n    Checkbox,\n    Collapse,\n    Group,\n    Paper,\n    Stack,\n    Text,\n    UnstyledButton,\n} from '@mantine/core';\nimport { useDisclosure } from '@mantine/hooks';\nimport type { AccountId } from '@postybirb/types';\nimport { SubmissionType } from '@postybirb/types';\nimport { IconChevronDown, IconChevronRight } from '@tabler/icons-react';\nimport { useCallback, useMemo } from 'react';\nimport { useAccounts } from '../../../stores/entity/account-store';\nimport {\n    useFileWebsites,\n    useMessageWebsites,\n} from '../../../stores/entity/website-store';\nimport type { AccountRecord, WebsiteRecord } from '../../../stores/records';\nimport './account-picker.css';\n\nexport interface AccountPickerProps {\n  /** Submission type to filter websites by support */\n  submissionType: SubmissionType;\n  /** Currently selected account IDs */\n  selectedAccountIds: AccountId[];\n  /** Callback when selection changes */\n  onSelectionChange: (accountIds: AccountId[]) => void;\n}\n\ninterface WebsiteAccountGroupProps {\n  website: WebsiteRecord;\n  accounts: AccountRecord[];\n  selectedAccountIds: AccountId[];\n  onToggleAccount: (accountId: AccountId, checked: boolean) => void;\n}\n\n/**\n * A single website group with collapsible account list.\n */\nfunction WebsiteAccountGroup({\n  website,\n  accounts,\n  selectedAccountIds,\n  onToggleAccount,\n}: WebsiteAccountGroupProps) {\n  const [expanded, { toggle }] = useDisclosure(false);\n\n  const selectedCount = useMemo(\n    () =>\n      accounts.filter((acc) => selectedAccountIds.includes(acc.accountId))\n        .length,\n    [accounts, selectedAccountIds],\n  );\n\n  const loggedInCount = useMemo(\n    () => accounts.filter((acc) => acc.isLoggedIn).length,\n    [accounts],\n  );\n\n  return (\n    <Paper withBorder radius=\"sm\" p={0}>\n      <UnstyledButton\n        onClick={toggle}\n        className=\"postybirb__account_picker_group_header\"\n      >\n        <Group gap=\"xs\" px=\"sm\" py=\"xs\" wrap=\"nowrap\">\n          {expanded ? (\n            <IconChevronDown size={14} style={{ flexShrink: 0 }} />\n          ) : (\n            <IconChevronRight size={14} style={{ flexShrink: 0 }} />\n          )}\n          <Text size=\"sm\" fw={500} style={{ flex: 1 }} truncate>\n            {website.displayName}\n          </Text>\n          <Group gap={4}>\n            {selectedCount > 0 && (\n              <Badge size=\"xs\" variant=\"filled\" color=\"blue\">\n                {selectedCount}\n              </Badge>\n            )}\n            <Badge\n              size=\"xs\"\n              variant=\"light\"\n\n            >\n              {loggedInCount}/{accounts.length}\n            </Badge>\n          </Group>\n        </Group>\n      </UnstyledButton>\n\n      <Collapse in={expanded}>\n        <Stack gap={2} pb=\"xs\" px=\"xs\">\n          {accounts.map((account) => {\n            const isSelected = selectedAccountIds.includes(account.accountId);\n            const { isLoggedIn } = account;\n\n            return (\n              <Box\n                key={account.id}\n                className=\"postybirb__account_picker_account_row\"\n                px=\"sm\"\n                py={4}\n              >\n                <Checkbox\n                  size=\"xs\"\n                  checked={isSelected}\n                  disabled={!isLoggedIn}\n                  onChange={(e) =>\n                    onToggleAccount(account.accountId, e.currentTarget.checked)\n                  }\n                  label={\n                    <Group gap=\"xs\">\n                      <Text size=\"sm\">{account.name}</Text>\n                      {account.username && (\n                        <Text size=\"xs\" c=\"dimmed\">\n                          ({account.username})\n                        </Text>\n                      )}\n                      {!isLoggedIn && (\n                        <Badge size=\"xs\" variant=\"light\" color=\"red\">\n                          <Trans>Not logged in</Trans>\n                        </Badge>\n                      )}\n                    </Group>\n                  }\n                />\n              </Box>\n            );\n          })}\n        </Stack>\n      </Collapse>\n    </Paper>\n  );\n}\n\n/**\n * Account picker component with websites grouped and expandable.\n */\nexport function AccountPicker({\n  submissionType,\n  selectedAccountIds,\n  onSelectionChange,\n}: AccountPickerProps) {\n  const accounts = useAccounts();\n  const fileWebsites = useFileWebsites();\n  const messageWebsites = useMessageWebsites();\n\n  // Group accounts by website - this must be done in useMemo to avoid infinite re-renders\n  const accountsByWebsite = useMemo(() => {\n    const grouped = new Map<string, AccountRecord[]>();\n    accounts.forEach((account) => {\n      const existing = grouped.get(account.website) ?? [];\n      existing.push(account);\n      grouped.set(account.website, existing);\n    });\n    return grouped;\n  }, [accounts]);\n\n  // Filter websites based on submission type\n  const websites = useMemo(\n    () =>\n      submissionType === SubmissionType.FILE ? fileWebsites : messageWebsites,\n    [submissionType, fileWebsites, messageWebsites],\n  );\n\n  // Toggle a single account\n  const handleToggleAccount = useCallback(\n    (accountId: AccountId, checked: boolean) => {\n      if (checked) {\n        onSelectionChange([...selectedAccountIds, accountId]);\n      } else {\n        onSelectionChange(selectedAccountIds.filter((id) => id !== accountId));\n      }\n    },\n    [selectedAccountIds, onSelectionChange],\n  );\n\n  return (\n    <Stack gap=\"xs\">\n      <Text fw={600} size=\"sm\">\n        <Trans>Websites</Trans>\n      </Text>\n      {websites.map((website) => {\n        const acc = accountsByWebsite.get(website.id) ?? [];\n        if (acc.length === 0) return null;\n\n        return (\n          <WebsiteAccountGroup\n            key={website.id}\n            website={website}\n            accounts={acc}\n            selectedAccountIds={selectedAccountIds}\n            onToggleAccount={handleToggleAccount}\n          />\n        );\n      })}\n    </Stack>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/account-picker/index.ts",
    "content": "export { AccountPicker } from './account-picker';\nexport type { AccountPickerProps } from './account-picker';\n\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/basic-website-select/basic-website-select.tsx",
    "content": "import { ComboboxItemGroup, MultiSelect } from '@mantine/core';\nimport { AccountId, IAccountDto } from '@postybirb/types';\nimport { useMemo } from 'react';\nimport {\n    groupAccountsByWebsite,\n    useAccounts,\n} from '../../../stores/entity/account-store';\nimport { AccountRecord } from '../../../stores/records';\n\nconst mapRecordToDto = (record: AccountRecord): IAccountDto => ({\n  id: record.id,\n  name: record.name,\n  website: record.website,\n  groups: record.groups,\n  state: record.state,\n  data: record.data,\n  websiteInfo: record.websiteInfo,\n  createdAt: record.createdAt.toISOString(),\n  updatedAt: record.updatedAt.toISOString(),\n});\n\ntype WebsiteSelectProps = {\n  selected: AccountId[];\n  size?: 'xs' | 'sm' | 'md' | 'lg';\n  label: JSX.Element;\n  onSelect(accounts: IAccountDto[]): void;\n};\n\nexport function BasicWebsiteSelect(props: WebsiteSelectProps) {\n  const { selected, size, label, onSelect } = props;\n  const accounts = useAccounts();\n\n  // Memoize grouped accounts to avoid re-creating Map on every render\n  const accountsByWebsite = useMemo(\n    () => groupAccountsByWebsite(accounts),\n    [accounts],\n  );\n\n  // Build options grouped by website display name\n  // Format: \"[WebsiteName] AccountName\" for tags\n  const options: ComboboxItemGroup[] = useMemo(() => {\n    const groups: ComboboxItemGroup[] = [];\n\n    accountsByWebsite.forEach(\n      (websiteAccounts: AccountRecord[], _websiteId: string) => {\n        if (websiteAccounts.length === 0) return;\n\n        // Get the display name from the first account's websiteInfo\n        const displayName = websiteAccounts[0].websiteDisplayName;\n\n        groups.push({\n          group: displayName,\n          items: websiteAccounts.map((account: AccountRecord) => ({\n            // Show \"[WebsiteName] AccountName\" format in the pills/tags\n            label: `[${displayName}] ${account.name}`,\n            value: account.id,\n          })),\n        });\n      },\n    );\n\n    // Sort groups alphabetically\n    groups.sort((a, b) => a.group.localeCompare(b.group));\n\n    return groups;\n  }, [accountsByWebsite]);\n\n  return (\n    <MultiSelect\n      className=\"postybirb__b-website-select\"\n      size={size ?? 'sm'}\n      label={label}\n      clearable\n      searchable\n      data={options}\n      value={selected}\n      onChange={(newValue) => {\n        const selectedAccounts = accounts\n          .filter((a: AccountRecord) => newValue.includes(a.id))\n          .map(mapRecordToDto);\n        onSelect(selectedAccounts);\n      }}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/copy-to-clipboard/copy-to-clipboard.tsx",
    "content": "/**\n * CopyToClipboard - Reusable copy to clipboard component.\n * Supports icon-only (ActionIcon) and button (with label) variants.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport { ActionIcon, Button, CopyButton, Tooltip } from '@mantine/core';\nimport { IconCheck, IconCopy } from '@tabler/icons-react';\n\nexport interface CopyToClipboardProps {\n  /** The value to copy to clipboard */\n  value: string | undefined;\n  /** Variant: 'icon' for ActionIcon, 'button' for Button with label */\n  variant?: 'icon' | 'button';\n  /** Size of the component */\n  size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';\n  /** Color when not copied (default: 'gray' for icon, 'blue' for button) */\n  color?: string;\n  /** Timeout in ms before resetting copied state (default: 2000) */\n  timeout?: number;\n  /** Tooltip position (for icon variant) */\n  tooltipPosition?: 'top' | 'right' | 'bottom' | 'left';\n}\n\n/** Icon size mapping based on component size */\nconst ICON_SIZE_MAP: Record<\n  NonNullable<CopyToClipboardProps['size']>,\n  number\n> = {\n  xs: 12,\n  sm: 14,\n  md: 16,\n  lg: 20,\n  xl: 24,\n};\n\n/**\n * Copy to clipboard component with icon or button variant.\n */\nexport function CopyToClipboard({\n  value,\n  variant = 'icon',\n  size = 'sm',\n  color,\n  timeout = 2000,\n  tooltipPosition = 'right',\n}: CopyToClipboardProps) {\n  if (!value || typeof value !== 'string') {\n    return null;\n  }\n\n  const defaultColor = variant === 'icon' ? 'gray' : 'blue';\n  const baseColor = color ?? defaultColor;\n  const iconSize = ICON_SIZE_MAP[size];\n\n  return (\n    <CopyButton value={value.trim()} timeout={timeout}>\n      {({ copied, copy }) => {\n        if (variant === 'button') {\n          return (\n            <Button\n              color={copied ? 'teal' : baseColor}\n              onClick={copy}\n              leftSection={<IconCopy size={iconSize} />}\n              size={size}\n              variant=\"subtle\"\n            >\n              {copied ? <Trans>Copied</Trans> : <Trans>Copy</Trans>}\n            </Button>\n          );\n        }\n\n        // Icon variant\n        return (\n          <Tooltip\n            label={<Trans>Copy</Trans>}\n            withArrow\n            position={tooltipPosition}\n          >\n            <ActionIcon\n              color={copied ? 'teal' : baseColor}\n              variant=\"subtle\"\n              onClick={copy}\n              size={size}\n            >\n              {copied ? (\n                <IconCheck size={iconSize} />\n              ) : (\n                <IconCopy size={iconSize} />\n              )}\n            </ActionIcon>\n          </Tooltip>\n        );\n      }}\n    </CopyButton>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/copy-to-clipboard/index.ts",
    "content": "export { CopyToClipboard } from './copy-to-clipboard';\nexport type { CopyToClipboardProps } from './copy-to-clipboard';\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/description-editor/components/bubble-toolbar.tsx",
    "content": "/* eslint-disable lingui/no-unlocalized-strings */\nimport {\n    ActionIcon,\n    CloseButton,\n    ColorInput,\n    Group,\n    Popover,\n    Stack,\n    Tooltip,\n} from '@mantine/core';\nimport { useDisclosure } from '@mantine/hooks';\nimport {\n    IconBold,\n    IconItalic,\n    IconStrikethrough,\n    IconUnderline,\n} from '@tabler/icons-react';\nimport type { Editor } from '@tiptap/react';\nimport { BubbleMenu } from '@tiptap/react/menus';\nimport { useCallback, useEffect, useRef } from 'react';\n\ninterface BubbleToolbarProps {\n  editor: Editor;\n}\n\n/**\n * Floating toolbar that appears on text selection.\n * Shows compact inline formatting options.\n */\nexport function BubbleToolbar({ editor }: BubbleToolbarProps) {\n  const [colorOpened, { toggle: toggleColor, close: closeColor }] = useDisclosure(false);\n  const bubbleRef = useRef<HTMLDivElement>(null);\n\n  // Close the color picker when the selection collapses (bubble menu hides)\n  useEffect(() => {\n    const handler = () => {\n      const { from, to } = editor.state.selection;\n      if (from === to) {\n        closeColor();\n      }\n    };\n    editor.on('selectionUpdate', handler);\n    return () => {\n      editor.off('selectionUpdate', handler);\n    };\n  }, [editor, closeColor]);\n\n  const handleColorChange = useCallback(\n    (color: string) => {\n      if (color) {\n        editor.chain().focus().setColor(color).run();\n      } else {\n        editor.chain().focus().unsetColor().run();\n      }\n    },\n    [editor],\n  );\n\n  const currentColor = editor.getAttributes('textStyle')?.color || '';\n\n  return (\n    <BubbleMenu\n      editor={editor}\n      options={{ placement: 'top', offset: 8, flip: true, shift: true, inline: true }}\n    >\n      <div\n        className=\"pb-bubble-menu\"\n        ref={bubbleRef}\n        role=\"toolbar\"\n        onMouseDown={(e) => {\n          // Prevent blur when interacting with the color picker\n          if (colorOpened) {\n            e.preventDefault();\n          }\n        }}\n      >\n        <Group\n          gap={2}\n          p={4}\n          style={{\n            background: 'var(--mantine-color-body)',\n            border: '1px solid var(--mantine-color-default-border)',\n            borderRadius: 'var(--mantine-radius-md)',\n            boxShadow: 'var(--mantine-shadow-md)',\n          }}\n        >\n          <BubbleButton\n            icon={<IconBold size={14} />}\n            label=\"Bold\"\n            isActive={editor.isActive('bold')}\n            onClick={() => editor.chain().focus().toggleBold().run()}\n          />\n          <BubbleButton\n            icon={<IconItalic size={14} />}\n            label=\"Italic\"\n            isActive={editor.isActive('italic')}\n            onClick={() => editor.chain().focus().toggleItalic().run()}\n          />\n          <BubbleButton\n            icon={<IconUnderline size={14} />}\n            label=\"Underline\"\n            isActive={editor.isActive('underline')}\n            onClick={() => editor.chain().focus().toggleUnderline().run()}\n          />\n          <BubbleButton\n            icon={<IconStrikethrough size={14} />}\n            label=\"Strike\"\n            isActive={editor.isActive('strike')}\n            onClick={() => editor.chain().focus().toggleStrike().run()}\n          />\n          <Popover\n            opened={colorOpened}\n            onClose={closeColor}\n            width={220}\n            shadow=\"md\"\n            withinPortal={false}\n            position=\"bottom\"\n          >\n            <Popover.Target>\n              <Tooltip label=\"Text Color\" withArrow openDelay={300}>\n                <ActionIcon\n                  size=\"xs\"\n                  variant=\"subtle\"\n                  color=\"gray\"\n                  style={currentColor ? { color: currentColor } : undefined}\n                  onClick={toggleColor}\n                >\n                  <span style={{ fontWeight: 'bold', fontSize: '12px' }}>A</span>\n                </ActionIcon>\n              </Tooltip>\n            </Popover.Target>\n            <Popover.Dropdown\n              onMouseDown={(e: React.MouseEvent) => e.preventDefault()}\n              onKeyDown={(e: React.KeyboardEvent) => {\n                if (e.key === 'Escape') {\n                  closeColor();\n                  editor.commands.focus();\n                }\n              }}\n            >\n              <Stack gap={4}>\n                <Group justify=\"flex-end\">\n                  <CloseButton size=\"xs\" onClick={() => { closeColor(); editor.commands.focus(); }} />\n                </Group>\n                <ColorInput\n                  size=\"xs\"\n                  value={currentColor}\n                  onChange={handleColorChange}\n                  swatches={[\n                    '#000000', '#868e96', '#fa5252', '#e64980', '#be4bdb',\n                    '#7950f2', '#4c6ef5', '#228be6', '#15aabf', '#12b886',\n                    '#40c057', '#82c91e', '#fab005', '#fd7e14',\n                  ]}\n                  swatchesPerRow={7}\n                />\n              </Stack>\n            </Popover.Dropdown>\n          </Popover>\n        </Group>\n      </div>\n    </BubbleMenu>\n  );\n}\n\nfunction BubbleButton({\n  icon,\n  label,\n  isActive,\n  onClick,\n}: {\n  icon: React.ReactNode;\n  label: string;\n  isActive: boolean;\n  onClick: () => void;\n}) {\n  return (\n    <Tooltip label={label} withArrow openDelay={150}>\n      <ActionIcon\n        size=\"xs\"\n        variant={isActive ? 'filled' : 'subtle'}\n        color={isActive ? 'blue' : 'gray'}\n        onClick={onClick}\n      >\n        {icon}\n      </ActionIcon>\n    </Tooltip>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/description-editor/components/description-toolbar.tsx",
    "content": "/* eslint-disable lingui/no-unlocalized-strings */\nimport { Trans } from '@lingui/react/macro';\nimport {\n  ActionIcon,\n  Button,\n  CloseButton,\n  ColorInput,\n  Divider,\n  Group,\n  Modal,\n  Popover,\n  Stack,\n  TextInput,\n  Tooltip,\n} from '@mantine/core';\nimport { useDisclosure } from '@mantine/hooks';\nimport {\n  IconAlignCenter,\n  IconAlignLeft,\n  IconAlignRight,\n  IconArrowBackUp,\n  IconArrowForwardUp,\n  IconBold,\n  IconEye,\n  IconH1,\n  IconH2,\n  IconH3,\n  IconIndentDecrease,\n  IconIndentIncrease,\n  IconItalic,\n  IconLine,\n  IconLink,\n  IconList,\n  IconListNumbers,\n  IconPhoto,\n  IconSourceCode,\n  IconStrikethrough,\n  IconUnderline,\n} from '@tabler/icons-react';\nimport type { Editor } from '@tiptap/react';\nimport { useCallback, useEffect, useState } from 'react';\n\ninterface DescriptionToolbarProps {\n  editor: Editor | null;\n  onEditHtml?: () => void;\n  onInsertMedia?: () => void;\n  onPreview?: () => void;\n}\n\n/**\n * Fixed toolbar rendered above the TipTap editor.\n * Each button reads editor state for active styling and dispatches the appropriate command.\n */\nexport function DescriptionToolbar({\n  editor,\n  onEditHtml,\n  onInsertMedia,\n  onPreview,\n}: DescriptionToolbarProps) {\n  if (!editor) return null;\n\n  return (\n    <Group\n      gap={2}\n      p={4}\n      style={{\n        borderBottom: '1px solid var(--mantine-color-default-border)',\n        flexWrap: 'wrap',\n      }}\n    >\n      {/* Formatting */}\n      <ToolbarButton\n        icon={<IconBold size={16} />}\n        label=\"Bold\"\n        isActive={editor.isActive('bold')}\n        onClick={() => editor.chain().focus().toggleBold().run()}\n      />\n      <ToolbarButton\n        icon={<IconItalic size={16} />}\n        label=\"Italic\"\n        isActive={editor.isActive('italic')}\n        onClick={() => editor.chain().focus().toggleItalic().run()}\n      />\n      <ToolbarButton\n        icon={<IconUnderline size={16} />}\n        label=\"Underline\"\n        isActive={editor.isActive('underline')}\n        onClick={() => editor.chain().focus().toggleUnderline().run()}\n      />\n      <ToolbarButton\n        icon={<IconStrikethrough size={16} />}\n        label=\"Strikethrough\"\n        isActive={editor.isActive('strike')}\n        onClick={() => editor.chain().focus().toggleStrike().run()}\n      />\n      <TextColorButton editor={editor} />\n\n      <Divider orientation=\"vertical\" mx={4} />\n\n      {/* Headings */}\n      <ToolbarButton\n        icon={<IconH1 size={16} />}\n        label=\"Heading 1\"\n        isActive={editor.isActive('heading', { level: 1 })}\n        onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}\n      />\n      <ToolbarButton\n        icon={<IconH2 size={16} />}\n        label=\"Heading 2\"\n        isActive={editor.isActive('heading', { level: 2 })}\n        onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}\n      />\n      <ToolbarButton\n        icon={<IconH3 size={16} />}\n        label=\"Heading 3\"\n        isActive={editor.isActive('heading', { level: 3 })}\n        onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}\n      />\n\n      <Divider orientation=\"vertical\" mx={4} />\n\n      {/* Block types */}\n      <ToolbarButton\n        icon={<IconList size={16} />}\n        label=\"Bullet List\"\n        isActive={editor.isActive('bulletList')}\n        onClick={() => editor.chain().focus().toggleBulletList().run()}\n      />\n      <ToolbarButton\n        icon={<IconListNumbers size={16} />}\n        label=\"Ordered List\"\n        isActive={editor.isActive('orderedList')}\n        onClick={() => editor.chain().focus().toggleOrderedList().run()}\n      />\n\n      <Divider orientation=\"vertical\" mx={4} />\n\n      {/* Insert */}\n      <ToolbarButton\n        icon={<IconLine size={16} />}\n        label=\"Horizontal Rule\"\n        isActive={false}\n        onClick={() => editor.chain().focus().setHorizontalRule().run()}\n      />\n      <LinkButton editor={editor} />\n      <ToolbarButton\n        icon={<IconPhoto size={16} />}\n        label=\"Insert Image / Video\"\n        isActive={false}\n        onClick={() => onInsertMedia?.()}\n      />\n\n      <Divider orientation=\"vertical\" mx={4} />\n\n      {/* Alignment */}\n      <ToolbarButton\n        icon={<IconAlignLeft size={16} />}\n        label=\"Align Left\"\n        isActive={editor.isActive({ textAlign: 'left' })}\n        onClick={() => editor.chain().focus().setTextAlign('left').run()}\n      />\n      <ToolbarButton\n        icon={<IconAlignCenter size={16} />}\n        label=\"Align Center\"\n        isActive={editor.isActive({ textAlign: 'center' })}\n        onClick={() => editor.chain().focus().setTextAlign('center').run()}\n      />\n      <ToolbarButton\n        icon={<IconAlignRight size={16} />}\n        label=\"Align Right\"\n        isActive={editor.isActive({ textAlign: 'right' })}\n        onClick={() => editor.chain().focus().setTextAlign('right').run()}\n      />\n\n      <Divider orientation=\"vertical\" mx={4} />\n\n      {/* Indent */}\n      <ToolbarButton\n        icon={<IconIndentIncrease size={16} />}\n        label=\"Indent\"\n        isActive={false}\n        onClick={() => editor.chain().focus().indent().run()}\n      />\n      <ToolbarButton\n        icon={<IconIndentDecrease size={16} />}\n        label=\"Outdent\"\n        isActive={false}\n        onClick={() => editor.chain().focus().outdent().run()}\n      />\n\n      <Divider orientation=\"vertical\" mx={4} />\n\n      {/* History */}\n      <ToolbarButton\n        icon={<IconArrowBackUp size={16} />}\n        label=\"Undo\"\n        isActive={false}\n        onClick={() => editor.chain().focus().undo().run()}\n        disabled={!editor.can().undo()}\n      />\n      <ToolbarButton\n        icon={<IconArrowForwardUp size={16} />}\n        label=\"Redo\"\n        isActive={false}\n        onClick={() => editor.chain().focus().redo().run()}\n        disabled={!editor.can().redo()}\n      />\n\n      <Divider orientation=\"vertical\" mx={4} />\n\n      {/* HTML source */}\n      <ToolbarButton\n        icon={<IconSourceCode size={16} />}\n        label=\"Edit HTML\"\n        isActive={false}\n        onClick={() => onEditHtml?.()}\n      />\n\n      {/* Preview parsed description */}\n      {onPreview && (\n        <ToolbarButton\n          icon={<IconEye size={16} />}\n          label=\"Preview\"\n          isActive={false}\n          onClick={() => onPreview()}\n        />\n      )}\n    </Group>\n  );\n}\n\n/** Generic toolbar action icon button */\nfunction ToolbarButton({\n  icon,\n  label,\n  isActive,\n  onClick,\n  disabled,\n}: {\n  icon: React.ReactNode;\n  label: string;\n  isActive: boolean;\n  onClick: () => void;\n  disabled?: boolean;\n}) {\n  return (\n    <Tooltip label={label} withArrow openDelay={500}>\n      <ActionIcon\n        size=\"sm\"\n        variant={isActive ? 'filled' : 'subtle'}\n        color={isActive ? 'blue' : 'gray'}\n        onClick={onClick}\n        disabled={disabled}\n      >\n        {icon}\n      </ActionIcon>\n    </Tooltip>\n  );\n}\n\n/** Link insert button with URL prompt */\nfunction LinkButton({ editor }: { editor: Editor }) {\n  const isActive = editor.isActive('link');\n  const [opened, { open, close }] = useDisclosure(false);\n  const [url, setUrl] = useState('');\n\n  // Read clipboard text when modal opens\n  useEffect(() => {\n    if (opened) {\n      const getClipboardUrl = async () => {\n        try {\n          const text = await navigator.clipboard.readText();\n\n          if (text.startsWith('http://') || text.startsWith('https://')) {\n            setUrl(text);\n          } else {\n            setUrl('');\n          }\n        } catch (err) {\n          // eslint-disable-next-line no-console\n          console.warn('Failed to read clipboard:', err);\n          setUrl('');\n        }\n      };\n      getClipboardUrl();\n    }\n  }, [opened]);\n\n  const handleInsertLink = useCallback(() => {\n    if (url && url.trim()) {\n      editor.chain().focus().setLink({ href: url.trim() }).run();\n    }\n    close();\n    setUrl('');\n  }, [editor, url, close]);\n\n  const handleRemoveLink = useCallback(() => {\n    editor.chain().focus().unsetLink().run();\n  }, [editor]);\n\n  const handleMainClick = useCallback(() => {\n    if (isActive) {\n      handleRemoveLink();\n    } else {\n      open();\n    }\n  }, [isActive, handleRemoveLink, open]);\n\n  return (\n    <>\n      <Tooltip\n        label={isActive ? 'Remove Link' : 'Insert Link'}\n        withArrow\n        openDelay={500}\n      >\n        <ActionIcon\n          size=\"sm\"\n          variant={isActive ? 'filled' : 'subtle'}\n          color={isActive ? 'blue' : 'gray'}\n          onClick={handleMainClick}\n        >\n          <IconLink size={16} />\n        </ActionIcon>\n      </Tooltip>\n\n      <Modal\n        opened={opened}\n        onClose={close}\n        title=\"Insert Link\"\n        size=\"md\"\n        centered\n      >\n        <TextInput\n          label=\"URL\"\n          placeholder=\"https://example.com\"\n          value={url}\n          onChange={(event) => setUrl(event.currentTarget.value)}\n          data-autofocus\n          mb=\"md\"\n        />\n        <Group justify=\"flex-end\">\n          <Button variant=\"default\" onClick={close}>\n            <Trans>Cancel</Trans>\n          </Button>\n          <Button onClick={handleInsertLink} disabled={!url.trim()}>\n            <Trans>Insert</Trans>\n          </Button>\n        </Group>\n      </Modal>\n    </>\n  );\n}\n\n/** Text color picker button */\nfunction TextColorButton({ editor }: { editor: Editor }) {\n  const [opened, { toggle, close }] = useDisclosure(false);\n  const currentColor = editor.getAttributes('textStyle')?.color || '';\n\n  const handleChange = useCallback(\n    (color: string) => {\n      if (color) {\n        editor.chain().focus().setColor(color).run();\n      } else {\n        editor.chain().focus().unsetColor().run();\n      }\n    },\n    [editor],\n  );\n\n  return (\n    <Popover\n      opened={opened}\n      onClose={close}\n      width={220}\n      shadow=\"md\"\n      position=\"bottom\"\n    >\n      <Popover.Target>\n        <Tooltip label=\"Text Color\" withArrow openDelay={500}>\n          <ActionIcon\n            size=\"sm\"\n            variant=\"subtle\"\n            color=\"gray\"\n            style={currentColor ? { color: currentColor } : undefined}\n            onClick={toggle}\n          >\n            <span style={{ fontWeight: 'bold', fontSize: '14px' }}>A</span>\n          </ActionIcon>\n        </Tooltip>\n      </Popover.Target>\n      <Popover.Dropdown\n        onKeyDown={(e: React.KeyboardEvent) => {\n          if (e.key === 'Escape') {\n            close();\n            editor.commands.focus();\n          }\n        }}\n      >\n        <Stack gap={4}>\n          <Group justify=\"flex-end\">\n            <CloseButton\n              size=\"xs\"\n              onClick={() => {\n                close();\n                editor.commands.focus();\n              }}\n            />\n          </Group>\n          <ColorInput\n            size=\"xs\"\n            value={currentColor}\n            onChange={handleChange}\n            swatches={[\n              '#000000',\n              '#868e96',\n              '#fa5252',\n              '#e64980',\n              '#be4bdb',\n              '#7950f2',\n              '#4c6ef5',\n              '#228be6',\n              '#15aabf',\n              '#12b886',\n              '#40c057',\n              '#82c91e',\n              '#fab005',\n              '#fd7e14',\n            ]}\n            swatchesPerRow={7}\n          />\n        </Stack>\n      </Popover.Dropdown>\n    </Popover>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/description-editor/components/html-edit-modal.tsx",
    "content": "/* eslint-disable lingui/no-unlocalized-strings */\nimport { Button, Group, Modal, Textarea } from '@mantine/core';\nimport type { Editor } from '@tiptap/react';\nimport { useCallback, useEffect, useState } from 'react';\n\ninterface HtmlEditModalProps {\n  editor: Editor;\n  opened: boolean;\n  onClose: () => void;\n}\n\n/**\n * Modal that lets users view and edit the raw HTML of the editor content.\n */\nexport function HtmlEditModal({ editor, opened, onClose }: HtmlEditModalProps) {\n  const [html, setHtml] = useState('');\n\n  // Sync editor HTML into state when the modal opens\n  useEffect(() => {\n    if (opened) {\n      setHtml(editor.getHTML());\n    }\n  }, [opened, editor]);\n\n  const handleApply = useCallback(() => {\n    editor.commands.setContent(html, { emitUpdate: true });\n    onClose();\n  }, [editor, html, onClose]);\n\n  return (\n    <Modal\n      opened={opened}\n      onClose={onClose}\n      title=\"Edit HTML\"\n      size=\"xl\"\n      centered\n    >\n      <Textarea\n        value={html}\n        onChange={(e) => setHtml(e.currentTarget.value)}\n        autosize\n        minRows={10}\n        maxRows={25}\n        styles={{\n          input: {\n            fontFamily: 'monospace',\n            fontSize: '13px',\n          },\n        }}\n      />\n      <Group justify=\"flex-end\" mt=\"md\">\n        <Button variant=\"subtle\" onClick={onClose}>\n          Cancel\n        </Button>\n        <Button onClick={handleApply}>Apply</Button>\n      </Group>\n    </Modal>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/description-editor/components/insert-media-modal.tsx",
    "content": "/* eslint-disable lingui/no-unlocalized-strings */\nimport { Trans } from '@lingui/react/macro';\nimport {\n    Button,\n    Group,\n    Modal,\n    NumberInput,\n    SegmentedControl,\n    Stack,\n    TextInput,\n} from '@mantine/core';\nimport type { Editor } from '@tiptap/react';\nimport { useCallback, useState } from 'react';\n\ninterface InsertMediaModalProps {\n  editor: Editor;\n  opened: boolean;\n  onClose: () => void;\n}\n\ntype MediaType = 'image' | 'video';\n\n/**\n * Modal for inserting an image or video from a URL with optional width/height.\n */\nexport function InsertMediaModal({\n  editor,\n  opened,\n  onClose,\n}: InsertMediaModalProps) {\n  const [mediaType, setMediaType] = useState<MediaType>('image');\n  const [url, setUrl] = useState('');\n  const [width, setWidth] = useState<number | ''>('');\n  const [height, setHeight] = useState<number | ''>('');\n\n  const reset = useCallback(() => {\n    setUrl('');\n    setWidth('');\n    setHeight('');\n    setMediaType('image');\n  }, []);\n\n  const handleClose = useCallback(() => {\n    reset();\n    onClose();\n  }, [onClose, reset]);\n\n  const handleInsert = useCallback(() => {\n    if (!url.trim()) return;\n\n    const src = url.trim();\n\n    if (mediaType === 'image') {\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n      const attrs: Record<string, any> = { src };\n      if (width) attrs.width = width;\n      if (height) attrs.height = height;\n      editor.chain().focus().insertContent({ type: 'image', attrs }).run();\n    } else {\n      // Insert a video element via raw HTML\n      const parts = [`<video src=\"${src}\" controls`];\n      if (width) parts.push(` width=\"${width}\"`);\n      if (height) parts.push(` height=\"${height}\"`);\n      parts.push('></video>');\n      editor.chain().focus().insertContent(parts.join('')).run();\n    }\n\n    handleClose();\n  }, [editor, url, width, height, mediaType, handleClose]);\n\n  return (\n    <Modal\n      opened={opened}\n      onClose={handleClose}\n      title={<Trans>Insert Media</Trans>}\n      size=\"sm\"\n    >\n      <Stack gap=\"sm\">\n        <SegmentedControl\n          fullWidth\n          value={mediaType}\n          onChange={(v) => setMediaType(v as MediaType)}\n          data={[\n            { label: 'Image', value: 'image' },\n            { label: 'Video', value: 'video' },\n          ]}\n        />\n\n        <TextInput\n          label={<Trans>URL</Trans>}\n          placeholder={\n            mediaType === 'image'\n              ? 'https://example.com/image.png'\n              : 'https://example.com/video.mp4'\n          }\n          value={url}\n          onChange={(e) => setUrl(e.currentTarget.value)}\n          required\n          data-autofocus\n        />\n\n        <Group grow>\n          <NumberInput\n            label={<Trans>Width</Trans>}\n            placeholder=\"auto\"\n            value={width}\n            onChange={(v) => setWidth(typeof v === 'number' ? v : '')}\n            min={1}\n            max={9999}\n            allowNegative={false}\n          />\n          <NumberInput\n            label={<Trans>Height</Trans>}\n            placeholder=\"auto\"\n            value={height}\n            onChange={(v) => setHeight(typeof v === 'number' ? v : '')}\n            min={1}\n            max={9999}\n            allowNegative={false}\n          />\n        </Group>\n\n        <Group justify=\"flex-end\" mt=\"xs\">\n          <Button variant=\"subtle\" onClick={handleClose}>\n            <Trans>Cancel</Trans>\n          </Button>\n          <Button onClick={handleInsert} disabled={!url.trim()}>\n            <Trans>Insert</Trans>\n          </Button>\n        </Group>\n      </Stack>\n    </Modal>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/description-editor/components/suggestion-menu.tsx",
    "content": "/* eslint-disable lingui/no-unlocalized-strings */\nimport { Box, Text, UnstyledButton } from '@mantine/core';\nimport {\n    forwardRef,\n    useCallback,\n    useEffect,\n    useImperativeHandle,\n    useRef,\n    useState,\n} from 'react';\n\nexport interface SuggestionItem {\n  title: string;\n  icon?: React.ReactNode;\n  group?: string;\n  aliases?: string[];\n  onSelect: () => void;\n}\n\nexport interface SuggestionMenuRef {\n  onKeyDown: (props: { event: KeyboardEvent }) => boolean;\n}\n\ninterface SuggestionMenuProps {\n  items: SuggestionItem[];\n  command: (item: SuggestionItem) => void;\n}\n\n/**\n * A Mantine-based dropdown menu used by all suggestion/slash-menu triggers.\n * TipTap's @tiptap/suggestion calls this component and manages its lifecycle.\n */\nexport const SuggestionMenu = forwardRef<SuggestionMenuRef, SuggestionMenuProps>(\n  ({ items, command }, ref) => {\n    const [selectedIndex, setSelectedIndex] = useState(0);\n    const scrollContainerRef = useRef<HTMLDivElement>(null);\n\n    // Reset selection when items change\n    useEffect(() => {\n      setSelectedIndex(0);\n    }, [items]);\n\n    // Scroll selected item into view\n    useEffect(() => {\n      const container = scrollContainerRef.current;\n      if (!container) return;\n      const selectedEl = container.querySelector(`[data-index=\"${selectedIndex}\"]`);\n      if (selectedEl) {\n        selectedEl.scrollIntoView({ block: 'nearest' });\n      }\n    }, [selectedIndex]);\n\n    const selectItem = useCallback(\n      (index: number) => {\n        const item = items[index];\n        if (item) {\n          command(item);\n        }\n      },\n      [items, command],\n    );\n\n    // Expose keyboard handler to TipTap's suggestion plugin\n    useImperativeHandle(ref, () => ({\n      onKeyDown: ({ event }: { event: KeyboardEvent }) => {\n        if (event.key === 'ArrowUp') {\n          setSelectedIndex((prev) => (prev <= 0 ? items.length - 1 : prev - 1));\n          return true;\n        }\n        if (event.key === 'ArrowDown') {\n          setSelectedIndex((prev) => (prev >= items.length - 1 ? 0 : prev + 1));\n          return true;\n        }\n        if (event.key === 'Enter') {\n          selectItem(selectedIndex);\n          return true;\n        }\n        return false;\n      },\n    }));\n\n    if (items.length === 0) {\n      return null;\n    }\n\n    // Group items by their group property\n    const groups: { name: string; items: (SuggestionItem & { globalIndex: number })[] }[] = [];\n    let globalIndex = 0;\n    for (const item of items) {\n      const groupName = item.group ?? '';\n      let group = groups.find((g) => g.name === groupName);\n      if (!group) {\n        group = { name: groupName, items: [] };\n        groups.push(group);\n      }\n      group.items.push({ ...item, globalIndex });\n      globalIndex++;\n    }\n\n    return (\n      <Box\n        ref={scrollContainerRef}\n        style={{\n          background: 'var(--mantine-color-body)',\n          border: '1px solid var(--mantine-color-default-border)',\n          borderRadius: 'var(--mantine-radius-md)',\n          boxShadow: 'var(--mantine-shadow-md)',\n          maxHeight: '300px',\n          overflow: 'auto',\n          padding: '4px',\n          minWidth: '200px',\n        }}\n      >\n        {groups.map((group) => (\n          <Box key={group.name}>\n            {group.name && (\n              <Text size=\"xs\" c=\"dimmed\" fw={600} px=\"xs\" pt=\"xs\" pb={4} tt=\"uppercase\">\n                {group.name}\n              </Text>\n            )}\n            {group.items.map((item) => (\n              <UnstyledButton\n                key={item.globalIndex}\n                data-index={item.globalIndex}\n                onClick={() => selectItem(item.globalIndex)}\n                style={{\n                  display: 'flex',\n                  alignItems: 'center',\n                  gap: '8px',\n                  width: '100%',\n                  padding: '6px 8px',\n                  borderRadius: 'var(--mantine-radius-sm)',\n                  background:\n                    selectedIndex === item.globalIndex\n                      ? 'var(--mantine-color-blue-light)'\n                      : 'transparent',\n                  transition: 'background 0.1s ease',\n                }}\n              >\n                {item.icon && (\n                  <Box style={{ display: 'flex', alignItems: 'center', opacity: 0.7 }}>\n                    {item.icon}\n                  </Box>\n                )}\n                <Text size=\"sm\">{item.title}</Text>\n              </UnstyledButton>\n            ))}\n          </Box>\n        ))}\n      </Box>\n    );\n  },\n);\n\nSuggestionMenu.displayName = 'SuggestionMenu';\n\n/**\n * Filter suggestion items by query string.\n * Matches against title and aliases.\n */\nexport function filterSuggestionItems(\n  items: SuggestionItem[],\n  query: string,\n): SuggestionItem[] {\n  const q = query.trim().toLowerCase();\n  if (!q) return items;\n\n  return items.filter((item) => {\n    const title = item.title.toLowerCase();\n    const aliases = item.aliases?.map((a) => a.toLowerCase()) ?? [];\n    return title.includes(q) || aliases.some((a) => a.includes(q));\n  });\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/description-editor/custom-blocks/index.ts",
    "content": "export { WebsiteOnlySelector } from './website-only-selector';\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/description-editor/custom-blocks/shortcut.css",
    "content": "/* Styling for username shortcut input */\n.username-shortcut-input {\n  border: none;\n  background: transparent;\n  outline: none;\n  font-family: inherit;\n  font-size: inherit;\n  color: inherit;\n  min-width: 60px;\n  width: 70px;\n  padding: 0 6px;\n  height: 100%;\n  line-height: inherit;\n  box-sizing: border-box;\n}\n\n.username-shortcut-input::placeholder {\n  color: var(--mantine-color-dimmed);\n  font-style: italic;\n  opacity: 0.6;\n}\n\n.username-shortcut-input:focus {\n  background-color: rgba(128, 128, 128, 0.08);\n  border-radius: var(--mantine-radius-sm);\n}\n\n/* Thin vertical separator before the username input */\n.username-shortcut-separator {\n  display: inline-block;\n  width: 1px;\n  height: 12px;\n  background-color: currentColor;\n  opacity: 0.25;\n  margin: 0 2px;\n  vertical-align: middle;\n}\n\n.only-website-toggle-btn:hover {\n  background-color: rgba(128, 128, 128, 0.1);\n}\n\n/* Hover effect for inline website selector text */\n.only-website-selector:hover {\n  background-color: rgba(128, 128, 128, 0.12);\n}\n\n/* Shortcut container styling */\n.default-shortcut-container,\n.custom-shortcut-container,\n.system-shortcut-container {\n  display: inline-flex;\n  align-items: center;\n  vertical-align: text-bottom;\n}\n\n/* Ensure badge labels align properly */\n.default-shortcut-container .mantine-Badge-root,\n.custom-shortcut-container .mantine-Badge-root,\n.system-shortcut-container .mantine-Badge-root {\n  display: inline-flex;\n  align-items: center;\n  height: fit-content;\n}\n\n/* Resizable image node styling */\n.resizable-image {\n  border: 2px solid transparent;\n  border-radius: var(--mantine-radius-xs);\n  transition: border-color 0.15s ease;\n}\n\n.resizable-image:hover {\n  border-color: var(--mantine-color-blue-4);\n}\n\n.resizable-image--selected {\n  border-color: var(--mantine-color-blue-5);\n  box-shadow: 0 0 0 1px var(--mantine-color-blue-2);\n}\n\n.resizable-image-wrapper--active .resizable-image {\n  border-color: var(--mantine-color-blue-5);\n}\n\n/* Drag handles */\n.resizable-image-handle {\n  opacity: 0;\n  transition: opacity 0.15s ease;\n}\n\n.resizable-image-wrapper--active .resizable-image-handle {\n  opacity: 1;\n}\n\n.resizable-image-handle:hover {\n  background: var(--mantine-color-blue-6) !important;\n  transform: scale(1.3);\n}\n\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/description-editor/custom-blocks/website-only-selector.tsx",
    "content": "/* eslint-disable lingui/no-unlocalized-strings */\nimport { Trans } from '@lingui/react/macro';\nimport {\n  Badge,\n  Box,\n  Button,\n  Checkbox,\n  Divider,\n  Group,\n  Popover,\n  Stack,\n  Text,\n  TextInput,\n  ThemeIcon,\n  Tooltip,\n  UnstyledButton,\n} from '@mantine/core';\nimport { useDebouncedValue, useDisclosure } from '@mantine/hooks';\nimport {\n  IconChevronDown,\n  IconSearch,\n  IconWorld,\n  IconX,\n} from '@tabler/icons-react';\nimport { useCallback, useEffect, useMemo, useState } from 'react';\nimport { useWebsites } from '../../../../stores';\n\nexport interface WebsiteOnlySelectorProps {\n  /** Comma-separated list of selected website IDs */\n  only: string;\n  /** Callback when the selection changes */\n  onOnlyChange: (newOnly: string) => void;\n}\n\n/**\n * Reusable component for selecting which websites a shortcut applies to.\n * Renders as a badge with a popover for website selection.\n */\nexport function WebsiteOnlySelector({\n  only,\n  onOnlyChange,\n}: WebsiteOnlySelectorProps) {\n  const websites = useWebsites();\n\n  // Popover state management for website selection\n  const [opened, { close, toggle }] = useDisclosure(false);\n\n  // Search state with debouncing for website filter\n  const [searchTerm, setSearchTerm] = useState('');\n  const [debouncedSearchTerm] = useDebouncedValue(searchTerm, 300);\n\n  // Local state for selected website IDs\n  const [selectedWebsiteIds, setSelectedWebsiteIds] = useState<string[]>(() =>\n    only ? only.split(',').filter(Boolean) : []\n  );\n\n  // Sync local website selection state when props change\n  useEffect(() => {\n    const propsIds = only ? only.split(',').filter(Boolean) : [];\n    setSelectedWebsiteIds(propsIds);\n  }, [only]);\n\n  // Available website options\n  const websiteOptions = useMemo(\n    () => websites.map((w) => ({ value: w.id, label: w.displayName })),\n    [websites]\n  );\n\n  // Filtered website options based on search\n  const filteredWebsiteOptions = useMemo(() => {\n    if (!debouncedSearchTerm) return websiteOptions;\n    return websiteOptions.filter((option) =>\n      option.label.toLowerCase().includes(debouncedSearchTerm.toLowerCase())\n    );\n  }, [websiteOptions, debouncedSearchTerm]);\n\n  // Update website selection\n  const updateWebsiteSelection = useCallback(\n    (newOnlyValue: string) => {\n      const newIds = newOnlyValue\n        ? newOnlyValue.split(',').filter(Boolean)\n        : [];\n      setSelectedWebsiteIds(newIds);\n      onOnlyChange(newOnlyValue);\n    },\n    [onOnlyChange]\n  );\n\n  // Handle individual website toggle\n  const handleWebsiteToggle = useCallback(\n    (websiteId: string) => {\n      const newSelected = selectedWebsiteIds.includes(websiteId)\n        ? selectedWebsiteIds.filter((id) => id !== websiteId)\n        : [...selectedWebsiteIds, websiteId];\n      updateWebsiteSelection(newSelected.join(','));\n    },\n    [selectedWebsiteIds, updateWebsiteSelection]\n  );\n\n  // Handle select all / deselect all\n  const handleSelectAll = useCallback(() => {\n    const allIds = websiteOptions.map((opt) => opt.value);\n    const isAllSelected = selectedWebsiteIds.length === allIds.length;\n    updateWebsiteSelection(isAllSelected ? '' : allIds.join(','));\n  }, [websiteOptions, selectedWebsiteIds, updateWebsiteSelection]);\n\n  // Display text for selected websites\n  const selectedDisplayText = useMemo(() => {\n    if (selectedWebsiteIds.length === 0) {\n      return <Trans>All Websites</Trans>;\n    }\n\n    if (selectedWebsiteIds.length === 1) {\n      const website = websites.find((w) => w.id === selectedWebsiteIds[0]);\n      return website?.displayName || <Trans>Unknown</Trans>;\n    }\n\n    const names = selectedWebsiteIds\n      .map((id) => websites.find((w) => w.id === id)?.displayName)\n      .filter(Boolean)\n      .slice(0, 3);\n    if (selectedWebsiteIds.length <= 3) {\n      return names.join(', ');\n    }\n\n    return `${names.join(', ')} + ${selectedWebsiteIds.length - 3}`;\n  }, [selectedWebsiteIds, websites]);\n\n  // Color logic for the website badge\n  const badgeColor = useMemo(() => {\n    if (selectedWebsiteIds.length === 0) return 'gray';\n    if (selectedWebsiteIds.length === websites.length) return 'green';\n    return 'blue';\n  }, [selectedWebsiteIds.length, websites.length]);\n\n  return (\n    <Popover\n      opened={opened}\n      onChange={(isOpen) => {\n        if (!isOpen) close();\n      }}\n      position=\"bottom-start\"\n      width={300}\n      shadow=\"md\"\n      withArrow\n      withinPortal\n    >\n      <Popover.Target>\n        <Tooltip\n          label={<Trans>Select websites to apply this shortcut to</Trans>}\n          withArrow\n        >\n          <Box\n            component=\"span\"\n            className=\"only-website-selector\"\n            contentEditable={false}\n            onClick={(e: React.MouseEvent) => {\n              e.stopPropagation();\n              toggle();\n            }}\n            style={{\n              cursor: 'pointer',\n              display: 'inline-flex',\n              alignItems: 'center',\n              gap: 3,\n              padding: '0 2px',\n              borderRadius: 'var(--mantine-radius-sm)',\n              transition: 'background-color 0.15s ease',\n              fontWeight: 500,\n              whiteSpace: 'nowrap',\n            }}\n          >\n            <Text span inherit size=\"xs\" style={{ lineHeight: 1 }}>\n              {selectedDisplayText}\n            </Text>\n            <IconChevronDown\n              size={10}\n              style={{\n                transform: opened ? 'rotate(180deg)' : 'rotate(0deg)',\n                transition: 'transform 0.2s ease',\n                opacity: 0.7,\n              }}\n            />\n          </Box>\n        </Tooltip>\n      </Popover.Target>\n\n      <Popover.Dropdown p={0}>\n        <Stack gap=\"xs\">\n          {/* Header */}\n          <Box>\n            <Group align=\"apart\" p=\"xs\" style={{ alignItems: 'center' }}>\n              <Group gap=\"xs\">\n                <ThemeIcon size=\"sm\" variant=\"light\" color=\"blue\">\n                  <IconWorld size={14} />\n                </ThemeIcon>\n                <Text size=\"sm\" fw={600}>\n                  <Trans>Websites</Trans>\n                </Text>\n              </Group>\n              <Badge size=\"xs\" variant=\"filled\" color={badgeColor}>\n                {selectedWebsiteIds.length === 0 ? (\n                  <Trans>All</Trans>\n                ) : (\n                  `${selectedWebsiteIds.length} / ${websites.length}`\n                )}\n              </Badge>\n            </Group>\n\n            <Divider />\n          </Box>\n\n          {/* Search */}\n          <Box p=\"sm\" py={0}>\n            <TextInput\n              leftSection={<IconSearch size={14} />}\n              value={searchTerm}\n              onChange={(e) => setSearchTerm(e.target.value)}\n              size=\"xs\"\n              rightSection={\n                searchTerm ? (\n                  <UnstyledButton onClick={() => setSearchTerm('')}>\n                    <IconX size={14} />\n                  </UnstyledButton>\n                ) : null\n              }\n            />\n          </Box>\n\n          {/* Action buttons */}\n          <Box px=\"sm\" pb=\"0\">\n            <Group gap=\"xs\">\n              <Button\n                size=\"xs\"\n                variant=\"light\"\n                color=\"blue\"\n                onClick={handleSelectAll}\n                style={{ flex: 1 }}\n              >\n                {selectedWebsiteIds.length === websites.length ? (\n                  <Trans>Deselect all</Trans>\n                ) : (\n                  <Trans>Select all</Trans>\n                )}\n              </Button>\n            </Group>\n          </Box>\n\n          <Divider />\n\n          {/* Website list */}\n          <Box style={{ maxHeight: '200px', overflow: 'auto' }}>\n            {filteredWebsiteOptions.length > 0 ? (\n              <Stack gap={0} p=\"xs\">\n                {filteredWebsiteOptions.map((option) => {\n                  const isSelected = selectedWebsiteIds.includes(option.value);\n                  return (\n                    <UnstyledButton\n                      className=\"only-website-toggle-btn\"\n                      key={option.value}\n                      onClick={() => handleWebsiteToggle(option.value)}\n                      style={{\n                        width: '100%',\n                        padding: '8px',\n                        borderRadius: 'var(--mantine-radius-sm)',\n                        transition: 'background-color 0.15s ease',\n                      }}\n                    >\n                      <Group gap=\"sm\" wrap=\"nowrap\">\n                        <Checkbox\n                          checked={isSelected}\n                          onChange={() => {}} // Handled by button click\n                          size=\"xs\"\n                          color=\"blue\"\n                          styles={{ input: { cursor: 'pointer' } }}\n                        />\n                        <Text\n                          size=\"sm\"\n                          style={{\n                            overflow: 'hidden',\n                            textOverflow: 'ellipsis',\n                            whiteSpace: 'nowrap',\n                            flex: 1,\n                          }}\n                          fw={isSelected ? 500 : 400}\n                          c={isSelected ? 'blue' : undefined}\n                        >\n                          {option.label}\n                        </Text>\n                      </Group>\n                    </UnstyledButton>\n                  );\n                })}\n              </Stack>\n            ) : (\n              <Box p=\"md\">\n                <Text ta=\"center\" c=\"dimmed\" size=\"sm\">\n                  <Trans>No results found</Trans>\n                </Text>\n              </Box>\n            )}\n          </Box>\n        </Stack>\n      </Popover.Dropdown>\n    </Popover>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/description-editor/description-editor.css",
    "content": "/* Description Editor Styles */\n\n/* Make the editor fill its container height */\n.description-editor-container {\n  display: flex;\n  flex-direction: column;\n  border: 1px solid var(--mantine-color-default-border);\n  border-radius: var(--mantine-radius-md);\n}\n\n.tiptap-editor-wrapper {\n  flex: 1;\n  display: flex;\n  flex-direction: column;\n  position: relative;\n  overflow: visible;\n}\n\n/* Bubble menu z-index */\n.pb-bubble-menu {\n  z-index: 1000;\n}\n\n.tiptap-editor-wrapper .tiptap {\n  flex: 1;\n  padding: 8px 16px;\n  outline: none;\n}\n\n/* Placeholder styling */\n.tiptap p.is-editor-empty:first-child::before {\n  color: var(--mantine-color-dimmed);\n  content: attr(data-placeholder);\n  float: left;\n  height: 0;\n  pointer-events: none;\n}\n\n "
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/description-editor/description-editor.tsx",
    "content": "/* eslint-disable lingui/text-restrictions */\n/* eslint-disable lingui/no-unlocalized-strings */\nimport { useLingui } from '@lingui/react/macro';\nimport { Box, useMantineColorScheme } from '@mantine/core';\nimport { useDisclosure } from '@mantine/hooks';\nimport type { Description } from '@postybirb/types';\nimport {\n  IconAlertTriangle,\n  IconH1,\n  IconH2,\n  IconH3,\n  IconLine,\n  IconList,\n  IconListNumbers,\n  IconQuote,\n  IconTags,\n  IconTextPlus,\n  IconUser,\n} from '@tabler/icons-react';\nimport { AnyExtension, Extension } from '@tiptap/core';\nimport Bold from '@tiptap/extension-bold';\nimport BulletList from '@tiptap/extension-bullet-list';\n\nimport Color from '@tiptap/extension-color';\nimport Document from '@tiptap/extension-document';\nimport Dropcursor from '@tiptap/extension-dropcursor';\nimport Gapcursor from '@tiptap/extension-gapcursor';\nimport HardBreak from '@tiptap/extension-hard-break';\nimport Heading from '@tiptap/extension-heading';\nimport History from '@tiptap/extension-history';\nimport HorizontalRule from '@tiptap/extension-horizontal-rule';\nimport Italic from '@tiptap/extension-italic';\nimport Link from '@tiptap/extension-link';\nimport ListItem from '@tiptap/extension-list-item';\nimport OrderedList from '@tiptap/extension-ordered-list';\nimport Paragraph from '@tiptap/extension-paragraph';\nimport Placeholder from '@tiptap/extension-placeholder';\nimport Strike from '@tiptap/extension-strike';\nimport TextNode from '@tiptap/extension-text';\nimport TextAlign from '@tiptap/extension-text-align';\nimport { TextStyle } from '@tiptap/extension-text-style';\nimport Underline from '@tiptap/extension-underline';\nimport { PluginKey } from '@tiptap/pm/state';\nimport { Editor, EditorContent, ReactRenderer, useEditor } from '@tiptap/react';\nimport Suggestion from '@tiptap/suggestion';\nimport { useCallback, useMemo, useRef } from 'react';\nimport tippy, { type Instance as TippyInstance } from 'tippy.js';\n\nimport { useCustomShortcuts } from '../../../stores/entity/custom-shortcut-store';\nimport { useWebsites } from '../../../stores/entity/website-store';\nimport { BubbleToolbar } from './components/bubble-toolbar';\nimport { DescriptionToolbar } from './components/description-toolbar';\nimport { HtmlEditModal } from './components/html-edit-modal';\nimport { InsertMediaModal } from './components/insert-media-modal';\nimport {\n  filterSuggestionItems,\n  SuggestionMenu,\n  type SuggestionItem,\n  type SuggestionMenuRef,\n} from './components/suggestion-menu';\nimport {\n  ContentWarningShortcutExtension,\n  CustomShortcutExtension,\n  DefaultShortcutExtension,\n  Indent,\n  ResizableImageExtension,\n  TagsShortcutExtension,\n  TitleShortcutExtension,\n  UsernameShortcutExtension,\n} from './extensions';\n\nimport './custom-blocks/shortcut.css';\nimport './description-editor.css';\n\nexport type DescriptionEditorProps = {\n  /** Initial content for the editor. */\n  value?: Description;\n  /** Callback when the editor content changes. */\n  onChange: (value: Description) => void;\n  /** Whether this is the default editor (hides certain options). */\n  isDefaultEditor?: boolean;\n  /** Whether to show the custom shortcuts menu. */\n  showCustomShortcuts?: boolean;\n  /** Minimum height of the editor. */\n  minHeight?: number;\n  /** Callback to toggle description preview panel. */\n  onPreview?: () => void;\n  /** Whether the editor is read-only (e.g., archived submission). */\n  readOnly?: boolean;\n  /** Id of the editor to force recreation of the editor instance which ensures that initial content gets updated */\n  id?: string;\n};\n\n/**\n * Creates a TipTap Suggestion-based extension for a given trigger character.\n */\nfunction createSuggestionExtension(\n  name: string,\n  triggerChar: string,\n  getItems: (query: string) => SuggestionItem[],\n) {\n  return Extension.create({\n    name,\n\n    addProseMirrorPlugins() {\n      return [\n        Suggestion<SuggestionItem>({\n          pluginKey: new PluginKey(`suggestion-${name}`),\n          editor: this.editor,\n          char: triggerChar,\n          items: ({ query }) => getItems(query),\n          command: ({ editor: e, range, props }) => {\n            // Delete the trigger character + query text, then run the item's action\n            e.chain().focus().deleteRange(range).run();\n            props.onSelect();\n          },\n          render: () => {\n            let component: ReactRenderer<SuggestionMenuRef> | null = null;\n            let popup: TippyInstance[] | null = null;\n\n            return {\n              onStart: (props) => {\n                component = new ReactRenderer(SuggestionMenu, {\n                  props: { items: props.items, command: props.command },\n                  editor: props.editor,\n                });\n\n                if (!props.clientRect) return;\n\n                popup = tippy('body', {\n                  getReferenceClientRect: props.clientRect as () => DOMRect,\n                  appendTo: () => document.body,\n                  content: component.element,\n                  showOnCreate: true,\n                  interactive: true,\n                  trigger: 'manual',\n                  placement: 'bottom-start',\n                });\n              },\n              onUpdate: (props) => {\n                component?.updateProps({\n                  items: props.items,\n                  command: props.command,\n                });\n                if (popup?.[0] && props.clientRect) {\n                  popup[0].setProps({\n                    getReferenceClientRect: props.clientRect as () => DOMRect,\n                  });\n                }\n              },\n              onKeyDown: (props) => {\n                if (props.event.key === 'Escape') {\n                  popup?.[0]?.hide();\n                  return true;\n                }\n                return component?.ref?.onKeyDown(props) ?? false;\n              },\n              onExit: () => {\n                popup?.[0]?.destroy();\n                component?.destroy();\n              },\n            };\n          },\n        }),\n      ];\n    },\n  });\n}\n\n/**\n * TipTap-based description editor with custom shortcuts support.\n */\nexport function DescriptionEditor({\n  value,\n  onChange,\n  isDefaultEditor = false,\n  showCustomShortcuts,\n  minHeight,\n  onPreview,\n  readOnly = false,\n  id = 'default',\n}: DescriptionEditorProps) {\n  const { colorScheme } = useMantineColorScheme();\n  const { t } = useLingui();\n  const customShortcuts = useCustomShortcuts();\n  const websites = useWebsites();\n  const onChangeRef = useRef(onChange);\n  onChangeRef.current = onChange;\n\n  const [htmlModalOpened, { open: openHtmlModal, close: closeHtmlModal }] =\n    useDisclosure(false);\n  const [mediaModalOpened, { open: openMediaModal, close: closeMediaModal }] =\n    useDisclosure(false);\n\n  // Editor ref for use in callbacks before editor is created\n  const editorRef = useRef<Editor | null>(null);\n\n  // Get username shortcuts from websites\n  const usernameShortcuts = useMemo(\n    () =>\n      websites\n        .map((w) => w.usernameShortcut)\n        .filter((shortcut): shortcut is NonNullable<typeof shortcut> =>\n          Boolean(shortcut),\n        ),\n    [websites],\n  );\n\n  // Slash menu items\n  const getSlashItems = useCallback(\n    (query: string): SuggestionItem[] => {\n      const items: SuggestionItem[] = [\n        {\n          title: 'Heading 1',\n          icon: <IconH1 size={16} />,\n          aliases: ['h1', 'heading1'],\n          group: 'Blocks',\n          onSelect: () => {\n            editor?.chain().focus().toggleHeading({ level: 1 }).run();\n          },\n        },\n        {\n          title: 'Heading 2',\n          icon: <IconH2 size={16} />,\n          aliases: ['h2', 'heading2'],\n          group: 'Blocks',\n          onSelect: () => {\n            editor?.chain().focus().toggleHeading({ level: 2 }).run();\n          },\n        },\n        {\n          title: 'Heading 3',\n          icon: <IconH3 size={16} />,\n          aliases: ['h3', 'heading3'],\n          group: 'Blocks',\n          onSelect: () => {\n            editor?.chain().focus().toggleHeading({ level: 3 }).run();\n          },\n        },\n        {\n          title: 'Bullet List',\n          icon: <IconList size={16} />,\n          aliases: ['ul', 'unordered', 'list'],\n          group: 'Blocks',\n          onSelect: () => {\n            editor?.chain().focus().toggleBulletList().run();\n          },\n        },\n        {\n          title: 'Ordered List',\n          icon: <IconListNumbers size={16} />,\n          aliases: ['ol', 'numbered', 'list'],\n          group: 'Blocks',\n          onSelect: () => {\n            editor?.chain().focus().toggleOrderedList().run();\n          },\n        },\n        {\n          title: 'Divider',\n          icon: <IconLine size={16} />,\n          aliases: ['hr', 'horizontal', 'rule', 'divider'],\n          group: 'Blocks',\n          onSelect: () => {\n            editor?.chain().focus().setHorizontalRule().run();\n          },\n        },\n      ];\n      return filterSuggestionItems(items, query);\n    },\n    // editor ref is captured below\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [],\n  );\n\n  // Shortcut menu items (for @, `, { triggers)\n  const getShortcutItems = useCallback(\n    (query: string): SuggestionItem[] => {\n      const items: SuggestionItem[] = [];\n\n      // Default shortcut (not in default editor to avoid recursion)\n      if (!isDefaultEditor) {\n        items.push({\n          title: 'Default',\n          aliases: ['default', 'placeholder', 'description'],\n          group: 'Shortcuts',\n          icon: <IconTextPlus size={18} />,\n          onSelect: () => {\n            editorRef.current\n              ?.chain()\n              .focus()\n              .insertContent({ type: 'defaultShortcut', attrs: { only: '' } })\n              .run();\n          },\n        });\n      }\n\n      // System shortcuts\n      items.push(\n        {\n          title: 'Title',\n          aliases: ['title', 'name'],\n          icon: <IconH1 size={16} />,\n          group: 'Shortcuts',\n          onSelect: () => {\n            editorRef.current\n              ?.chain()\n              .focus()\n              .insertContent([\n                { type: 'titleShortcut', attrs: { only: '' } },\n                { type: 'text', text: ' ' },\n              ])\n              .run();\n          },\n        },\n        {\n          title: 'Tags',\n          aliases: ['tags', 'keywords'],\n          icon: <IconTags size={16} />,\n          group: 'Shortcuts',\n          onSelect: () => {\n            editorRef.current\n              ?.chain()\n              .focus()\n              .insertContent([\n                { type: 'tagsShortcut', attrs: { only: '' } },\n                { type: 'text', text: ' ' },\n              ])\n              .run();\n          },\n        },\n        {\n          title: 'Content Warning',\n          aliases: ['content warning', 'cw', 'warning', 'nsfw'],\n          icon: <IconAlertTriangle size={16} />,\n          group: 'Shortcuts',\n          onSelect: () => {\n            editorRef.current\n              ?.chain()\n              .focus()\n              .insertContent([\n                { type: 'contentWarningShortcut', attrs: { only: '' } },\n                { type: 'text', text: ' ' },\n              ])\n              .run();\n          },\n        },\n      );\n\n      // Custom shortcuts\n      if (showCustomShortcuts && customShortcuts) {\n        for (const sc of customShortcuts) {\n          items.push({\n            title: sc.name,\n            icon: <IconQuote size={16} />,\n            group: 'Custom Shortcuts',\n            onSelect: () => {\n              editorRef.current\n                ?.chain()\n                .focus()\n                .insertContent([\n                  { type: 'customShortcut', attrs: { id: sc.id, only: '' } },\n                  { type: 'text', text: ' ' },\n                ])\n                .run();\n            },\n          });\n        }\n      }\n\n      // Username shortcuts\n      for (const sc of usernameShortcuts) {\n        items.push({\n          title: sc.id,\n          icon: <IconUser size={16} />,\n          group: 'Username Shortcuts',\n          onSelect: () => {\n            editorRef.current\n              ?.chain()\n              .focus()\n              .insertContent([\n                {\n                  type: 'username',\n                  attrs: {\n                    shortcut: sc.id,\n                    only: '',\n                    username: '',\n                  },\n                },\n                { type: 'text', text: ' ' },\n              ])\n              .run();\n          },\n        });\n      }\n\n      return filterSuggestionItems(items, query);\n    },\n    [isDefaultEditor, showCustomShortcuts, customShortcuts, usernameShortcuts],\n  );\n\n  // Build suggestion extensions\n  const suggestionExtensions: AnyExtension[] = useMemo(\n    (): AnyExtension[] => [\n      createSuggestionExtension('slashMenu', '/', getSlashItems),\n      createSuggestionExtension('shortcutMenuAt', '@', getShortcutItems),\n      createSuggestionExtension('shortcutMenuBacktick', '`', getShortcutItems),\n      createSuggestionExtension('shortcutMenuBrace', '{', getShortcutItems),\n    ],\n    [getSlashItems, getShortcutItems],\n  );\n\n  const editor = useEditor(\n    {\n      extensions: [\n        // Core\n        Document,\n        Paragraph,\n        TextNode,\n        HardBreak,\n\n        // Formatting marks\n        Bold,\n        Italic,\n        Strike,\n        Underline,\n        TextStyle,\n        Color,\n\n        // Block types\n        Heading.configure({ levels: [1, 2, 3] }),\n        HorizontalRule,\n        BulletList,\n        OrderedList,\n        ListItem,\n\n        // Behavior\n        History,\n        Dropcursor,\n        Gapcursor,\n        ResizableImageExtension,\n        Link.configure({ openOnClick: false }),\n        TextAlign.configure({ types: ['heading', 'paragraph'] }),\n        Indent,\n        Placeholder.configure({\n          placeholder: t`Type / for commands or @, \\` or '{' for shortcuts`,\n        }),\n\n        // Custom shortcut nodes\n        DefaultShortcutExtension,\n        CustomShortcutExtension,\n        UsernameShortcutExtension,\n        TitleShortcutExtension,\n        TagsShortcutExtension,\n        ContentWarningShortcutExtension,\n\n        // Suggestion menus\n        ...suggestionExtensions,\n      ],\n      content:\n        value && value.content && value.content.length > 0 ? value : undefined,\n      editable: !readOnly,\n      onUpdate: ({ editor: e }) => {\n        if (readOnly) return;\n        onChangeRef.current(e.getJSON() as Description);\n      },\n    },\n    [id],\n  );\n\n  // Keep ref in sync with editor for callbacks\n  editorRef.current = editor;\n\n  return (\n    <Box\n      style={{\n        minHeight,\n        height: '100%',\n        pointerEvents: readOnly ? 'none' : undefined,\n      }}\n      className=\"description-editor-container\"\n      data-theme={colorScheme}\n    >\n      {!readOnly && (\n        <DescriptionToolbar\n          editor={editor}\n          onEditHtml={openHtmlModal}\n          onInsertMedia={openMediaModal}\n          onPreview={onPreview}\n        />\n      )}\n      <Box className=\"tiptap-editor-wrapper\" style={{ flex: 1 }}>\n        {editor && !readOnly && <BubbleToolbar editor={editor} />}\n        <EditorContent editor={editor} />\n      </Box>\n      {editor && (\n        <HtmlEditModal\n          editor={editor}\n          opened={htmlModalOpened}\n          onClose={closeHtmlModal}\n        />\n      )}\n      {editor && (\n        <InsertMediaModal\n          editor={editor}\n          opened={mediaModalOpened}\n          onClose={closeMediaModal}\n        />\n      )}\n    </Box>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/description-editor/extensions/content-warning-shortcut.tsx",
    "content": "/* eslint-disable lingui/no-unlocalized-strings */\nimport { Trans } from '@lingui/react/macro';\nimport { Badge } from '@mantine/core';\nimport { IconArrowRight } from '@tabler/icons-react';\nimport { mergeAttributes, Node } from '@tiptap/core';\nimport { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';\nimport { useCallback } from 'react';\nimport { WebsiteOnlySelector } from '../custom-blocks/website-only-selector';\n\n/**\n * React component rendered inside the editor for the ContentWarningShortcut inline node.\n */\nfunction ContentWarningShortcutView({\n  node,\n  updateAttributes,\n}: {\n  node: { attrs: { only: string } };\n  updateAttributes: (attrs: Record<string, unknown>) => void;\n}) {\n  const handleOnlyChange = useCallback(\n    (newOnly: string) => {\n      updateAttributes({ only: newOnly });\n    },\n    [updateAttributes],\n  );\n\n  return (\n    <NodeViewWrapper as=\"span\" className=\"system-shortcut-container\" style={{ verticalAlign: 'text-bottom' }}>\n      <Badge\n        variant=\"light\"\n        radius=\"xl\"\n        size=\"sm\"\n        tt=\"none\"\n        color=\"orange\"\n        contentEditable={false}\n        styles={{ label: { display: 'flex', alignItems: 'center', gap: 4 } }}\n      >\n        <span style={{ fontWeight: 600 }}><Trans>Content warning</Trans></span>\n        <IconArrowRight size={12} style={{ opacity: 0.5 }} />\n        <WebsiteOnlySelector only={node.attrs.only} onOnlyChange={handleOnlyChange} />\n      </Badge>\n    </NodeViewWrapper>\n  );\n}\n\n/**\n * TipTap Node extension for the Content Warning system shortcut.\n * Renders as an orange badge; resolved to the content warning at parse time.\n */\nexport const ContentWarningShortcutExtension = Node.create({\n  name: 'contentWarningShortcut',\n  group: 'inline',\n  inline: true,\n  atom: true,\n\n  addAttributes() {\n    return {\n      only: { default: '' },\n    };\n  },\n\n  parseHTML() {\n    return [{ tag: 'span[data-type=\"contentWarningShortcut\"]' }];\n  },\n\n  renderHTML({ HTMLAttributes }) {\n    return ['span', mergeAttributes(HTMLAttributes, { 'data-type': 'contentWarningShortcut' })];\n  },\n\n  addNodeView() {\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    return ReactNodeViewRenderer(ContentWarningShortcutView as any);\n  },\n\n  addCommands() {\n    return {\n      insertContentWarningShortcut:\n        (attrs?: { only?: string }) =>\n        // eslint-disable-next-line @typescript-eslint/no-explicit-any\n        ({ commands }: { commands: any }) => commands.insertContent([\n            { type: this.name, attrs: { only: attrs?.only ?? '' } },\n            { type: 'text', text: ' ' },\n          ]),\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    } as any;\n  },\n});\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/description-editor/extensions/custom-shortcut.tsx",
    "content": "/* eslint-disable lingui/no-unlocalized-strings */\nimport { Badge } from '@mantine/core';\nimport { IconArrowRight } from '@tabler/icons-react';\nimport { mergeAttributes, Node } from '@tiptap/core';\nimport { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';\nimport { useCallback, useMemo } from 'react';\nimport { useCustomShortcuts } from '../../../../stores';\nimport { WebsiteOnlySelector } from '../custom-blocks/website-only-selector';\n\n/**\n * React component rendered inside the editor for the CustomShortcut inline node.\n */\nfunction CustomShortcutView({\n  node,\n  updateAttributes,\n}: {\n  node: { attrs: { id: string; only: string } };\n  updateAttributes: (attrs: Record<string, unknown>) => void;\n}) {\n  const shortcuts = useCustomShortcuts();\n\n  const shortcut = useMemo(() => {\n    if (!node.attrs.id) return undefined;\n    return shortcuts?.find((s) => s.id === node.attrs.id);\n  }, [shortcuts, node.attrs.id]);\n\n  const name = shortcut?.name ?? node.attrs.id;\n\n  const handleOnlyChange = useCallback(\n    (newOnly: string) => {\n      updateAttributes({ only: newOnly });\n    },\n    [updateAttributes],\n  );\n\n  return (\n    <NodeViewWrapper as=\"span\" className=\"custom-shortcut-container\" style={{ verticalAlign: 'text-bottom' }}>\n      <Badge\n        variant=\"light\"\n        radius=\"xl\"\n        size=\"sm\"\n        tt=\"none\"\n        color=\"grape\"\n        contentEditable={false}\n        styles={{ label: { display: 'flex', alignItems: 'center', gap: 4 } }}\n      >\n        <span style={{ fontWeight: 600 }}>{name}</span>\n        <IconArrowRight size={12} style={{ opacity: 0.5 }} />\n        <WebsiteOnlySelector only={node.attrs.only} onOnlyChange={handleOnlyChange} />\n      </Badge>\n    </NodeViewWrapper>\n  );\n}\n\n/**\n * TipTap Node extension for custom shortcut inline nodes.\n * Renders as a grape-colored badge showing the shortcut name.\n */\nexport const CustomShortcutExtension = Node.create({\n  name: 'customShortcut',\n  group: 'inline',\n  inline: true,\n  atom: true,\n\n  addAttributes() {\n    return {\n      id: { default: '' },\n      only: { default: '' },\n    };\n  },\n\n  parseHTML() {\n    return [{ tag: 'span[data-type=\"customShortcut\"]' }];\n  },\n\n  renderHTML({ HTMLAttributes }) {\n    return ['span', mergeAttributes(HTMLAttributes, { 'data-type': 'customShortcut' })];\n  },\n\n  addNodeView() {\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    return ReactNodeViewRenderer(CustomShortcutView as any);\n  },\n\n  addCommands() {\n    return {\n      insertCustomShortcut:\n        (attrs: { id: string; only?: string }) =>\n        // eslint-disable-next-line @typescript-eslint/no-explicit-any\n        ({ commands }: { commands: any }) => commands.insertContent([\n            { type: this.name, attrs: { id: attrs.id, only: attrs.only ?? '' } },\n            { type: 'text', text: ' ' },\n          ]),\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    } as any;\n  },\n});\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/description-editor/extensions/default-shortcut.tsx",
    "content": "/* eslint-disable lingui/no-unlocalized-strings */\nimport { Trans } from '@lingui/react/macro';\nimport { Badge } from '@mantine/core';\nimport { IconArrowRight } from '@tabler/icons-react';\nimport { mergeAttributes, Node } from '@tiptap/core';\nimport { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';\nimport { useCallback } from 'react';\nimport { WebsiteOnlySelector } from '../custom-blocks/website-only-selector';\n\n/**\n * React component rendered inside the editor for the DefaultShortcut node.\n */\nfunction DefaultShortcutView({\n  node,\n  updateAttributes,\n}: {\n  node: { attrs: { only: string } };\n  updateAttributes: (attrs: Record<string, unknown>) => void;\n}) {\n  const handleOnlyChange = useCallback(\n    (newOnly: string) => {\n      updateAttributes({ only: newOnly });\n    },\n    [updateAttributes],\n  );\n\n  return (\n    <NodeViewWrapper as=\"div\" className=\"default-shortcut-container\" style={{ padding: '4px 0' }}>\n      <Badge\n        variant=\"light\"\n        radius=\"xl\"\n        tt=\"none\"\n        size=\"sm\"\n        color=\"gray\"\n        contentEditable={false}\n        styles={{ label: { display: 'flex', alignItems: 'center', gap: 4 } }}\n      >\n        <span style={{ fontWeight: 600 }}><Trans>Default</Trans></span>\n        <IconArrowRight size={12} style={{ opacity: 0.5 }} />\n        <WebsiteOnlySelector only={node.attrs.only} onOnlyChange={handleOnlyChange} />\n      </Badge>\n    </NodeViewWrapper>\n  );\n}\n\n/**\n * TipTap Node extension for the \"Default Description\" block shortcut.\n * Renders as a non-editable badge that expands to the default description at parse time.\n */\nexport const DefaultShortcutExtension = Node.create({\n  name: 'defaultShortcut',\n  group: 'block',\n  atom: true,\n  draggable: true,\n\n  addAttributes() {\n    return {\n      only: { default: '' },\n    };\n  },\n\n  parseHTML() {\n    return [{ tag: 'div[data-type=\"defaultShortcut\"]' }];\n  },\n\n  renderHTML({ HTMLAttributes }) {\n    return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'defaultShortcut' }), 0];\n  },\n\n  addNodeView() {\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    return ReactNodeViewRenderer(DefaultShortcutView as any);\n  },\n\n  addCommands() {\n    return {\n      setDefaultShortcut:\n        (attrs?: { only?: string }) =>\n        // eslint-disable-next-line @typescript-eslint/no-explicit-any\n        ({ commands }: { commands: any }) => commands.insertContent({\n            type: this.name,\n            attrs: { only: attrs?.only ?? '' },\n          }),\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    } as any;\n  },\n});\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/description-editor/extensions/indent.ts",
    "content": "/* eslint-disable lingui/no-unlocalized-strings */\nimport { Extension } from '@tiptap/core';\n\nexport interface IndentOptions {\n  /** Node types that support indentation. */\n  types: string[];\n  /** Minimum indent level. */\n  minLevel: number;\n  /** Maximum indent level. */\n  maxLevel: number;\n}\n\ndeclare module '@tiptap/core' {\n  interface Commands<ReturnType> {\n    indent: {\n      indent: () => ReturnType;\n      outdent: () => ReturnType;\n    };\n  }\n}\n\n/**\n * TipTap extension that adds indent/outdent support to paragraphs and headings.\n * Stores an integer `indent` attribute (0–6) on supported block nodes and renders\n * it as a `margin-left` style. Tab / Shift+Tab keyboard shortcuts are included\n * but only fire when the selection is NOT inside a list (lists handle Tab natively).\n */\nexport const Indent = Extension.create<IndentOptions>({\n  name: 'indent',\n\n  addOptions() {\n    return {\n      types: ['paragraph', 'heading'],\n      minLevel: 0,\n      maxLevel: 6,\n    };\n  },\n\n  addGlobalAttributes() {\n    return [\n      {\n        types: this.options.types,\n        attributes: {\n          indent: {\n            default: 0,\n            parseHTML: (element) => {\n              const level = parseInt(element.getAttribute('data-indent') || '0', 10);\n              return Math.min(Math.max(level, this.options.minLevel), this.options.maxLevel);\n            },\n            renderHTML: (attributes) => {\n              if (!attributes.indent || attributes.indent <= 0) {\n                return {};\n              }\n              return {\n                'data-indent': attributes.indent,\n                style: `margin-left: ${attributes.indent * 2}em`,\n              };\n            },\n          },\n        },\n      },\n    ];\n  },\n\n  addCommands() {\n    return {\n      indent:\n        () =>\n        ({ tr, state, dispatch }) => {\n          const { selection } = state;\n          let applied = false;\n\n          state.doc.nodesBetween(selection.from, selection.to, (node, pos) => {\n            if (this.options.types.includes(node.type.name)) {\n              const currentLevel = (node.attrs.indent as number) || 0;\n              const newLevel = Math.min(currentLevel + 1, this.options.maxLevel);\n              if (newLevel !== currentLevel) {\n                tr.setNodeMarkup(pos, undefined, {\n                  ...node.attrs,\n                  indent: newLevel,\n                });\n                applied = true;\n              }\n            }\n          });\n\n          if (applied && dispatch) {\n            dispatch(tr);\n          }\n          return applied;\n        },\n\n      outdent:\n        () =>\n        ({ tr, state, dispatch }) => {\n          const { selection } = state;\n          let applied = false;\n\n          state.doc.nodesBetween(selection.from, selection.to, (node, pos) => {\n            if (this.options.types.includes(node.type.name)) {\n              const currentLevel = (node.attrs.indent as number) || 0;\n              const newLevel = Math.max(currentLevel - 1, this.options.minLevel);\n              if (newLevel !== currentLevel) {\n                tr.setNodeMarkup(pos, undefined, {\n                  ...node.attrs,\n                  indent: newLevel,\n                });\n                applied = true;\n              }\n            }\n          });\n\n          if (applied && dispatch) {\n            dispatch(tr);\n          }\n          return applied;\n        },\n    };\n  },\n\n  addKeyboardShortcuts() {\n    return {\n      Tab: ({ editor }) => {\n        // Don't handle Tab in lists — let the list extension handle nesting\n        if (editor.isActive('listItem')) {\n          return false;\n        }\n        return editor.commands.indent();\n      },\n      'Shift-Tab': ({ editor }) => {\n        if (editor.isActive('listItem')) {\n          return false;\n        }\n        return editor.commands.outdent();\n      },\n    };\n  },\n});\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/description-editor/extensions/index.ts",
    "content": "export { ContentWarningShortcutExtension } from './content-warning-shortcut';\nexport { CustomShortcutExtension } from './custom-shortcut';\nexport { DefaultShortcutExtension } from './default-shortcut';\nexport { Indent } from './indent';\nexport { ResizableImageExtension } from './resizable-image';\nexport { TagsShortcutExtension } from './tags-shortcut';\nexport { TitleShortcutExtension } from './title-shortcut';\nexport { UsernameShortcutExtension } from './username-shortcut';\n\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/description-editor/extensions/resizable-image.tsx",
    "content": "/* eslint-disable lingui/no-unlocalized-strings */\nimport { Trans } from '@lingui/react/macro';\nimport {\n    ActionIcon,\n    Group,\n    NumberInput,\n    Popover,\n    Stack,\n    Text,\n    Tooltip,\n} from '@mantine/core';\nimport { useDisclosure } from '@mantine/hooks';\nimport { IconTrash } from '@tabler/icons-react';\nimport { mergeAttributes, Node } from '@tiptap/core';\nimport { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';\nimport { useCallback, useEffect, useRef, useState } from 'react';\n\nimport '../custom-blocks/shortcut.css';\n\ninterface ResizableImageAttrs {\n  src: string;\n  alt: string;\n  title: string;\n  width: number | null;\n  height: number | null;\n}\n\n/**\n * React component for the resizable image node view.\n * Shows a popover with width/height controls when clicked,\n * and drag handles on corners/edges for mouse-drag resizing.\n */\nfunction ResizableImageView({\n  node,\n  updateAttributes,\n  deleteNode,\n  selected,\n}: {\n  node: { attrs: ResizableImageAttrs };\n  updateAttributes: (attrs: Record<string, unknown>) => void;\n  deleteNode: () => void;\n  selected: boolean;\n}) {\n  const { src, alt, title, width, height } = node.attrs;\n  const [opened, { open, close }] = useDisclosure(false);\n  const imgRef = useRef<HTMLImageElement>(null);\n  const wrapperRef = useRef<HTMLDivElement>(null);\n  const popoverRef = useRef<HTMLDivElement>(null);\n\n  // Track whether the image is \"active\" (clicked on) to show handles\n  const [active, setActive] = useState(false);\n\n  // Natural aspect ratio — computed once when the image loads\n  const aspectRatio = useRef<number>(1);\n\n  // Local state for dimension inputs\n  const [localWidth, setLocalWidth] = useState<number | ''>(width ?? '');\n  const [localHeight, setLocalHeight] = useState<number | ''>(height ?? '');\n\n  // Drag state\n  const [dragging, setDragging] = useState(false);\n  const dragState = useRef<{\n    startX: number;\n    startY: number;\n    startWidth: number;\n    startHeight: number;\n    handle: string;\n  } | null>(null);\n\n  // Compute aspect ratio from natural image dimensions on load\n  const handleImageLoad = useCallback(() => {\n    const img = imgRef.current;\n    if (img && img.naturalWidth && img.naturalHeight) {\n      aspectRatio.current = img.naturalWidth / img.naturalHeight;\n    }\n  }, []);\n\n  // Sync local state when node attrs change (undo/redo)\n  useEffect(() => {\n    setLocalWidth(width ?? '');\n    setLocalHeight(height ?? '');\n  }, [width, height]);\n\n  // Close popover and deactivate on outside click\n  useEffect(() => {\n    if (!active && !opened) return undefined;\n    const handleClickOutside = (e: MouseEvent) => {\n      const target = e.target as HTMLElement;\n      const inWrapper = wrapperRef.current?.contains(target);\n      const inPopover = popoverRef.current?.contains(target);\n      if (!inWrapper && !inPopover) {\n        setActive(false);\n        close();\n      }\n    };\n    document.addEventListener('mousedown', handleClickOutside);\n    return () => document.removeEventListener('mousedown', handleClickOutside);\n  }, [active, opened, close]);\n\n  const commitDimensions = useCallback(\n    (w: number | '', h: number | '') => {\n      setLocalWidth(w);\n      setLocalHeight(h);\n      updateAttributes({\n        width: w || null,\n        height: h || null,\n      });\n    },\n    [updateAttributes],\n  );\n\n  // Width changed in popover — compute height from aspect ratio\n  const handleWidthChange = useCallback(\n    (val: number | string) => {\n      const v = typeof val === 'number' ? val : '';\n      if (typeof v === 'number' && aspectRatio.current) {\n        const newH = Math.round(v / aspectRatio.current);\n        commitDimensions(v, newH);\n      } else {\n        commitDimensions(v, '');\n      }\n    },\n    [commitDimensions],\n  );\n\n  // Height changed in popover — compute width from aspect ratio\n  const handleHeightChange = useCallback(\n    (val: number | string) => {\n      const v = typeof val === 'number' ? val : '';\n      if (typeof v === 'number' && aspectRatio.current) {\n        const newW = Math.round(v * aspectRatio.current);\n        commitDimensions(newW, v);\n      } else {\n        commitDimensions('', v);\n      }\n    },\n    [commitDimensions],\n  );\n\n  // --- Drag resize logic ---\n  const onDragStart = useCallback(\n    (e: React.MouseEvent, handle: string) => {\n      e.preventDefault();\n      e.stopPropagation();\n      const img = imgRef.current;\n      if (!img) return;\n\n      const rect = img.getBoundingClientRect();\n      dragState.current = {\n        startX: e.clientX,\n        startY: e.clientY,\n        startWidth: rect.width,\n        startHeight: rect.height,\n        handle,\n      };\n      setDragging(true);\n    },\n    [],\n  );\n\n  useEffect(() => {\n    if (!dragging) return undefined;\n\n    const onMouseMove = (e: MouseEvent) => {\n      const ds = dragState.current;\n      if (!ds) return;\n\n      const dx = e.clientX - ds.startX;\n      const dy = e.clientY - ds.startY;\n      const ratio = aspectRatio.current;\n      const { handle } = ds;\n\n      // Determine primary delta based on handle direction\n      let newWidth: number;\n\n      if (handle.includes('left')) {\n        newWidth = Math.max(20, ds.startWidth - dx);\n      } else if (handle === 'bottom') {\n        // Bottom-only: derive width from height change\n        const newH = Math.max(20, ds.startHeight + dy);\n        newWidth = Math.round(newH * ratio);\n      } else {\n        // right, or any corner\n        newWidth = Math.max(20, ds.startWidth + dx);\n      }\n\n      newWidth = Math.round(newWidth);\n      const newHeight = Math.round(newWidth / ratio);\n\n      setLocalWidth(newWidth);\n      setLocalHeight(newHeight);\n    };\n\n    const onMouseUp = () => {\n      setDragging(false);\n      // Commit final dimensions\n      if (typeof localWidth === 'number' && typeof localHeight === 'number') {\n        updateAttributes({\n          width: localWidth,\n          height: localHeight,\n        });\n      }\n      dragState.current = null;\n    };\n\n    document.addEventListener('mousemove', onMouseMove);\n    document.addEventListener('mouseup', onMouseUp);\n    return () => {\n      document.removeEventListener('mousemove', onMouseMove);\n      document.removeEventListener('mouseup', onMouseUp);\n    };\n  }, [dragging, localWidth, localHeight, updateAttributes]);\n\n  const handles = [\n    { position: 'top-left', cursor: 'nwse-resize' },\n    { position: 'top-right', cursor: 'nesw-resize' },\n    { position: 'bottom-left', cursor: 'nesw-resize' },\n    { position: 'bottom-right', cursor: 'nwse-resize' },\n    { position: 'right', cursor: 'ew-resize' },\n    { position: 'bottom', cursor: 'ns-resize' },\n  ];\n\n  const handleStyle = (pos: string, cursor: string): React.CSSProperties => {\n    const base: React.CSSProperties = {\n      position: 'absolute',\n      width: 8,\n      height: 8,\n      background: 'var(--mantine-color-blue-5)',\n      border: '1px solid white',\n      borderRadius: 2,\n      cursor,\n      zIndex: 10,\n    };\n    if (pos.includes('top')) base.top = -4;\n    if (pos.includes('bottom')) base.bottom = -4;\n    if (pos.includes('left')) base.left = -4;\n    if (pos.includes('right') && pos.includes('-')) base.right = -4;\n\n    // Edge-only handles (centered)\n    if (pos === 'right') { base.right = -4; base.top = '50%'; base.transform = 'translateY(-50%)'; }\n    if (pos === 'bottom') { base.bottom = -4; base.left = '50%'; base.transform = 'translateX(-50%)'; }\n\n    return base;\n  };\n\n  return (\n    <NodeViewWrapper as=\"span\" style={{ display: 'inline-block', lineHeight: 0 }}>\n      <Popover\n        opened={opened}\n        position=\"top\"\n        shadow=\"md\"\n        withArrow\n        withinPortal\n      >\n        <Popover.Target>\n          <div\n            ref={wrapperRef}\n            className={`resizable-image-wrapper${active || selected ? ' resizable-image-wrapper--active' : ''}`}\n            style={{ position: 'relative', display: 'inline-block', lineHeight: 0 }}\n          >\n            {/* eslint-disable jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/no-noninteractive-tabindex */}\n            <img\n              ref={imgRef}\n              src={src}\n              alt={alt}\n              title={title}\n              width={localWidth || undefined}\n              height={localHeight || undefined}\n              onLoad={handleImageLoad}\n              onClick={(e) => {\n                e.stopPropagation();\n                setActive(true);\n                open();\n              }}\n              onKeyDown={(e) => {\n                if (e.key === 'Enter' || e.key === ' ') {\n                  e.preventDefault();\n                  setActive(true);\n                  open();\n                }\n              }}\n              tabIndex={0}\n              draggable={false}\n              className={`resizable-image${selected ? ' resizable-image--selected' : ''}`}\n              style={{\n                cursor: 'pointer',\n                maxWidth: '100%',\n                display: 'block',\n              }}\n            />\n            {/* eslint-enable jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/no-noninteractive-tabindex */}\n            {/* Drag handles — only visible when active/selected */}\n            {(active || selected) &&\n              handles.map(({ position, cursor }) => (\n                <div\n                  key={position}\n                  role=\"presentation\"\n                  className=\"resizable-image-handle\"\n                  style={handleStyle(position, cursor)}\n                  onMouseDown={(e) => onDragStart(e, position)}\n                />\n              ))}\n          </div>\n        </Popover.Target>\n\n        <Popover.Dropdown p=\"xs\" ref={popoverRef}>\n          <Stack gap={6}>\n            <Text size=\"xs\" fw={600} c=\"dimmed\">\n              <Trans>Image Size</Trans>\n            </Text>\n            <Group gap=\"xs\" wrap=\"nowrap\">\n              <NumberInput\n                label={<Trans>Width</Trans>}\n                size=\"xs\"\n                value={localWidth}\n                onChange={handleWidthChange}\n                placeholder=\"auto\"\n                min={1}\n                max={9999}\n                allowNegative={false}\n                style={{ width: 90 }}\n              />\n              <Text size=\"xs\" c=\"dimmed\" mt={24}>\n                ×\n              </Text>\n              <NumberInput\n                label={<Trans>Height</Trans>}\n                size=\"xs\"\n                value={localHeight}\n                onChange={handleHeightChange}\n                placeholder=\"auto\"\n                min={1}\n                max={9999}\n                allowNegative={false}\n                style={{ width: 90 }}\n              />\n              <Tooltip label={<Trans>Remove image</Trans>} withArrow>\n                <ActionIcon\n                  variant=\"subtle\"\n                  color=\"red\"\n                  size=\"sm\"\n                  mt={24}\n                  onClick={() => {\n                    close();\n                    deleteNode();\n                  }}\n                >\n                  <IconTrash size={14} />\n                </ActionIcon>\n              </Tooltip>\n            </Group>\n          </Stack>\n        </Popover.Dropdown>\n      </Popover>\n    </NodeViewWrapper>\n  );\n}\n\n/**\n * Custom TipTap Image extension with resizable node view.\n * Extends the default Image node with width/height attributes\n * and a click-to-edit popover.\n */\nexport const ResizableImageExtension = Node.create({\n  name: 'image',\n  group: 'inline',\n  inline: true,\n  atom: true,\n  draggable: true,\n\n  addAttributes() {\n    return {\n      src: { default: null },\n      alt: { default: '' },\n      title: { default: '' },\n      width: { default: null },\n      height: { default: null },\n    };\n  },\n\n  parseHTML() {\n    return [{ tag: 'img[src]' }];\n  },\n\n  renderHTML({ HTMLAttributes }) {\n    return ['img', mergeAttributes(HTMLAttributes)];\n  },\n\n  addNodeView() {\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    return ReactNodeViewRenderer(ResizableImageView as any);\n  },\n\n  addCommands() {\n    return {\n      setImage:\n        (attrs: { src: string; alt?: string; title?: string; width?: number; height?: number }) =>\n        // eslint-disable-next-line @typescript-eslint/no-explicit-any\n        ({ commands }: { commands: any }) => commands.insertContent({\n            type: this.name,\n            attrs,\n          }),\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    } as any;\n  },\n});\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/description-editor/extensions/tags-shortcut.tsx",
    "content": "/* eslint-disable lingui/no-unlocalized-strings */\nimport { Trans } from '@lingui/react/macro';\nimport { Badge } from '@mantine/core';\nimport { IconArrowRight } from '@tabler/icons-react';\nimport { mergeAttributes, Node } from '@tiptap/core';\nimport { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';\nimport { useCallback } from 'react';\nimport { WebsiteOnlySelector } from '../custom-blocks/website-only-selector';\n\n/**\n * React component rendered inside the editor for the TagsShortcut inline node.\n */\nfunction TagsShortcutView({\n  node,\n  updateAttributes,\n}: {\n  node: { attrs: { only: string } };\n  updateAttributes: (attrs: Record<string, unknown>) => void;\n}) {\n  const handleOnlyChange = useCallback(\n    (newOnly: string) => {\n      updateAttributes({ only: newOnly });\n    },\n    [updateAttributes],\n  );\n\n  return (\n    <NodeViewWrapper as=\"span\" className=\"system-shortcut-container\" style={{ verticalAlign: 'text-bottom' }}>\n      <Badge\n        variant=\"light\"\n        radius=\"xl\"\n        size=\"sm\"\n        tt=\"none\"\n        color=\"teal\"\n        contentEditable={false}\n        styles={{ label: { display: 'flex', alignItems: 'center', gap: 4 } }}\n      >\n        <span style={{ fontWeight: 600 }}><Trans>Tags</Trans></span>\n        <IconArrowRight size={12} style={{ opacity: 0.5 }} />\n        <WebsiteOnlySelector only={node.attrs.only} onOnlyChange={handleOnlyChange} />\n      </Badge>\n    </NodeViewWrapper>\n  );\n}\n\n/**\n * TipTap Node extension for the Tags system shortcut.\n * Renders as a teal badge; resolved to submission tags at parse time.\n */\nexport const TagsShortcutExtension = Node.create({\n  name: 'tagsShortcut',\n  group: 'inline',\n  inline: true,\n  atom: true,\n\n  addAttributes() {\n    return {\n      only: { default: '' },\n    };\n  },\n\n  parseHTML() {\n    return [{ tag: 'span[data-type=\"tagsShortcut\"]' }];\n  },\n\n  renderHTML({ HTMLAttributes }) {\n    return ['span', mergeAttributes(HTMLAttributes, { 'data-type': 'tagsShortcut' })];\n  },\n\n  addNodeView() {\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    return ReactNodeViewRenderer(TagsShortcutView as any);\n  },\n\n  addCommands() {\n    return {\n      insertTagsShortcut:\n        (attrs?: { only?: string }) =>\n        // eslint-disable-next-line @typescript-eslint/no-explicit-any\n        ({ commands }: { commands: any }) => commands.insertContent([\n            { type: this.name, attrs: { only: attrs?.only ?? '' } },\n            { type: 'text', text: ' ' },\n          ]),\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    } as any;\n  },\n});\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/description-editor/extensions/title-shortcut.tsx",
    "content": "/* eslint-disable lingui/no-unlocalized-strings */\nimport { Trans } from '@lingui/react/macro';\nimport { Badge } from '@mantine/core';\nimport { IconArrowRight } from '@tabler/icons-react';\nimport { mergeAttributes, Node } from '@tiptap/core';\nimport { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';\nimport { useCallback } from 'react';\nimport { WebsiteOnlySelector } from '../custom-blocks/website-only-selector';\n\n/**\n * React component rendered inside the editor for the TitleShortcut inline node.\n */\nfunction TitleShortcutView({\n  node,\n  updateAttributes,\n}: {\n  node: { attrs: { only: string } };\n  updateAttributes: (attrs: Record<string, unknown>) => void;\n}) {\n  const handleOnlyChange = useCallback(\n    (newOnly: string) => {\n      updateAttributes({ only: newOnly });\n    },\n    [updateAttributes],\n  );\n\n  return (\n    <NodeViewWrapper as=\"span\" className=\"system-shortcut-container\" style={{ verticalAlign: 'text-bottom' }}>\n      <Badge\n        variant=\"light\"\n        radius=\"xl\"\n        size=\"sm\"\n        tt=\"none\"\n        color=\"blue\"\n        contentEditable={false}\n        styles={{ label: { display: 'flex', alignItems: 'center', gap: 4 } }}\n      >\n        <span style={{ fontWeight: 600 }}><Trans>Title</Trans></span>\n        <IconArrowRight size={12} style={{ opacity: 0.5 }} />\n        <WebsiteOnlySelector only={node.attrs.only} onOnlyChange={handleOnlyChange} />\n      </Badge>\n    </NodeViewWrapper>\n  );\n}\n\n/**\n * TipTap Node extension for the Title system shortcut.\n * Renders as a blue badge; resolved to the submission title at parse time.\n */\nexport const TitleShortcutExtension = Node.create({\n  name: 'titleShortcut',\n  group: 'inline',\n  inline: true,\n  atom: true,\n\n  addAttributes() {\n    return {\n      only: { default: '' },\n    };\n  },\n\n  parseHTML() {\n    return [{ tag: 'span[data-type=\"titleShortcut\"]' }];\n  },\n\n  renderHTML({ HTMLAttributes }) {\n    return ['span', mergeAttributes(HTMLAttributes, { 'data-type': 'titleShortcut' })];\n  },\n\n  addNodeView() {\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    return ReactNodeViewRenderer(TitleShortcutView as any);\n  },\n\n  addCommands() {\n    return {\n      insertTitleShortcut:\n        (attrs?: { only?: string }) =>\n        // eslint-disable-next-line @typescript-eslint/no-explicit-any\n        ({ commands }: { commands: any }) => commands.insertContent([\n            { type: this.name, attrs: { only: attrs?.only ?? '' } },\n            { type: 'text', text: ' ' },\n          ]),\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    } as any;\n  },\n});\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/description-editor/extensions/username-shortcut.tsx",
    "content": "/* eslint-disable consistent-return */\n/* eslint-disable lingui/no-unlocalized-strings */\nimport { Trans } from '@lingui/react/macro';\nimport {\n  Badge,\n  Box,\n  Button,\n  Checkbox,\n  Divider,\n  Group,\n  Popover,\n  Stack,\n  Text,\n  TextInput,\n  ThemeIcon,\n  Tooltip,\n  UnstyledButton,\n} from '@mantine/core';\nimport { useDebouncedValue, useDisclosure } from '@mantine/hooks';\nimport {\n  IconArrowRight,\n  IconChevronDown,\n  IconSearch,\n  IconWorld,\n  IconX,\n} from '@tabler/icons-react';\nimport { mergeAttributes, Node } from '@tiptap/core';\nimport { Selection } from '@tiptap/pm/state';\nimport { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { useWebsites } from '../../../../stores';\n\n/**\n * React component rendered inside the editor for the UsernameShortcut inline node.\n * Features:\n * - Two-part badge: left side shows shortcut name + website selector, right side has editable username input\n * - Arrow key navigation between editor and input field\n * - Inline website selection popover with search\n */\nfunction UsernameShortcutView({\n  node,\n  updateAttributes,\n  editor: tiptapEditor,\n}: {\n  node: { attrs: { shortcut: string; only: string; username: string } };\n  updateAttributes: (attrs: Record<string, unknown>) => void;\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  editor: any;\n}) {\n  const websites = useWebsites();\n\n  const shortcutName = node.attrs.shortcut;\n  const onlyProp = node.attrs.only;\n  const usernameProp = node.attrs.username;\n\n  // Ref for the input element\n  const inputRef = useRef<HTMLInputElement>(null);\n\n  // Local state for the username input (controlled)\n  const [usernameValue, setUsernameValue] = useState(usernameProp);\n\n  // Sync local username state when props change (e.g., undo/redo)\n  useEffect(() => {\n    setUsernameValue(usernameProp);\n  }, [usernameProp]);\n\n  // Arrow key navigation: listen for adjacency to this node\n  useEffect(() => {\n    const pmView = tiptapEditor?.view;\n    if (!pmView) return;\n\n    let isAdjacentRef: 'before' | 'after' | null = null;\n\n    const checkAdjacency = () => {\n      const { state } = pmView;\n      const { selection } = state;\n\n      if (!selection.empty) {\n        isAdjacentRef = null;\n        return;\n      }\n\n      const pos = selection.from;\n      const $pos = state.doc.resolve(pos);\n\n      // Check node before cursor\n      if (pos > 0) {\n        const nodeBefore = state.doc.nodeAt(pos - 1);\n        if (nodeBefore?.type.name === 'username' && nodeBefore.attrs.shortcut === shortcutName) {\n          isAdjacentRef = 'after';\n          return;\n        }\n      }\n\n      // Check node after cursor\n      const nodeAfter = state.doc.nodeAt(pos);\n      if (nodeAfter?.type.name === 'username' && nodeAfter.attrs.shortcut === shortcutName) {\n        isAdjacentRef = 'before';\n        return;\n      }\n\n      isAdjacentRef = null;\n    };\n\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (document.activeElement === inputRef.current) return;\n\n      if (e.key === 'ArrowRight' && isAdjacentRef === 'before') {\n        e.preventDefault();\n        e.stopPropagation();\n        inputRef.current?.focus();\n        inputRef.current?.setSelectionRange(0, 0);\n      } else if (e.key === 'ArrowLeft' && isAdjacentRef === 'after') {\n        e.preventDefault();\n        e.stopPropagation();\n        inputRef.current?.focus();\n        const len = inputRef.current?.value.length || 0;\n        inputRef.current?.setSelectionRange(len, len);\n      }\n    };\n\n    // ProseMirror update handler to check adjacency on selection changes\n    const origDispatch = pmView.dispatch.bind(pmView);\n    // We use a simpler approach: check on each transaction\n    const onTransaction = () => {\n      checkAdjacency();\n    };\n\n    // Listen to editor updates\n    tiptapEditor.on('selectionUpdate', onTransaction);\n    checkAdjacency();\n\n    document.addEventListener('keydown', handleKeyDown, true);\n\n    return () => {\n      tiptapEditor.off('selectionUpdate', onTransaction);\n      document.removeEventListener('keydown', handleKeyDown, true);\n    };\n  }, [tiptapEditor, shortcutName]);\n\n  // Popover state management for website selection\n  const [opened, { close, toggle }] = useDisclosure(false);\n  const [searchTerm, setSearchTerm] = useState('');\n  const [debouncedSearchTerm] = useDebouncedValue(searchTerm, 300);\n\n  // Local state for selected website IDs\n  const [selectedWebsiteIds, setSelectedWebsiteIds] = useState<string[]>(\n    () => (onlyProp ? onlyProp.split(',').filter(Boolean) : []),\n  );\n\n  useEffect(() => {\n    const propsIds = onlyProp ? onlyProp.split(',').filter(Boolean) : [];\n    setSelectedWebsiteIds(propsIds);\n  }, [onlyProp]);\n\n  const websiteOptions = useMemo(\n    () => websites.map((w) => ({ value: w.id, label: w.displayName })),\n    [websites],\n  );\n\n  const filteredWebsiteOptions = useMemo(() => {\n    if (!debouncedSearchTerm) return websiteOptions;\n    return websiteOptions.filter((option) =>\n      option.label.toLowerCase().includes(debouncedSearchTerm.toLowerCase()),\n    );\n  }, [websiteOptions, debouncedSearchTerm]);\n\n  // Commit username changes via TipTap's updateAttributes\n  const commitUsername = useCallback(\n    (value: string) => {\n      if (value !== usernameProp) {\n        updateAttributes({ username: value });\n      }\n    },\n    [usernameProp, updateAttributes],\n  );\n\n  const handleUsernameChange = useCallback(\n    (e: React.ChangeEvent<HTMLInputElement>) => {\n      const newValue = e.target.value;\n      setUsernameValue(newValue);\n      commitUsername(newValue);\n    },\n    [commitUsername],\n  );\n\n  const handleUsernameKeyDown = useCallback(\n    (e: React.KeyboardEvent<HTMLInputElement>) => {\n      e.stopPropagation();\n\n      const input = e.target as HTMLInputElement;\n      const { selectionStart, selectionEnd, value } = input;\n      const isAtStart = selectionStart === 0 && selectionEnd === 0;\n      const isAtEnd = selectionStart === value.length && selectionEnd === value.length;\n\n      if (e.key === 'Enter') {\n        e.preventDefault();\n        input.blur();\n      } else if (e.key === 'Escape') {\n        e.preventDefault();\n        setUsernameValue(usernameProp);\n        commitUsername(usernameProp);\n        input.blur();\n      } else if (e.key === 'ArrowLeft' && isAtStart) {\n        e.preventDefault();\n        input.blur();\n        const pmView = tiptapEditor?.view;\n        if (pmView) {\n          const { state } = pmView;\n          state.doc.descendants((docNode: { type: { name: string }; attrs: { shortcut: string } }, pos: number) => {\n            if (docNode.type.name === 'username' && docNode.attrs.shortcut === shortcutName) {\n              const tr = state.tr.setSelection(Selection.near(state.doc.resolve(pos)));\n              pmView.dispatch(tr);\n              pmView.focus();\n              return false;\n            }\n            return true;\n          });\n        }\n      } else if (e.key === 'ArrowRight' && isAtEnd) {\n        e.preventDefault();\n        input.blur();\n        const pmView = tiptapEditor?.view;\n        if (pmView) {\n          const { state } = pmView;\n          state.doc.descendants((docNode: { type: { name: string }; attrs: { shortcut: string }; nodeSize: number }, pos: number) => {\n            if (docNode.type.name === 'username' && docNode.attrs.shortcut === shortcutName) {\n              const tr = state.tr.setSelection(Selection.near(state.doc.resolve(pos + docNode.nodeSize)));\n              pmView.dispatch(tr);\n              pmView.focus();\n              return false;\n            }\n            return true;\n          });\n        }\n      }\n    },\n    [usernameProp, commitUsername, tiptapEditor, shortcutName],\n  );\n\n  const handleInputClick = useCallback((e: React.MouseEvent) => {\n    e.stopPropagation();\n    inputRef.current?.focus();\n  }, []);\n\n  const updateWebsiteSelection = useCallback(\n    (newOnlyValue: string) => {\n      const newIds = newOnlyValue ? newOnlyValue.split(',').filter(Boolean) : [];\n      setSelectedWebsiteIds(newIds);\n      updateAttributes({ only: newOnlyValue });\n    },\n    [updateAttributes],\n  );\n\n  const handleWebsiteToggle = useCallback(\n    (websiteId: string) => {\n      const newSelected = selectedWebsiteIds.includes(websiteId)\n        ? selectedWebsiteIds.filter((id) => id !== websiteId)\n        : [...selectedWebsiteIds, websiteId];\n      updateWebsiteSelection(newSelected.join(','));\n    },\n    [selectedWebsiteIds, updateWebsiteSelection],\n  );\n\n  const handleSelectAll = useCallback(() => {\n    const allIds = websiteOptions.map((opt) => opt.value);\n    const isAllSelected = selectedWebsiteIds.length === allIds.length;\n    updateWebsiteSelection(isAllSelected ? '' : allIds.join(','));\n  }, [websiteOptions, selectedWebsiteIds, updateWebsiteSelection]);\n\n  const selectedDisplayText = useMemo(() => {\n    if (selectedWebsiteIds.length === 0) {\n      return <Trans>All Websites</Trans>;\n    }\n    if (selectedWebsiteIds.length === 1) {\n      const website = websites.find((w) => w.id === selectedWebsiteIds[0]);\n      return website?.displayName || <Trans>Unknown</Trans>;\n    }\n    const names = selectedWebsiteIds\n      .map((id) => websites.find((w) => w.id === id)?.displayName)\n      .filter(Boolean)\n      .slice(0, 3);\n    if (selectedWebsiteIds.length <= 3) return names.join(', ');\n    return `${names.join(', ')} + ${selectedWebsiteIds.length - 3}`;\n  }, [selectedWebsiteIds, websites]);\n\n  const badgeColor = useMemo(() => {\n    if (selectedWebsiteIds.length === 0) return 'gray';\n    if (selectedWebsiteIds.length === websites.length) return 'green';\n    return 'blue';\n  }, [selectedWebsiteIds.length, websites.length]);\n\n  return (\n    <NodeViewWrapper as=\"span\" className=\"username-shortcut\" style={{ verticalAlign: 'text-bottom', position: 'relative' }}>\n      <Badge\n        className=\"username-shortcut-badge\"\n        variant=\"light\"\n        contentEditable={false}\n        radius=\"xl\"\n        tt=\"none\"\n        size=\"sm\"\n        styles={{ label: { display: 'flex', alignItems: 'center', gap: 4 } }}\n      >\n        <span style={{ fontWeight: 600 }}>{shortcutName}</span>\n        <IconArrowRight size={12} style={{ opacity: 0.5 }} />\n        <Popover\n          opened={opened}\n          onChange={(isOpen) => { if (!isOpen) close(); }}\n          position=\"bottom-start\"\n          width={300}\n          shadow=\"md\"\n          withArrow\n          withinPortal\n        >\n          <Popover.Target>\n            <Tooltip label={<Trans>Select websites to apply usernames to</Trans>} withArrow>\n              <Box\n                component=\"span\"\n                className=\"only-website-selector\"\n                contentEditable={false}\n                onClick={(e: React.MouseEvent) => {\n                  e.stopPropagation();\n                  toggle();\n                }}\n                style={{\n                  cursor: 'pointer',\n                  display: 'inline-flex',\n                  alignItems: 'center',\n                  gap: 3,\n                  padding: '0 2px',\n                  borderRadius: 'var(--mantine-radius-sm)',\n                  transition: 'background-color 0.15s ease',\n                  fontWeight: 500,\n                  whiteSpace: 'nowrap',\n                }}\n              >\n                <Text span inherit size=\"xs\" style={{ lineHeight: 1 }}>\n                  {selectedDisplayText}\n                </Text>\n                <IconChevronDown\n                  size={10}\n                  style={{\n                    transform: opened ? 'rotate(180deg)' : 'rotate(0deg)',\n                    transition: 'transform 0.2s ease',\n                    opacity: 0.7,\n                  }}\n                />\n              </Box>\n            </Tooltip>\n          </Popover.Target>\n\n          <Popover.Dropdown p={0} onMouseDown={(e: React.MouseEvent) => e.stopPropagation()}>\n            <Stack gap=\"xs\">\n              <Box>\n                <Group align=\"apart\" p=\"xs\" style={{ alignItems: 'center' }}>\n                  <Group gap=\"xs\">\n                    <ThemeIcon size=\"sm\" variant=\"light\" color=\"blue\">\n                      <IconWorld size={14} />\n                    </ThemeIcon>\n                    <Text size=\"sm\" fw={600}><Trans>Websites</Trans></Text>\n                  </Group>\n                  <Badge size=\"xs\" variant=\"filled\" color={badgeColor}>\n                    {selectedWebsiteIds.length === 0 ? <Trans>All</Trans> : `${selectedWebsiteIds.length} / ${websites.length}`}\n                  </Badge>\n                </Group>\n                <Divider />\n              </Box>\n\n              <Box p=\"sm\" py={0}>\n                <TextInput\n                  leftSection={<IconSearch size={14} />}\n                  value={searchTerm}\n                  onChange={(e) => setSearchTerm(e.target.value)}\n                  size=\"xs\"\n                  rightSection={searchTerm ? <UnstyledButton onClick={() => setSearchTerm('')}><IconX size={14} /></UnstyledButton> : null}\n                />\n              </Box>\n\n              <Box px=\"sm\" pb=\"0\">\n                <Group gap=\"xs\">\n                  <Button size=\"xs\" variant=\"light\" color=\"blue\" onClick={handleSelectAll} style={{ flex: 1 }}>\n                    {selectedWebsiteIds.length === websites.length ? <Trans>Deselect all</Trans> : <Trans>Select all</Trans>}\n                  </Button>\n                </Group>\n              </Box>\n\n              <Divider />\n\n              <Box style={{ maxHeight: '200px', overflow: 'auto' }}>\n                {filteredWebsiteOptions.length > 0 ? (\n                  <Stack gap={0} p=\"xs\">\n                    {filteredWebsiteOptions.map((option) => {\n                      const isSelected = selectedWebsiteIds.includes(option.value);\n                      return (\n                        <UnstyledButton\n                          className=\"only-website-toggle-btn\"\n                          key={option.value}\n                          onClick={() => handleWebsiteToggle(option.value)}\n                          style={{\n                            width: '100%',\n                            padding: '8px',\n                            borderRadius: 'var(--mantine-radius-sm)',\n                            transition: 'background-color 0.15s ease',\n                          }}\n                        >\n                          <Group gap=\"sm\" wrap=\"nowrap\">\n                            <Checkbox checked={isSelected} onChange={() => {}} size=\"xs\" color=\"blue\" styles={{ input: { cursor: 'pointer' } }} />\n                            <Text\n                              size=\"sm\"\n                              style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}\n                              fw={isSelected ? 500 : 400}\n                              c={isSelected ? 'blue' : undefined}\n                            >\n                              {option.label}\n                            </Text>\n                          </Group>\n                        </UnstyledButton>\n                      );\n                    })}\n                  </Stack>\n                ) : (\n                  <Box p=\"md\">\n                    <Text ta=\"center\" c=\"dimmed\" size=\"sm\"><Trans>No results found</Trans></Text>\n                  </Box>\n                )}\n              </Box>\n            </Stack>\n          </Popover.Dropdown>\n        </Popover>\n\n        <span\n          className=\"username-shortcut-separator\"\n          contentEditable={false}\n        />\n        <input\n          ref={inputRef}\n          type=\"text\"\n          className=\"username-shortcut-input\"\n          value={usernameValue}\n          onChange={handleUsernameChange}\n          onKeyDown={handleUsernameKeyDown}\n          onClick={handleInputClick}\n          placeholder=\"username\"\n        />\n      </Badge>\n    </NodeViewWrapper>\n  );\n}\n\n/**\n * TipTap Node extension for the Username shortcut inline node.\n * Most complex custom node — features an editable username input and website selector.\n */\nexport const UsernameShortcutExtension = Node.create({\n  name: 'username',\n  group: 'inline',\n  inline: true,\n  atom: true,\n\n  addAttributes() {\n    return {\n      shortcut: { default: '' },\n      only: { default: '' },\n      username: { default: '' },\n    };\n  },\n\n  parseHTML() {\n    return [{ tag: 'span[data-type=\"username\"]' }];\n  },\n\n  renderHTML({ HTMLAttributes }) {\n    return ['span', mergeAttributes(HTMLAttributes, { 'data-type': 'username' })];\n  },\n\n  addNodeView() {\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    return ReactNodeViewRenderer(UsernameShortcutView as any);\n  },\n\n  addCommands() {\n    return {\n      insertUsernameShortcut:\n        (attrs: { shortcut: string; only?: string; username?: string }) =>\n        // eslint-disable-next-line @typescript-eslint/no-explicit-any\n        ({ commands }: { commands: any }) => commands.insertContent([\n            {\n              type: this.name,\n              attrs: {\n                shortcut: attrs.shortcut,\n                only: attrs.only ?? '',\n                username: attrs.username ?? '',\n              },\n            },\n            { type: 'text', text: ' ' },\n          ]),\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    } as any;\n  },\n});\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/description-editor/index.ts",
    "content": "export { DescriptionEditor } from './description-editor';\nexport type { DescriptionEditorProps } from './description-editor';\n\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/description-editor/types.ts",
    "content": "/**\n * Re-export TipTap JSON types from the shared types library.\n * These are used throughout the description editor system.\n */\nexport type { TipTapDoc, TipTapMark, TipTapNode } from '@postybirb/types';\n\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/external-link/external-link.tsx",
    "content": "/**\n * ExternalLink - Opens links in the system's default browser.\n * Handles both Electron and web environments.\n */\n\nimport { Anchor, Tooltip } from '@mantine/core';\nimport type { AnchorHTMLAttributes, PropsWithChildren } from 'react';\nimport { CopyToClipboard } from '../copy-to-clipboard';\n\n/**\n * Opens a URL in the system's default browser.\n * In Electron, uses the IPC bridge. In web, opens a new tab.\n */\nexport function openLink(url?: string) {\n  if (url) {\n    if (window.electron?.openExternalLink) {\n      window.electron.openExternalLink(url);\n    } else {\n      window.open(url, '_blank');\n    }\n  }\n}\n\n/**\n * An anchor component that opens links externally.\n * Includes a tooltip with the URL and a copy button.\n */\nexport function ExternalLink(\n  props: PropsWithChildren<\n    Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'target' | 'onClick'>\n  >,\n) {\n  const { href } = props;\n  return (\n    <Tooltip label={href} position=\"top\" withArrow>\n      <span>\n        <Anchor\n          {...props}\n          target=\"_blank\"\n          c=\"blue\"\n          inherit // Inherit parent styles\n          onClickCapture={(event) => {\n            event.preventDefault();\n            event.stopPropagation();\n            openLink(href);\n          }}\n        />\n        <CopyToClipboard value={href} variant=\"icon\" size=\"xs\" color=\"blue\" />\n      </span>\n    </Tooltip>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/external-link/index.ts",
    "content": "export { ExternalLink, openLink } from './external-link';\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/index.ts",
    "content": "export { AccountPicker } from './account-picker';\nexport type { AccountPickerProps } from './account-picker';\nexport { BasicWebsiteSelect } from './basic-website-select/basic-website-select';\nexport { CopyToClipboard } from './copy-to-clipboard';\nexport type { CopyToClipboardProps } from './copy-to-clipboard';\nexport { DescriptionEditor } from './description-editor';\nexport type { DescriptionEditorProps } from './description-editor';\nexport { MultiSchedulerModal } from './multi-scheduler-modal';\nexport type { MultiSchedulerModalProps } from './multi-scheduler-modal';\nexport { RatingInput } from './rating-input';\nexport type { RatingInputProps } from './rating-input';\nexport { ReorderableSubmissionList } from './reorderable-submission-list';\nexport type { ReorderableSubmissionListProps } from './reorderable-submission-list';\nexport { SearchInput } from './search-input';\nexport { SimpleTagInput } from './simple-tag-input';\nexport type { SimpleTagInputProps } from './simple-tag-input';\nexport { SubmissionPicker, SubmissionPickerModal } from './submission-picker';\nexport type { SubmissionPickerProps, SubmissionPickerModalProps } from './submission-picker';\nexport { TemplatePicker } from './template-picker/template-picker';\n\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/multi-scheduler-modal/index.ts",
    "content": "/**\n * MultiSchedulerModal - Modal for scheduling multiple submissions.\n */\n\nexport { MultiSchedulerModal } from './multi-scheduler-modal';\nexport type { MultiSchedulerModalProps } from './multi-scheduler-modal';\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/multi-scheduler-modal/multi-scheduler-modal.css",
    "content": "/**\n * Styles for MultiSchedulerModal component.\n */\n\n.postybirb__multi-scheduler-body {\n  padding: 0;\n}\n\n.postybirb__multi-scheduler-content {\n  display: flex;\n  gap: var(--mantine-spacing-md);\n  padding: var(--mantine-spacing-md);\n  min-height: 400px;\n}\n\n.postybirb__multi-scheduler-list {\n  flex: 1;\n  min-width: 0;\n  max-width: calc(100% - 300px - var(--mantine-spacing-md) * 2);\n  display: flex;\n  flex-direction: column;\n  overflow: hidden;\n}\n\n.postybirb__multi-scheduler-divider {\n  flex-shrink: 0;\n}\n\n.postybirb__multi-scheduler-form {\n  flex: 0 0 300px;\n  display: flex;\n  flex-direction: column;\n}\n\n/* Responsive adjustments for smaller screens */\n@media (max-width: 768px) {\n  .postybirb__multi-scheduler-content {\n    flex-direction: column;\n  }\n\n  .postybirb__multi-scheduler-divider {\n    display: none;\n  }\n\n  .postybirb__multi-scheduler-form {\n    flex: none;\n  }\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx",
    "content": "/**\n * MultiSchedulerModal - Modal for scheduling multiple submissions at once.\n * Features a two-column layout with reorderable submission list and schedule form.\n */\n\nimport { Trans, useLingui } from '@lingui/react/macro';\nimport {\n  Box,\n  Button,\n  Checkbox,\n  Divider,\n  Group,\n  Modal,\n  NumberInput,\n  Stack,\n  Text,\n  Title,\n} from '@mantine/core';\nimport { DateTimePicker } from '@mantine/dates';\nimport { ScheduleType } from '@postybirb/types';\nimport { IconCalendarEvent } from '@tabler/icons-react';\nimport { useCallback, useMemo, useState } from 'react';\nimport { useLocalStorage } from 'react-use';\nimport submissionApi from '../../../api/submission.api';\nimport { useLocale } from '../../../hooks';\nimport type { SubmissionRecord } from '../../../stores/records';\nimport {\n  showErrorNotification,\n  showSuccessNotification,\n} from '../../../utils/notifications';\nimport { ReorderableSubmissionList } from '../reorderable-submission-list';\nimport './multi-scheduler-modal.css';\n\n// LocalStorage key for last used schedule date\nconst SCHEDULE_STORAGE_KEY = 'postybirb-last-schedule-date';\n\nexport interface MultiSchedulerModalProps {\n  /** Whether the modal is open */\n  opened: boolean;\n  /** Callback when modal is closed */\n  onClose: () => void;\n  /** Submissions to schedule */\n  submissions: SubmissionRecord[];\n}\n\n/**\n * Calculate the next date based on index and increments.\n * First submission (index 0) is at baseDate, subsequent submissions are offset by increments * index.\n */\nfunction getNextDate(\n  baseDate: Date,\n  index: number,\n  increments: { days: number; hours: number; minutes: number },\n): Date {\n  const { days, hours, minutes } = increments;\n  const nextDate = new Date(baseDate);\n\n  // Simply multiply each increment by the index\n  nextDate.setDate(nextDate.getDate() + days * index);\n  nextDate.setHours(nextDate.getHours() + hours * index);\n  nextDate.setMinutes(nextDate.getMinutes() + minutes * index);\n\n  return nextDate;\n}\n\n/**\n * MultiSchedulerModal component.\n * Provides a two-column interface for scheduling multiple submissions.\n */\nexport function MultiSchedulerModal({\n  opened,\n  onClose,\n  submissions: initialSubmissions,\n}: MultiSchedulerModalProps) {\n  const { t } = useLingui();\n  const { formatDateTime } = useLocale();\n\n  // Reorderable submissions state\n  const [orderedSubmissions, setOrderedSubmissions] =\n    useState<SubmissionRecord[]>(initialSubmissions);\n\n  // Schedule form state\n  const [lastUsedDate, setLastUsedDate] = useLocalStorage<string | undefined>(\n    SCHEDULE_STORAGE_KEY,\n    undefined,\n  );\n  const [selectedDate, setSelectedDate] = useState<Date | null>(() => {\n    if (lastUsedDate) {\n      const parsed = new Date(lastUsedDate);\n      if (!Number.isNaN(parsed.getTime())) {\n        return parsed;\n      }\n    }\n    return new Date();\n  });\n  const [days, setDays] = useState(1);\n  const [hours, setHours] = useState(0);\n  const [minutes, setMinutes] = useState(0);\n  const [onlySetDate, setOnlySetDate] = useState(false);\n  const [isSubmitting, setIsSubmitting] = useState(false);\n\n  // Validation - at least one interval must be set\n  const isValid = days > 0 || hours > 0 || minutes > 0;\n\n  // Reset state when modal opens with new submissions\n  useMemo(() => {\n    setOrderedSubmissions(initialSubmissions);\n  }, [initialSubmissions]);\n\n  // Handle date change\n  const handleDateChange = useCallback(\n    (value: Date | string | null) => {\n      // DateTimePicker may pass string or Date\n      const date = value\n        ? typeof value === 'string'\n          ? new Date(value)\n          : value\n        : null;\n      setSelectedDate(date);\n      if (date && !Number.isNaN(date.getTime())) {\n        setLastUsedDate(date.toISOString());\n      }\n    },\n    [setLastUsedDate],\n  );\n\n  // Render schedule time preview for each item\n  const renderSchedulePreview = useCallback(\n    (_submission: SubmissionRecord, index: number) => {\n      if (!selectedDate) return null;\n      const scheduledDate = getNextDate(selectedDate, index, {\n        days,\n        hours,\n        minutes,\n      });\n      return (\n        <Text size=\"xs\" c=\"dimmed\">\n          {formatDateTime(scheduledDate)}\n        </Text>\n      );\n    },\n    [selectedDate, days, hours, minutes, formatDateTime],\n  );\n\n  // Handle apply\n  const handleApply = useCallback(async () => {\n    if (!isValid || !selectedDate) return;\n\n    setIsSubmitting(true);\n    try {\n      // Schedule each submission\n      const promises = orderedSubmissions.map((submission, index) => {\n        const scheduledFor = getNextDate(selectedDate, index, {\n          days,\n          hours,\n          minutes,\n        });\n\n        return submissionApi.update(submission.id, {\n          scheduleType: ScheduleType.SINGLE,\n          scheduledFor: scheduledFor.toISOString(),\n          isScheduled: !onlySetDate,\n        });\n      });\n\n      await Promise.all(promises);\n\n      showSuccessNotification(\n        <Trans>\n          Successfully scheduled ${orderedSubmissions.length} submissions\n        </Trans>,\n      );\n      onClose();\n    } catch (error) {\n      showErrorNotification(\n        error instanceof Error\n          ? error.message\n          : t`Failed to schedule submissions`,\n      );\n    } finally {\n      setIsSubmitting(false);\n    }\n  }, [\n    isValid,\n    selectedDate,\n    orderedSubmissions,\n    days,\n    hours,\n    minutes,\n    onlySetDate,\n    onClose,\n    t,\n  ]);\n\n  return (\n    <Modal\n      opened={opened}\n      onClose={onClose}\n      title={\n        <Group gap=\"xs\">\n          <IconCalendarEvent size={20} />\n          <Title order={4}>\n            <Trans>Schedule Submissions</Trans>\n          </Title>\n        </Group>\n      }\n      size=\"xl\"\n      centered\n      radius=\"md\"\n      classNames={{\n        body: 'postybirb__multi-scheduler-body',\n      }}\n    >\n      <Box className=\"postybirb__multi-scheduler-content\">\n        {/* Left column - Reorderable list */}\n        <Box className=\"postybirb__multi-scheduler-list\">\n          <Text size=\"sm\" fw={500} mb=\"xs\">\n            <Trans>Submission Order</Trans>\n          </Text>\n          <ReorderableSubmissionList\n            submissions={orderedSubmissions}\n            onReorder={setOrderedSubmissions}\n            renderExtra={renderSchedulePreview}\n            maxHeight=\"350px\"\n          />\n        </Box>\n\n        {/* Divider */}\n        <Divider\n          orientation=\"vertical\"\n          className=\"postybirb__multi-scheduler-divider\"\n        />\n\n        {/* Right column - Schedule form */}\n        <Box className=\"postybirb__multi-scheduler-form\">\n          <Text size=\"sm\" fw={500} mb=\"xs\">\n            <Trans>Schedule Settings</Trans>\n          </Text>\n          <Stack gap=\"md\">\n            <DateTimePicker\n              // eslint-disable-next-line lingui/no-unlocalized-strings\n              valueFormat=\"YYYY-MM-DD HH:mm\"\n              label={<Trans>Start Date</Trans>}\n              value={selectedDate}\n              onChange={handleDateChange}\n              minDate={new Date()}\n              clearable\n              required\n            />\n\n            <Text size=\"xs\" c=\"dimmed\">\n              <Trans>\n                Set the interval between each submission.\n              </Trans>\n            </Text>\n\n            <Group grow>\n              <NumberInput\n                label={<Trans>Days</Trans>}\n                value={days}\n                onChange={(v) => setDays(Number(v) || 0)}\n                min={0}\n                step={1}\n              />\n              <NumberInput\n                label={<Trans>Hours</Trans>}\n                value={hours}\n                onChange={(v) => setHours(Number(v) || 0)}\n                min={0}\n                step={1}\n              />\n              <NumberInput\n                label={<Trans>Minutes</Trans>}\n                value={minutes}\n                onChange={(v) => setMinutes(Number(v) || 0)}\n                min={0}\n                step={1}\n              />\n            </Group>\n\n            <Checkbox\n              label={<Trans>Only set scheduled date (don't activate)</Trans>}\n              checked={onlySetDate}\n              onChange={(e) => setOnlySetDate(e.currentTarget.checked)}\n            />\n          </Stack>\n        </Box>\n      </Box>\n\n      {/* Footer */}\n      <Group justify=\"flex-end\" mt=\"md\">\n        <Button variant=\"default\" onClick={onClose} disabled={isSubmitting}>\n          <Trans>Cancel</Trans>\n        </Button>\n        <Button\n          onClick={handleApply}\n          disabled={!isValid}\n          loading={isSubmitting}\n        >\n          <Trans>Schedule</Trans>\n        </Button>\n      </Group>\n    </Modal>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/rating-input/index.ts",
    "content": "export { RatingInput, type RatingInputProps } from './rating-input';\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/rating-input/rating-input.tsx",
    "content": "/**\n * RatingInput - Submission rating selector using icon-based rating component.\n * Provides a 4-level rating system: General, Mature, Adult, Extreme.\n */\n\nimport { useLingui } from '@lingui/react/macro';\nimport { Group, Rating, Tooltip } from '@mantine/core';\nimport { SubmissionRating } from '@postybirb/types';\nimport {\n  IconCircleLetterE,\n  IconCircleLetterM,\n  IconExclamationCircle,\n  IconRating18Plus,\n} from '@tabler/icons-react';\nimport { useCallback, useState } from 'react';\n\nexport interface RatingInputProps {\n  /** Current rating value */\n  value: SubmissionRating;\n  /** Callback when rating changes */\n  onChange: (rating: SubmissionRating) => void;\n  /** Size variant */\n  size?: 'xs' | 'sm' | 'md';\n  /** Whether to show tooltip */\n  showTooltip?: boolean;\n}\n\n// Map rating enum to numeric value (1-4)\nconst ratingToValue: Record<SubmissionRating, number> = {\n  [SubmissionRating.GENERAL]: 1,\n  [SubmissionRating.MATURE]: 2,\n  [SubmissionRating.ADULT]: 3,\n  [SubmissionRating.EXTREME]: 4,\n};\n\n// Map numeric value to rating enum\nconst valueToRating: Record<number, SubmissionRating> = {\n  1: SubmissionRating.GENERAL,\n  2: SubmissionRating.MATURE,\n  3: SubmissionRating.ADULT,\n  4: SubmissionRating.EXTREME,\n};\n\n// Size to icon dimensions mapping\nconst sizeToIconDimensions: Record<'xs' | 'sm' | 'md', number> = {\n  xs: 16,\n  sm: 18,\n  md: 22,\n};\n\nconst getIconStyle = (size: 'xs' | 'sm' | 'md', color?: string) => ({\n  width: sizeToIconDimensions[size],\n  height: sizeToIconDimensions[size],\n  color: color ? `var(--mantine-color-${color}-7)` : undefined,\n});\n\nfunction getEmptyIcon(size: 'xs' | 'sm' | 'md', value: number) {\n  const iconStyle = getIconStyle(size);\n\n  switch (value) {\n    case 1:\n      return <IconCircleLetterE style={iconStyle} />;\n    case 2:\n      return <IconCircleLetterM style={iconStyle} />;\n    case 3:\n      return <IconRating18Plus style={iconStyle} />;\n    case 4:\n      return <IconExclamationCircle style={iconStyle} />;\n    default:\n      return null;\n  }\n}\n\nfunction getFullIcon(size: 'xs' | 'sm' | 'md', value: number) {\n  switch (value) {\n    case 1:\n      return <IconCircleLetterE style={getIconStyle(size, 'green')} />;\n    case 2:\n      return <IconCircleLetterM style={getIconStyle(size, 'yellow')} />;\n    case 3:\n      return <IconRating18Plus style={getIconStyle(size, 'orange')} />;\n    case 4:\n      return <IconExclamationCircle style={getIconStyle(size, 'red')} />;\n    default:\n      return null;\n  }\n}\n\n/**\n * Rating input component using Mantine Rating with custom icons.\n * Displays 4 icons representing General, Mature, Adult, Extreme ratings.\n * Only the selected rating icon is colored.\n */\nexport function RatingInput({\n  value,\n  onChange,\n  size = 'sm',\n  showTooltip = true,\n}: RatingInputProps) {\n  const { t } = useLingui();\n  const numericValue = ratingToValue[value] ?? 1;\n  const [hoveredValue, setHoveredValue] = useState<number | null>(null);\n\n  // Rating labels for tooltips\n  const ratingLabels: Record<number, string> = {\n    1: t`General`,\n    2: t`Mature`,\n    3: t`Adult`,\n    4: t`Extreme`,\n  };\n\n  // Show tooltip for hovered value, or current value if not hovering\n  const displayValue = hoveredValue ?? numericValue;\n  const ratingLabel = ratingLabels[displayValue];\n\n  const handleChange = useCallback(\n    (newValue: number) => {\n      // Rating component can return 0 if clicked on same value, keep at least 1\n      const safeValue = Math.max(1, newValue);\n      const newRating = valueToRating[safeValue];\n      if (newRating && newRating !== value) {\n        onChange(newRating);\n      }\n    },\n    [value, onChange],\n  );\n\n  // Create icon function that only colors the selected value\n  const getSymbol = useCallback(\n    (val: number) => {\n      // Only show the colored icon for the exact selected value\n      if (val === numericValue) {\n        return getFullIcon(size, val);\n      }\n      return getEmptyIcon(size, val);\n    },\n    [size, numericValue],\n  );\n\n  const ratingComponent = (\n    <Rating\n      value={numericValue}\n      count={4}\n      onChange={handleChange}\n      onHover={setHoveredValue}\n      emptySymbol={getSymbol}\n      fullSymbol={getSymbol}\n      highlightSelectedOnly\n    />\n  );\n\n  if (!showTooltip) {\n    return <Group gap=\"xs\">{ratingComponent}</Group>;\n  }\n\n  return (\n    <Group gap=\"xs\" onClick={(e) => e.stopPropagation()}>\n      <Tooltip label={ratingLabel} position=\"top\">\n        {ratingComponent}\n      </Tooltip>\n    </Group>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/reorderable-submission-list/index.ts",
    "content": "/**\n * ReorderableSubmissionList - Shared component for reordering submissions.\n */\n\nexport { ReorderableSubmissionList } from './reorderable-submission-list';\nexport type { ReorderableSubmissionListProps } from './reorderable-submission-list';\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/reorderable-submission-list/reorderable-submission-list.css",
    "content": "/**\n * Styles for ReorderableSubmissionList component.\n */\n\n.postybirb__reorderable-container {\n  flex: 1;\n  min-width: 0;\n  overflow: hidden;\n  width: 100%;\n}\n\n.postybirb__reorderable-item {\n  cursor: default;\n  transition: box-shadow 150ms ease, transform 150ms ease;\n  overflow: hidden;\n  max-width: 100%;\n}\n\n.postybirb__reorderable-item:focus {\n  outline: 2px solid var(--mantine-primary-color-filled);\n  outline-offset: 2px;\n}\n\n.postybirb__reorderable-item:hover {\n  box-shadow: var(--mantine-shadow-sm);\n}\n\n.postybirb__reorderable-item--failed {\n  background-color: color-mix(\n    in srgb,\n    var(--mantine-color-red-light) 15%,\n    transparent\n  );\n  border-color: var(--mantine-color-red-6);\n}\n\n.postybirb__reorderable-item--failed:hover {\n  background-color: color-mix(\n    in srgb,\n    var(--mantine-color-red-light) 20%,\n    transparent\n  );\n}\n\n.postybirb__reorderable-item-content {\n  display: flex;\n  align-items: center;\n  gap: var(--mantine-spacing-xs);\n  min-width: 0;\n  overflow: hidden;\n  max-width: 100%;\n}\n\n.postybirb__reorderable-handle {\n  cursor: grab;\n  color: var(--mantine-color-dimmed);\n  display: flex;\n  align-items: center;\n  padding: 4px;\n  border-radius: var(--mantine-radius-sm);\n  transition: background-color 150ms ease;\n  flex-shrink: 0;\n}\n\n.postybirb__reorderable-handle:hover {\n  background-color: var(--mantine-color-default-hover);\n}\n\n.postybirb__reorderable-handle:active {\n  cursor: grabbing;\n}\n\n.postybirb__reorderable-item-main {\n  flex: 1;\n  min-width: 0;\n  display: flex;\n  flex-direction: column;\n  gap: 2px;\n  overflow: hidden;\n}\n\n.postybirb__reorderable-item-main > * {\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.postybirb__reorderable-ghost {\n  opacity: 0.5;\n  background-color: var(--mantine-color-default);\n}\n\n.postybirb__reorderable-empty {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  min-height: 100px;\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/reorderable-submission-list/reorderable-submission-list.tsx",
    "content": "/**\n * ReorderableSubmissionList - Shared component for reordering submissions.\n * Supports drag-and-drop and keyboard navigation (Tab to focus, Arrow keys to reorder).\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport { Box, Paper, ScrollArea, Stack, Text } from '@mantine/core';\nimport { PostRecordState } from '@postybirb/types';\nimport { IconGripVertical } from '@tabler/icons-react';\nimport { useCallback, useEffect, useRef } from 'react';\nimport {\n  closestCenter,\n  DndContext,\n  DragEndEvent,\n  KeyboardSensor,\n  PointerSensor,\n  useSensor,\n  useSensors,\n} from '@dnd-kit/core';\nimport {\n  arrayMove,\n  SortableContext,\n  sortableKeyboardCoordinates,\n  useSortable,\n  verticalListSortingStrategy,\n} from '@dnd-kit/sortable';\nimport { CSS } from '@dnd-kit/utilities';\nimport type { SubmissionRecord } from '../../../stores/records';\nimport { cn } from '../../../utils/class-names';\nimport './reorderable-submission-list.css';\n\nexport interface ReorderableSubmissionListProps {\n  /** List of submissions to display */\n  submissions: SubmissionRecord[];\n  /** Callback when submissions are reordered */\n  onReorder: (submissions: SubmissionRecord[]) => void;\n  /** Optional render function for additional content per item */\n  renderExtra?: (\n    submission: SubmissionRecord,\n    index: number,\n  ) => React.ReactNode;\n  /** Maximum height of the list */\n  maxHeight?: string | number;\n}\n\n/**\n * ReorderableSubmissionList component.\n * Renders a list of submissions that can be reordered via drag-and-drop or keyboard.\n */\nexport function ReorderableSubmissionList({\n  submissions,\n  onReorder,\n  renderExtra,\n  maxHeight = '400px',\n}: ReorderableSubmissionListProps) {\n  const focusedIndexRef = useRef<number>(-1);\n  const containerRef = useRef<HTMLDivElement>(null);\n\n  const sensors = useSensors(\n    useSensor(PointerSensor, {\n      activationConstraint: { distance: 8 },\n    }),\n    useSensor(KeyboardSensor, {\n      coordinateGetter: sortableKeyboardCoordinates,\n    }),\n  );\n\n  const handleDragEnd = useCallback(\n    (event: DragEndEvent) => {\n      const { active, over } = event;\n      if (!over || active.id === over.id) return;\n\n      const oldIndex = submissions.findIndex((s) => s.id === active.id);\n      const newIndex = submissions.findIndex((s) => s.id === over.id);\n      if (oldIndex !== -1 && newIndex !== -1) {\n        onReorder(arrayMove(submissions, oldIndex, newIndex));\n      }\n    },\n    [submissions, onReorder],\n  );\n\n  // Handle keyboard navigation (arrow keys to reorder)\n  const handleKeyDown = useCallback(\n    (event: React.KeyboardEvent, index: number) => {\n      if (event.key === 'ArrowUp' && index > 0) {\n        event.preventDefault();\n        onReorder(arrayMove(submissions, index, index - 1));\n        focusedIndexRef.current = index - 1;\n      } else if (event.key === 'ArrowDown' && index < submissions.length - 1) {\n        event.preventDefault();\n        onReorder(arrayMove(submissions, index, index + 1));\n        focusedIndexRef.current = index + 1;\n      }\n    },\n    [submissions, onReorder],\n  );\n\n  // Restore focus after reorder\n  useEffect(() => {\n    if (focusedIndexRef.current >= 0 && containerRef.current) {\n      const items = containerRef.current.querySelectorAll(\n        '.postybirb__reorderable-item',\n      );\n      const targetItem = items[focusedIndexRef.current] as HTMLElement;\n      if (targetItem) {\n        targetItem.focus();\n      }\n      focusedIndexRef.current = -1;\n    }\n  }, [submissions]);\n\n  if (submissions.length === 0) {\n    return (\n      <Paper\n        withBorder\n        p=\"xl\"\n        radius=\"md\"\n        className=\"postybirb__reorderable-empty\"\n      >\n        <Text size=\"sm\" c=\"dimmed\" ta=\"center\">\n          <Trans>No submissions selected</Trans>\n        </Text>\n      </Paper>\n    );\n  }\n\n  return (\n    <Box className=\"postybirb__reorderable-container\">\n      <Text size=\"xs\" c=\"dimmed\" mb=\"xs\">\n        <Trans>Drag or use arrow keys to reorder</Trans>\n      </Text>\n      <Box style={{ overflow: 'hidden', maxHeight }}>\n        <ScrollArea h=\"100%\" scrollbars=\"y\">\n          <DndContext\n            sensors={sensors}\n            collisionDetection={closestCenter}\n            onDragEnd={handleDragEnd}\n          >\n            <SortableContext\n              items={submissions.map((s) => s.id)}\n              strategy={verticalListSortingStrategy}\n            >\n              <Stack gap=\"xs\" ref={containerRef}>\n                {submissions.map((submission, index) => (\n                  <SortableReorderableItem\n                    key={submission.id}\n                    submission={submission}\n                    index={index}\n                    onKeyDown={handleKeyDown}\n                    renderExtra={renderExtra}\n                  />\n                ))}\n              </Stack>\n            </SortableContext>\n          </DndContext>\n        </ScrollArea>\n      </Box>\n    </Box>\n  );\n}\n\n/** Sortable item wrapper using dnd-kit */\nfunction SortableReorderableItem({\n  submission,\n  index,\n  onKeyDown,\n  renderExtra,\n}: {\n  submission: SubmissionRecord;\n  index: number;\n  onKeyDown: (event: React.KeyboardEvent, index: number) => void;\n  renderExtra?: ReorderableSubmissionListProps['renderExtra'];\n}) {\n  const {\n    attributes,\n    listeners,\n    setNodeRef,\n    transform,\n    transition,\n    isDragging,\n  } = useSortable({ id: submission.id });\n\n  const style = {\n    transform: CSS.Transform.toString(transform),\n    transition,\n    opacity: isDragging ? 0.5 : 1,\n  };\n\n  const title = submission.getDefaultOptions()?.data?.title;\n  const lastPost = submission.latestPost;\n  const hasFailedPost =\n    lastPost && lastPost.state === PostRecordState.FAILED;\n\n  return (\n    <Paper\n      ref={setNodeRef}\n      style={style}\n      withBorder\n      p=\"xs\"\n      radius=\"sm\"\n      tabIndex={0}\n      className={cn(['postybirb__reorderable-item'], {\n        'postybirb__reorderable-item--failed': hasFailedPost,\n      })}\n      onKeyDown={(e) => onKeyDown(e, index)}\n    >\n      <Box className=\"postybirb__reorderable-item-content\">\n        <Box\n          className=\"postybirb__reorderable-handle\"\n          {...attributes}\n          {...listeners}\n        >\n          <IconGripVertical size={16} />\n        </Box>\n        <Box className=\"postybirb__reorderable-item-main\">\n          <Text size=\"sm\" fw={500} truncate>\n            {title || <Trans>Untitled</Trans>}\n          </Text>\n          {renderExtra && renderExtra(submission, index)}\n        </Box>\n      </Box>\n    </Paper>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/schedule-popover/cron-picker.tsx",
    "content": "/**\n * CronPicker - Intuitive CRON builder with frequency/day/time pickers\n * plus manual CRON input toggle for power users.\n */\n\nimport { Trans, useLingui as useLinguiMacro } from '@lingui/react/macro';\nimport {\n  Anchor,\n  Box,\n  Chip,\n  Group,\n  SegmentedControl,\n  Select,\n  Stack,\n  Text,\n  TextInput,\n  Tooltip,\n  useMantineColorScheme,\n} from '@mantine/core';\nimport { TimeInput } from '@mantine/dates';\nimport {\n  IconAt,\n  IconCalendar,\n  IconCode,\n  IconExternalLink,\n} from '@tabler/icons-react';\nimport { Cron } from 'croner';\nimport cronstrue from 'cronstrue';\nimport { useCallback, useMemo, useState } from 'react';\nimport { useLocale } from '../../../hooks';\n\nexport interface CronPickerProps {\n  /** Current CRON expression */\n  value: string;\n  /** Callback when CRON changes */\n  onChange: (cron: string) => void;\n}\n\ntype Frequency = 'daily' | 'weekly' | 'monthly';\ntype CronMode = 'builder' | 'custom';\n\n/* eslint-disable lingui/no-unlocalized-strings */\n// Days of week for chip selection\nconst DAYS_OF_WEEK = [\n  { value: '1', label: 'Mon' },\n  { value: '2', label: 'Tue' },\n  { value: '3', label: 'Wed' },\n  { value: '4', label: 'Thu' },\n  { value: '5', label: 'Fri' },\n  { value: '6', label: 'Sat' },\n  { value: '0', label: 'Sun' },\n];\n\n// Days of month options\nconst DAYS_OF_MONTH = Array.from({ length: 31 }, (_, i) => ({\n  value: String(i + 1),\n  label: `${i + 1}${getOrdinalSuffix(i + 1)}`,\n}));\n/* eslint-enable lingui/no-unlocalized-strings */\n\nfunction getOrdinalSuffix(n: number): string {\n  const s = ['th', 'st', 'nd', 'rd'];\n  const v = n % 100;\n  return s[(v - 20) % 10] || s[v] || s[0];\n}\n\n/**\n * Parse a CRON expression to extract frequency, days, and time.\n */\nfunction parseCron(cron: string): {\n  frequency: Frequency;\n  selectedDays: string[];\n  dayOfMonth: string;\n  hour: number;\n  minute: number;\n} {\n  try {\n    const parts = cron.trim().split(' ');\n    if (parts.length !== 5) {\n      return {\n        frequency: 'weekly',\n        selectedDays: ['5'],\n        dayOfMonth: '1',\n        hour: 9,\n        minute: 0,\n      };\n    }\n\n    const [minuteStr, hourStr, dayStr, , dayOfWeekStr] = parts;\n    const minute = parseInt(minuteStr, 10) || 0;\n    const hour = parseInt(hourStr, 10) || 0;\n\n    // Determine frequency\n    if (dayOfWeekStr !== '*' && dayStr === '*') {\n      // Weekly - has specific days of week\n      const selectedDays = dayOfWeekStr.split(',').filter((d) => d !== '*');\n      return {\n        frequency: 'weekly',\n        selectedDays,\n        dayOfMonth: '1',\n        hour,\n        minute,\n      };\n    }\n    if (dayStr !== '*') {\n      // Monthly - has specific day of month\n      return {\n        frequency: 'monthly',\n        selectedDays: [],\n        dayOfMonth: dayStr,\n        hour,\n        minute,\n      };\n    }\n    // Daily - runs every day\n    return {\n      frequency: 'daily',\n      selectedDays: [],\n      dayOfMonth: '1',\n      hour,\n      minute,\n    };\n  } catch {\n    return {\n      frequency: 'weekly',\n      selectedDays: ['5'],\n      dayOfMonth: '1',\n      hour: 9,\n      minute: 0,\n    };\n  }\n}\n\n/**\n * Build a CRON expression from frequency, days, and time.\n */\nfunction buildCron(\n  frequency: Frequency,\n  selectedDays: string[],\n  dayOfMonth: string,\n  hour: number,\n  minute: number,\n): string {\n  const minuteStr = String(minute);\n  const hourStr = String(hour);\n\n  switch (frequency) {\n    case 'daily':\n      return `${minuteStr} ${hourStr} * * *`;\n    case 'weekly': {\n      const days = selectedDays.length > 0 ? selectedDays.join(',') : '*';\n      return `${minuteStr} ${hourStr} * * ${days}`;\n    }\n    case 'monthly':\n      return `${minuteStr} ${hourStr} ${dayOfMonth} * *`;\n    default:\n      return `${minuteStr} ${hourStr} * * *`;\n  }\n}\n\n/**\n * CronPicker component with intuitive builder and manual input modes.\n */\nexport function CronPicker({ value, onChange }: CronPickerProps) {\n  const { t } = useLinguiMacro();\n  const { cronstrueLocale, formatDateTime } = useLocale();\n  const { colorScheme } = useMantineColorScheme();\n  const [mode, setMode] = useState<CronMode>('builder');\n  const [manualCron, setManualCron] = useState(value);\n\n  // Parse current value for display - derived state, no local copies\n  const parsed = useMemo(() => parseCron(value), [value]);\n\n  // Helper to emit new cron from builder with updated field\n  const emitBuilderChange = useCallback(\n    (updates: Partial<{\n      frequency: Frequency;\n      selectedDays: string[];\n      dayOfMonth: string;\n      hour: number;\n      minute: number;\n    }>) => {\n      const newFrequency = updates.frequency ?? parsed.frequency;\n      const newSelectedDays = updates.selectedDays ?? parsed.selectedDays;\n      const newDayOfMonth = updates.dayOfMonth ?? parsed.dayOfMonth;\n      const newHour = updates.hour ?? parsed.hour;\n      const newMinute = updates.minute ?? parsed.minute;\n\n      const cron = buildCron(\n        newFrequency,\n        newSelectedDays,\n        newDayOfMonth,\n        newHour,\n        newMinute,\n      );\n      if (cron !== value) {\n        onChange(cron);\n      }\n    },\n    [parsed, value, onChange],\n  );\n\n  // User interaction handlers - each explicitly calls onChange\n  const handleFrequencyChange = useCallback(\n    (newFrequency: Frequency) => {\n      emitBuilderChange({ frequency: newFrequency });\n    },\n    [emitBuilderChange],\n  );\n\n  const handleDaysChange = useCallback(\n    (newDays: string[]) => {\n      emitBuilderChange({ selectedDays: newDays });\n    },\n    [emitBuilderChange],\n  );\n\n  const handleDayOfMonthChange = useCallback(\n    (newDay: string) => {\n      emitBuilderChange({ dayOfMonth: newDay });\n    },\n    [emitBuilderChange],\n  );\n\n  const handleTimeChange = useCallback(\n    (timeStr: string) => {\n      if (!timeStr) return;\n      const [h, m] = timeStr.split(':').map((s) => parseInt(s, 10));\n      if (Number.isNaN(h) || Number.isNaN(m)) return;\n      emitBuilderChange({ hour: h, minute: m });\n    },\n    [emitBuilderChange],\n  );\n\n  // Handle manual CRON change\n  const handleManualChange = useCallback(\n    (newCron: string) => {\n      setManualCron(newCron);\n      onChange(newCron);\n    },\n    [onChange],\n  );\n\n  // Sync manual input when switching modes or value changes externally\n  const handleModeChange = useCallback(\n    (newMode: CronMode) => {\n      setMode(newMode);\n      if (newMode === 'custom') {\n        setManualCron(value);\n      }\n    },\n    [value],\n  );\n\n  // Validate CRON\n  const cronToValidate = mode === 'custom' ? manualCron : value;\n  const isValidCron = useMemo(() => {\n    try {\n      return !!Cron(cronToValidate);\n    } catch {\n      return false;\n    }\n  }, [cronToValidate]);\n\n  // Get next run time\n  const nextRun = useMemo(() => {\n    try {\n      return Cron(cronToValidate)?.nextRun();\n    } catch {\n      return null;\n    }\n  }, [cronToValidate]);\n\n  // Get human-readable description\n  const cronDescription = useMemo(() => {\n    try {\n      return cronstrue.toString(cronToValidate, { locale: cronstrueLocale });\n    } catch {\n      return null;\n    }\n  }, [cronToValidate, cronstrueLocale]);\n\n  // Format time for TimeInput - derived from parsed value\n  const timeValue = useMemo(() => {\n    const h = String(parsed.hour).padStart(2, '0');\n    const m = String(parsed.minute).padStart(2, '0');\n    return `${h}:${m}`;\n  }, [parsed.hour, parsed.minute]);\n\n  return (\n    <Stack gap=\"sm\">\n      {/* Mode toggle */}\n      <SegmentedControl\n        value={mode}\n        onChange={(v) => handleModeChange(v as CronMode)}\n        size=\"xs\"\n        data={[\n          {\n            value: 'builder',\n            label: (\n              <Group gap={4}>\n                <IconCalendar size={14} />\n                <Trans>Builder</Trans>\n              </Group>\n            ),\n          },\n          {\n            value: 'custom',\n            label: (\n              <Group gap={4}>\n                <IconCode size={14} />\n                <Trans>Custom</Trans>\n              </Group>\n            ),\n          },\n        ]}\n      />\n\n      {mode === 'builder' ? (\n        <Stack gap=\"sm\">\n          {/* Frequency selector */}\n          <Select\n            label={<Trans>Frequency</Trans>}\n            size=\"xs\"\n            value={parsed.frequency}\n            onChange={(v) => v && handleFrequencyChange(v as Frequency)}\n            data={[\n              { value: 'daily', label: t`Daily` },\n              { value: 'weekly', label: t`Weekly` },\n              { value: 'monthly', label: t`Monthly` },\n            ]}\n          />\n\n          {/* Day picker for weekly */}\n          {parsed.frequency === 'weekly' && (\n            <Box>\n              <Text size=\"xs\" fw={500} mb={4}>\n                <Trans>Days</Trans>\n              </Text>\n              <Chip.Group\n                multiple\n                value={parsed.selectedDays}\n                onChange={handleDaysChange}\n              >\n                <Group gap={4}>\n                  {DAYS_OF_WEEK.map((day) => (\n                    <Chip key={day.value} value={day.value} size=\"xs\">\n                      {day.label}\n                    </Chip>\n                  ))}\n                </Group>\n              </Chip.Group>\n            </Box>\n          )}\n\n          {/* Day of month for monthly */}\n          {parsed.frequency === 'monthly' && (\n            <Select\n              label={<Trans>Day of Month</Trans>}\n              size=\"xs\"\n              value={parsed.dayOfMonth}\n              onChange={(v) => v && handleDayOfMonthChange(v)}\n              data={DAYS_OF_MONTH}\n              searchable\n            />\n          )}\n\n          {/* Time picker */}\n          <TimeInput\n            label={<Trans>Time</Trans>}\n            size=\"xs\"\n            value={timeValue}\n            onChange={(e) => handleTimeChange(e.currentTarget.value)}\n          />\n        </Stack>\n      ) : (\n        <Stack gap=\"xs\">\n          {/* Manual CRON input */}\n          <TextInput\n            label={\n              <Group gap={4}>\n                <Trans>CRON Expression</Trans>\n                <Tooltip label={<Trans>Open CRON helper</Trans>}>\n                  <Anchor\n                    href=\"https://crontab.cronhub.io/\"\n                    target=\"_blank\"\n                    size=\"xs\"\n                  >\n                    <IconExternalLink size={12} />\n                  </Anchor>\n                </Tooltip>\n              </Group>\n            }\n            size=\"xs\"\n            placeholder=\"0 9 * * 1-5\"\n            value={manualCron}\n            onChange={(e) => handleManualChange(e.currentTarget.value)}\n            error={\n              manualCron && !isValidCron ? (\n                <Trans>Invalid CRON expression</Trans>\n              ) : null\n            }\n          />\n          <Text size=\"xs\" c=\"dimmed\">\n            <Trans>Format: minute hour day month weekday</Trans>\n          </Text>\n        </Stack>\n      )}\n\n      {/* Description and next run */}\n      {isValidCron && cronDescription && (\n        <Box p=\"xs\">\n          <Text size=\"xs\" fw={500} c=\"green\">\n            {cronDescription}\n          </Text>\n          {nextRun && (\n            <Text size=\"xs\" c=\"dimmed\">\n              <IconAt size=\"1em\" style={{ verticalAlign: 'middle' }} />{' '}\n              {formatDateTime(nextRun)}\n            </Text>\n          )}\n        </Box>\n      )}\n    </Stack>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/schedule-popover/index.ts",
    "content": "/**\n * Schedule popover components for editing submission schedules.\n */\n\nexport { CronPicker, type CronPickerProps } from './cron-picker';\nexport { SchedulePopover, type SchedulePopoverProps } from './schedule-popover';\n\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/schedule-popover/schedule-popover.tsx",
    "content": "/**\n * SchedulePopover - Compact popover for editing submission schedules.\n * Supports None/Once/Recurring schedule types with date picker and CRON builder.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport {\n  ActionIcon,\n  Box,\n  Group,\n  Popover,\n  SegmentedControl,\n  Stack,\n  Switch,\n  Text,\n  Tooltip,\n} from '@mantine/core';\nimport { DateTimePicker } from '@mantine/dates';\nimport { useDisclosure } from '@mantine/hooks';\nimport { ISubmissionScheduleInfo, ScheduleType } from '@postybirb/types';\nimport {\n  IconCalendar,\n  IconCalendarOff,\n  IconClock,\n  IconRepeat,\n  IconX,\n} from '@tabler/icons-react';\nimport { Cron } from 'croner';\nimport moment from 'moment';\nimport { useCallback, useEffect, useMemo, useState } from 'react';\nimport { useLocalStorage } from 'react-use';\nimport { CronPicker } from './cron-picker';\n\nexport interface SchedulePopoverProps {\n  /** Current schedule info */\n  schedule: ISubmissionScheduleInfo;\n  /** Whether the submission is currently scheduled */\n  isScheduled: boolean;\n  /** Callback when schedule changes */\n  onChange: (schedule: ISubmissionScheduleInfo, isScheduled: boolean) => void;\n  /** Whether the popover trigger is disabled */\n  disabled?: boolean;\n  /** Size of the trigger button */\n  size?: 'xs' | 'sm' | 'md';\n}\n\nconst SCHEDULE_GLOBAL_KEY = 'postybirb-last-schedule';\nconst DEFAULT_CRON = '0 9 * * 5'; // Friday at 9 AM\n\n/**\n * Schedule editing popover with None/Once/Recurring options.\n */\nexport function SchedulePopover({\n  schedule,\n  isScheduled,\n  onChange,\n  disabled,\n  size = 'xs',\n}: SchedulePopoverProps) {\n  const [opened, { close, toggle }] = useDisclosure(false);\n  const [internalSchedule, setInternalSchedule] =\n    useState<ISubmissionScheduleInfo>(schedule);\n  const [internalIsScheduled, setInternalIsScheduled] =\n    useState<boolean>(isScheduled);\n\n  // Persist last used schedule date\n  const [lastUsedDate, setLastUsedDate] = useLocalStorage<string | undefined>(\n    SCHEDULE_GLOBAL_KEY,\n    undefined,\n  );\n\n  // Sync internal state with props when popover is closed\n  useEffect(() => {\n    if (!opened) {\n      setInternalSchedule(schedule);\n      setInternalIsScheduled(isScheduled);\n    }\n  }, [schedule, isScheduled, opened]);\n\n  // Handle schedule type change\n  const handleTypeChange = useCallback(\n    (type: string) => {\n      const scheduleType = type as ScheduleType;\n      let newSchedule: ISubmissionScheduleInfo;\n\n      switch (scheduleType) {\n        case ScheduleType.SINGLE: {\n          // Use last used date if valid, otherwise tomorrow\n          let scheduledFor: string;\n          if (lastUsedDate && new Date(lastUsedDate) > new Date()) {\n            scheduledFor = lastUsedDate;\n          } else {\n            scheduledFor = moment()\n              .add(1, 'day')\n              .hour(9)\n              .minute(0)\n              .toISOString();\n          }\n          newSchedule = {\n            scheduleType,\n            scheduledFor,\n            cron: undefined,\n          };\n          break;\n        }\n        case ScheduleType.RECURRING: {\n          const nextRun = Cron(DEFAULT_CRON)?.nextRun()?.toISOString();\n          newSchedule = {\n            scheduleType,\n            cron: DEFAULT_CRON,\n            scheduledFor: nextRun,\n          };\n          break;\n        }\n        case ScheduleType.NONE:\n        default:\n          newSchedule = {\n            scheduleType: ScheduleType.NONE,\n            scheduledFor: undefined,\n            cron: undefined,\n          };\n          break;\n      }\n\n      setInternalSchedule(newSchedule);\n      // Don't auto-activate - user must explicitly toggle the switch\n      if (scheduleType === ScheduleType.NONE) {\n        setInternalIsScheduled(false);\n      }\n    },\n    [lastUsedDate],\n  );\n\n  // Handle date change for single schedule\n  const handleDateChange = useCallback(\n    (date: Date | null) => {\n      if (!date) return;\n      const scheduledFor = date.toISOString();\n      const newSchedule: ISubmissionScheduleInfo = {\n        ...internalSchedule,\n        scheduledFor,\n      };\n      setInternalSchedule(newSchedule);\n      setLastUsedDate(scheduledFor);\n      // Don't call onChange here - will be called on popover close\n    },\n    [internalSchedule, setLastUsedDate],\n  );\n\n  // Handle CRON change for recurring schedule\n  const handleCronChange = useCallback(\n    (cron: string) => {\n      let scheduledFor: string | undefined;\n      try {\n        scheduledFor = Cron(cron)?.nextRun()?.toISOString();\n      } catch {\n        // Invalid cron\n      }\n      const newSchedule: ISubmissionScheduleInfo = {\n        ...internalSchedule,\n        cron,\n        scheduledFor,\n      };\n      setInternalSchedule(newSchedule);\n      // Don't call onChange here - will be called on popover close\n    },\n    [internalSchedule],\n  );\n\n  // Memoize display info to avoid re-creating on every render\n  const displayInfo = useMemo(() => {\n    // Check if schedule is configured (has date or cron)\n    const hasScheduleConfig = Boolean(\n      internalSchedule.scheduledFor || internalSchedule.cron,\n    );\n\n    if (\n      !hasScheduleConfig ||\n      internalSchedule.scheduleType === ScheduleType.NONE\n    ) {\n      return {\n        icon: IconCalendarOff,\n        color: 'gray',\n        tooltip: <Trans>Not scheduled</Trans>,\n      };\n    }\n\n    // Has config but not activated\n    if (!internalIsScheduled) {\n      if (internalSchedule.scheduleType === ScheduleType.RECURRING) {\n        return {\n          icon: IconRepeat,\n          color: 'yellow',\n          tooltip: <Trans>Schedule configured (inactive)</Trans>,\n        };\n      }\n      return {\n        icon: IconClock,\n        color: 'yellow',\n        tooltip: <Trans>Schedule configured (inactive)</Trans>,\n      };\n    }\n\n    // Active schedule\n    if (internalSchedule.scheduleType === ScheduleType.RECURRING) {\n      return {\n        icon: IconRepeat,\n        color: 'blue',\n        tooltip: <Trans>Recurring schedule (active)</Trans>,\n      };\n    }\n    return {\n      icon: IconClock,\n      color: 'blue',\n      tooltip: <Trans>Scheduled (active)</Trans>,\n    };\n  }, [internalSchedule, internalIsScheduled]);\n\n  // Handle toggling schedule active state\n  const handleToggleActive = useCallback((checked: boolean) => {\n    setInternalIsScheduled(checked);\n  }, []);\n\n  // Handle popover close - save changes\n  const handleClose = useCallback(() => {\n    // Only call onChange if something actually changed (deep comparison)\n    if (\n      JSON.stringify(internalSchedule) !== JSON.stringify(schedule) ||\n      internalIsScheduled !== isScheduled\n    ) {\n      onChange(internalSchedule, internalIsScheduled);\n    }\n    close();\n  }, [\n    internalSchedule,\n    internalIsScheduled,\n    schedule,\n    isScheduled,\n    onChange,\n    close,\n  ]);\n\n  const DisplayIcon = displayInfo.icon;\n\n  // Parse date for picker\n  const scheduledDate = internalSchedule.scheduledFor\n    ? new Date(internalSchedule.scheduledFor)\n    : null;\n  const isDateInPast = scheduledDate ? scheduledDate < new Date() : false;\n\n  return (\n    <Popover\n      closeOnClickOutside\n      opened={opened}\n      onClose={handleClose}\n      position=\"right\"\n      withArrow\n      shadow=\"md\"\n      width={320}\n      trapFocus\n      returnFocus\n    >\n      <Popover.Target>\n        <Tooltip label={displayInfo.tooltip}>\n          <ActionIcon\n            size={size}\n            variant=\"subtle\"\n            color={displayInfo.color}\n            onClick={toggle}\n            onKeyDown={(e) => e.stopPropagation()}\n            disabled={disabled}\n          >\n            <DisplayIcon size={size === 'xs' ? 14 : size === 'sm' ? 16 : 18} />\n          </ActionIcon>\n        </Tooltip>\n      </Popover.Target>\n\n      <Popover.Dropdown>\n        <Stack gap=\"sm\">\n          <Group justify=\"space-between\" align=\"center\">\n            <Text size=\"sm\" fw={500}>\n              <Trans>Schedule</Trans>\n            </Text>\n            <ActionIcon\n              size=\"xs\"\n              variant=\"subtle\"\n              color=\"gray\"\n              onClick={handleClose}\n            >\n              <IconX size={14} />\n            </ActionIcon>\n          </Group>\n\n          {/* Schedule type selector */}\n          <SegmentedControl\n            value={internalSchedule.scheduleType}\n            onChange={handleTypeChange}\n            size=\"xs\"\n            fullWidth\n            data={[\n              {\n                value: ScheduleType.NONE,\n                label: (\n                  <Group gap={4} justify=\"center\">\n                    <IconCalendarOff size={14} />\n                    <Trans>None</Trans>\n                  </Group>\n                ),\n              },\n              {\n                value: ScheduleType.SINGLE,\n                label: (\n                  <Group gap={4} justify=\"center\">\n                    <IconCalendar size={14} />\n                    <Trans>Once</Trans>\n                  </Group>\n                ),\n              },\n              {\n                value: ScheduleType.RECURRING,\n                label: (\n                  <Group gap={4} justify=\"center\">\n                    <IconRepeat size={14} />\n                    <Trans>Recurring</Trans>\n                  </Group>\n                ),\n              },\n            ]}\n          />\n\n          {/* Single schedule - Date picker */}\n          {internalSchedule.scheduleType === ScheduleType.SINGLE && (\n            <Box>\n              <DateTimePicker\n                label={<Trans>Date and Time</Trans>}\n                size=\"xs\"\n                clearable={false}\n                // eslint-disable-next-line lingui/no-unlocalized-strings\n                valueFormat=\"YYYY-MM-DD HH:mm\"\n                highlightToday\n                minDate={new Date()}\n                value={scheduledDate}\n                onChange={(value) => {\n                  // DateTimePicker returns string when valueFormat is specified\n                  if (value) {\n                    handleDateChange(new Date(value));\n                  } else {\n                    handleDateChange(null);\n                  }\n                }}\n                error={isDateInPast ? <Trans>Date is in the past</Trans> : null}\n                popoverProps={{ withinPortal: true }}\n              />\n              {scheduledDate && !isDateInPast && (\n                <Text size=\"xs\" c=\"dimmed\" mt={4}>\n                  {moment(scheduledDate).fromNow()}\n                </Text>\n              )}\n            </Box>\n          )}\n\n          {/* Recurring schedule - CRON picker */}\n          {internalSchedule.scheduleType === ScheduleType.RECURRING && (\n            <CronPicker\n              value={internalSchedule.cron || DEFAULT_CRON}\n              onChange={handleCronChange}\n            />\n          )}\n\n          {/* None - info text */}\n          {internalSchedule.scheduleType === ScheduleType.NONE && (\n            <Text size=\"xs\" c=\"dimmed\">\n              <Trans>No schedule configured</Trans>\n            </Text>\n          )}\n\n          {/* Activation toggle - only show when schedule is configured */}\n          {internalSchedule.scheduleType !== ScheduleType.NONE && (\n            <Box onClick={(e) => e.stopPropagation()}>\n              <Switch\n                label={<Trans>Activate schedule</Trans>}\n                description={\n                  internalIsScheduled ? (\n                    <Trans>Submission will be posted automatically</Trans>\n                  ) : (\n                    <Trans>Schedule configured (inactive)</Trans>\n                  )\n                }\n                checked={internalIsScheduled}\n                onChange={(e) => handleToggleActive(e.currentTarget.checked)}\n                size=\"sm\"\n              />\n            </Box>\n          )}\n        </Stack>\n      </Popover.Dropdown>\n    </Popover>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/search-input.tsx",
    "content": "/**\n * SearchInput - Standardized search input component.\n * Provides consistent search functionality across sections and drawers.\n */\n\nimport { useLingui } from '@lingui/react/macro';\nimport { ActionIcon, TextInput, type TextInputProps } from '@mantine/core';\nimport { IconSearch, IconX } from '@tabler/icons-react';\n\ntype SearchInputSize = 'xs' | 'sm' | 'md';\n\ninterface SearchInputProps\n  extends Omit<\n    TextInputProps,\n    'leftSection' | 'rightSection' | 'onChange' | 'placeholder'\n  > {\n  /** Current search value */\n  value: string;\n  /** Callback when search value changes */\n  onChange: (value: string) => void;\n  /** Size variant - affects icon sizes too */\n  size?: SearchInputSize;\n  /** Whether to show the clear button when there's a value */\n  showClear?: boolean;\n  /** Additional callback when clear is clicked */\n  onClear?: () => void;\n}\n\nconst ICON_SIZES: Record<SearchInputSize, { search: number; clear: number }> = {\n  xs: { search: 14, clear: 12 },\n  sm: { search: 16, clear: 14 },\n  md: { search: 18, clear: 16 },\n};\n\n/**\n * Standardized search input with search icon and optional clear button.\n * Uses a consistent translated \"Search...\" placeholder.\n */\nexport function SearchInput({\n  value,\n  onChange,\n  size = 'sm',\n  showClear = true,\n  onClear,\n  ...props\n}: SearchInputProps) {\n  const { t } = useLingui();\n  const iconSizes = ICON_SIZES[size];\n\n  const handleClear = () => {\n    onChange('');\n    onClear?.();\n  };\n\n  return (\n    <TextInput\n      placeholder={t`Search...`}\n      size={size}\n      leftSection={<IconSearch size={iconSizes.search} />}\n      rightSection={\n        showClear && value ? (\n          <ActionIcon\n            size={size}\n            variant=\"subtle\"\n            onClick={handleClear}\n            // eslint-disable-next-line lingui/no-unlocalized-strings\n            aria-label=\"Clear search\"\n          >\n            <IconX size={iconSizes.clear} />\n          </ActionIcon>\n        ) : null\n      }\n      value={value}\n      onChange={(e) => onChange(e.currentTarget.value)}\n      {...props}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/simple-tag-input/index.ts",
    "content": "/**\n * SimpleTagInput exports.\n */\n\nexport { SimpleTagInput, type SimpleTagInputProps } from './simple-tag-input';\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/simple-tag-input/simple-tag-input.tsx",
    "content": "/**\n * SimpleTagInput - A shared tag input component with tag group and search support.\n *\n * Features:\n * - Tag group insertion (select groups to add all their tags)\n * - Tag search provider integration (e.g., e621 autocomplete)\n * - Clean UI with tag pills and group pills\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport { Box, Pill, TagsInput, TagsInputProps, Text } from '@mantine/core';\nimport { Tag, TagGroupDto } from '@postybirb/types';\nimport { IconTag } from '@tabler/icons-react';\nimport { flatten, uniq } from 'lodash';\nimport { useCallback, useMemo } from 'react';\nimport { useTagSearch } from '../../../hooks/tag-search';\nimport { useTagSearchProvider } from '../../../stores/entity/settings-store';\nimport { useTagGroups } from '../../../stores/entity/tag-group-store';\n\n/**\n * Special prefix used to identify tag group options in the dropdown.\n */\nconst TAG_GROUP_LABEL = 'GROUP:';\n\nexport interface SimpleTagInputProps {\n  /** Current tag value */\n  value: Tag[];\n  /** Callback when tags change */\n  onChange: (tags: Tag[]) => void;\n  /** Optional placeholder text */\n  placeholder?: string;\n  /** Field-specific search provider ID (overrides user settings) */\n  searchProviderId?: string;\n  /** Size of the input */\n  size?: TagsInputProps['size'];\n  /** Whether to show the tag icon in the input */\n  showIcon?: boolean;\n  /** Whether the input should be clearable */\n  clearable?: boolean;\n  /** Additional class name */\n  className?: string;\n  /** Maximum number of tags allowed */\n  maxTags?: number;\n  /** Whether the field is required */\n  required?: boolean;\n  /** Label for the input */\n  label?: string;\n  /** Description text below the input */\n  description?: string;\n  /** Error message to display */\n  error?: string;\n}\n\n/**\n * Check if all tags in a group are already in the current tags list.\n */\nfunction containsAllTagsInGroup(tags: Tag[], groupTags: Tag[]): boolean {\n  return groupTags.every((tag) => tags.includes(tag));\n}\n\n/**\n * SimpleTagInput - Tag input with tag group and search provider support.\n */\nexport function SimpleTagInput({\n  value,\n  onChange,\n  placeholder,\n  searchProviderId,\n  size = 'sm',\n  showIcon = true,\n  clearable = true,\n  className,\n  maxTags,\n  required,\n  label,\n  description,\n  error,\n}: SimpleTagInputProps) {\n  const tagGroups = useTagGroups();\n  const search = useTagSearch(searchProviderId);\n  const tagSearchProviderSettings = useTagSearchProvider();\n\n  // Build tag group options for the dropdown\n  const tagGroupOptions = useMemo(\n    () =>\n      tagGroups\n        .filter((group) => group.tags.length > 0)\n        .map((tagGroup) => {\n          // Create a TagGroupDto-like object for serialization\n          const groupData: TagGroupDto = {\n            id: tagGroup.id,\n            name: tagGroup.name,\n            tags: tagGroup.tags,\n            createdAt: tagGroup.createdAt.toString(),\n            updatedAt: tagGroup.updatedAt.toString(),\n          };\n          return {\n            label: `${TAG_GROUP_LABEL}${JSON.stringify(groupData)}`,\n            value: `${TAG_GROUP_LABEL}${JSON.stringify(groupData)}`,\n            disabled: containsAllTagsInGroup(value, tagGroup.tags),\n          };\n        }),\n    [tagGroups, value],\n  );\n\n  // Combine search results with tag group options\n  const dropdownData = useMemo(\n    () => [...search.data, ...tagGroupOptions],\n    [search.data, tagGroupOptions],\n  );\n\n  // Handle tag changes, including expanding tag groups\n  const handleChange = useCallback(\n    (tags: string[]) => {\n      const expandedTags = flatten(\n        tags.map((tag) => {\n          // If this is a tag group, extract its tags\n          if (tag.startsWith(TAG_GROUP_LABEL)) {\n            const group: TagGroupDto = JSON.parse(\n              tag.slice(TAG_GROUP_LABEL.length),\n            );\n            return group.tags;\n          }\n          return tag;\n        }),\n      );\n      onChange(uniq(expandedTags));\n    },\n    [onChange],\n  );\n\n  // Handle clearing all tags\n  const handleClear = useCallback(() => {\n    onChange([]);\n  }, [onChange]);\n\n  // Render custom dropdown options (tag groups with their tags shown)\n  const renderOption = useCallback(\n    (tagOption: { option: { value: string } }) => {\n      const { value: optionValue } = tagOption.option;\n\n      // Render tag group options with special formatting\n      if (optionValue.startsWith(TAG_GROUP_LABEL)) {\n        const group: TagGroupDto = JSON.parse(\n          optionValue.slice(TAG_GROUP_LABEL.length),\n        );\n        return (\n          <Box>\n            <Pill c=\"teal\" mr=\"xs\">\n              <strong>{group.name}</strong>\n            </Pill>\n            {group.tags.map((tag) => (\n              <Pill key={tag} c=\"gray\" ml=\"4\">\n                {tag}\n              </Pill>\n            ))}\n          </Box>\n        );\n      }\n\n      // Render search provider custom item if available\n      if (search.provider && tagSearchProviderSettings) {\n        const view = search.provider.renderSearchItem(\n          optionValue,\n          tagSearchProviderSettings,\n        );\n        if (view) return view;\n      }\n\n      // Default text rendering\n      return <Text inherit>{optionValue}</Text>;\n    },\n    [search.provider, tagSearchProviderSettings],\n  );\n\n  // Build description with count if maxTags is set\n  const inputDescription = useMemo(() => {\n    if (maxTags) {\n      return (\n        <Text size=\"xs\" c=\"dimmed\">\n          {value.length} / {maxTags} <Trans>tags</Trans>\n        </Text>\n      );\n    }\n    return description;\n  }, [maxTags, value.length, description]);\n\n  return (\n    <TagsInput\n      className={className}\n      size={size}\n      clearable={clearable}\n      required={required}\n      label={label}\n      description={inputDescription}\n      error={error}\n      placeholder={placeholder}\n      leftSection={showIcon ? <IconTag size={16} /> : undefined}\n      value={value}\n      data={dropdownData}\n      searchValue={search.searchValue}\n      onSearchChange={search.onSearchChange}\n      onClear={handleClear}\n      onChange={handleChange}\n      renderOption={renderOption}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/submission-picker/index.ts",
    "content": "export { SubmissionPicker } from './submission-picker';\nexport type { SubmissionPickerProps } from './submission-picker';\nexport { SubmissionPickerModal } from './submission-picker-modal';\nexport type { SubmissionPickerModalProps } from './submission-picker-modal';\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/submission-picker/submission-picker-modal.tsx",
    "content": "/**\n * SubmissionPickerModal - Modal wrapper for selecting submissions with merge mode options.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport { Button, Group, Modal, Radio, Stack, Text } from '@mantine/core';\nimport { SubmissionType } from '@postybirb/types';\nimport { useState, useEffect } from 'react';\nimport { SubmissionPicker } from './submission-picker';\n\nexport interface SubmissionPickerModalProps {\n  /** Whether the modal is open */\n  opened: boolean;\n  /** Callback to close the modal */\n  onClose: () => void;\n  /** Callback when user confirms selection */\n  onConfirm: (submissionIds: string[], merge: boolean) => void;\n  /** Filter submissions by type */\n  type: SubmissionType;\n  /** Submission IDs to exclude from the picker */\n  excludeIds?: string[];\n  /** Initial IDs to pre-select when the modal opens */\n  initialSelectedIds?: string[];\n  /** Modal title */\n  title?: React.ReactNode;\n}\n\n/**\n * Modal for selecting multiple submissions with merge mode options.\n */\nexport function SubmissionPickerModal({\n  opened,\n  onClose,\n  onConfirm,\n  type,\n  excludeIds = [],\n  initialSelectedIds = [],\n  title,\n}: SubmissionPickerModalProps) {\n  const [selectedIds, setSelectedIds] = useState<string[]>(initialSelectedIds);\n  const [mergeMode, setMergeMode] = useState<'merge' | 'replace'>('merge');\n\n  // Sync initial selection when modal opens\n  useEffect(() => {\n    if (opened) {\n      setSelectedIds(initialSelectedIds);\n    }\n  }, [opened, initialSelectedIds]);\n\n  const handleConfirm = () => {\n    onConfirm(selectedIds, mergeMode === 'merge');\n    // Reset state after confirm\n    setSelectedIds([]);\n    setMergeMode('merge');\n  };\n\n  const handleClose = () => {\n    // Reset state on close\n    setSelectedIds([]);\n    setMergeMode('merge');\n    onClose();\n  };\n\n  return (\n    <Modal\n      opened={opened}\n      onClose={handleClose}\n      title={title ?? <Trans>Apply</Trans>}\n      size=\"lg\"\n      centered\n    >\n      <Stack gap=\"md\">\n        <SubmissionPicker\n          value={selectedIds}\n          onChange={setSelectedIds}\n          type={type}\n          excludeIds={excludeIds}\n        />\n\n        <Radio.Group\n          value={mergeMode}\n          onChange={(value) => setMergeMode(value as 'merge' | 'replace')}\n          label={<Trans>Merge Mode</Trans>}\n        >\n          <Stack gap=\"xs\" mt=\"xs\">\n            <Radio\n              value=\"merge\"\n              label={\n                <Stack gap={2}>\n                  <Text size=\"sm\" fw={500}>\n                    <Trans>Merge (recommended)</Trans>\n                  </Text>\n                  <Text size=\"xs\" c=\"dimmed\">\n                    <Trans>\n                      Overwrite overlapping website options only. Keeps existing\n                      website options that are not specified in the source.\n                    </Trans>\n                  </Text>\n                </Stack>\n              }\n            />\n            <Radio\n              value=\"replace\"\n              label={\n                <Stack gap={2}>\n                  <Text size=\"sm\" fw={500}>\n                    <Trans>Replace All</Trans>\n                  </Text>\n                  <Text size=\"xs\" c=\"dimmed\">\n                    <Trans>\n                      Delete all existing website options and use only those\n                      specified in the source.\n                    </Trans>\n                  </Text>\n                </Stack>\n              }\n            />\n          </Stack>\n        </Radio.Group>\n\n        <Group justify=\"flex-end\" mt=\"md\">\n          <Button variant=\"default\" onClick={handleClose}>\n            <Trans>Cancel</Trans>\n          </Button>\n          <Button onClick={handleConfirm} disabled={selectedIds.length === 0}>\n            <Trans>Apply</Trans>\n          </Button>\n        </Group>\n      </Stack>\n    </Modal>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/submission-picker/submission-picker.tsx",
    "content": "/**\n * SubmissionPicker - MultiSelect component for choosing submissions.\n * Shows thumbnail previews when available, filters out posting/archived submissions.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport {\n  Avatar,\n  Checkbox,\n  Group,\n  Image,\n  MultiSelect,\n  type MultiSelectProps,\n  Stack,\n  Text,\n} from '@mantine/core';\nimport { SubmissionType } from '@postybirb/types';\nimport { IconFile, IconMessage } from '@tabler/icons-react';\nimport { useMemo } from 'react';\nimport { useSubmissionsByType } from '../../../stores/entity/submission-store';\nimport { getThumbnailUrl } from '../../sections/submissions-section/submission-card/utils';\n\nexport interface SubmissionPickerProps extends Omit<\n  MultiSelectProps,\n  'data' | 'value' | 'onChange'\n> {\n  /** Selected submission IDs */\n  value: string[];\n  /** Callback when selection changes */\n  onChange: (ids: string[]) => void;\n  /** Filter submissions by type */\n  type: SubmissionType;\n  /** Submission IDs to exclude from the picker (e.g., the source submission) */\n  excludeIds?: string[];\n  /** Initial IDs to pre-select when mounting */\n  initialSelectedIds?: string[];\n}\n\ninterface SubmissionMeta {\n  thumbnail: string | undefined;\n  type: SubmissionType;\n}\n\n/**\n * MultiSelect component for picking multiple submissions.\n * Filters out templates, multi-submissions, posting, and archived submissions.\n */\nexport function SubmissionPicker({\n  value,\n  onChange,\n  type,\n  excludeIds = [],\n  label,\n  placeholder,\n  ...selectProps\n}: SubmissionPickerProps) {\n  const allSubmissions = useSubmissionsByType(type);\n\n  // Build options and metadata map\n  const { options, metaMap } = useMemo(() => {\n    // Filter out templates, multi-submissions, posting, archived, and excluded IDs\n    const filtered = allSubmissions.filter(\n      (s) =>\n        !s.isTemplate &&\n        !s.isMultiSubmission &&\n        !s.isPosting &&\n        !s.isArchived &&\n        !excludeIds.includes(s.id),\n    );\n\n    // Sort alphabetically by title\n    const sorted = filtered.sort((a, b) => a.title.localeCompare(b.title));\n\n    const opts = sorted.map((submission) => ({\n      value: submission.id,\n      // eslint-disable-next-line lingui/no-unlocalized-strings\n      label: submission.title || 'Untitled',\n    }));\n\n    const meta = new Map<string, SubmissionMeta>();\n    sorted.forEach((submission) => {\n      meta.set(submission.id, {\n        thumbnail: getThumbnailUrl(submission),\n        type: submission.type,\n      });\n    });\n\n    return { options: opts, metaMap: meta };\n  }, [allSubmissions, excludeIds]);\n\n  // Custom render option with thumbnail preview\n  const renderOption = (input: {\n    option: { value: string; label: string };\n    checked?: boolean;\n  }) => {\n    const { option, checked = false } = input;\n    const meta = metaMap.get(option.value);\n\n    return (\n      <Group gap=\"sm\" wrap=\"nowrap\">\n        <Checkbox checked={checked} readOnly tabIndex={-1} />\n        {meta?.thumbnail ? (\n          <Image\n            src={meta.thumbnail}\n            alt=\"\"\n            h={32}\n            w={32}\n            fit=\"cover\"\n            radius={4}\n          />\n        ) : (\n          <Avatar size={32} radius={4} color=\"gray\">\n            {meta?.type === SubmissionType.FILE ? (\n              <IconFile size={18} />\n            ) : (\n              <IconMessage size={18} />\n            )}\n          </Avatar>\n        )}\n        <Text size=\"sm\" truncate=\"end\" style={{ flex: 1 }}>\n          {option.label}\n        </Text>\n      </Group>\n    );\n  };\n\n  return (\n    <Stack gap=\"xs\">\n      <MultiSelect\n        data={options}\n        value={value}\n        onChange={onChange}\n        label={label ?? <Trans>Select submissions</Trans>}\n        placeholder={placeholder}\n        searchable\n        clearable\n        maxDropdownHeight={300}\n        renderOption={renderOption}\n        nothingFoundMessage={<Trans>No results found</Trans>}\n        {...selectProps}\n      />\n      {value.length > 0 && (\n        <Text size=\"xs\" c=\"dimmed\">\n          <Trans>{value.length} submissions selected</Trans>\n        </Text>\n      )}\n    </Stack>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/template-picker/index.ts",
    "content": "export { TemplatePicker } from './template-picker';\nexport { TemplatePickerModal } from './template-picker-modal';\n\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/template-picker/template-picker-modal.tsx",
    "content": "/**\n * TemplatePickerModal - Modal for selecting templates and applying options to submissions.\n * Allows per-account template selection and override controls for title/description.\n */\n\nimport { Trans, useLingui } from '@lingui/react/macro';\nimport {\n  Box,\n  Button,\n  Checkbox,\n  ComboboxItem,\n  ComboboxItemGroup,\n  Divider,\n  Fieldset,\n  Group,\n  Modal,\n  MultiSelect,\n  ScrollArea,\n  Stack,\n  Text,\n  Title,\n  Tooltip,\n} from '@mantine/core';\nimport {\n  AccountId,\n  IWebsiteFormFields,\n  NULL_ACCOUNT_ID,\n  SubmissionId,\n  SubmissionType,\n  WebsiteOptionsDto,\n} from '@postybirb/types';\nimport { IconInfoCircle } from '@tabler/icons-react';\nimport { useCallback, useMemo, useState } from 'react';\nimport submissionApi from '../../../api/submission.api';\nimport {\n  AccountRecord,\n  SubmissionRecord,\n  useAccounts,\n  useSubmissionsByType,\n  useTemplateSubmissions,\n} from '../../../stores';\nimport {\n  showErrorNotification,\n  showSuccessNotification,\n} from '../../../utils/notifications';\n\ninterface TemplatePickerModalProps {\n  /** Target submission IDs to apply templates to (also excluded from sources) */\n  targetSubmissionIds: SubmissionId[];\n  /** Filter by submission type */\n  type: SubmissionType;\n  /** Called when modal should close */\n  onClose: () => void;\n  /** Called after successful apply */\n  onApply?: () => void;\n}\n\ntype SubmissionOptionPair = {\n  option: WebsiteOptionsDto;\n  submission: SubmissionRecord;\n};\n\ntype AccountGroup = {\n  account:\n    | AccountRecord\n    | { id: AccountId; name: string; websiteDisplayName: string };\n  submissions: SubmissionOptionPair[];\n};\n\n/**\n * Groups website options from selected submissions by account.\n */\nfunction groupWebsiteOptions(\n  submissions: SubmissionRecord[],\n  accounts: AccountRecord[],\n  defaultLabel: string,\n): Record<AccountId, AccountGroup> {\n  const groups: Record<AccountId, AccountGroup> = {};\n\n  submissions.forEach((submission) => {\n    submission.options.forEach((option) => {\n      const account = accounts.find((a) => a.id === option.accountId);\n      const groupAccount = account ?? {\n        id: NULL_ACCOUNT_ID as AccountId,\n        name: defaultLabel,\n        websiteDisplayName: defaultLabel,\n      };\n\n      if (!groups[groupAccount.id]) {\n        groups[groupAccount.id] = { account: groupAccount, submissions: [] };\n      }\n\n      groups[groupAccount.id].submissions.push({ submission, option });\n    });\n  });\n\n  return groups;\n}\n\n/**\n * Modal for picking templates and applying their options to submissions.\n */\nexport function TemplatePickerModal({\n  targetSubmissionIds,\n  type,\n  onClose,\n  onApply,\n}: TemplatePickerModalProps) {\n  const { t } = useLingui();\n  const accounts = useAccounts();\n  const templates = useTemplateSubmissions();\n  const submissions = useSubmissionsByType(type);\n\n  // State for selected source templates/submissions\n  const [selected, setSelected] = useState<string[]>([]);\n  // State for per-account option selections\n  const [selectedWebsiteOptions, setSelectedWebsiteOptions] =\n    useState<Record<AccountId, WebsiteOptionsDto | null>>();\n  // Override flags\n  const [overrideDescription, setOverrideDescription] = useState(true);\n  const [overrideTitle, setOverrideTitle] = useState(false);\n  // Loading state\n  const [isApplying, setIsApplying] = useState(false);\n\n  // Filter templates by type\n  const filteredTemplates = useMemo(\n    () => templates.filter((tmpl) => tmpl.type === type),\n    [templates, type],\n  );\n\n  // Filter submissions (non-archived, not targets, same type)\n  const filteredSubmissions = useMemo(\n    () =>\n      submissions\n        .filter((s) => !s.isArchived)\n        .filter((s) => !targetSubmissionIds.includes(s.id))\n        .filter((s) => !s.isTemplate)\n        .filter((s) => !s.isMultiSubmission),\n    [submissions, targetSubmissionIds],\n  );\n\n  // Build options for MultiSelect\n  const options: ComboboxItemGroup[] = useMemo(() => {\n    const templateItems: ComboboxItem[] = filteredTemplates.map((tmpl) => ({\n      label: tmpl.title || t`Untitled Template`,\n      value: tmpl.id,\n    }));\n\n    const submissionItems: ComboboxItem[] = filteredSubmissions.map((sub) => ({\n      label: sub.title || t`Untitled`,\n      value: sub.id,\n    }));\n\n    return [\n      { group: t`Templates`, items: templateItems },\n      { group: t`Submissions`, items: submissionItems },\n    ];\n  }, [filteredTemplates, filteredSubmissions, t]);\n\n  // Get the selected submissions/templates\n  const selectedSources = useMemo(\n    () =>\n      selected\n        .map((id) =>\n          [...filteredTemplates, ...filteredSubmissions].find(\n            (s) => s.id === id,\n          ),\n        )\n        .filter(Boolean) as SubmissionRecord[],\n    [selected, filteredTemplates, filteredSubmissions],\n  );\n\n  // Group selected options by account\n  const selectedGroups = useMemo(\n    () => groupWebsiteOptions(selectedSources, accounts, t`Default`),\n    [selectedSources, accounts, t],\n  );\n\n  // Handle selection change\n  const handleSelectionChange = useCallback(\n    (newSelected: string[]) => {\n      setSelected(newSelected);\n\n      // Initialize account options on first selection\n      if (!selectedWebsiteOptions && newSelected.length) {\n        const firstSource = [...filteredTemplates, ...filteredSubmissions].find(\n          (s) => s.id === newSelected[0],\n        );\n        if (firstSource) {\n          const initial: Record<AccountId, WebsiteOptionsDto> = {};\n          firstSource.options.forEach((opt) => {\n            initial[opt.accountId as AccountId] = opt;\n          });\n          setSelectedWebsiteOptions(initial);\n        }\n      }\n\n      // Reset on empty selection\n      if (!newSelected.length) {\n        setSelectedWebsiteOptions(undefined);\n      }\n    },\n    [selectedWebsiteOptions, filteredTemplates, filteredSubmissions],\n  );\n\n  // Handle apply\n  const handleApply = useCallback(async () => {\n    if (!selectedWebsiteOptions) return;\n\n    // Build options array from selections\n    const optionsToApply = Object.values(selectedWebsiteOptions)\n      .filter((opt): opt is WebsiteOptionsDto => opt !== null)\n      .map((opt) => {\n        const data = { ...opt.data };\n\n        // Strip description if not overriding or if empty (check array length)\n        if (!overrideDescription || !data.description?.description?.content?.length) {\n          delete data.description;\n        }\n\n        // Strip title if not overriding or if empty\n        if (!overrideTitle || !data.title?.trim()) {\n          delete data.title;\n        }\n\n        return {\n          accountId: opt.accountId as AccountId,\n          data: data as IWebsiteFormFields,\n        };\n      });\n\n    if (optionsToApply.length === 0) {\n      showErrorNotification(<Trans>No options selected to apply</Trans>);\n      return;\n    }\n\n    setIsApplying(true);\n    try {\n      const result = await submissionApi.applyTemplateOptions({\n        targetSubmissionIds,\n        options: optionsToApply,\n        overrideTitle,\n        overrideDescription,\n      });\n\n      const successCount = result.body.success;\n      const failedCount = result.body.failed;\n      if (failedCount > 0) {\n        showErrorNotification(\n          <Trans>\n            Applied to {successCount} submissions, {failedCount} failed\n          </Trans>,\n        );\n      } else {\n        showSuccessNotification(\n          <Trans>Applied template options to {successCount} submissions</Trans>,\n        );\n      }\n\n      onApply?.();\n      onClose();\n    } catch {\n      showErrorNotification(<Trans>Failed to apply template options</Trans>);\n    } finally {\n      setIsApplying(false);\n    }\n  }, [\n    selectedWebsiteOptions,\n    targetSubmissionIds,\n    overrideTitle,\n    overrideDescription,\n    onApply,\n    onClose,\n  ]);\n\n  // Render per-account selection fieldsets\n  const accountFieldsets = selectedWebsiteOptions\n    ? Object.values(selectedGroups).map((group) => {\n        // Get the account ID - either from AccountRecord or from the fallback object\n        const accountId =\n          group.account instanceof AccountRecord\n            ? group.account.accountId\n            : group.account.id;\n\n        // Get the display name\n        const displayName =\n          accountId === NULL_ACCOUNT_ID\n            ? t`Default`\n            : group.account instanceof AccountRecord\n              ? `${group.account.websiteDisplayName} - ${group.account.name}`\n              : `${group.account.websiteDisplayName} - ${group.account.name}`;\n\n        // Build checkbox options (None + each template option)\n        const checkboxOptions: Array<{\n          label: string | JSX.Element;\n          id: string;\n          option?: WebsiteOptionsDto;\n        }> = [{ label: <Trans>None</Trans>, id: accountId }];\n\n        group.submissions.forEach(({ submission, option }) => {\n          checkboxOptions.push({\n            id: option.id,\n            label: submission.title || t`Untitled`,\n            option,\n          });\n        });\n\n        const currentSelection = selectedWebsiteOptions[accountId];\n\n        return (\n          <Fieldset\n            key={accountId}\n            legend={\n              <Text fw={500} size=\"sm\">\n                {displayName}\n              </Text>\n            }\n          >\n            <Checkbox.Group value={[currentSelection?.id ?? accountId]}>\n              {checkboxOptions.map((opt) => (\n                <Checkbox\n                  mt=\"xs\"\n                  key={opt.id}\n                  value={opt.id}\n                  label={opt.label}\n                  onChange={() => {\n                    setSelectedWebsiteOptions({\n                      ...selectedWebsiteOptions,\n                      [accountId]: opt.option ?? null,\n                    });\n                  }}\n                />\n              ))}\n            </Checkbox.Group>\n          </Fieldset>\n        );\n      })\n    : null;\n\n  // Override options section\n  const overrideOptionsSection = accountFieldsets ? (\n    <>\n      <Divider\n        label={\n          <Text fw={500}>\n            <Trans>Options</Trans>\n          </Text>\n        }\n        labelPosition=\"center\"\n        mt=\"sm\"\n      />\n      <Group mt=\"xs\">\n        <Checkbox\n          checked={overrideTitle}\n          label={\n            <Group gap={4} wrap=\"nowrap\">\n              <Trans context=\"import.override-title\">Replace title</Trans>\n              <Tooltip\n                label={\n                  <Trans>\n                    Replace the current title with the selected template's title\n                  </Trans>\n                }\n                position=\"top\"\n                withArrow\n              >\n                <IconInfoCircle\n                  size=\"1em\"\n                  style={{ opacity: 0.5 }}\n                  stroke={1.5}\n                />\n              </Tooltip>\n            </Group>\n          }\n          onChange={() => setOverrideTitle(!overrideTitle)}\n        />\n        <Checkbox\n          checked={overrideDescription}\n          label={\n            <Group gap={4} wrap=\"nowrap\">\n              <Trans context=\"import.override-description\">\n                Replace description\n              </Trans>\n              <Tooltip\n                label={\n                  <Trans>\n                    Replace the current description with the selected template's\n                    description\n                  </Trans>\n                }\n                position=\"top\"\n                withArrow\n              >\n                <IconInfoCircle\n                  size=\"1em\"\n                  style={{ opacity: 0.5 }}\n                  stroke={1.5}\n                />\n              </Tooltip>\n            </Group>\n          }\n          onChange={() => setOverrideDescription(!overrideDescription)}\n        />\n      </Group>\n    </>\n  ) : null;\n\n  return (\n    <Modal\n      opened\n      onClose={onClose}\n      title={\n        <Title order={4}>\n          <Trans context=\"template.picker-modal-header\">Choose Templates</Trans>\n        </Title>\n      }\n      size=\"lg\"\n      padding=\"md\"\n      styles={{\n        body: {\n          display: 'grid',\n          // eslint-disable-next-line lingui/no-unlocalized-strings\n          gridTemplateRows: 'auto 1fr auto',\n          height: 'calc(90vh - 100px)',\n          gap: 'var(--mantine-spacing-md)',\n          paddingBottom: 'var(--mantine-spacing-md)',\n        },\n      }}\n    >\n      {/* Header - Template Selection */}\n      <Box>\n        <MultiSelect\n          clearable\n          required\n          searchable\n          nothingFoundMessage={<Trans>No results found</Trans>}\n          label={<Trans>Select templates or submissions to import</Trans>}\n          data={options}\n          value={selected}\n          onChange={handleSelectionChange}\n        />\n      </Box>\n\n      {/* Middle - Scrollable Content */}\n      <Box style={{ overflow: 'hidden', display: 'flex', flexDirection: 'column', minHeight: 0 }}>\n        {selected.length > 0 && (\n          <Box style={{ flexShrink: 0 }}>\n            <Divider\n              label={\n                <Text fw={500}>\n                  <Trans>Account Options</Trans>\n                </Text>\n              }\n              labelPosition=\"center\"\n              my=\"sm\"\n            />\n            <Text size=\"sm\" c=\"dimmed\" mb=\"xs\">\n              <Trans>Select which template to use for each account</Trans>\n            </Text>\n          </Box>\n        )}\n\n        <ScrollArea style={{ flex: 1, minHeight: 0 }}>\n          <Stack gap=\"xs\" pb=\"md\">\n            {accountFieldsets}\n            {overrideOptionsSection}\n          </Stack>\n        </ScrollArea>\n      </Box>\n\n      {/* Footer - Buttons */}\n      <Group justify=\"end\">\n        <Button\n          variant=\"subtle\"\n          c=\"var(--mantine-color-text)\"\n          onClick={onClose}\n        >\n          <Trans>Cancel</Trans>\n        </Button>\n        <Button\n          disabled={\n            !selectedWebsiteOptions ||\n            Object.keys(selectedWebsiteOptions).length === 0\n          }\n          loading={isApplying}\n          onClick={handleApply}\n        >\n          <Trans>Apply</Trans>\n        </Button>\n      </Group>\n    </Modal>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/shared/template-picker/template-picker.tsx",
    "content": "/**\n * TemplatePicker - Simple select component for choosing submission templates.\n * Uses the submissions store to get available templates.\n */\n\nimport { t } from '@lingui/core/macro';\nimport { Trans } from '@lingui/react/macro';\nimport { Select, type SelectProps } from '@mantine/core';\nimport { SubmissionType } from '@postybirb/types';\nimport { useMemo } from 'react';\nimport { useTemplateSubmissions } from '../../../stores';\n\ninterface TemplatePickerProps\n  extends Omit<SelectProps, 'data' | 'value' | 'onChange'> {\n  /** Selected template ID */\n  value?: string;\n  /** Callback when template selection changes */\n  onChange: (templateId: string | null) => void;\n  /** Filter templates by submission type */\n  type?: SubmissionType;\n}\n\n/**\n * Select component for picking a submission template.\n * Filters by submission type if provided.\n */\nexport function TemplatePicker({\n  value,\n  onChange,\n  type,\n  label,\n  ...selectProps\n}: TemplatePickerProps) {\n  const templates = useTemplateSubmissions();\n\n  const options = useMemo(() => {\n    let filtered = templates;\n\n    // Filter by type if specified\n    if (type) {\n      filtered = templates.filter((tmpl) => tmpl.type === type);\n    }\n\n    // Sort alphabetically by name\n    return filtered\n      .sort((a, b) => a.title.localeCompare(b.title))\n      .map((template) => ({\n        value: template.id,\n        label: template.title,\n      }));\n  }, [templates, type]);\n\n  return (\n    <Select\n      label={label ?? <Trans>Template</Trans>}\n      placeholder={t`Select a template`}\n      data={options}\n      value={value ?? null}\n      onChange={onChange}\n      clearable\n      searchable\n      nothingFoundMessage={<Trans>No results found</Trans>}\n      {...selectProps}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/theme-picker/index.ts",
    "content": "export { ThemePicker } from './theme-picker';\n\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/theme-picker/theme-picker.tsx",
    "content": "/**\n * ThemePicker - Theme toggle component for switching between light and dark mode.\n * Can be used in navigation or standalone contexts.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport { Box, Kbd, NavLink as MantineNavLink, Tooltip, useMantineColorScheme } from '@mantine/core';\nimport { IconMoon, IconSun } from '@tabler/icons-react';\nimport { formatKeybindingDisplay } from '../../shared/platform-utils';\nimport { useAppearanceActions } from '../../stores/ui/appearance-store';\nimport '../../styles/layout.css';\n\ninterface ThemePickerProps {\n  /** Whether to show in collapsed mode (icon only with tooltip) */\n  collapsed?: boolean;\n  /** Optional keyboard shortcut to display */\n  kbd?: string;\n}\n\n/**\n * Renders a theme toggle as a NavLink-style component.\n * Shows sun icon in dark mode (to switch to light) and moon icon in light mode (to switch to dark).\n */\nexport function ThemePicker({ collapsed = false, kbd }: ThemePickerProps) {\n  const { colorScheme: mantineColorScheme } = useMantineColorScheme();\n  const { setColorScheme } = useAppearanceActions();\n  \n  // Use Mantine's computed color scheme (handles 'auto' resolution)\n  const isDark = mantineColorScheme === 'dark';\n\n  const toggleTheme = () => {\n    // Toggle between light and dark (explicit choice, not auto)\n    setColorScheme(isDark ? 'light' : 'dark');\n  };\n\n  const themeIcon = isDark ? <IconSun size={20} /> : <IconMoon size={20} />;\n  const themeLabel = collapsed ? undefined : (\n    <Box className=\"postybirb__nav_item_label\">\n      <span>{isDark ? <Trans>Light Mode</Trans> : <Trans>Dark Mode</Trans>}</span>\n      {kbd && <Kbd size=\"xs\">{formatKeybindingDisplay(kbd)}</Kbd>}\n    </Box>\n  );\n\n  const themeContent = (\n    <MantineNavLink\n      onClick={toggleTheme}\n      label={themeLabel}\n      leftSection={themeIcon}\n    />\n  );\n\n  if (collapsed) {\n    return (\n      <Tooltip\n        label={\n          <Box className=\"postybirb__tooltip_content\">\n            <span>{isDark ? <Trans>Light Mode</Trans> : <Trans>Dark Mode</Trans>}</span>\n            {kbd && (\n              <Kbd size=\"xs\" className=\"postybirb__kbd_aligned\">\n                {formatKeybindingDisplay(kbd)}\n              </Kbd>\n            )}\n          </Box>\n        }\n        position=\"right\"\n        withArrow\n      >\n        {themeContent}\n      </Tooltip>\n    );\n  }\n\n  return themeContent;\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/update-button/index.ts",
    "content": "export { UpdateButton } from './update-button';\nexport { UpdateModal } from './update-modal';\n\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/update-button/update-button.tsx",
    "content": "/**\n * UpdateButton - Shows app update availability and handles the update process.\n * Displays in the side navigation when an update is available.\n * Opens a modal for update details and actions.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport { Box, NavLink as MantineNavLink, Text, Tooltip } from '@mantine/core';\nimport { useDisclosure } from '@mantine/hooks';\nimport { UPDATE_UPDATES } from '@postybirb/socket-events';\nimport { UpdateState } from '@postybirb/types';\nimport { IconDeviceDesktopUp, IconDownload } from '@tabler/icons-react';\nimport { useCallback, useEffect, useState } from 'react';\nimport { useQuery } from 'react-query';\nimport updateApi from '../../api/update.api';\nimport AppSocket from '../../transports/websocket';\nimport { UpdateModal } from './update-modal';\n\ninterface UpdateButtonProps {\n  /** Whether the sidenav is collapsed */\n  collapsed: boolean;\n}\n\n/**\n * Check if running in development mode for testing.\n */\nconst isDevelopment = process.env.NODE_ENV === 'development';\n\n/**\n * Mock update state for testing in development environment.\n * Simulates an available update with release notes.\n */\n/* eslint-disable lingui/no-unlocalized-strings */\nconst MOCK_UPDATE_STATE: UpdateState = {\n  updateAvailable: true,\n  updateDownloaded: false,\n  updateDownloading: false,\n  updateError: undefined,\n  updateProgress: 0,\n  updateNotes: [\n    {\n      version: '4.2.0',\n      note: '<ul><li>New feature: Improved update UI with modal</li><li>Bug fix: Fixed submission ordering</li><li>Enhancement: Better error handling</li></ul>',\n    },\n    {\n      version: '4.1.5',\n      note: '<ul><li>Performance improvements</li><li>Fixed memory leak in file uploads</li></ul>',\n    },\n    {\n      version: '4.1.4',\n      note: '<ul><li>Minor bug fixes</li><li>Updated translations</li></ul>',\n    },\n  ],\n};\n/* eslint-enable lingui/no-unlocalized-strings */\n\n/**\n * Update button component that displays when an app update is available.\n * Opens a modal with update details and action buttons.\n * In development mode, shows mock data for testing.\n */\nexport function UpdateButton({ collapsed }: UpdateButtonProps) {\n  const [modalOpened, modal] = useDisclosure(false);\n  const [updateState, setUpdateState] = useState<UpdateState>(\n    isDevelopment ? MOCK_UPDATE_STATE : {},\n  );\n  const [mockDownloading, setMockDownloading] = useState(false);\n  const [mockProgress, setMockProgress] = useState(0);\n  const [mockDownloaded, setMockDownloaded] = useState(false);\n\n  // Initial fetch of update state (disabled in dev mode)\n  const { data: initialUpdate } = useQuery(\n    'update',\n    () => updateApi.checkForUpdates().then((res) => res.body),\n    {\n      refetchInterval: 5 * 60_000, // Fallback polling every 5 minutes\n      enabled: !isDevelopment, // Disable in dev mode to use mock data\n      onSuccess: (data) => {\n        if (data) {\n          setUpdateState(data);\n        }\n      },\n    },\n  );\n\n  // Subscribe to real-time update events\n  useEffect(() => {\n    const handleUpdateEvent = (data: UpdateState) => {\n      setUpdateState(data);\n    };\n\n    AppSocket.on(UPDATE_UPDATES, handleUpdateEvent);\n\n    return () => {\n      AppSocket.off(UPDATE_UPDATES, handleUpdateEvent);\n    };\n  }, []);\n\n  // Simulate download progress in development mode\n  useEffect(() => {\n    if (!isDevelopment || !mockDownloading) return undefined;\n\n    const interval = setInterval(() => {\n      setMockProgress((prev) => {\n        const next = prev + Math.random() * 15;\n        if (next >= 100) {\n          setMockDownloading(false);\n          setMockDownloaded(true);\n          clearInterval(interval);\n          return 100;\n        }\n        return next;\n      });\n    }, 300);\n\n    return () => clearInterval(interval);\n  }, [mockDownloading]);\n\n  // Update mock state when simulating download\n  useEffect(() => {\n    if (!isDevelopment) return;\n\n    setUpdateState((prev) => ({\n      ...prev,\n      updateDownloading: mockDownloading,\n      updateDownloaded: mockDownloaded,\n      updateProgress: mockProgress,\n    }));\n  }, [mockDownloading, mockDownloaded, mockProgress]);\n\n  // Handler for mock download in development\n  const handleMockStartDownload = useCallback(() => {\n    if (isDevelopment && !mockDownloading && !mockDownloaded) {\n      setMockDownloading(true);\n      setMockProgress(0);\n    }\n  }, [mockDownloading, mockDownloaded]);\n\n  // Use WebSocket state if available, fallback to query data\n  const update = updateState.updateAvailable ? updateState : initialUpdate;\n\n  // Don't render if no update available\n  if (!update || !update.updateAvailable) {\n    return null;\n  }\n\n  const isDownloading = update.updateDownloading;\n  const isDownloaded = update.updateDownloaded;\n\n  // Determine icon based on state\n  const icon = isDownloaded ? (\n    <IconDeviceDesktopUp size={20} color=\"var(--mantine-color-green-6)\" />\n  ) : (\n    <IconDownload size={20} color=\"var(--mantine-color-green-6)\" />\n  );\n\n  // Determine label based on state\n  const label = isDownloading ? (\n    <Box className=\"postybirb__nav_item_label\">\n      <Text>{update.updateProgress?.toFixed(0)}%</Text>\n    </Box>\n  ) : isDownloaded ? (\n    <Box className=\"postybirb__nav_item_label\">\n      <span>\n        <Trans>Ready to Install</Trans>\n      </span>\n    </Box>\n  ) : (\n    <Box className=\"postybirb__nav_item_label\">\n      <span>\n        <Trans>Update available</Trans>\n      </span>\n    </Box>\n  );\n\n  const tooltipLabel = isDownloading ? (\n    <Text>{update.updateProgress?.toFixed(0)}%</Text>\n  ) : isDownloaded ? (\n    <Trans>Update ready</Trans>\n  ) : (\n    <Trans>Update available</Trans>\n  );\n\n  const navLink = (\n    <MantineNavLink\n      label={collapsed ? undefined : label}\n      leftSection={icon}\n      active\n      color=\"green\"\n      variant=\"light\"\n      onClick={modal.open}\n    />\n  );\n\n  return (\n    <>\n      {collapsed ? (\n        <Tooltip label={tooltipLabel} position=\"right\" withArrow>\n          {navLink}\n        </Tooltip>\n      ) : (\n        navLink\n      )}\n\n      <UpdateModal\n        opened={modalOpened}\n        onClose={modal.close}\n        updateState={update}\n        onMockStartDownload={\n          isDevelopment ? handleMockStartDownload : undefined\n        }\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/update-button/update-modal.tsx",
    "content": "/**\n * UpdateModal - Modal for displaying update information and controlling the update process.\n * Shows version info, changelog, download progress, and action buttons.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport {\n  Alert,\n  Box,\n  Button,\n  Group,\n  Modal,\n  Progress,\n  ScrollArea,\n  Stack,\n  Text,\n  Title,\n} from '@mantine/core';\nimport { UpdateState } from '@postybirb/types';\nimport { IconDeviceDesktopUp, IconDownload } from '@tabler/icons-react';\nimport updateApi from '../../api/update.api';\n\ninterface UpdateModalProps {\n  /** Whether the modal is open */\n  opened: boolean;\n  /** Callback when the modal should close */\n  onClose: () => void;\n  /** Current update state */\n  updateState: UpdateState;\n  /** Optional callback for mock download in development mode */\n  onMockStartDownload?: () => void;\n}\n\n/**\n * Modal component for managing app updates.\n * Displays release notes, download progress, and action buttons.\n */\nexport function UpdateModal({\n  opened,\n  onClose,\n  updateState,\n  onMockStartDownload,\n}: UpdateModalProps) {\n  const isDownloading = updateState.updateDownloading;\n  const isDownloaded = updateState.updateDownloaded;\n  const progress = updateState.updateProgress ?? 0;\n\n  const handleStartUpdate = async () => {\n    if (onMockStartDownload) {\n      // Use mock handler in development mode\n      onMockStartDownload();\n    } else {\n      await updateApi.startUpdate();\n    }\n  };\n\n  const handleInstallUpdate = async () => {\n    await updateApi.installUpdate();\n  };\n\n  // Get the latest version from release notes\n  const latestVersion = updateState.updateNotes?.[0]?.version;\n\n  return (\n    <Modal\n      opened={opened}\n      onClose={onClose}\n      title={\n        <Group gap=\"xs\">\n          <IconDownload size={20} color=\"var(--mantine-color-green-6)\" />\n          <Title order={4}>\n            <Trans>Update available</Trans>\n          </Title>\n        </Group>\n      }\n      size=\"md\"\n      centered\n    >\n      <Stack gap=\"md\">\n        {/* Version info */}\n        {latestVersion && (\n          <Text size=\"sm\" c=\"dimmed\">\n            <Trans>New version:</Trans>{' '}\n            <Text span fw={600} c=\"green\">\n              {latestVersion}\n            </Text>\n          </Text>\n        )}\n\n        {/* Error alert */}\n        {updateState.updateError && (\n          <Alert color=\"red\" title={<Trans>Update Error</Trans>}>\n            {updateState.updateError}\n          </Alert>\n        )}\n\n        {/* Download progress */}\n        {isDownloading && (\n          <Box>\n            <Text size=\"sm\" mb=\"xs\">\n              <Trans>Downloading...</Trans> {progress.toFixed(0)}%\n            </Text>\n            <Progress value={progress} color=\"green\" size=\"lg\" animated />\n          </Box>\n        )}\n\n        {/* Downloaded status */}\n        {isDownloaded && (\n          <Alert color=\"green\" title={<Trans>Ready to Install</Trans>}>\n            <Trans>\n              The update has been downloaded and is ready to install. Click\n              \"Restart Now\" to apply the update.\n            </Trans>\n          </Alert>\n        )}\n\n        {/* Release notes */}\n        {updateState.updateNotes && updateState.updateNotes.length > 0 && (\n          <Box>\n            <Text size=\"sm\" fw={600} mb=\"xs\">\n              <Trans>What's New</Trans>\n            </Text>\n            <ScrollArea.Autosize mah={250} offsetScrollbars>\n              <Stack gap=\"sm\">\n                {updateState.updateNotes.map((note) => (\n                  <Box\n                    key={note.version}\n                    p=\"xs\"\n                    style={{\n                      backgroundColor: 'var(--mantine-color-dark-6)',\n                      borderRadius: 'var(--mantine-radius-sm)',\n                    }}\n                  >\n                    <Text fw={600} size=\"sm\" c=\"green\">\n                      {note.version}\n                    </Text>\n                    {note.note && (\n                      <Text\n                        size=\"xs\"\n                        c=\"dimmed\"\n                        mt={4}\n                        // eslint-disable-next-line react/no-danger\n                        dangerouslySetInnerHTML={{ __html: note.note }}\n                      />\n                    )}\n                  </Box>\n                ))}\n              </Stack>\n            </ScrollArea.Autosize>\n          </Box>\n        )}\n\n        {/* Action buttons */}\n        <Group justify=\"flex-end\" mt=\"sm\">\n          <Button variant=\"default\" onClick={onClose}>\n            <Trans>Later</Trans>\n          </Button>\n          {isDownloaded ? (\n            <Button\n              color=\"green\"\n              leftSection={<IconDeviceDesktopUp size={16} />}\n              onClick={handleInstallUpdate}\n            >\n              <Trans>Restart Now</Trans>\n            </Button>\n          ) : (\n            <Button\n              color=\"green\"\n              leftSection={<IconDownload size={16} />}\n              loading={isDownloading}\n              onClick={handleStartUpdate}\n              disabled={isDownloading}\n            >\n              {isDownloading ? (\n                <Trans>Downloading...</Trans>\n              ) : (\n                <Trans>Download Update</Trans>\n              )}\n            </Button>\n          )}\n        </Group>\n      </Stack>\n    </Modal>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/website-components/bluesky/description-preview.tsx",
    "content": "/* eslint-disable lingui/no-unlocalized-strings */\nimport { Box } from '@mantine/core';\nimport { descriptionPreviewRendererByWebsite } from '../../sections/submissions-section/submission-edit-card/account-selection/form/fields/description-preview-panel';\n\ndescriptionPreviewRendererByWebsite.set('bluesky', ({ description }) => {\n  const parsed = JSON.parse(description) as {\n    text: string;\n    links: { start: number; end: number; href: string }[];\n  };\n\n  let { text } = parsed;\n\n  // Insert links, process in reverse order to avoid index shifting\n  const sortedLinks = [...parsed.links].sort((a, b) => b.start - a.start);\n  for (const link of sortedLinks) {\n    const before = text.slice(0, link.start);\n    const linkText = text.slice(link.start, link.end);\n    const after = text.slice(link.end);\n    text = `${before}<a href=\"${link.href}\" target=\"_blank\" rel=\"noopener noreferrer\">${linkText}</a>${after}`;\n  }\n\n  text = text.replaceAll('\\r\\n', '<br/>');\n\n  text = text.replaceAll(\n    /#(\\w+)/g,\n    '<span style=\"color: var(--mantine-primary-color-filled)\">#$1<span/>',\n  );\n\n  return <Box dangerouslySetInnerHTML={{ __html: text }} />;\n});\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/website-components/e621/e621-dtext-renderer.tsx",
    "content": "/* eslint-disable lingui/no-unlocalized-strings */\nimport reactPreset from '@bbob/preset-react';\nimport BBCode from '@bbob/react';\nimport { descriptionPreviewRendererByWebsite } from '../../sections/submissions-section/submission-edit-card/account-selection/form/fields/description-preview-panel';\n\n// ----------------------------------------------------------------------\n// Custom preset that extends the default React preset with DText‑specific tags\n// ----------------------------------------------------------------------\nconst dtextPreset = reactPreset.extend((tags, options) => ({\n  ...tags,\n  // Render [spoiler] as a <details> block\n  spoiler: (node) => {\n    const content = Array.isArray(node.content)\n      ? node.content\n      : node.content\n        ? [node.content]\n        : [];\n    return {\n      tag: 'details',\n      content: [{ tag: 'summary', content: 'Spoiler' }, ...content],\n    };\n  },\n}));\n\n// ----------------------------------------------------------------------\n// React component\n// ----------------------------------------------------------------------\nexport interface E621DtextProps {\n  dtext: string;\n}\n\nexport function E621Dtext({ dtext }: E621DtextProps) {\n  let processed = dtext;\n\n  // Plain URLs: https://example.com\n  processed = processed.replace(\n    /(https?:\\/\\/[^\\s<]+)/g,\n    (match) => `[url]${match}[/url]`,\n  );\n\n  // Hyperlinks: \"A link\":https://example.com\n  processed = processed.replace(\n    /\"([^\"]+)\":([^\\s\\]]+)/g,\n    (_, title, url) => `[url=${url}]${title}[/url]`,\n  );\n\n  // Custom header format to bbcode (h1. to [h1][/h1])\n  processed = processed.replace(\n    /^(h[1-6])\\.\\s+(.*)$/gim,\n    (_, tag, content) => `[${tag}]${content}[/${tag}]`,\n  );\n\n  return (\n    <div style={{ whiteSpace: 'pre-wrap' }}>\n      <BBCode plugins={[dtextPreset()]} options={{ onlyAllowTags: undefined }}>\n        {processed}\n      </BBCode>\n    </div>\n  );\n}\n\ndescriptionPreviewRendererByWebsite.set('e621', ({ description }) => (\n  <E621Dtext dtext={description} />\n));\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/website-components/e621/e621-tag-search-provider.tsx",
    "content": "import { Plural, Trans } from '@lingui/react/macro';\nimport {\n  Badge,\n  Box,\n  DefaultMantineColor,\n  Divider,\n  Group,\n  Loader,\n  Popover,\n  Stack,\n  Text,\n} from '@mantine/core';\nimport { E621TagCategory, TagSearchProviderSettings } from '@postybirb/types';\nimport { IconBook, IconPhoto } from '@tabler/icons-react';\nimport { useCallback, useRef, useState } from 'react';\nimport { useAsync } from 'react-use';\nimport { TagSearchProvider } from '../../../hooks/tag-search/tag-search-provider';\nimport { E621Dtext } from './e621-dtext-renderer';\n\nconst headers = new Headers({\n  // eslint-disable-next-line lingui/no-unlocalized-strings\n  'User-Agent': `PostyBirb/${window.electron?.app_version ?? '0.0.0'}`,\n});\n\nasync function e621Get<T>(url: string): Promise<T> {\n  return (await (await fetch(url, { headers })).json()) as T;\n}\n\ninterface E621AutocompleteTag {\n  name: string;\n  post_count: number;\n  category: E621TagCategory;\n}\n\n// From https://e621.net/help/tags#top\nconst colors: Record<E621TagCategory, undefined | DefaultMantineColor> = {\n  [E621TagCategory.Artist]: 'orange',\n  [E621TagCategory.Character]: 'green',\n  [E621TagCategory.Copyright]: 'grape',\n  [E621TagCategory.Species]: 'yellow',\n  [E621TagCategory.General]: undefined,\n  [E621TagCategory.Meta]: undefined,\n  [E621TagCategory.Invalid]: 'red',\n  [E621TagCategory.Contributor]: 'silver',\n  [E621TagCategory.Lore]: 'green',\n};\n\nclass E621TagSearchProvider extends TagSearchProvider {\n  private tags = new Map<string, E621AutocompleteTag>();\n\n  protected async searchImplementation(query: string): Promise<string[]> {\n    if (query.length < 3) return []; // e621 does not supports query with less then 3 characters\n\n    const url = `https://e621.net/tags/autocomplete.json?expiry=7&search[name_matches]=${encodeURIComponent(query)}`;\n    const tags = await e621Get<E621AutocompleteTag[]>(url);\n\n    for (const tag of tags) this.tags.set(tag.name, tag);\n\n    return tags.map((e) => e.name);\n  }\n\n  renderSearchItem(\n    tagName: string,\n    settings: TagSearchProviderSettings,\n  ): React.ReactNode {\n    const tag = this.tags.get(tagName);\n    if (!tag) return undefined;\n\n    return <E621TagSearchItem tag={tag} settings={settings} />;\n  }\n}\n\ninterface E621WikiPage {\n  body: string;\n}\n\nconst wikiPagesCache = new Map<string, E621WikiPage>();\n\nfunction E621TagSearchItem(props: {\n  tag: E621AutocompleteTag;\n  settings: TagSearchProviderSettings;\n}) {\n  const [opened, setOpened] = useState(false);\n  const { tag, settings } = props;\n  const closeTimeoutRef = useRef<number | NodeJS.Timeout>();\n\n  const handleMouseEnter = useCallback(() => {\n    clearTimeout(closeTimeoutRef.current);\n    setOpened(true);\n  }, []);\n\n  const handleMouseLeave = useCallback(() => {\n    clearTimeout(closeTimeoutRef.current);\n    closeTimeoutRef.current = setTimeout(() => setOpened(false), 300);\n  }, []);\n\n  const wikiPage = useAsync(async () => {\n    if (!settings.showWikiInHelpOnHover) return undefined;\n\n    const cache = wikiPagesCache.get(tag.name);\n    if (cache) return cache;\n\n    if (!opened) return undefined;\n\n    const url = `https://e621.net/wiki_pages.json?search[title]=${encodeURIComponent(tag.name)}`;\n    const pages = await e621Get<E621WikiPage[]>(url);\n    if (!pages.length) return undefined;\n\n    const page = pages[0];\n    wikiPagesCache.set(tag.name, page);\n    return page;\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [opened && settings.showWikiInHelpOnHover, tag.name]);\n\n  return (\n    <Popover position=\"left\" shadow=\"md\" opened={opened}>\n      <Popover.Target>\n        <Group\n          gap={4}\n          wrap=\"nowrap\"\n          onMouseEnter={handleMouseEnter}\n          onMouseLeave={handleMouseLeave}\n          style={{ cursor: 'help' }}\n        >\n          <Text inherit c={colors[tag.category]} fw={500}>\n            {tag.name}\n          </Text>\n          <Badge\n            size=\"xs\"\n            variant=\"light\"\n            color={colors[tag.category] || 'gray'}\n          >\n            {tag.post_count}\n          </Badge>\n        </Group>\n      </Popover.Target>\n      <Popover.Dropdown\n        p=\"md\"\n        style={{ maxWidth: 400 }}\n        onMouseEnter={handleMouseEnter}\n        onMouseLeave={handleMouseLeave}\n      >\n        <Box onClick={(event) => event.stopPropagation()}>\n          <Stack gap=\"sm\">\n            <Group gap=\"xs\">\n              <Text size=\"lg\" fw={600} c={colors[tag.category]}>\n                {tag.name}\n              </Text>\n              <Badge\n                size=\"sm\"\n                variant=\"filled\"\n                color={colors[tag.category] || 'gray'}\n              >\n                {E621TagCategory[tag.category]}\n              </Badge>\n            </Group>\n\n            <Divider />\n\n            <Group gap=\"xs\">\n              <IconPhoto size={16} />\n              <Text size=\"sm\" c=\"dimmed\">\n                <Plural value={tag.post_count} one=\"# post\" other=\"# posts\" />\n              </Text>\n            </Group>\n\n            {settings.showWikiInHelpOnHover && (\n              <Group gap=\"xs\" align=\"flex-start\">\n                <IconBook size={16} style={{ marginTop: 2 }} />\n                <Box style={{ flex: 1 }}>\n                  <Text size=\"sm\" fw={500} mb={4}>\n                    <Trans>Description</Trans>\n                  </Text>\n                  {wikiPage.loading ? (\n                    <Group gap=\"xs\">\n                      <Loader size=\"xs\" />\n                      <Text size=\"xs\" c=\"dimmed\">\n                        <Loader />\n                      </Text>\n                    </Group>\n                  ) : wikiPage.value?.body ? (\n                    <Box\n                      style={{\n                        maxHeight: 300,\n                        overflowY: 'auto',\n                        // eslint-disable-next-line lingui/no-unlocalized-strings\n                        padding: '8px 0',\n                        wordBreak: 'break-word',\n                      }}\n                    >\n                      <E621Dtext dtext={wikiPage.value.body} />\n                    </Box>\n                  ) : (\n                    <Text size=\"sm\" c=\"dimmed\" fs=\"italic\">\n                      <Trans>No wiki page available</Trans>\n                    </Text>\n                  )}\n                </Box>\n              </Group>\n            )}\n          </Stack>\n        </Box>\n      </Popover.Dropdown>\n    </Popover>\n  );\n}\n\nexport const e621TagSearchProvider = new E621TagSearchProvider();\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/website-components/furaffinity/furaffinity-bbcode-renderer.tsx",
    "content": "/* eslint-disable lingui/no-unlocalized-strings */\nimport { Box } from '@mantine/core';\nimport { descriptionPreviewRendererByWebsite } from '../../sections/submissions-section/submission-edit-card/account-selection/form/fields/description-preview-panel';\nimport { furaffinityBBCodeRenderToHTML } from './furaffinity-bbcode';\n\ndescriptionPreviewRendererByWebsite.set('fur-affinity', ({ description }) => {\n  const view = furaffinityBBCodeRenderToHTML(description, {\n    automaticParagraphs: true,\n  });\n\n  return <Box dangerouslySetInnerHTML={{ __html: view }} />;\n});\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/website-components/furaffinity/furaffinity-bbcode.ts",
    "content": "/* eslint-disable */\nimport { parse as parseUrl } from 'url';\n\n// Based on https://github.com/FurAffinity/bbcode-js/blob/master/index.js\n\n// -----------------------------------------------------------------------------\n// Types & Constants\n// -----------------------------------------------------------------------------\n\nexport interface RenderOptions {\n  automaticParagraphs?: boolean;\n}\n\ntype TokenType =\n  | 'TEXT'\n  | 'OPEN_TAG'\n  | 'CLOSE_TAG'\n  | 'ICON_AND_USERNAME_LINK'\n  | 'ICON_ONLY_LINK'\n  | 'USERNAME_ONLY_LINK'\n  | 'HORIZONTAL_RULE'\n  | 'LINE_BREAK'\n  | 'FORCED_LINE_BREAK'\n  | 'FORCED_PARAGRAPH_BREAK'\n  | 'AUTOMATIC_LINK'\n  | 'SERIES_NAVIGATION';\n\ninterface BaseToken {\n  type: TokenType;\n  text: string;\n}\n\ninterface TextToken extends BaseToken {\n  type: 'TEXT';\n}\n\ninterface OpenTagToken extends BaseToken {\n  type: 'OPEN_TAG';\n  name: string;\n  value?: string; // for color, quote, url\n}\n\ninterface CloseTagToken extends BaseToken {\n  type: 'CLOSE_TAG';\n  name: string;\n}\n\ninterface IconAndUsernameLinkToken extends BaseToken {\n  type: 'ICON_AND_USERNAME_LINK';\n  username: string;\n}\n\ninterface IconOnlyLinkToken extends BaseToken {\n  type: 'ICON_ONLY_LINK';\n  username: string;\n}\n\ninterface UsernameOnlyLinkToken extends BaseToken {\n  type: 'USERNAME_ONLY_LINK';\n  username: string;\n}\n\ninterface HorizontalRuleToken extends BaseToken {\n  type: 'HORIZONTAL_RULE';\n}\n\ninterface LineBreakToken extends BaseToken {\n  type: 'LINE_BREAK';\n}\n\ninterface ForcedLineBreakToken extends BaseToken {\n  type: 'FORCED_LINE_BREAK';\n}\n\ninterface ForcedParagraphBreakToken extends BaseToken {\n  type: 'FORCED_PARAGRAPH_BREAK';\n}\n\ninterface AutomaticLinkToken extends BaseToken {\n  type: 'AUTOMATIC_LINK';\n}\n\ninterface SeriesNavigationToken extends BaseToken {\n  type: 'SERIES_NAVIGATION';\n  previous: string;\n  first: string;\n  next: string;\n}\n\ntype Token =\n  | TextToken\n  | OpenTagToken\n  | CloseTagToken\n  | IconAndUsernameLinkToken\n  | IconOnlyLinkToken\n  | UsernameOnlyLinkToken\n  | HorizontalRuleToken\n  | LineBreakToken\n  | ForcedLineBreakToken\n  | ForcedParagraphBreakToken\n  | AutomaticLinkToken\n  | SeriesNavigationToken;\n\nconst allowedProtocols = [null, 'http:', 'https:', 'mailto:', 'irc:', 'ircs:'];\n\nconst symbols: Record<string, string> = {\n  c: '©',\n  r: '®',\n  tm: '™',\n};\n\nconst unnestable = ['b', 'i', 's', 'u', 'url', 'h1', 'h2', 'h3', 'h4', 'h5'];\n\nconst maximumStackSize = 20;\n\n// -----------------------------------------------------------------------------\n// Helpers\n// -----------------------------------------------------------------------------\n\nconst escapeAttributeValue = (value: string): string =>\n  value.replace(/&/g, '&amp;').replace(/\"/g, '&#34;');\n\nconst escapeContent = (value: string): string =>\n  value.replace(/&/g, '&amp;').replace(/</g, '&lt;');\n\nconst linkInfo = (uri: string): { allowed: boolean; internal: boolean } => {\n  const info = parseUrl(uri);\n  return {\n    allowed: allowedProtocols.includes(info.protocol ?? null),\n    internal:\n      info.hostname === null ||\n      /(?:^|\\.)(?:furaffinity|facdn)\\.net$/i.test(info.hostname),\n  };\n};\n\n// -----------------------------------------------------------------------------\n// Tokenizer\n// -----------------------------------------------------------------------------\n\nconst CSS3_OPAQUE_COLOUR =\n  /^(?:#?([\\da-f]{3}|[\\da-f]{6})|rgb\\((?:(?:\\s*(?:25[0-5]|2[0-4]\\d|[01]?\\d{1,2})\\s*,){2}\\s*(?:25[0-5]|2[0-4]\\d|[01]?\\d{1,2})\\s*|(?:\\s*(?:100|0?\\d{1,2})%\\s*,){2}\\s*(?:100|0?\\d{1,2})%\\s*)\\)|hsl\\(\\s*(?:180|1[0-7]\\d|0?\\d{1,2})\\s*,\\s*(?:100|0?\\d{1,2})%\\s*,\\s*(?:100|0?\\d{1,2})%\\s*\\)|black|silver|gr[ae]y|white|maroon|red|purple|fuchsia|green|lime|olive|yellow|navy|blue|teal|aqua|aliceblue|antiquewhite|aqua|aquamarine|azure|beige|bisque|blanchedalmond|blueviolet|brown|burlywood|cadetblue|chartreuse|chocolate|coral|cornflowerblue|cornsilk|crimson|cyan|darkblue|darkcyan|darkgoldenrod|darkgr[ae]y|darkgreen|darkkhaki|darkmagenta|darkolivegreen|darkorange|darkorchid|darkred|darksalmon|darkseagreen|darkslateblue|darkslategr[ae]y|darkturquoise|darkviolet|deeppink|deepskyblue|dimgr[ae]y|dodgerblue|firebrick|floralwhite|forestgreen|gainsboro|ghostwhite|gold|goldenrod|greenyellow|honeydew|hotpink|indianred|indigo|ivory|khaki|lavender|lavenderblush|lawngreen|lemonchiffon|lightblue|lightcoral|lightcyan|lightgoldenrodyellow|lightgr[ae]y|lightgreen|lightpink|lightsalmon|lightseagreen|lightskyblue|lightslategr[ae]y|lightsteelblue|lightyellow|limegreen|linen|mediumaquamarine|mediumblue|mediumorchid|mediumpurple|mediumseagreen|mediumslateblue|mediumspringgreen|mediumturquoise|mediumvioletred|midnightblue|mintcream|mistyrose|moccasin|navajowhite|oldlace|olivedrab|orange|orangered|orchid|palegoldenrod|palegreen|paleturquoise|palevioletred|papayawhip|peachpuff|peru|pink|plum|powderblue|rosybrown|royalblue|saddlebrown|salmon|sandybrown|seagreen|seashell|sienna|skyblue|slateblue|slategr[ae]y|snow|springgreen|steelblue|tan|thistle|tomato|turquoise|violet|wheat|whitesmoke|yellowgreen)$/i;\n\nfunction tokenize(input: string): Token[] {\n  const TOKEN = new RegExp(\n    '\\\\[([bisu]|sub|sup|quote|left|center|right|h[1-5])\\\\]' +\n      '|\\\\[/([bisu]|sub|sup|color|quote|left|center|right|url|h[1-5])\\\\]' +\n      '|\\\\[(color|quote|url)=(?:\"([^\"]+)\"|(\\\\S*?))\\\\]' +\n      '|:(icon|link)([\\\\w-]+):' +\n      '|:([\\\\w-]+)icon:' +\n      '|(\\\\r?\\\\n?-{5,}\\\\r?\\\\n?)' +\n      '|(\\\\r\\\\n|[\\\\r\\\\n\\\\u2028\\\\u2029])' +\n      '|\\\\((c|r|tm)\\\\)' +\n      '|(\\\\bhttps?:\\\\/\\\\/(?:[^\\\\s?!.,;[\\\\]]|[?!.,;]\\\\w)+)' +\n      '|\\\\[(\\\\d+|-)\\\\s*,\\\\s*(\\\\d+|-)\\\\s*,\\\\s*(\\\\d+|-)\\\\]',\n    'gi',\n  );\n\n  const tokens: Token[] = [];\n  let end = 0;\n  let m: RegExpExecArray | null;\n\n  while ((m = TOKEN.exec(input)) !== null) {\n    const start = m.index;\n    const text = m[0];\n\n    if (start !== end) {\n      tokens.push({ type: 'TEXT', text: input.substring(end, start) });\n    }\n\n    end = start + text.length;\n\n    if (m[1] !== undefined) {\n      tokens.push({ type: 'OPEN_TAG', name: m[1].toLowerCase(), text });\n    } else if (m[2] !== undefined) {\n      tokens.push({ type: 'CLOSE_TAG', name: m[2].toLowerCase(), text });\n    } else if (m[3] !== undefined) {\n      tokens.push({\n        type: 'OPEN_TAG',\n        name: m[3].toLowerCase(),\n        value: m[4] !== undefined ? m[4] : m[5],\n        text,\n      });\n    } else if (m[6] === 'icon') {\n      tokens.push({ type: 'ICON_AND_USERNAME_LINK', username: m[7], text });\n    } else if (m[6] === 'link') {\n      tokens.push({ type: 'USERNAME_ONLY_LINK', username: m[7], text });\n    } else if (m[8] !== undefined) {\n      tokens.push({ type: 'ICON_ONLY_LINK', username: m[8], text });\n    } else if (m[9] !== undefined) {\n      tokens.push({ type: 'HORIZONTAL_RULE', text });\n    } else if (m[10] === '\\u2028') {\n      tokens.push({ type: 'FORCED_LINE_BREAK', text });\n    } else if (m[10] === '\\u2029') {\n      tokens.push({ type: 'FORCED_PARAGRAPH_BREAK', text });\n    } else if (m[10] !== undefined) {\n      tokens.push({ type: 'LINE_BREAK', text });\n    } else if (m[11] !== undefined) {\n      tokens.push({\n        type: 'TEXT',\n        text: symbols[m[11].toLowerCase()],\n      });\n    } else if (m[12] !== undefined) {\n      tokens.push({ type: 'AUTOMATIC_LINK', text });\n    } else if (m[13] !== undefined) {\n      tokens.push({\n        type: 'SERIES_NAVIGATION',\n        previous: m[13],\n        first: m[14],\n        next: m[15],\n        text,\n      });\n    }\n  }\n\n  if (end !== input.length) {\n    tokens.push({ type: 'TEXT', text: input.substring(end) });\n  }\n\n  return tokens;\n}\n\n// -----------------------------------------------------------------------------\n// Tag Transformations\n// -----------------------------------------------------------------------------\n\ninterface Node {\n  text: string | null;\n  prev: Node | null;\n  next: Node | null;\n}\n\ninterface OpenTagInfo {\n  token: OpenTagToken; // the opening tag token (has value)\n  node: Node;\n  closedOverBy: ClosedOverInfo[];\n  redundant: boolean;\n}\n\ninterface ClosedOverInfo {\n  insertOpeningNodeBefore: Node;\n  insertClosingNodeAfter: Node;\n}\n\nfunction transform(\n  closes: OpenTagInfo,\n  token: Token,\n  startNode: Node,\n  endNode: Node,\n): boolean {\n  const name = closes.token.name;\n\n  switch (name) {\n    case 'b':\n    case 'i':\n    case 'u':\n    case 's':\n    case 'sub':\n    case 'sup':\n      startNode.text = `<${name}>`;\n      endNode.text = `</${name}>`;\n      return true;\n\n    case 'left':\n    case 'center':\n    case 'right':\n      startNode.text = `<div style=\"text-align: ${name}\">`;\n      endNode.text = `</div>`;\n      return true;\n\n    case 'quote':\n      startNode.text = '<blockquote>';\n      const quotes = closes.token.value;\n      if (quotes) {\n        startNode.text += `<header><cite>${escapeContent(quotes)}</cite> wrote:</header> `;\n      }\n      endNode.text = '</blockquote>';\n      return true;\n\n    case 'url': {\n      const urlValue = closes.token.value;\n      if (!urlValue) {\n        endNode.text = token.text;\n        return false;\n      }\n      const info = linkInfo(urlValue);\n      if (info.allowed) {\n        startNode.text = `<a href=\"${escapeAttributeValue(urlValue)}${info.internal ? '\">' : '\" rel=\"external nofollow\">'}`;\n        endNode.text = '</a>';\n        return true;\n      }\n      endNode.text = token.text;\n      return false;\n    }\n\n    case 'color': {\n      let colour = closes.token.value?.trim() ?? '';\n      const m = CSS3_OPAQUE_COLOUR.exec(colour);\n      if (m) {\n        if (m[1] !== undefined) {\n          colour = '#' + m[1];\n        }\n        startNode.text = `<span style=\"color: ${colour};\">`;\n        endNode.text = '</span>';\n        return true;\n      }\n      endNode.text = token.text;\n      return false;\n    }\n\n    case 'h1':\n    case 'h2':\n    case 'h3':\n    case 'h4':\n    case 'h5': {\n      const level = name[1];\n      startNode.text = `<h${level}>`;\n      endNode.text = `</h${level}>`;\n      return true;\n    }\n\n    default:\n      throw new Error('Unexpected');\n  }\n}\n\n// -----------------------------------------------------------------------------\n// Renderer\n// -----------------------------------------------------------------------------\n\nfunction createSeriesLink(label: string, id: string): string {\n  if (id === '-') return label;\n  return `<a href=\"/submissions/${id}\">${label}</a>`;\n}\n\nfunction createSeriesNavigation(token: SeriesNavigationToken): string {\n  return `${createSeriesLink('&lt;&lt;&lt; PREV', token.previous)} | ${createSeriesLink('FIRST', token.first)} | ${createSeriesLink('NEXT &gt;&gt;&gt;', token.next)}`;\n}\n\nexport function furaffinityBBCodeRenderToHTML(\n  input: string,\n  options?: RenderOptions,\n): string {\n  const automaticParagraphs = Boolean(options?.automaticParagraphs);\n\n  const tokens = tokenize(input);\n  const openTags: OpenTagInfo[] = [];\n  const openTagNames: string[] = [];\n\n  const head: Node = { text: null, prev: null, next: null };\n  let tail: Node = { text: null, prev: head, next: null };\n  head.next = tail;\n\n  function append(text: string | null): Node {\n    tail.text = text;\n    tail = { text: null, prev: tail, next: null };\n    tail.prev!.next = tail;\n    return tail.prev!;\n  }\n\n  if (automaticParagraphs) {\n    append('<p>');\n  }\n\n  for (let i = 0; i < tokens.length; i++) {\n    const token = tokens[i];\n    const name = (token as OpenTagToken | CloseTagToken).name;\n\n    switch (token.type) {\n      case 'TEXT':\n        append(escapeContent(token.text));\n        break;\n\n      case 'OPEN_TAG': {\n        if (openTags.length > maximumStackSize) {\n          append(escapeContent(token.text));\n          break;\n        }\n\n        const redundant =\n          unnestable.includes(name) && openTagNames.includes(name);\n        const openingNode = append(escapeContent(token.text));\n        openTags.push({\n          token: token as OpenTagToken,\n          node: openingNode,\n          closedOverBy: [],\n          redundant,\n        });\n        openTagNames.push(name);\n        break;\n      }\n\n      case 'CLOSE_TAG': {\n        const closesIndex = openTagNames.lastIndexOf(name);\n        if (closesIndex === -1) {\n          append(token.text);\n          break;\n        }\n\n        const closes = openTags[closesIndex];\n        openTagNames.splice(closesIndex, 1);\n        openTags.splice(closesIndex, 1);\n\n        const closingNode = append(null);\n        const afterClosingNode = append(null);\n\n        if (closes.redundant) {\n          closes.node.text = null;\n        } else {\n          transform(closes, token, closes.node, closingNode);\n        }\n\n        // Update tags that were closed over by this one\n        for (let j = closesIndex; j < openTags.length; j++) {\n          openTags[j].closedOverBy.push({\n            insertOpeningNodeBefore: afterClosingNode,\n            insertClosingNodeAfter: closingNode.prev!,\n          });\n        }\n\n        // Apply closed‑over transformations with a dummy token (text won't be used)\n        const dummyToken: TextToken = { type: 'TEXT', text: '' };\n        for (const closedOverBy of closes.closedOverBy) {\n          const newOpeningNode: Node = { text: null, prev: null, next: null };\n          const newClosingNode: Node = { text: null, prev: null, next: null };\n          if (transform(closes, dummyToken, newOpeningNode, newClosingNode)) {\n            newOpeningNode.prev = closedOverBy.insertOpeningNodeBefore.prev;\n            newOpeningNode.next = closedOverBy.insertOpeningNodeBefore;\n            closedOverBy.insertOpeningNodeBefore.prev!.next = newOpeningNode;\n            closedOverBy.insertOpeningNodeBefore.prev = newOpeningNode;\n\n            newClosingNode.prev = closedOverBy.insertClosingNodeAfter;\n            newClosingNode.next = closedOverBy.insertClosingNodeAfter.next;\n            closedOverBy.insertClosingNodeAfter.next!.prev = newClosingNode;\n            closedOverBy.insertClosingNodeAfter.next = newClosingNode;\n          }\n        }\n        break;\n      }\n\n      case 'ICON_AND_USERNAME_LINK':\n        append(\n          generateHref(`/user/${token.username}/`, true) +\n            `<img src=\"https://www.furaffinity.net/user/1424255659/${token.username}.gif\">` +\n            `${token.username}</a>`,\n        );\n        break;\n\n      case 'USERNAME_ONLY_LINK':\n        append(\n          `${generateHref(`/user/${token.username}/`, true)}${token.username}</a>`,\n        );\n        break;\n\n      case 'ICON_ONLY_LINK':\n        append(\n          generateHref(`/user/${token.username}/`, true) +\n            `<img src=\"https://a.furaffinity.net/user/1424255659/${token.username}.gif\">` +\n            `</a>`,\n        );\n        break;\n\n      case 'AUTOMATIC_LINK': {\n        const info = linkInfo(token.text);\n        if (info.allowed) {\n          append(\n            `${generateHref(token.text, info.internal)}${escapeContent(token.text)}</a>`,\n          );\n        } else {\n          append(escapeContent(token.text));\n        }\n        break;\n      }\n\n      case 'HORIZONTAL_RULE':\n        append('<hr>');\n        break;\n\n      case 'LINE_BREAK':\n        if (automaticParagraphs) {\n          let count = 1;\n          while (i < tokens.length - 1 && tokens[i + 1].type === 'LINE_BREAK') {\n            count++;\n            i++;\n          }\n          if (count > 1) {\n            append('</p>');\n            append(new Array(count - 1).join('<br>'));\n            append('<p>');\n            break;\n          }\n        }\n        append('<br>');\n        break;\n\n      case 'FORCED_LINE_BREAK':\n        append('<br>');\n        break;\n\n      case 'FORCED_PARAGRAPH_BREAK':\n        append('</p>');\n        append('<p>');\n        break;\n\n      case 'SERIES_NAVIGATION':\n        append(createSeriesNavigation(token));\n        break;\n\n      default:\n        throw new Error(`Unrecognized token type: ${(token as Token).type}`);\n    }\n  }\n\n  if (automaticParagraphs) {\n    if (tail.prev!.text === '<p>') {\n      tail.prev!.prev!.next = null;\n    } else {\n      append('</p>');\n    }\n  }\n\n  let result = '';\n  let node: Node | null = head;\n  while ((node = node.next) != null) {\n    if (node.text !== null) {\n      result += node.text;\n    }\n  }\n  return result;\n}\n\nfunction generateHref(href: string, internal: boolean) {\n  return `<a href=\"${internal ? 'https://www.furaffinity.net/' : ''}${escapeAttributeValue(href)}${internal ? '\" target=\"_blank\" rel=\"external nofollow noopener noreferrer\">' : '\" target=\"_blank\" rel=\"external nofollow noopener noreferrer\">'}`;\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/website-components/index.ts",
    "content": "import './bluesky/description-preview';\nimport './furaffinity/furaffinity-bbcode-renderer';\nimport './telegram/telegram-format-renderer';\nimport './tumblr/tumblr-npf-renderer';\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/website-components/telegram/telegram-format-renderer.tsx",
    "content": "/* eslint-disable lingui/no-unlocalized-strings */\nimport { Box } from '@mantine/core';\nimport { descriptionPreviewRendererByWebsite } from '../../sections/submissions-section/submission-edit-card/account-selection/form/fields/description-preview-panel';\n\ndescriptionPreviewRendererByWebsite.set('telegram', ({ description }) => {\n  const parsed = JSON.parse(description) as {\n    rendered: string;\n  };\n\n  return <Box dangerouslySetInnerHTML={{ __html: parsed.rendered }} />;\n});\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/website-components/tumblr/tumblr-npf-renderer.tsx",
    "content": "/* eslint-disable lingui/no-unlocalized-strings */\nimport { Box, Image, Stack, Text, Title } from '@mantine/core';\nimport {\n  NPFContentBlock,\n  NPFImageBlock,\n  NPFLinkBlock,\n  NPFTextBlock,\n} from '@postybirb/types';\nimport { descriptionPreviewRendererByWebsite } from '../../sections/submissions-section/submission-edit-card/account-selection/form/fields/description-preview-panel';\n\ndescriptionPreviewRendererByWebsite.set('tumblr', ({ description }) => {\n  if (!description) {\n    return null;\n  }\n\n  let blocks: NPFContentBlock[];\n  try {\n    blocks = JSON.parse(description) as NPFContentBlock[];\n  } catch (e) {\n    // eslint-disable-next-line no-console\n    console.error('Failed to parse NPF description', e);\n    return null;\n  }\n\n  if (!Array.isArray(blocks)) {\n    return null;\n  }\n\n  // Group consecutive list items into proper <ul>/<ol> for cleaner rendering.\n  // This is optional but improves semantics. For simplicity, we render each list item\n  // as a separate block with proper styling (already done in renderTextBlock).\n  // Here we just map every block to its component.\n  const renderedBlocks = blocks.map((block, idx) => {\n    const key = JSON.stringify(block);\n    switch (block.type) {\n      case 'text':\n        return <Box key={key}>{renderTextBlock(block)}</Box>;\n      case 'image':\n        return <Box key={key}>{renderImageBlock(block)}</Box>;\n      case 'link':\n        return <Box key={key}>{renderLinkBlock(block)}</Box>;\n      case 'audio':\n      case 'video':\n        return <Box key={key}>{renderOtherBlock(block)}</Box>;\n      default:\n        return null;\n    }\n  });\n\n  return <Stack gap=\"xs\">{renderedBlocks}</Stack>;\n});\n\nfunction renderTextBlock(block: NPFTextBlock): React.ReactNode {\n  let { text } = block;\n  const formatting = block.formatting || [];\n\n  // Apply formatting in reverse order to preserve indices\n  const sortedFormatting = [...formatting].sort((a, b) => b.start - a.start);\n  for (const fmt of sortedFormatting) {\n    const before = text.slice(0, fmt.start);\n    const formattedText = text.slice(fmt.start, fmt.end);\n    const after = text.slice(fmt.end);\n\n    let wrapped = formattedText;\n    switch (fmt.type) {\n      case 'bold':\n        wrapped = `<strong>${wrapped}</strong>`;\n        break;\n      case 'italic':\n        wrapped = `<em>${wrapped}</em>`;\n        break;\n      case 'strikethrough':\n        wrapped = `<del>${wrapped}</del>`;\n        break;\n      case 'small':\n        wrapped = `<small>${wrapped}</small>`;\n        break;\n      case 'link':\n        if (fmt.url) {\n          wrapped = `<a href=\"${fmt.url}\" target=\"_blank\" rel=\"noopener noreferrer\">${wrapped}</a>`;\n        }\n        break;\n      case 'mention':\n        // Mention could link to blog.tumblr.com/uuid – simplified: just render as text\n        wrapped = `<span class=\"mention\">@${wrapped}</span>`;\n        break;\n      case 'color':\n        if (fmt.hex) {\n          wrapped = `<span style=\"color: ${fmt.hex}\">${wrapped}</span>`;\n        }\n        break;\n      default:\n        break;\n    }\n    text = `${before}${wrapped}${after}`;\n  }\n\n  // Replace newlines with <br/>\n  text = text.replace(/\\r?\\n/g, '<br/>');\n\n  // eslint-disable-next-line react/no-danger\n  const innerHtml = <div dangerouslySetInnerHTML={{ __html: text }} />;\n\n  // Apply block‑level styling based on subtype\n  switch (block.subtype) {\n    case 'heading1':\n      return <Title order={1}>{innerHtml}</Title>;\n    case 'heading2':\n      return <Title order={2}>{innerHtml}</Title>;\n    case 'quote':\n      return (\n        <Box\n          component=\"blockquote\"\n          style={{\n            margin: '0.5em 0',\n            paddingLeft: '1rem',\n            borderLeft: '3px solid var(--mantine-color-gray-4)',\n            fontStyle: 'italic',\n          }}\n        >\n          {innerHtml}\n        </Box>\n      );\n    case 'indented':\n      return (\n        <Box style={{ marginLeft: `${(block.indent_level ?? 1) * 1.5}rem` }}>\n          {innerHtml}\n        </Box>\n      );\n    case 'ordered-list-item':\n      return (\n        <Box component=\"li\" style={{ marginLeft: '1.5rem' }}>\n          {innerHtml}\n        </Box>\n      );\n    case 'unordered-list-item':\n      return (\n        <Box\n          component=\"li\"\n          style={{ marginLeft: '1.5rem', listStyleType: 'disc' }}\n        >\n          {innerHtml}\n        </Box>\n      );\n    default:\n      return <Text>{innerHtml}</Text>;\n  }\n}\n\n/**\n * Renders an NPF image block.\n */\nfunction renderImageBlock(block: NPFImageBlock): React.ReactNode {\n  const media = block.media[0];\n  if (!media) return null;\n  return (\n    <Box my=\"xs\">\n      <Image\n        src={media.url}\n        alt={block.alt_text || ''}\n        width={media.width}\n        height={media.height}\n        fit=\"contain\"\n      />\n      {block.caption && (\n        <Text size=\"sm\" c=\"dimmed\" mt={4}>\n          {block.caption}\n        </Text>\n      )}\n    </Box>\n  );\n}\n\n/**\n * Renders a generic link block (card style).\n */\nfunction renderLinkBlock(block: NPFLinkBlock): React.ReactNode {\n  return (\n    <Box\n      component=\"a\"\n      href={block.url}\n      target=\"_blank\"\n      rel=\"noopener noreferrer\"\n      style={{\n        display: 'block',\n        border: '1px solid var(--mantine-color-default-border)',\n        borderRadius: '8px',\n        padding: '0.75rem',\n        margin: '0.5rem 0',\n        textDecoration: 'none',\n        color: 'inherit',\n        backgroundColor: 'var(--mantine-color-body)',\n      }}\n    >\n      {block.poster?.[0] && (\n        <Image\n          src={block.poster[0].url}\n          alt=\"\"\n          width={80}\n          height={80}\n          fit=\"cover\"\n          style={{ float: 'right', marginLeft: '0.75rem' }}\n        />\n      )}\n      <Text fw={700}>{block.title || block.url}</Text>\n      {block.description && (\n        <Text size=\"sm\" lineClamp={2}>\n          {block.description}\n        </Text>\n      )}\n      <Text size=\"xs\" c=\"dimmed\">\n        {block.site_name || block.display_url || block.url}\n      </Text>\n    </Box>\n  );\n}\n\n/**\n * Fallback for audio/video or other blocks.\n */\nfunction renderOtherBlock(block: NPFContentBlock): React.ReactNode {\n  if (block.type === 'audio') {\n    return (\n      <Text size=\"sm\" c=\"dimmed\" fs=\"italic\">\n        🎵 Audio: {block.title || block.artist || 'untitled'}\n      </Text>\n    );\n  }\n  if (block.type === 'video') {\n    return (\n      <Text size=\"sm\" c=\"dimmed\" fs=\"italic\">\n        📽️ Video: {block.provider || 'embedded'}\n      </Text>\n    );\n  }\n  return null;\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/website-login-views/bluesky/bluesky-login-view.tsx",
    "content": "import { Trans } from '@lingui/react/macro';\nimport {\n  Alert,\n  Box,\n  Button,\n  Group,\n  Stack,\n  Switch,\n  Text,\n  TextInput,\n} from '@mantine/core';\nimport { BlueskyAccountData, BlueskyOAuthRoutes } from '@postybirb/types';\nimport { IconAlertCircle, IconInfoCircle } from '@tabler/icons-react';\nimport { useState } from 'react';\nimport websitesApi from '../../../api/websites.api';\nimport { ExternalLink } from '../../shared/external-link';\nimport {\n  createLoginHttpErrorHandler,\n  notifyLoginFailed,\n  notifyLoginSuccess,\n} from '../helpers';\nimport { LoginViewContainer } from '../login-view-container';\nimport type { LoginViewProps } from '../types';\n\nconst formId = 'bluesky-login-form';\n\n// Source: https://atproto.com/specs/handle\nconst usernameRegexp =\n  /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;\n\nfunction safeUrlParse(url: string) {\n  try {\n    return new URL(url);\n  } catch (e) {\n    // eslint-disable-next-line lingui/no-unlocalized-strings, no-console\n    console.error(`bsky login page custom url parse error \"${url}\"`, e);\n    return undefined;\n  }\n}\n\nexport default function BlueskyLoginView(\n  props: LoginViewProps<BlueskyAccountData>,\n): JSX.Element {\n  const { account, data } = props;\n  const { id } = account;\n  const [username, setUsername] = useState(data?.username ?? '');\n  const [password, setPassword] = useState(data?.password ?? '');\n  const [isSubmitting, setSubmitting] = useState<boolean>(false);\n  const isUsingEmail = username.includes('@') && !username.startsWith('@');\n\n  const [isUsingCustomPdsOrAppView, setIsUsingCustomPdsOrAppView] =\n    useState<boolean>(!!data?.serviceUrl || !!data?.appViewUrl);\n  const [customPds, setCustomPds] = useState(data?.serviceUrl ?? '');\n  const [appViewUrl, setAppViewUrl] = useState(data?.appViewUrl ?? '');\n\n  return (\n    <LoginViewContainer>\n      <form\n        id={formId}\n        onSubmit={(event) => {\n          event.preventDefault();\n          setSubmitting(true);\n          websitesApi\n            .performOAuthStep<BlueskyOAuthRoutes>(id, 'login', {\n              username: username.trim().replace(/^@/, ''),\n              password: password.trim(),\n              serviceUrl: customPds || undefined, // Don't pass empty string\n              appViewUrl: appViewUrl || undefined,\n            })\n            .then(({ result }) => {\n              if (result) {\n                notifyLoginSuccess(undefined, account);\n                setPassword('');\n              } else {\n                notifyLoginFailed(\n                  <Trans>\n                    Check that handle and password are valid or try using email\n                    instead of handle.\n                  </Trans>,\n                );\n              }\n            })\n            .catch(createLoginHttpErrorHandler())\n            .finally(() => {\n              setSubmitting(false);\n            });\n        }}\n      >\n        <Stack gap=\"md\">\n          <Alert\n            icon={<IconInfoCircle size={16} />}\n            color=\"blue\"\n            variant=\"light\"\n          >\n            <Text size=\"sm\">\n              <Trans>\n                Use your Bluesky handle or email along with an app password. App\n                passwords are more secure than your main password and can be\n                revoked at any time.\n              </Trans>\n            </Text>\n          </Alert>\n\n          <Switch\n            checked={isUsingCustomPdsOrAppView}\n            onChange={(v) => setIsUsingCustomPdsOrAppView(v.target.checked)}\n            label={<Trans>Custom PDS or AppView</Trans>}\n          />\n\n          {isUsingCustomPdsOrAppView && (\n            <TextInput\n              label={<Trans>PDS (Personal Data Server)</Trans>}\n              name=\"pds\"\n              placeholder=\"https://bsky.social\"\n              description={\n                <Text size=\"xs\" c=\"dimmed\">\n                  <Trans>If you are using pds other then bsky.social</Trans>\n                </Text>\n              }\n              minLength={1}\n              value={customPds}\n              error={\n                customPds &&\n                !safeUrlParse(customPds) && (\n                  <Group gap=\"xs\">\n                    <IconAlertCircle size={14} />\n                    <Text size=\"xs\">\n                      <Trans comment=\"Bluesky login form\">\n                        Invalid url. Format should be{' '}\n                        <code>https://bsky.social/</code>\n                      </Trans>\n                    </Text>\n                  </Group>\n                )\n              }\n              onChange={(event) => {\n                let url = event.currentTarget.value;\n                if (!customPds && !url.includes(':')) url = `https://${url}`;\n                setCustomPds(url === 'https://bsky.social/' ? '' : url);\n              }}\n            />\n          )}\n\n          {isUsingCustomPdsOrAppView && (\n            <TextInput\n              label={<Trans>App view URL</Trans>}\n              name=\"appView\"\n              placeholder=\"https://bsky.app\"\n              description={\n                <Text size=\"xs\" c=\"dimmed\">\n                  <Trans>\n                    Used for other sites (like e621) as source url base.\n                    Independent from custom pds.\n                  </Trans>\n                </Text>\n              }\n              minLength={1}\n              value={appViewUrl}\n              error={\n                appViewUrl &&\n                !safeUrlParse(appViewUrl) && (\n                  <Group gap=\"xs\">\n                    <IconAlertCircle size={14} />\n                    <Text size=\"xs\">\n                      <Trans comment=\"Bluesky login form\">\n                        Invalid url. Format should be{' '}\n                        <code>https://bsky.app/</code>\n                      </Trans>\n                    </Text>\n                  </Group>\n                )\n              }\n              onChange={(event) => {\n                let url = event.currentTarget.value;\n                if (!appViewUrl && !url.includes(':')) url = `https://${url}`;\n                setAppViewUrl(url === 'https://bsky.app/' ? '' : url);\n              }}\n            />\n          )}\n\n          <TextInput\n            label={<Trans>Username or Email</Trans>}\n            name=\"username\"\n            placeholder=\"yourname.bsky.social\"\n            description={\n              <>\n                <Text size=\"xs\" c=\"dimmed\">\n                  <Trans>\n                    Your handle (e.g. <code>yourname.bsky.social</code>) or\n                    custom domain (e.g. <code>username.ext</code>)\n                  </Trans>\n                </Text>\n                <Text size=\"xs\" c=\"dimmed\" mt={4}>\n                  <Trans>\n                    <strong>Tip:</strong> If your handle doesn't work, try using\n                    the email linked to your account\n                  </Trans>\n                </Text>\n              </>\n            }\n            required\n            minLength={1}\n            value={username}\n            error={\n              username &&\n              !isUsingEmail &&\n              !usernameRegexp.test(username) && (\n                <Group gap=\"xs\">\n                  <IconAlertCircle size={14} />\n                  <Text size=\"xs\">\n                    <Trans comment=\"Bluesky login form\">\n                      Format should be <code>handle.bsky.social</code> or{' '}\n                      <code>domain.ext</code> for custom domains\n                    </Trans>\n                  </Text>\n                </Group>\n              )\n            }\n            onChange={(event) => {\n              setUsername(event.currentTarget.value);\n            }}\n          />\n\n          <TextInput\n            label={<Trans>App Password</Trans>}\n            name=\"password\"\n            type=\"password\"\n            placeholder=\"xxxx-xxxx-xxxx-xxxx\"\n            description={\n              <>\n                <Text size=\"xs\" c=\"dimmed\">\n                  <Trans comment=\"Bluesky login form\">\n                    An <strong>app password</strong> (not your main password)\n                  </Trans>\n                </Text>\n                <ExternalLink href=\"https://bsky.app/settings/app-passwords\">\n                  <Trans>Create an app password in Bluesky Settings</Trans>\n                </ExternalLink>\n              </>\n            }\n            required\n            minLength={1}\n            value={password}\n            error={password && !/^([a-z0-9]{4}-){3}[a-z0-9]{4}$/.test(password)}\n            onChange={(event) => {\n              setPassword(event.currentTarget.value);\n            }}\n          />\n\n          <Box mt=\"md\">\n            <Button\n              type=\"submit\"\n              form={formId}\n              loading={isSubmitting}\n              disabled={!username || !password}\n              fullWidth\n            >\n              <Trans>Save</Trans>\n            </Button>\n          </Box>\n        </Stack>\n      </form>\n    </LoginViewContainer>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/website-login-views/bluesky/index.ts",
    "content": "export { default as BlueskyLoginView } from './bluesky-login-view';\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/website-login-views/custom/custom-login-view.tsx",
    "content": "/* eslint-disable lingui/no-unlocalized-strings */\n// Do not care to translate this file\nimport {\n  ActionIcon,\n  Alert,\n  Box,\n  Button,\n  Group,\n  NumberInput,\n  Radio,\n  Stack,\n  Text,\n  TextInput,\n} from '@mantine/core';\nimport { CustomAccountData } from '@postybirb/types';\nimport { IconPlus, IconTrash } from '@tabler/icons-react';\nimport { useEffect, useState } from 'react';\nimport accountApi from '../../../api/account.api';\nimport { notifyLoginFailed, notifyLoginSuccess } from '../helpers';\nimport { LoginViewContainer } from '../login-view-container';\nimport type { LoginViewProps } from '../types';\n\ninterface HeaderWithId {\n  id: string;\n  name: string;\n  value: string;\n}\n\nconst formId = 'custom-login-form';\nconst HEADER_NAME_PLACEHOLDER = 'Header name';\nconst HEADER_VALUE_PLACEHOLDER = 'Header value';\n\nexport default function CustomLoginView(\n  props: LoginViewProps<CustomAccountData>,\n): JSX.Element {\n  const { account, data } = props;\n  const { id } = account;\n\n  const [headersWithIds, setHeadersWithIds] = useState<HeaderWithId[]>([]);\n  const [formData, setFormData] = useState<Omit<CustomAccountData, 'headers'>>({\n    descriptionField: 'description',\n    descriptionType: 'html',\n    fileField: 'file',\n    fileUrl: '',\n    notificationUrl: '',\n    ratingField: 'rating',\n    tagField: 'tags',\n    thumbnailField: 'thumbnail',\n    titleField: 'title',\n    altTextField: 'alt',\n  });\n\n  // Initialize form data from props\n  useEffect(() => {\n    if (data) {\n      const { headers, ...rest } = data;\n      setFormData((prev) => ({ ...prev, ...rest }));\n\n      if (headers) {\n        setHeadersWithIds(\n          headers.map((header, index) => ({\n            id: `header-${Date.now()}-${index}`,\n            ...header,\n          })),\n        );\n      }\n    }\n  }, [data]);\n\n  const [isSubmitting, setSubmitting] = useState<boolean>(false);\n\n  const isFormValid =\n    formData.fileUrl?.trim() || formData.notificationUrl?.trim();\n\n  const handleSubmit = async (event: React.FormEvent) => {\n    event.preventDefault();\n    if (!isFormValid) return;\n\n    setSubmitting(true);\n\n    try {\n      // Convert headers back to original format for saving\n      const headers = headersWithIds\n        .filter((header) => header.name.trim() && header.value.trim())\n        .map(({ name, value }) => ({ name, value }));\n\n      const cleanedData: CustomAccountData = { ...formData, headers };\n\n      await accountApi.setWebsiteData<CustomAccountData>({\n        id,\n        data: cleanedData,\n      });\n\n      notifyLoginSuccess(undefined, account);\n    } catch (error) {\n      notifyLoginFailed((error as Error).message);\n    } finally {\n      setSubmitting(false);\n    }\n  };\n\n  const updateFormData = (\n    field: keyof Omit<CustomAccountData, 'headers'>,\n    value: string | number,\n  ) => {\n    setFormData((prev) => ({ ...prev, [field]: value }));\n  };\n\n  const addHeader = () => {\n    const newHeader: HeaderWithId = {\n      id: `header-${Date.now()}-${Math.random()}`,\n      name: '',\n      value: '',\n    };\n    setHeadersWithIds((prev) => [...prev, newHeader]);\n  };\n\n  const updateHeader = (\n    headerId: string,\n    field: 'name' | 'value',\n    value: string,\n  ) => {\n    setHeadersWithIds((prev) =>\n      prev.map((header) =>\n        header.id === headerId ? { ...header, [field]: value } : header,\n      ),\n    );\n  };\n\n  const removeHeader = (headerId: string) => {\n    setHeadersWithIds((prev) =>\n      prev.filter((header) => header.id !== headerId),\n    );\n  };\n\n  return (\n    <LoginViewContainer>\n      <form id={formId} onSubmit={handleSubmit}>\n        <Stack>\n          <Alert color=\"blue\">\n            <Text>\n              Configure custom webhook URLs and field mappings for your custom\n              integration. You need at least one URL (File URL for file\n              submissions or Notification URL for text-only posts).\n            </Text>\n            <br />\n            <br />\n            <Text>\n              Custom website is for websites that you own and understand the\n              actual backend of.\n            </Text>\n          </Alert>\n\n          <TextInput\n            label={<Text>File URL</Text>}\n            description={\n              <Text>The URL that will be posted to when submitting files</Text>\n            }\n            value={formData.fileUrl || ''}\n            onChange={(event) =>\n              updateFormData('fileUrl', event.currentTarget.value)\n            }\n            placeholder=\"https://your-server.com/api/files\"\n          />\n\n          <TextInput\n            label={<Text>Notification URL</Text>}\n            description={\n              <Text>\n                The URL that will be posted to when submitting text-only posts\n              </Text>\n            }\n            value={formData.notificationUrl || ''}\n            onChange={(event) =>\n              updateFormData('notificationUrl', event.currentTarget.value)\n            }\n            placeholder=\"https://your-server.com/api/notifications\"\n          />\n\n          <NumberInput\n            label={<Text>File Batch Limit</Text>}\n            description={\n              <Text>\n                The maximum number of files that can be submitted in a single\n                batch\n              </Text>\n            }\n            value={formData.fileBatchLimit || ''}\n            onChange={(value) =>\n              updateFormData(\n                'fileBatchLimit',\n                Number.isNaN(Number(value)) ? 1 : Number(value),\n              )\n            }\n          />\n\n          <Group grow>\n            <TextInput\n              label={<Text>Title Field</Text>}\n              value={formData.titleField || ''}\n              onChange={(event) =>\n                updateFormData('titleField', event.currentTarget.value)\n              }\n            />\n            <TextInput\n              label={<Text>Description Field</Text>}\n              value={formData.descriptionField || ''}\n              onChange={(event) =>\n                updateFormData('descriptionField', event.currentTarget.value)\n              }\n            />\n          </Group>\n\n          <Group>\n            <Text size=\"sm\" fw={500}>\n              <Text>Description Type</Text>\n            </Text>\n            <Radio.Group\n              value={formData.descriptionType || 'html'}\n              onChange={(value) =>\n                updateFormData('descriptionType', value || 'html')\n              }\n            >\n              <Group>\n                <Radio value=\"html\" label=\"HTML\" />\n                <Radio value=\"text\" label={<Text>Plain Text</Text>} />\n                <Radio value=\"md\" label={<Text>Markdown</Text>} />\n                <Radio value=\"bbcode\" label={<Text>BBCode</Text>} />\n              </Group>\n            </Radio.Group>\n          </Group>\n\n          <Group grow>\n            <TextInput\n              label={<Text>Tag Field</Text>}\n              value={formData.tagField || ''}\n              onChange={(event) =>\n                updateFormData('tagField', event.currentTarget.value)\n              }\n            />\n            <TextInput\n              label={<Text>Rating Field</Text>}\n              value={formData.ratingField || ''}\n              onChange={(event) =>\n                updateFormData('ratingField', event.currentTarget.value)\n              }\n            />\n          </Group>\n\n          <Group grow>\n            <TextInput\n              label={<Text>File Field</Text>}\n              value={formData.fileField || ''}\n              onChange={(event) =>\n                updateFormData('fileField', event.currentTarget.value)\n              }\n            />\n            <TextInput\n              label={<Text>Thumbnail Field</Text>}\n              value={formData.thumbnailField || ''}\n              onChange={(event) =>\n                updateFormData('thumbnailField', event.currentTarget.value)\n              }\n            />\n          </Group>\n\n          <TextInput\n            label={<Text>Alt Text Field</Text>}\n            value={formData.altTextField || ''}\n            onChange={(event) =>\n              updateFormData('altTextField', event.currentTarget.value)\n            }\n          />\n\n          <Box>\n            <Group justify=\"space-between\" mb=\"sm\">\n              <Text size=\"sm\" fw={500}>\n                <Text>Headers</Text>\n              </Text>\n              <ActionIcon variant=\"light\" onClick={addHeader} size=\"sm\">\n                <IconPlus size={16} />\n              </ActionIcon>\n            </Group>\n            <Text size=\"xs\" c=\"dimmed\" mb=\"md\">\n              <Text>Custom headers for authentication or other purposes</Text>\n            </Text>\n\n            <Stack gap=\"xs\">\n              {headersWithIds.map((header) => (\n                <Group key={header.id} align=\"end\">\n                  <TextInput\n                    placeholder={HEADER_NAME_PLACEHOLDER}\n                    value={header.name}\n                    onChange={(event) =>\n                      updateHeader(header.id, 'name', event.currentTarget.value)\n                    }\n                    style={{ flex: 1 }}\n                  />\n                  <TextInput\n                    placeholder={HEADER_VALUE_PLACEHOLDER}\n                    value={header.value}\n                    onChange={(event) =>\n                      updateHeader(\n                        header.id,\n                        'value',\n                        event.currentTarget.value,\n                      )\n                    }\n                    style={{ flex: 1 }}\n                  />\n                  <ActionIcon\n                    variant=\"light\"\n                    color=\"red\"\n                    onClick={() => removeHeader(header.id)}\n                    size=\"sm\"\n                  >\n                    <IconTrash size={16} />\n                  </ActionIcon>\n                </Group>\n              ))}\n            </Stack>\n          </Box>\n        </Stack>\n\n        <Box mt=\"md\">\n          <Button\n            type=\"submit\"\n            form={formId}\n            loading={isSubmitting}\n            disabled={!isFormValid}\n            fullWidth\n          >\n            <Text>Save</Text>\n          </Button>\n        </Box>\n      </form>\n    </LoginViewContainer>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/website-login-views/custom/index.ts",
    "content": "export { default as CustomLoginView } from './custom-login-view';\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/website-login-views/discord/discord-login-view.tsx",
    "content": "import { Trans } from '@lingui/react/macro';\nimport {\n  Alert,\n  Box,\n  Button,\n  Checkbox,\n  Group,\n  SegmentedControl,\n  Stack,\n  Text,\n  TextInput,\n} from '@mantine/core';\nimport { DiscordAccountData } from '@postybirb/types';\nimport { IconInfoCircle } from '@tabler/icons-react';\nimport { useState } from 'react';\nimport accountApi from '../../../api/account.api';\nimport { ExternalLink } from '../../shared/external-link';\nimport { createLoginHttpErrorHandler, notifyLoginSuccess } from '../helpers';\nimport { LoginViewContainer } from '../login-view-container';\nimport type { LoginViewProps } from '../types';\n\nconst formId = 'discord-login-form';\n\nconst isWebhookValid = (str: string): boolean | undefined => {\n  if (str === '') {\n    return undefined;\n  }\n\n  try {\n    const url = new URL(str);\n    // Discord webhook URLs follow pattern: https://discord.com/api/webhooks/{id}/{token}\n    const isDiscordWebhook =\n      (url.hostname === 'discord.com' || url.hostname === 'discordapp.com') &&\n      url.pathname.startsWith('/api/webhooks/');\n\n    return isDiscordWebhook && str.trim().length > 0;\n  } catch {\n    return false;\n  }\n};\n\nexport default function DiscordLoginView(\n  props: LoginViewProps<DiscordAccountData>,\n): JSX.Element {\n  const { account, data } = props;\n  const { id } = account;\n  const [webhook, setWebhook] = useState<string>(data?.webhook ?? '');\n  const [serverLevel, setServerLevel] = useState<number>(\n    data?.serverLevel ?? 0,\n  );\n  const [isForum, setIsForum] = useState<boolean>(data?.isForum ?? false);\n  const [isSubmitting, setSubmitting] = useState<boolean>(false);\n\n  const webhookValid = isWebhookValid(webhook);\n\n  return (\n    <LoginViewContainer>\n      <form\n        id={formId}\n        onSubmit={(event) => {\n          event.preventDefault();\n          setSubmitting(true);\n          accountApi\n            .setWebsiteData<DiscordAccountData>({\n              id,\n              data: { webhook: webhook.trim(), serverLevel, isForum },\n            })\n            .then(() => {\n              notifyLoginSuccess(undefined, account);\n            })\n            .catch(createLoginHttpErrorHandler())\n            .finally(() => {\n              setSubmitting(false);\n            });\n        }}\n      >\n        <Stack gap=\"md\">\n          <Alert\n            icon={<IconInfoCircle size={16} />}\n            color=\"blue\"\n            variant=\"light\"\n          >\n            <Text size=\"sm\">\n              <Trans>\n                PostyBirb uses Discord webhooks to post content to your server.\n                Make sure you have the necessary permissions to create webhooks\n                in your target channel.\n              </Trans>\n            </Text>\n          </Alert>\n\n          <TextInput\n            // eslint-disable-next-line lingui/no-unlocalized-strings\n            label=\"Webhook URL\"\n            name=\"webhook\"\n            placeholder=\"https://discord.com/api/webhooks/...\"\n            required\n            minLength={1}\n            value={webhook}\n            error={webhookValid === false}\n            description={\n              <ExternalLink href=\"https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks\">\n                <Trans context=\"discord.webhook-help\">\n                  How to create a webhook\n                </Trans>\n              </ExternalLink>\n            }\n            onChange={(event) => {\n              setWebhook(event.currentTarget.value);\n            }}\n          />\n\n          <Box>\n            <Text size=\"sm\" fw={500} mb={4}>\n              <Trans>Server Boost Level</Trans>\n            </Text>\n            <SegmentedControl\n              fullWidth\n              value={String(serverLevel)}\n              onChange={(value) => setServerLevel(Number(value))}\n              data={[\n                { value: '0', label: <Trans>None</Trans> },\n                { value: '1', label: '1' },\n                { value: '2', label: '2' },\n                { value: '3', label: '3' },\n              ]}\n            />\n            <Text size=\"xs\" c=\"dimmed\" mt={4}>\n              <Trans>Higher boost levels allow larger file uploads</Trans>\n            </Text>\n          </Box>\n\n          <Checkbox\n            label={\n              <Group gap=\"xs\">\n                <Text size=\"sm\">\n                  <Trans>This is a forum channel</Trans>\n                </Text>\n              </Group>\n            }\n            checked={isForum}\n            onChange={(event) => {\n              setIsForum(event.currentTarget.checked);\n            }}\n          />\n\n          <Box mt=\"md\">\n            <Button\n              type=\"submit\"\n              form={formId}\n              loading={isSubmitting}\n              disabled={!webhookValid}\n              fullWidth\n            >\n              <Trans>Save</Trans>\n            </Button>\n          </Box>\n        </Stack>\n      </form>\n    </LoginViewContainer>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/website-login-views/discord/index.ts",
    "content": "export { default as DiscordLoginView } from './discord-login-view';\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/website-login-views/e621/e621-login-view.tsx",
    "content": "import { Trans } from '@lingui/react/macro';\nimport { Alert, Box, Button, Stack, Text, TextInput } from '@mantine/core';\nimport { E621AccountData, E621OAuthRoutes } from '@postybirb/types';\nimport { IconInfoCircle } from '@tabler/icons-react';\nimport { useState } from 'react';\nimport websitesApi from '../../../api/websites.api';\nimport { ExternalLink } from '../../shared/external-link';\nimport {\n  createLoginHttpErrorHandler,\n  notifyLoginFailed,\n  notifyLoginSuccess,\n} from '../helpers';\nimport { LoginViewContainer } from '../login-view-container';\nimport type { LoginViewProps } from '../types';\n\nconst formId = 'e621-login-form';\n\nexport default function E621LoginView(\n  props: LoginViewProps<E621AccountData>,\n): JSX.Element {\n  const { account, data } = props;\n  const { id } = account;\n  const [username, setUsername] = useState(data?.username ?? '');\n  const [key, setKey] = useState(data?.key ?? '');\n  const [isSubmitting, setSubmitting] = useState<boolean>(false);\n\n  return (\n    <LoginViewContainer>\n      <form\n        id={formId}\n        onSubmit={(event) => {\n          event.preventDefault();\n          setSubmitting(true);\n          websitesApi\n            .performOAuthStep<E621OAuthRoutes>(id, 'login', {\n              username: username.trim(),\n              key: key.trim(),\n            })\n            .then(({ result }) => {\n              if (result) {\n                notifyLoginSuccess(undefined, account);\n                setKey('');\n              } else {\n                notifyLoginFailed();\n              }\n            })\n            .catch(createLoginHttpErrorHandler())\n            .finally(() => {\n              setSubmitting(false);\n            });\n        }}\n      >\n        <Stack gap=\"md\">\n          <Alert\n            icon={<IconInfoCircle size={16} />}\n            color=\"blue\"\n            variant=\"light\"\n          >\n            <Text size=\"sm\">\n              <Trans>\n                e621 requires API credentials for posting. You'll need to\n                generate an API key from your account settings to authenticate\n                PostyBirb.\n              </Trans>\n            </Text>\n          </Alert>\n\n          <TextInput\n            label={<Trans>Username</Trans>}\n            name=\"username\"\n            placeholder=\"your_username\"\n            required\n            minLength={1}\n            value={username}\n            onChange={(event) => {\n              setUsername(event.currentTarget.value);\n            }}\n          />\n\n          <TextInput\n            // eslint-disable-next-line lingui/no-unlocalized-strings\n            label=\"API Key\"\n            name=\"password\"\n            type=\"password\"\n            // eslint-disable-next-line lingui/no-unlocalized-strings\n            placeholder=\"Your API key\"\n            description={\n              <Text size=\"xs\" c=\"dimmed\">\n                <Trans comment=\"E621 login form\">\n                  Generate an API key from your{' '}\n                  <ExternalLink href=\"https://e621.net/users/home\">\n                    account settings\n                  </ExternalLink>\n                </Trans>\n              </Text>\n            }\n            required\n            minLength={1}\n            value={key}\n            onChange={(event) => {\n              setKey(event.currentTarget.value);\n            }}\n          />\n\n          <Box mt=\"md\">\n            <Button\n              type=\"submit\"\n              form={formId}\n              loading={isSubmitting}\n              disabled={!username || !key}\n              fullWidth\n            >\n              <Trans>Save</Trans>\n            </Button>\n          </Box>\n        </Stack>\n      </form>\n    </LoginViewContainer>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/website-login-views/e621/index.ts",
    "content": "export { default as E621LoginView } from './e621-login-view';\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/website-login-views/helpers.tsx",
    "content": "/**\n * Helper utilities for website login views.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport { notifications } from '@mantine/notifications';\nimport { IconCheck, IconInfoCircle, IconX } from '@tabler/icons-react';\nimport accountApi from '../../api/account.api';\nimport type HttpErrorResponse from '../../models/http-error-response';\nimport { AccountRecord } from '../../stores';\n\n/**\n * Show an info notification.\n */\nexport function notifyInfo(title: React.ReactNode, message: React.ReactNode) {\n  notifications.show({\n    title,\n    message,\n    color: 'blue',\n    icon: <IconInfoCircle size={16} />,\n  });\n}\n\n/**\n * Show a success notification for successful login and close login form for current website.\n */\nexport function notifyLoginSuccess(\n  message: React.ReactNode | undefined,\n  account: AccountRecord | undefined,\n) {\n  if (account) accountApi.refreshLogin(account?.id);\n  notifications.show({\n    title: <Trans>{account?.websiteDisplayName}: Login successful</Trans>,\n    color: 'green',\n    message,\n    icon: <IconCheck size={16} />,\n  });\n}\n\n/**\n * Show an error notification for failed login.\n */\nexport function notifyLoginFailed(message?: React.ReactNode) {\n  notifications.show({\n    title: <Trans>Login failed</Trans>,\n    message: message ?? (\n      <Trans>Please check your credentials and try again</Trans>\n    ),\n    color: 'red',\n    icon: <IconX size={16} />,\n  });\n}\n\n/**\n * Show an error notification for HTTP/network errors during login.\n */\nexport function notifyLoginError(error: Error) {\n  notifications.show({\n    title: <Trans>Connection error</Trans>,\n    message: error.message || <Trans>Failed to connect to the server</Trans>,\n    color: 'red',\n    icon: <IconX size={16} />,\n  });\n}\n\n/**\n * Create an error handler for HTTP errors during login operations.\n * Use this as a .catch() handler for API calls.\n */\nexport function createLoginHttpErrorHandler(action?: React.ReactNode) {\n  return ({ error }: { error: HttpErrorResponse }) => {\n    notifications.show({\n      title: (\n        <span>\n          {action ?? <Trans>Login failed</Trans>}: {error.statusCode}{' '}\n          {error.error}\n        </span>\n      ),\n      message: error.message,\n      color: 'red',\n      icon: <IconX size={16} />,\n    });\n  };\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/website-login-views/index.ts",
    "content": "/**\n * Registry of custom login view components for websites.\n * Maps website login component names to their React components.\n */\n\nimport { BlueskyLoginView } from './bluesky';\nimport { CustomLoginView } from './custom';\nimport { DiscordLoginView } from './discord';\nimport { E621LoginView } from './e621';\nimport { InkbunnyLoginView } from './inkbunny';\nimport { InstagramLoginView } from './instagram';\nimport { MegalodonLoginView } from './megalodon';\nimport { MisskeyLoginView } from './misskey';\nimport { TelegramLoginView } from './telegram';\nimport { TwitterLoginView } from './twitter';\nimport type { LoginViewComponent } from './types';\n\n// Re-export types and helpers\nexport {\n    createLoginHttpErrorHandler,\n    notifyInfo,\n    notifyLoginError,\n    notifyLoginFailed\n} from './helpers';\nexport { LoginViewContainer } from './login-view-container';\nexport type { LoginViewComponent, LoginViewProps } from './types';\n\n/**\n * Registry mapping loginComponentName to login view components.\n * The key must match the `loginComponentName` from the website's CustomLoginType.\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nconst loginViewRegistry: Record<string, LoginViewComponent<any>> = {\n  Bluesky: BlueskyLoginView,\n  Custom: CustomLoginView,\n  Discord: DiscordLoginView,\n  Friendica: MegalodonLoginView,\n  GoToSocial: MegalodonLoginView,\n  Inkbunny: InkbunnyLoginView,\n  Instagram: InstagramLoginView,\n  Mastodon: MegalodonLoginView,\n  Misskey: MisskeyLoginView,\n  Pixelfed: MegalodonLoginView,\n  Pleroma: MegalodonLoginView,\n  Telegram: TelegramLoginView,\n  Twitter: TwitterLoginView,\n  e621: E621LoginView,\n};\n\n/**\n * Get the custom login view component for a website.\n * @param loginComponentName - The name from CustomLoginType.loginComponentName\n * @returns The login view component, or undefined if not found\n */\nexport function getLoginViewComponent(\n  loginComponentName: string,\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n): LoginViewComponent<any> | undefined {\n  return loginViewRegistry[loginComponentName];\n}\n\n/**\n * Check if a custom login view exists for a website.\n * @param loginComponentName - The name from CustomLoginType.loginComponentName\n */\nexport function hasLoginViewComponent(loginComponentName: string): boolean {\n  return loginComponentName in loginViewRegistry;\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/website-login-views/inkbunny/index.ts",
    "content": "export { InkbunnyLoginView } from './inkbunny-login-view';\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/website-login-views/inkbunny/inkbunny-login-view.tsx",
    "content": "/**\n * InkbunnyLoginView - Custom login form for Inkbunny.\n * Authenticates via the Inkbunny API and stores the session ID.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport {\n  Alert,\n  Box,\n  Button,\n  PasswordInput,\n  Stack,\n  Text,\n  TextInput,\n} from '@mantine/core';\nimport type { InkbunnyAccountData, InkbunnyOAuthRoutes } from '@postybirb/types';\nimport { IconInfoCircle } from '@tabler/icons-react';\nimport { useState } from 'react';\nimport websitesApi from '../../../api/websites.api';\nimport { ExternalLink } from '../../shared/external-link';\nimport {\n  createLoginHttpErrorHandler,\n  notifyLoginSuccess,\n} from '../helpers';\nimport { LoginViewContainer } from '../login-view-container';\nimport type { LoginViewProps } from '../types';\n\nconst formId = 'inkbunny-login-form';\n\nexport function InkbunnyLoginView({\n  account,\n  data,\n}: LoginViewProps<InkbunnyAccountData>) {\n  const [username, setUsername] = useState<string>(data?.username ?? '');\n  const [password, setPassword] = useState<string>('');\n  const [isSubmitting, setSubmitting] = useState<boolean>(false);\n\n  const isFormValid = username.trim().length > 0 && password.trim().length > 0;\n\n  const handleSubmit = async (event: React.FormEvent) => {\n    event.preventDefault();\n    if (!isFormValid) return;\n\n    setSubmitting(true);\n\n    // Authenticate via backend to avoid CORS issues\n    // Password is only sent to Inkbunny API, never stored\n    websitesApi\n      .performOAuthStep<InkbunnyOAuthRoutes>(account.id, 'login', {\n        username: username.trim(),\n        password,\n      })\n      .then(() => {\n        notifyLoginSuccess(undefined, account);\n      })\n      .catch(createLoginHttpErrorHandler())\n      .finally(() => {\n        setSubmitting(false);\n        setPassword(''); // Clear password for security\n      });\n  };\n\n  return (\n    <LoginViewContainer>\n      <form id={formId} onSubmit={handleSubmit}>\n        <Stack gap=\"md\">\n          <Alert\n            icon={<IconInfoCircle size={16} />}\n            color=\"blue\"\n            variant=\"light\"\n          >\n            <Text size=\"sm\">\n              <Trans>\n                You must first enable API access in your account settings under\n                \"API (External Scripting)\" before you can authenticate with\n                PostyBirb.\n              </Trans>\n            </Text>\n            <ExternalLink href=\"https://inkbunny.net/account.php\">\n              <Trans>Open Inkbunny Account Settings</Trans>\n            </ExternalLink>\n          </Alert>\n\n          <TextInput\n            label={<Trans>Username</Trans>}\n            name=\"username\"\n            placeholder=\"your_username\"\n            required\n            value={username}\n            onChange={(event) => setUsername(event.currentTarget.value)}\n          />\n\n          <PasswordInput\n            label={<Trans>Password</Trans>}\n            name=\"password\"\n            // eslint-disable-next-line lingui/no-unlocalized-strings\n            placeholder=\"Your password\"\n            required\n            value={password}\n            onChange={(event) => setPassword(event.currentTarget.value)}\n            description={\n              <Text size=\"xs\" c=\"dimmed\">\n                <Trans>Your password will not be stored</Trans>\n              </Text>\n            }\n          />\n\n          <Box mt=\"md\">\n            <Button\n              type=\"submit\"\n              form={formId}\n              loading={isSubmitting}\n              disabled={!isFormValid}\n              fullWidth\n            >\n              <Trans>Save</Trans>\n            </Button>\n          </Box>\n        </Stack>\n      </form>\n    </LoginViewContainer>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/website-login-views/instagram/index.ts",
    "content": "export { default as InstagramLoginView } from './instagram-login-view';\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/website-login-views/instagram/instagram-login-view.tsx",
    "content": "/* eslint-disable lingui/no-unlocalized-strings */\nimport { Trans } from '@lingui/react/macro';\nimport {\n  Alert,\n  Box,\n  Button,\n  Group,\n  Loader,\n  Paper,\n  PasswordInput,\n  Stack,\n  Stepper,\n  Text,\n  TextInput,\n  Title,\n} from '@mantine/core';\nimport {\n  InstagramAccountData,\n  InstagramOAuthRoutes,\n} from '@postybirb/types';\nimport {\n  IconArrowLeft,\n  IconCheck,\n  IconKey,\n  IconLogin,\n  IconRefresh,\n} from '@tabler/icons-react';\nimport { useCallback, useEffect, useRef, useState } from 'react';\nimport websitesApi from '../../../api/websites.api';\nimport { showSuccessNotification } from '../../../utils';\nimport type { WebviewTag } from '../../sections/accounts-section/webview-tag';\nimport {\n  createLoginHttpErrorHandler,\n  notifyLoginFailed,\n  notifyLoginSuccess,\n} from '../helpers';\nimport { LoginViewContainer } from '../login-view-container';\nimport type { LoginViewProps } from '../types';\nimport { InstagramSetupGuide } from './instagram-setup-guide';\n\nexport default function InstagramLoginView(\n  props: LoginViewProps<InstagramAccountData>,\n): JSX.Element {\n  const { account, data } = props;\n  const { id } = account;\n\n  const [appId, setAppId] = useState(data?.appId ?? '');\n  const [appSecret, setAppSecret] = useState(data?.appSecret ?? '');\n  const [authUrl, setAuthUrl] = useState<string | undefined>(undefined);\n  const [isStoringKeys, setIsStoringKeys] = useState(false);\n  const [isGettingAuthUrl, setIsGettingAuthUrl] = useState(false);\n  const [isExchangingCode, setIsExchangingCode] = useState(false);\n  const [isRefreshing, setIsRefreshing] = useState(false);\n  const [loggedInAs, setLoggedInAs] = useState<string | undefined>(undefined);\n  const [tokenExpiry, setTokenExpiry] = useState<string | undefined>(\n    data?.tokenExpiry,\n  );\n  const [keysStored, setKeysStored] = useState(false);\n  const [activeStep, setActiveStep] = useState(0);\n  const webviewRef = useRef<WebviewTag | null>(null);\n  const codeHandledRef = useRef(false);\n\n  const keysReady = appId.trim().length > 0 && appSecret.trim().length > 0;\n\n  // Reset all state when switching between accounts\n  const prevIdRef = useRef(id);\n  useEffect(() => {\n    if (prevIdRef.current !== id) {\n      prevIdRef.current = id;\n      setAppId(data?.appId ?? '');\n      setAppSecret(data?.appSecret ?? '');\n      setAuthUrl(undefined);\n      setIsStoringKeys(false);\n      setIsGettingAuthUrl(false);\n      setIsExchangingCode(false);\n      setIsRefreshing(false);\n      setLoggedInAs(data?.igUsername ? `@${data.igUsername}` : undefined);\n      setTokenExpiry(data?.tokenExpiry);\n      setKeysStored(!!(data?.appId && data?.appSecret));\n      setActiveStep(0);\n      webviewRef.current = null;\n      codeHandledRef.current = false;\n    }\n  }, [id, data]);\n\n  // Sync from incoming data (e.g. after server-side changes)\n  useEffect(() => {\n    if (data?.igUsername && !loggedInAs) {\n      setLoggedInAs(`@${data.igUsername}`);\n    }\n    if (data?.appId && data?.appSecret && !keysStored) {\n      setKeysStored(true);\n    }\n    if (data?.tokenExpiry) {\n      setTokenExpiry(data.tokenExpiry);\n    }\n  }, [\n    data?.igUsername,\n    data?.appId,\n    data?.appSecret,\n    data?.tokenExpiry,\n    loggedInAs,\n    keysStored,\n  ]);\n\n  const handleGoBack = () => {\n    if (activeStep === 2) {\n      setAuthUrl(undefined);\n      codeHandledRef.current = false;\n      setActiveStep(1);\n    } else if (activeStep === 1) {\n      setAuthUrl(undefined);\n      codeHandledRef.current = false;\n      setKeysStored(false);\n      setActiveStep(0);\n    }\n  };\n\n  const handleStartOver = () => {\n    setKeysStored(false);\n    setAuthUrl(undefined);\n    codeHandledRef.current = false;\n    setLoggedInAs(undefined);\n    setTokenExpiry(undefined);\n    setActiveStep(0);\n  };\n\n  // Exchange the authorization code for tokens\n  const doExchangeCode = useCallback(\n    (code: string) => {\n      if (isExchangingCode) return;\n      setIsExchangingCode(true);\n      websitesApi\n        .performOAuthStep<InstagramOAuthRoutes, 'exchangeCode'>(\n          id,\n          'exchangeCode',\n          { code },\n        )\n        .then((res) => {\n          if (res.success) {\n            notifyLoginSuccess(undefined, account);\n            setAuthUrl(undefined);\n            setActiveStep(3);\n            if (res.igUsername) {\n              setLoggedInAs(`@${res.igUsername}`);\n            }\n            if (res.tokenExpiry) {\n              setTokenExpiry(res.tokenExpiry);\n            }\n          } else {\n            notifyLoginFailed(res.message);\n            // Allow retrying\n            codeHandledRef.current = false;\n          }\n        })\n        .catch(\n          createLoginHttpErrorHandler(\n            <Trans>Failed to complete authorization</Trans>,\n          ),\n        )\n        .finally(() => setIsExchangingCode(false));\n    },\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [id, account, isExchangingCode],\n  );\n\n  // Listen for webview navigation to the callback URL to capture the code\n  useEffect(() => {\n    const webview = webviewRef.current;\n    if (!webview || !authUrl) return undefined;\n\n    const callbackPath = '/api/websites/instagram/callback';\n\n    const handleNavigate = (event: Electron.DidNavigateEvent) => {\n      if (codeHandledRef.current) return;\n\n      try {\n        const url = new URL(event.url);\n        if (url.pathname === callbackPath) {\n          const code = url.searchParams.get('code');\n          const error = url.searchParams.get('error');\n\n          if (code) {\n            codeHandledRef.current = true;\n            doExchangeCode(code);\n          } else if (error) {\n            const desc = url.searchParams.get('error_description') || error;\n            notifyLoginFailed(desc);\n          }\n        }\n      } catch {\n        // Not a valid URL, ignore\n      }\n    };\n\n    webview.addEventListener('did-navigate', handleNavigate);\n    return () => {\n      webview.removeEventListener('did-navigate', handleNavigate);\n    };\n  }, [authUrl, doExchangeCode]);\n\n  const formatExpiry = (iso: string) => {\n    try {\n      return new Date(iso).toLocaleDateString(undefined, {\n        year: 'numeric',\n        month: 'long',\n        day: 'numeric',\n      });\n    } catch {\n      return iso;\n    }\n  };\n\n  return (\n    <LoginViewContainer>\n      <Stack gap=\"lg\">\n        <Title order={3}>\n          <Trans>Instagram Authentication</Trans>\n        </Title>\n\n        {loggedInAs && (\n          <Alert color=\"green\" icon={<IconCheck size={16} />}>\n            <Stack gap=\"xs\">\n              <Group justify=\"space-between\">\n                <Text>\n                  <Trans>Successfully logged in as {loggedInAs}</Trans>\n                </Text>\n                <Button\n                  variant=\"light\"\n                  size=\"compact-sm\"\n                  leftSection={<IconRefresh size={14} />}\n                  onClick={handleStartOver}\n                >\n                  <Trans>Start Over</Trans>\n                </Button>\n              </Group>\n              {tokenExpiry && (\n                <Text size=\"xs\" c=\"dimmed\">\n                  <Trans>\n                    Token expires: {formatExpiry(tokenExpiry)}\n                  </Trans>\n                </Text>\n              )}\n              <Button\n                variant=\"light\"\n                size=\"compact-sm\"\n                color=\"blue\"\n                loading={isRefreshing}\n                onClick={() => {\n                  setIsRefreshing(true);\n                  websitesApi\n                    .performOAuthStep<InstagramOAuthRoutes, 'refreshToken'>(\n                      id,\n                      'refreshToken',\n                      {},\n                    )\n                    .then((res) => {\n                      if (res.success) {\n                        if (res.tokenExpiry) setTokenExpiry(res.tokenExpiry);\n                        showSuccessNotification(\n                          <Trans>Token refreshed successfully</Trans>,\n                        );\n                      } else {\n                        notifyLoginFailed(res.message);\n                      }\n                    })\n                    .catch(\n                      createLoginHttpErrorHandler(\n                        <Trans>Failed to refresh token</Trans>,\n                      ),\n                    )\n                    .finally(() => setIsRefreshing(false));\n                }}\n              >\n                <Trans>Refresh Token</Trans>\n              </Button>\n            </Stack>\n          </Alert>\n        )}\n\n        <Stepper\n          active={activeStep}\n          orientation=\"vertical\"\n          size=\"sm\"\n          completedIcon={<IconCheck size={16} />}\n        >\n          {/* Step 0: Configure App Credentials */}\n          <Stepper.Step\n            label={<Trans>Configure Meta App</Trans>}\n            description={<Trans>Enter your app credentials</Trans>}\n            icon={<IconKey size={16} />}\n          >\n            <Paper p=\"md\" withBorder>\n              <Stack gap=\"md\">\n                <InstagramSetupGuide />\n\n                <TextInput\n                  label={<Trans>App ID</Trans>}\n                  placeholder=\"Enter your Meta App ID\"\n                  required\n                  value={appId}\n                  onChange={(e) => setAppId(e.currentTarget.value.trim())}\n                />\n\n                <PasswordInput\n                  label={<Trans>App Secret</Trans>}\n                  placeholder=\"Enter your Meta App Secret\"\n                  required\n                  value={appSecret}\n                  onChange={(e) => setAppSecret(e.currentTarget.value.trim())}\n                  description={\n                    <Trans>Keep this secret and never share it publicly</Trans>\n                  }\n                />\n\n                <Group>\n                  <Button\n                    disabled={!keysReady}\n                    loading={isStoringKeys}\n                    onClick={() => {\n                      setIsStoringKeys(true);\n                      websitesApi\n                        .performOAuthStep<\n                          InstagramOAuthRoutes,\n                          'setAppCredentials'\n                        >(id, 'setAppCredentials', { appId, appSecret })\n                        .then(() => {\n                          setKeysStored(true);\n                          setActiveStep(1);\n                          showSuccessNotification(\n                            <Trans>App credentials saved</Trans>,\n                          );\n                        })\n                        .catch(\n                          createLoginHttpErrorHandler(\n                            <Trans>Failed to store app credentials</Trans>,\n                          ),\n                        )\n                        .finally(() => setIsStoringKeys(false));\n                    }}\n                  >\n                    {keysStored ? (\n                      <Trans>Update Credentials</Trans>\n                    ) : (\n                      <Trans>Save Credentials</Trans>\n                    )}\n                  </Button>\n\n                  {keysStored && (\n                    <Button variant=\"light\" onClick={() => setActiveStep(1)}>\n                      <Trans>Proceed to Authorization</Trans>\n                    </Button>\n                  )}\n                </Group>\n              </Stack>\n            </Paper>\n          </Stepper.Step>\n\n          {/* Step 1: Authorize via embedded webview */}\n          <Stepper.Step\n            label={<Trans>Authorize</Trans>}\n            description={<Trans>Log in to Instagram to grant access</Trans>}\n            icon={<IconLogin size={16} />}\n          >\n            <Stack gap=\"md\">\n              {!authUrl && (\n                <Paper p=\"md\" withBorder>\n                  <Stack gap=\"md\">\n                    <Alert color=\"blue\" variant=\"light\">\n                      <Text size=\"sm\">\n                        <Trans>\n                          Click below to start authorization. An Instagram login\n                          window will appear — log in and grant permissions. The\n                          code will be captured automatically.\n                        </Trans>\n                      </Text>\n                    </Alert>\n\n                    <Button\n                      loading={isGettingAuthUrl}\n                      leftSection={<IconLogin size={16} />}\n                      onClick={() => {\n                        setIsGettingAuthUrl(true);\n                        codeHandledRef.current = false;\n                        websitesApi\n                          .performOAuthStep<\n                            InstagramOAuthRoutes,\n                            'getAuthUrl'\n                          >(id, 'getAuthUrl', {})\n                          .then((res) => {\n                            if (res.success && res.url) {\n                              setAuthUrl(res.url);\n                            } else {\n                              notifyLoginFailed(res.message);\n                            }\n                          })\n                          .catch(\n                            createLoginHttpErrorHandler(\n                              <Trans>Failed to generate auth URL</Trans>,\n                            ),\n                          )\n                          .finally(() => setIsGettingAuthUrl(false));\n                      }}\n                    >\n                      <Trans>Start Instagram Authorization</Trans>\n                    </Button>\n                  </Stack>\n                </Paper>\n              )}\n\n              {authUrl && (\n                <Box\n                  style={{\n                    height: 500,\n                    borderRadius: 8,\n                    overflow: 'hidden',\n                    border: '1px solid var(--mantine-color-default-border)',\n                    position: 'relative',\n                  }}\n                >\n                  {isExchangingCode && (\n                    <Box\n                      style={{\n                        position: 'absolute',\n                        inset: 0,\n                        display: 'flex',\n                        alignItems: 'center',\n                        justifyContent: 'center',\n                        background: 'rgba(255,255,255,0.85)',\n                        zIndex: 10,\n                      }}\n                    >\n                      <Stack align=\"center\" gap=\"sm\">\n                        <Loader size=\"lg\" />\n                        <Text size=\"sm\">\n                          <Trans>Completing authorization...</Trans>\n                        </Text>\n                      </Stack>\n                    </Box>\n                  )}\n                  <webview\n                    src={authUrl}\n                    ref={(ref) => {\n                      webviewRef.current = ref as WebviewTag;\n                    }}\n                    style={{ width: '100%', height: '100%' }}\n                    // eslint-disable-next-line react/no-unknown-property\n                    partition={`persist:instagram-oauth-${id}`}\n                  />\n                </Box>\n              )}\n            </Stack>\n          </Stepper.Step>\n        </Stepper>\n\n        {/* Navigation Controls */}\n        <Group justify=\"space-between\" mt=\"md\">\n          <Button\n            variant=\"light\"\n            leftSection={<IconArrowLeft size={16} />}\n            onClick={handleGoBack}\n            disabled={activeStep === 0}\n          >\n            <Trans>Back</Trans>\n          </Button>\n\n          <Button\n            leftSection={<IconRefresh size={16} />}\n            onClick={handleStartOver}\n            color=\"red\"\n            variant=\"light\"\n          >\n            <Trans>Start Over</Trans>\n          </Button>\n        </Group>\n      </Stack>\n    </LoginViewContainer>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/website-login-views/instagram/instagram-setup-guide.tsx",
    "content": "/* eslint-disable lingui/no-unlocalized-strings */\nimport {\n    Accordion,\n    Badge,\n    Checkbox,\n    Code,\n    Divider,\n    Group,\n    Paper,\n    Progress,\n    Stack,\n    Text,\n    ThemeIcon,\n    Title,\n} from '@mantine/core';\nimport {\n    IconCircleCheck,\n    IconKey,\n    IconSettings,\n    IconUserCheck,\n} from '@tabler/icons-react';\nimport { useCallback, useMemo, useState } from 'react';\nimport { ExternalLink } from '../../shared/external-link';\n\ninterface SetupStep {\n  id: string;\n  label: string;\n  bold?: string[];\n}\n\ninterface SetupSection {\n  id: string;\n  title: string;\n  subtitle: string;\n  icon: React.ReactNode;\n  steps: SetupStep[];\n}\n\nconst REDIRECT_URL = `https://localhost:${window.electron?.app_port || '9487'}/api/websites/instagram/callback`;\n\nconst SETUP_SECTIONS: SetupSection[] = [\n  {\n    id: 'app-creation',\n    title: 'Step 1: App Creation',\n    subtitle: 'Create a new Meta app for Instagram',\n    icon: <IconSettings size={18} />,\n    steps: [\n      {\n        id: '1-1',\n        label: 'Go to Meta Developer Apps and click \"Create App\"',\n      },\n      { id: '1-2', label: 'Provide your App Name' },\n      { id: '1-3', label: 'Provide your Email address' },\n      { id: '1-4', label: 'Click \"Next\"' },\n      {\n        id: '1-5',\n        label: 'Under Use Cases, select \"Content management\"',\n      },\n      {\n        id: '1-6',\n        label: 'Select \"Manage messaging & content on Instagram\"',\n      },\n      { id: '1-7', label: 'Click \"Next\"' },\n      {\n        id: '1-8',\n        label:\n          'Click the checkbox \"I don\\'t want to connect a business portfolio yet.\"',\n      },\n      { id: '1-9', label: 'Click \"Next\"' },\n      { id: '1-10', label: 'Click \"Next\" again' },\n      { id: '1-11', label: 'Click \"Create App\"' },\n    ],\n  },\n  {\n    id: 'keys-permissions',\n    title: 'Step 2.1: Getting Keys & Permissions',\n    subtitle: 'Gather your App ID and Secret',\n    icon: <IconKey size={18} />,\n    steps: [\n      {\n        id: '2a-1',\n        label:\n          'Open a text editor to save your App ID and Instagram App Secret',\n      },\n      {\n        id: '2a-2',\n        label:\n          'Click \"Customize the Manage messaging & content on Instagram use case\"',\n      },\n      { id: '2a-3', label: 'Copy your Instagram App ID' },\n      {\n        id: '2a-4',\n        label: 'Click \"Show\" on Instagram App Secret',\n      },\n      { id: '2a-5', label: 'Copy your Instagram App Secret' },\n      { id: '2a-6', label: 'Click \"Add all required permissions\"' },\n    ],\n  },\n  {\n    id: 'access-tokens',\n    title: 'Step 2.2: Generate Access Tokens',\n    subtitle: 'Add your account as a tester and authorize',\n    icon: <IconUserCheck size={18} />,\n    steps: [\n      { id: '2b-1', label: 'Click the \"Roles\" hyperlink' },\n      { id: '2b-2', label: 'Click \"Add People\"' },\n      {\n        id: '2b-3',\n        label:\n          'In the \"Add people to your app\" popup, click \"Instagram Tester\"',\n      },\n      {\n        id: '2b-4',\n        label: 'Enter the username of your Instagram account',\n      },\n      { id: '2b-5', label: 'Click \"Add\"' },\n      {\n        id: '2b-6',\n        label: 'Verify the status shows as \"Pending\"',\n      },\n      {\n        id: '2b-7',\n        label: 'Click the \"Apps and Websites\" hyperlink',\n      },\n      { id: '2b-8', label: 'Click on the \"Tester Invites\" tab' },\n      { id: '2b-9', label: 'Click \"Accept\"' },\n      {\n        id: '2b-10',\n        label:\n          'Refresh the App Roles page to verify the \"Pending\" status is gone',\n      },\n      { id: '2b-11', label: 'Go back to the App Setup page' },\n      { id: '2b-12', label: 'Click \"Add account\"' },\n      { id: '2b-13', label: 'Click \"Continue\"' },\n      { id: '2b-14', label: 'Log in to your Instagram account' },\n      { id: '2b-15', label: 'Click \"Allow\"' },\n    ],\n  },\n  {\n    id: 'business-login',\n    title: 'Step 2.3: Set Up Instagram Business Login',\n    subtitle: 'Configure the redirect URL',\n    icon: <IconSettings size={18} />,\n    steps: [\n      { id: '2c-1', label: 'Click \"Set up\"' },\n      {\n        id: '2c-2',\n        label: `Paste this into the Redirect URL field:`,\n      },\n      { id: '2c-3', label: 'Click \"Save\"' },\n    ],\n  },\n];\n\nconst STORAGE_KEY = 'instagram-setup-checklist';\n\nfunction loadCheckedState(): Record<string, boolean> {\n  try {\n    const stored = localStorage.getItem(STORAGE_KEY);\n    return stored ? JSON.parse(stored) : {};\n  } catch {\n    return {};\n  }\n}\n\nfunction saveCheckedState(state: Record<string, boolean>) {\n  try {\n    localStorage.setItem(STORAGE_KEY, JSON.stringify(state));\n  } catch {\n    // Ignore storage errors\n  }\n}\n\nexport function InstagramSetupGuide() {\n  const [checked, setChecked] =\n    useState<Record<string, boolean>>(loadCheckedState);\n\n  const toggleStep = useCallback((stepId: string) => {\n    setChecked((prev) => {\n      const next = { ...prev, [stepId]: !prev[stepId] };\n      saveCheckedState(next);\n      return next;\n    });\n  }, []);\n\n  const totalSteps = useMemo(\n    () => SETUP_SECTIONS.reduce((sum, s) => sum + s.steps.length, 0),\n    [],\n  );\n\n  const completedSteps = useMemo(\n    () =>\n      SETUP_SECTIONS.reduce(\n        (sum, s) => sum + s.steps.filter((step) => checked[step.id]).length,\n        0,\n      ),\n    [checked],\n  );\n\n  const progressPct = totalSteps > 0 ? (completedSteps / totalSteps) * 100 : 0;\n\n  const sectionProgress = useCallback(\n    (section: SetupSection) => {\n      const done = section.steps.filter((s) => checked[s.id]).length;\n      return { done, total: section.steps.length };\n    },\n    [checked],\n  );\n\n  const allDone = completedSteps === totalSteps;\n\n  return (\n    <Paper p=\"md\" withBorder>\n      <Stack gap=\"md\">\n        <Group justify=\"space-between\" align=\"flex-start\">\n          <div>\n            <Title order={5}>Meta App Setup Guide</Title>\n            <Text size=\"xs\" c=\"dimmed\">\n              Complete these steps in the Meta Developer portal before\n              continuing\n            </Text>\n          </div>\n          <Badge color={allDone ? 'green' : 'blue'} variant=\"light\" size=\"lg\">\n            {completedSteps} / {totalSteps}\n          </Badge>\n        </Group>\n\n        <Progress\n          value={progressPct}\n          color={allDone ? 'green' : 'blue'}\n          size=\"sm\"\n          radius=\"xl\"\n          animated={!allDone && completedSteps > 0}\n        />\n\n        {allDone && (\n          <Group gap=\"xs\">\n            <ThemeIcon color=\"green\" variant=\"light\" size=\"sm\">\n              <IconCircleCheck size={14} />\n            </ThemeIcon>\n            <Text size=\"sm\" c=\"green\" fw={600}>\n              All setup steps completed! Enter your credentials below.\n            </Text>\n          </Group>\n        )}\n\n        <Text size=\"sm\">\n          {'Start by visiting '}\n          <ExternalLink href=\"https://developers.facebook.com/apps/\">\n            Meta Developer Apps\n          </ExternalLink>\n          {' and follow the steps below.'}\n        </Text>\n\n        <Text size=\"sm\" c=\"orange\">\n          {'Your Instagram account must be a '}\n          <Text span fw={600}>\n            Professional account\n          </Text>\n          {\n            ' (Business or Creator). You can convert in Instagram app → Settings → Account → Switch to Professional account.'\n          }\n        </Text>\n\n        <Divider />\n\n        <Accordion variant=\"separated\" multiple defaultValue={['app-creation']}>\n          {SETUP_SECTIONS.map((section) => {\n            const { done, total } = sectionProgress(section);\n            const sectionDone = done === total;\n\n            return (\n              <Accordion.Item key={section.id} value={section.id}>\n                <Accordion.Control\n                  icon={\n                    <ThemeIcon\n                      color={sectionDone ? 'green' : 'blue'}\n                      variant=\"light\"\n                      size=\"md\"\n                    >\n                      {sectionDone ? (\n                        <IconCircleCheck size={16} />\n                      ) : (\n                        section.icon\n                      )}\n                    </ThemeIcon>\n                  }\n                >\n                  <Group justify=\"space-between\" pr=\"sm\">\n                    <div>\n                      <Text size=\"sm\" fw={600}>\n                        {section.title}\n                      </Text>\n                      <Text size=\"xs\" c=\"dimmed\">\n                        {section.subtitle}\n                      </Text>\n                    </div>\n                    <Badge\n                      color={sectionDone ? 'green' : 'gray'}\n                      variant=\"light\"\n                      size=\"sm\"\n                    >\n                      {done}/{total}\n                    </Badge>\n                  </Group>\n                </Accordion.Control>\n                <Accordion.Panel>\n                  <Stack gap=\"xs\">\n                    {section.steps.map((step) => (\n                      <div key={step.id}>\n                        <Checkbox\n                          label={\n                            <Text\n                              size=\"sm\"\n                              td={checked[step.id] ? 'line-through' : undefined}\n                              c={checked[step.id] ? 'dimmed' : undefined}\n                            >\n                              {step.label}\n                            </Text>\n                          }\n                          checked={!!checked[step.id]}\n                          onChange={() => toggleStep(step.id)}\n                          styles={{\n                            root: {\n                              padding: '4px 0',\n                            },\n                          }}\n                        />\n                        {/* Show redirect URL inline for step 2c-2 */}\n                        {step.id === '2c-2' && (\n                          <Code\n                            block\n                            style={{\n                              marginTop: 4,\n                              marginLeft: 30,\n                              fontSize: 12,\n                              userSelect: 'all',\n                            }}\n                          >\n                            {REDIRECT_URL}\n                          </Code>\n                        )}\n                      </div>\n                    ))}\n                  </Stack>\n                </Accordion.Panel>\n              </Accordion.Item>\n            );\n          })}\n        </Accordion>\n      </Stack>\n    </Paper>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/website-login-views/login-view-container.tsx",
    "content": "import { Box, type BoxProps } from '@mantine/core';\nimport type { ReactNode } from 'react';\n\nexport interface LoginViewContainerProps extends BoxProps {\n  children: ReactNode;\n}\n\n/**\n * A consistent container wrapper for all login view components.\n * Provides max-width constraint and horizontal centering\n * to ensure login forms don't stretch edge-to-edge in the content area.\n * Note: Padding is handled by the parent scrollable container.\n */\nexport function LoginViewContainer({\n  children,\n  ...boxProps\n}: LoginViewContainerProps): JSX.Element {\n  return (\n    <Box maw={500} mx=\"auto\" {...boxProps}>\n      {children}\n    </Box>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/website-login-views/megalodon/index.ts",
    "content": "export { default as MegalodonLoginView } from './megalodon-login-view';\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/website-login-views/megalodon/megalodon-login-view.tsx",
    "content": "import { Trans } from '@lingui/react/macro';\nimport {\n  Alert,\n  Button,\n  Paper,\n  Stack,\n  Stepper,\n  Text,\n  TextInput,\n  Title,\n} from '@mantine/core';\nimport { MegalodonAccountData, MegalodonOAuthRoutes } from '@postybirb/types';\nimport { IconCheck, IconExternalLink, IconServer } from '@tabler/icons-react';\nimport { useState } from 'react';\nimport websitesApi from '../../../api/websites.api';\nimport { showSuccessNotification } from '../../../utils';\nimport { ExternalLink } from '../../shared/external-link';\nimport { createLoginHttpErrorHandler, notifyLoginSuccess } from '../helpers';\nimport { LoginViewContainer } from '../login-view-container';\nimport type { LoginViewProps } from '../types';\n\nconst AUTH_CODE_PLACEHOLDER = 'Authorization code';\n\nexport default function MegalodonLoginView(\n  props: LoginViewProps<MegalodonAccountData>,\n): JSX.Element {\n  const { account, data } = props;\n  const { id } = account;\n\n  const [instanceUrl, setInstanceUrl] = useState(data?.instanceUrl ?? '');\n  const [authorizationUrl, setAuthorizationUrl] = useState<string>('');\n  const [authCode, setAuthCode] = useState('');\n  const [isRegistering, setIsRegistering] = useState(false);\n  const [isCompleting, setIsCompleting] = useState(false);\n  const [loggedInAs, setLoggedInAs] = useState<string | undefined>(\n    data?.username,\n  );\n  const [activeStep, setActiveStep] = useState(loggedInAs ? 2 : 0);\n\n  const canRegister = instanceUrl.trim().length > 0;\n  const canComplete = authCode.trim().length > 0;\n\n  const handleStartOver = () => {\n    setInstanceUrl('');\n    setAuthorizationUrl('');\n    setAuthCode('');\n    setLoggedInAs(undefined);\n    setActiveStep(0);\n  };\n\n  return (\n    <LoginViewContainer>\n      <Stack gap=\"lg\">\n        <Title order={3}>\n          <Trans>Fediverse Authentication</Trans>\n        </Title>\n\n        {loggedInAs && (\n          <Alert color=\"green\" icon={<IconCheck size={16} />}>\n            <Text>\n              <Trans>Successfully logged in as {loggedInAs}</Trans>\n            </Text>\n            <Button\n              size=\"xs\"\n              variant=\"subtle\"\n              onClick={handleStartOver}\n              mt=\"xs\"\n            >\n              <Trans>Log in to different instance</Trans>\n            </Button>\n          </Alert>\n        )}\n\n        <Stepper active={activeStep} orientation=\"vertical\" size=\"sm\">\n          <Stepper.Step\n            label={<Trans>Enter Instance URL</Trans>}\n            description={<Trans>Specify your Fediverse instance</Trans>}\n            icon={<IconServer size={16} />}\n          >\n            <Paper p=\"md\" withBorder>\n              <Stack gap=\"md\">\n                <Alert color=\"blue\" variant=\"light\">\n                  <Text size=\"sm\">\n                    <Trans>\n                      Enter the URL of your Mastodon, Pleroma, or Pixelfed\n                      instance (e.g., &quot;mastodon.social&quot; or\n                      &quot;pixelfed.social&quot;)\n                    </Trans>\n                  </Text>\n                </Alert>\n\n                <TextInput\n                  label={<Trans>Instance URL</Trans>}\n                  placeholder=\"mastodon.social\"\n                  required\n                  value={instanceUrl}\n                  onChange={(e) => setInstanceUrl(e.currentTarget.value.trim())}\n                />\n\n                <Button\n                  disabled={!canRegister}\n                  loading={isRegistering}\n                  onClick={() => {\n                    setIsRegistering(true);\n                    const cleanedUrl = instanceUrl\n                      .replace(/^https?:\\/\\//, '')\n                      .replace(/\\/$/, '');\n                    websitesApi\n                      .performOAuthStep<MegalodonOAuthRoutes, 'registerApp'>(\n                        id,\n                        'registerApp',\n                        { instanceUrl: cleanedUrl },\n                      )\n                      .then((res) => {\n                        if (res.success && res.authorizationUrl) {\n                          setAuthorizationUrl(res.authorizationUrl);\n                          setActiveStep(1);\n                          showSuccessNotification(\n                            <Trans>App registered successfully</Trans>,\n                          );\n                        } else if (!res.success && res.message) {\n                          throw new Error(res.message);\n                        }\n                      })\n                      .catch(\n                        createLoginHttpErrorHandler(\n                          <Trans>Failed to register with instance</Trans>,\n                        ),\n                      )\n                      .finally(() => setIsRegistering(false));\n                  }}\n                >\n                  <Trans>Register Instance</Trans>\n                </Button>\n              </Stack>\n            </Paper>\n          </Stepper.Step>\n\n          <Stepper.Step\n            label={<Trans>Authorize</Trans>}\n            description={<Trans>Get authorization code</Trans>}\n            icon={<IconExternalLink size={16} />}\n          >\n            <Paper p=\"md\" withBorder>\n              <Stack gap=\"md\">\n                {authorizationUrl && (\n                  <Alert color=\"blue\" variant=\"light\">\n                    <ExternalLink href={authorizationUrl}>\n                      <Trans>Click here to authorize PostyBirb</Trans>\n                    </ExternalLink>\n                    <Text size=\"sm\" mt=\"xs\">\n                      <Trans>\n                        After authorizing, copy the code and paste it below\n                      </Trans>\n                    </Text>\n                  </Alert>\n                )}\n\n                <TextInput\n                  label={<Trans>Authorization Code</Trans>}\n                  placeholder={AUTH_CODE_PLACEHOLDER}\n                  value={authCode}\n                  required\n                  onChange={(e) => setAuthCode(e.currentTarget.value.trim())}\n                />\n\n                <Button\n                  disabled={!canComplete}\n                  loading={isCompleting}\n                  onClick={() => {\n                    setIsCompleting(true);\n                    websitesApi\n                      .performOAuthStep<MegalodonOAuthRoutes, 'completeOAuth'>(\n                        id,\n                        'completeOAuth',\n                        { authCode },\n                      )\n                      .then((res) => {\n                        if (res.success && res.username) {\n                          setLoggedInAs(res.username);\n                          setActiveStep(2);\n                          notifyLoginSuccess(undefined, account);\n                        } else if (!res.success && res.message) {\n                          throw new Error(res.message);\n                        }\n                      })\n                      .catch(createLoginHttpErrorHandler())\n                      .finally(() => setIsCompleting(false));\n                  }}\n                >\n                  <Trans>Login</Trans>\n                </Button>\n\n                <Button\n                  variant=\"subtle\"\n                  onClick={() => {\n                    setActiveStep(0);\n                    setAuthCode('');\n                  }}\n                >\n                  <Trans>Go back</Trans>\n                </Button>\n              </Stack>\n            </Paper>\n          </Stepper.Step>\n\n          <Stepper.Completed>\n            <Paper p=\"md\" withBorder>\n              <Alert color=\"green\">\n                <Trans>Logged in</Trans>\n              </Alert>\n            </Paper>\n          </Stepper.Completed>\n        </Stepper>\n      </Stack>\n    </LoginViewContainer>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/website-login-views/misskey/index.ts",
    "content": "export { default as MisskeyLoginView } from './misskey-login-view';\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/website-login-views/misskey/misskey-login-view.tsx",
    "content": "import { Trans } from '@lingui/react/macro';\nimport {\n    Alert,\n    Button,\n    Paper,\n    Stack,\n    Stepper,\n    Text,\n    TextInput,\n    Title,\n} from '@mantine/core';\nimport { MisskeyAccountData, MisskeyOAuthRoutes } from '@postybirb/types';\nimport { IconCheck, IconExternalLink, IconServer } from '@tabler/icons-react';\nimport { useState } from 'react';\nimport websitesApi from '../../../api/websites.api';\nimport { showSuccessNotification } from '../../../utils';\nimport { ExternalLink } from '../../shared/external-link';\nimport { createLoginHttpErrorHandler, notifyLoginSuccess } from '../helpers';\nimport { LoginViewContainer } from '../login-view-container';\nimport type { LoginViewProps } from '../types';\n\nexport default function MisskeyLoginView(\n  props: LoginViewProps<MisskeyAccountData>,\n): JSX.Element {\n  const { account, data } = props;\n  const { id } = account;\n\n  const [instanceUrl, setInstanceUrl] = useState(data?.instanceUrl ?? '');\n  const [authUrl, setAuthUrl] = useState<string>('');\n  const [isGenerating, setIsGenerating] = useState(false);\n  const [isCompleting, setIsCompleting] = useState(false);\n  const [loggedInAs, setLoggedInAs] = useState<string | undefined>(\n    data?.username,\n  );\n  const [activeStep, setActiveStep] = useState(loggedInAs ? 2 : 0);\n\n  const canGenerate = instanceUrl.trim().length > 0;\n\n  const handleStartOver = () => {\n    setInstanceUrl('');\n    setAuthUrl('');\n    setLoggedInAs(undefined);\n    setActiveStep(0);\n  };\n\n  return (\n    <LoginViewContainer>\n      <Stack gap=\"lg\">\n        <Title order={3}>\n          <Trans>Misskey Authentication</Trans>\n        </Title>\n\n        {loggedInAs && (\n          <Alert color=\"green\" icon={<IconCheck size={16} />}>\n            <Text>\n              <Trans>Successfully logged in as {loggedInAs}</Trans>\n            </Text>\n            <Button\n              size=\"xs\"\n              variant=\"subtle\"\n              onClick={handleStartOver}\n              mt=\"xs\"\n            >\n              <Trans>Log in to different instance</Trans>\n            </Button>\n          </Alert>\n        )}\n\n        <Stepper active={activeStep} orientation=\"vertical\" size=\"sm\">\n          <Stepper.Step\n            label={<Trans>Enter Instance URL</Trans>}\n            description={<Trans>Specify your Misskey instance</Trans>}\n            icon={<IconServer size={16} />}\n          >\n            <Paper p=\"md\" withBorder>\n              <Stack gap=\"md\">\n                <Alert color=\"blue\" variant=\"light\">\n                  <Text size=\"sm\">\n                    <Trans>\n                      Enter the URL of your Misskey instance (e.g.,\n                      &quot;misskey.io&quot; or &quot;sharkey.example.com&quot;).\n                      Works with Misskey and compatible forks.\n                    </Trans>\n                  </Text>\n                </Alert>\n\n                <TextInput\n                  label={<Trans>Instance URL</Trans>}\n                  placeholder=\"misskey.io\"\n                  required\n                  value={instanceUrl}\n                  onChange={(e) => setInstanceUrl(e.currentTarget.value.trim())}\n                />\n\n                <Button\n                  disabled={!canGenerate}\n                  loading={isGenerating}\n                  onClick={() => {\n                    setIsGenerating(true);\n                    const cleanedUrl = instanceUrl\n                      .replace(/^https?:\\/\\//, '')\n                      .replace(/\\/$/, '');\n                    websitesApi\n                      .performOAuthStep<\n                        MisskeyOAuthRoutes,\n                        'generateAuthUrl'\n                      >(id, 'generateAuthUrl', { instanceUrl: cleanedUrl })\n                      .then((res) => {\n                        if (res.success && res.authUrl) {\n                          setAuthUrl(res.authUrl);\n                          setActiveStep(1);\n                          showSuccessNotification(\n                            <Trans>Authorization URL generated</Trans>,\n                          );\n                        } else if (!res.success && res.message) {\n                          throw new Error(res.message);\n                        }\n                      })\n                      .catch(\n                        createLoginHttpErrorHandler(\n                          <Trans>Failed to connect to instance</Trans>,\n                        ),\n                      )\n                      .finally(() => setIsGenerating(false));\n                  }}\n                >\n                  <Trans>Connect to Instance</Trans>\n                </Button>\n              </Stack>\n            </Paper>\n          </Stepper.Step>\n\n          <Stepper.Step\n            label={<Trans>Authorize</Trans>}\n            description={\n              <Trans>Authorize PostyBirb on your instance</Trans>\n            }\n            icon={<IconExternalLink size={16} />}\n          >\n            <Paper p=\"md\" withBorder>\n              <Stack gap=\"md\">\n                {authUrl && (\n                  <Alert color=\"blue\" variant=\"light\">\n                    <ExternalLink href={authUrl}>\n                      <Trans>Click here to authorize PostyBirb</Trans>\n                    </ExternalLink>\n                    <Text size=\"sm\" mt=\"xs\">\n                      <Trans>\n                        After clicking &quot;Allow&quot; on your instance, come\n                        back here and click &quot;Complete Login&quot;\n                      </Trans>\n                    </Text>\n                  </Alert>\n                )}\n\n                <Button\n                  loading={isCompleting}\n                  onClick={() => {\n                    setIsCompleting(true);\n                    websitesApi\n                      .performOAuthStep<MisskeyOAuthRoutes, 'completeAuth'>(\n                        id,\n                        'completeAuth',\n                        {},\n                      )\n                      .then((res) => {\n                        if (res.success && res.username) {\n                          setLoggedInAs(res.username);\n                          setActiveStep(2);\n                          notifyLoginSuccess(undefined, account);\n                        } else if (!res.success && res.message) {\n                          throw new Error(res.message);\n                        }\n                      })\n                      .catch(createLoginHttpErrorHandler())\n                      .finally(() => setIsCompleting(false));\n                  }}\n                >\n                  <Trans>Complete Login</Trans>\n                </Button>\n\n                <Button\n                  variant=\"subtle\"\n                  onClick={() => {\n                    setActiveStep(0);\n                    setAuthUrl('');\n                  }}\n                >\n                  <Trans>Go back</Trans>\n                </Button>\n              </Stack>\n            </Paper>\n          </Stepper.Step>\n\n          <Stepper.Completed>\n            <Paper p=\"md\" withBorder>\n              <Alert color=\"green\">\n                <Trans>Logged in</Trans>\n              </Alert>\n            </Paper>\n          </Stepper.Completed>\n        </Stepper>\n      </Stack>\n    </LoginViewContainer>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/website-login-views/telegram/index.ts",
    "content": "export { default as TelegramLoginView } from './telegram-login-view';\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/website-login-views/telegram/telegram-login-view.tsx",
    "content": "import { Trans } from '@lingui/react/macro';\nimport {\n  Alert,\n  Box,\n  Button,\n  NumberInput,\n  Stack,\n  Text,\n  TextInput,\n} from '@mantine/core';\nimport { TelegramAccountData, TelegramOAuthRoutes } from '@postybirb/types';\nimport { IconInfoCircle } from '@tabler/icons-react';\nimport { useState } from 'react';\nimport websitesApi from '../../../api/websites.api';\nimport { ExternalLink } from '../../shared/external-link';\nimport {\n  createLoginHttpErrorHandler,\n  notifyInfo,\n  notifyLoginFailed,\n  notifyLoginSuccess,\n} from '../helpers';\nimport { LoginViewContainer } from '../login-view-container';\nimport type { LoginViewProps } from '../types';\n\nexport default function TelegramLoginView(\n  props: LoginViewProps<TelegramAccountData>,\n): JSX.Element {\n  const { account, data } = props;\n  const { id } = account;\n  const [phoneNumber, setPhoneNumber] = useState(data?.phoneNumber ?? '');\n  const [appHash, setAppHash] = useState(data?.appHash ?? '');\n  const [appId, setAppId] = useState(data?.appId);\n  const [code, setCode] = useState('');\n  const [displayCodeDialog, setDisplayCodeDialog] = useState(false);\n  const [password, setPassword] = useState('');\n  const [passwordRequired, setPasswordRequired] = useState(false);\n  const [passwordInvalid, setPasswordInvalid] = useState(false);\n  const [codeInvalid, setCodeInvalid] = useState(false);\n\n  const [isSendingCode, setIsSendingCode] = useState(false);\n  const [isAuthenticating, setIsAuthenticating] = useState(false);\n\n  const isValid = !!appId && !!appHash && !!phoneNumber;\n\n  return (\n    <LoginViewContainer>\n      <Stack gap=\"md\">\n        <Alert icon={<IconInfoCircle size={16} />} color=\"blue\" variant=\"light\">\n          <Text size=\"sm\">\n            <Trans>\n              Telegram requires API credentials and phone number authentication.\n              You'll need to create a Telegram app and verify your phone number.\n            </Trans>\n          </Text>\n        </Alert>\n\n        <NumberInput\n          label={<Trans>App API ID</Trans>}\n          name=\"appId\"\n          required\n          minLength={1}\n          defaultValue={appId || undefined}\n          description={\n            <ExternalLink href=\"https://core.telegram.org/myapp\">\n              <Trans context=\"telegram.app-id-help\">\n                Create telegram app to retrieve api_id and api_hash\n              </Trans>\n            </ExternalLink>\n          }\n          onChange={(event) => {\n            setAppId(Number(event));\n          }}\n        />\n\n        <TextInput\n          label={<Trans>App API Hash</Trans>}\n          required\n          defaultValue={appHash}\n          minLength={1}\n          onBlur={(event) => {\n            setAppHash(event.currentTarget.value.trim());\n          }}\n        />\n\n        <TextInput\n          label={<Trans>Phone Number</Trans>}\n          defaultValue={phoneNumber}\n          required\n          description={\n            <ExternalLink href=\"https://www.twilio.com/docs/glossary/what-e164\">\n              <Trans context=\"telegram.phone-number-help\">\n                Phone number must be in international format\n              </Trans>\n            </ExternalLink>\n          }\n          onChange={(event) => {\n            setPhoneNumber(event.currentTarget.value.replace(/[^0-9+]/g, ''));\n          }}\n        />\n\n        <Box mt=\"md\">\n          <Button\n            loading={isSendingCode}\n            disabled={!isValid}\n            fullWidth\n            onClick={(event) => {\n              event.preventDefault();\n              setIsSendingCode(true);\n\n              websitesApi\n                .performOAuthStep<TelegramOAuthRoutes, 'startAuthentication'>(\n                  id,\n                  'startAuthentication',\n                  { appId: appId as number, phoneNumber, appHash },\n                )\n                .catch(\n                  createLoginHttpErrorHandler(\n                    <Trans>Failed to send code to begin authentication</Trans>,\n                  ),\n                )\n                .then(() => {\n                  setDisplayCodeDialog(true);\n                  notifyInfo(\n                    <Trans>Code sent</Trans>,\n                    <Trans>Check your telegram messages</Trans>,\n                  );\n                })\n                .finally(() => setIsSendingCode(false));\n            }}\n          >\n            <Trans>Send Code</Trans>\n          </Button>\n        </Box>\n      </Stack>\n\n      {displayCodeDialog && (\n        <Stack gap=\"md\" mt=\"md\">\n          <TextInput\n            label={<Trans>Code</Trans>}\n            autoFocus\n            required\n            value={code}\n            error={\n              codeInvalid && (\n                <Trans>\n                  Code invalid, ensure it matches the code sent from telegram\n                </Trans>\n              )\n            }\n            onChange={(event) => {\n              setCodeInvalid(false);\n              setCode(event.currentTarget.value.trim());\n            }}\n            minLength={1}\n          />\n\n          <TextInput\n            label={<Trans>Cloud Password</Trans>}\n            type=\"password\"\n            defaultValue={password}\n            error={passwordInvalid && <Trans>Password is invalid</Trans>}\n            minLength={1}\n            description={\n              passwordRequired ? (\n                <Trans>\n                  2FA enabled, password required. It won't be stored.\n                </Trans>\n              ) : (\n                <Trans>Required if 2FA is enabled.</Trans>\n              )\n            }\n            required={passwordRequired}\n            onBlur={(event) => {\n              setPasswordInvalid(false);\n              setPassword(event.currentTarget.value.trim());\n            }}\n          />\n\n          <Box mt=\"md\">\n            <Button\n              loading={isAuthenticating}\n              disabled={!code}\n              fullWidth\n              onClick={() => {\n                submit();\n\n                function submit() {\n                  setIsAuthenticating(true);\n                  websitesApi\n                    .performOAuthStep<TelegramOAuthRoutes, 'authenticate'>(\n                      id,\n                      'authenticate',\n                      {\n                        appHash,\n                        appId: appId as number,\n                        phoneNumber,\n                        password,\n                        code,\n                      },\n                    )\n                    .then((res) => {\n                      if (!res) return;\n\n                      if (res.success) {\n                        notifyLoginSuccess(undefined, account);\n                        setPassword('');\n                        setIsAuthenticating(false);\n                      } else {\n                        if (res.passwordRequired) {\n                          setPasswordRequired(true);\n\n                          // Send new code without closing dialog\n                          // Don't do it without immediate because isAuthenticated will be overriden by Promise.finally\n                          setImmediate(submit);\n                        }\n\n                        if (res.passwordInvalid) {\n                          setPasswordInvalid(true);\n                        }\n\n                        // For some reason if password is required it replies with both errors\n                        if (res.codeInvalid && !res.passwordInvalid) {\n                          setCodeInvalid(true);\n                        }\n\n                        notifyLoginFailed(res.message);\n                      }\n                    })\n                    .catch(\n                      createLoginHttpErrorHandler(\n                        <Trans>Error while authenticating Telegram</Trans>,\n                      ),\n                    )\n                    .finally(() => {\n                      setCode('');\n                      setIsAuthenticating(false);\n                    });\n                }\n              }}\n            >\n              <Trans>Authenticate</Trans>\n            </Button>\n          </Box>\n        </Stack>\n      )}\n    </LoginViewContainer>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/website-login-views/twitter/index.ts",
    "content": "export { default as TwitterLoginView } from './twitter-login-view';\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/website-login-views/twitter/twitter-login-view.tsx",
    "content": "import { Trans } from '@lingui/react/macro';\nimport {\n  Alert,\n  Button,\n  Group,\n  Paper,\n  PasswordInput,\n  Stack,\n  Stepper,\n  Text,\n  TextInput,\n  Title,\n} from '@mantine/core';\nimport { TwitterAccountData, TwitterOAuthRoutes } from '@postybirb/types';\nimport {\n  IconArrowLeft,\n  IconCheck,\n  IconExternalLink,\n  IconKey,\n  IconRefresh,\n} from '@tabler/icons-react';\nimport { useEffect, useState } from 'react';\nimport websitesApi from '../../../api/websites.api';\nimport { showSuccessNotification } from '../../../utils';\nimport { ExternalLink, openLink } from '../../shared/external-link';\nimport {\n  createLoginHttpErrorHandler,\n  notifyLoginFailed,\n  notifyLoginSuccess,\n} from '../helpers';\nimport { LoginViewContainer } from '../login-view-container';\nimport type { LoginViewProps } from '../types';\n\nconst formId = 'twitter-login-form';\n\nexport default function TwitterLoginView(\n  props: LoginViewProps<TwitterAccountData>,\n): JSX.Element {\n  const { account, data } = props;\n  const { id } = account;\n\n  const [apiKey, setApiKey] = useState(data?.apiKey ?? '');\n  const [apiSecret, setApiSecret] = useState(data?.apiSecret ?? '');\n  const [requestToken, setRequestToken] = useState<string | undefined>(\n    undefined, // Don't initialize from data, always start fresh\n  );\n  const [authorizationUrl, setAuthorizationUrl] = useState<string>('');\n  const [pin, setPin] = useState('');\n  const [isStoringKeys, setIsStoringKeys] = useState(false);\n  const [isRequestingToken, setIsRequestingToken] = useState(false);\n  const [isCompleting, setIsCompleting] = useState(false);\n  const [loggedInAs, setLoggedInAs] = useState<string | undefined>(\n    undefined, // Don't initialize from data, let user see the flow\n  );\n  const [keysStored, setKeysStored] = useState(false); // Always start fresh\n\n  const keysReady = apiKey.trim().length > 0 && apiSecret.trim().length > 0;\n  const canRequestToken = keysReady && keysStored && !requestToken;\n  const canComplete = Boolean(requestToken) && pin.trim().length > 0;\n\n  // Always start on the first step when component loads\n  const [activeStep, setActiveStep] = useState(0);\n\n  // Only update loggedInAs when data changes, but don't affect step progression\n  useEffect(() => {\n    if (data?.screenName && !loggedInAs) {\n      setLoggedInAs(`@${data.screenName}`);\n    }\n    // If there are existing API keys, mark them as stored so user can proceed\n    if (data?.apiKey && data?.apiSecret && !keysStored) {\n      setKeysStored(true);\n    }\n  }, [data?.screenName, data?.apiKey, data?.apiSecret, loggedInAs, keysStored]);\n\n  const handleGoBack = () => {\n    if (activeStep === 2) {\n      // Clear authorization state and go back to step 1\n      setRequestToken(undefined);\n      setAuthorizationUrl('');\n      setPin('');\n      setActiveStep(1);\n    } else if (activeStep === 1) {\n      // Go back to API keys setup\n      setKeysStored(false);\n      setActiveStep(0);\n    }\n  };\n\n  const handleStartOver = () => {\n    // Reset all state to start from beginning\n    setKeysStored(false);\n    setRequestToken(undefined);\n    setAuthorizationUrl('');\n    setPin('');\n    setLoggedInAs(undefined);\n    setActiveStep(0);\n  };\n\n  return (\n    <LoginViewContainer>\n      <Stack gap=\"lg\">\n        <Title order={3}>\n          <Trans>Twitter / X Authentication</Trans>\n        </Title>\n\n        {loggedInAs && (\n          <Alert color=\"green\" icon={<IconCheck size={16} />}>\n            <Group justify=\"space-between\">\n              <Text>\n                <Trans>Successfully logged in as {loggedInAs}</Trans>\n              </Text>\n              <Button\n                variant=\"light\"\n                size=\"compact-sm\"\n                leftSection={<IconRefresh size={14} />}\n                onClick={handleStartOver}\n              >\n                <Trans>Start Over</Trans>\n              </Button>\n            </Group>\n          </Alert>\n        )}\n\n        <Stepper\n          active={activeStep}\n          orientation=\"vertical\"\n          size=\"sm\"\n          completedIcon={<IconCheck size={16} />}\n        >\n          <Stepper.Step\n            label={<Trans>Configure API Keys</Trans>}\n            description={<Trans>Enter your Twitter app credentials</Trans>}\n            icon={<IconKey size={16} />}\n          >\n            <Paper p=\"md\" withBorder>\n              <Stack gap=\"md\">\n                <Alert color=\"blue\" variant=\"light\">\n                  <Text size=\"sm\">\n                    <Trans>\n                      You need to create a Twitter app to get API keys.{' '}\n                      <ExternalLink href=\"https://developer.twitter.com/en/portal/dashboard\">\n                        Visit Twitter Developer Portal\n                      </ExternalLink>\n                    </Trans>\n                  </Text>\n                </Alert>\n\n                <TextInput\n                  label={<Trans>API Key (Consumer Key)</Trans>}\n                  // eslint-disable-next-line lingui/no-unlocalized-strings\n                  placeholder=\"Enter your API key\"\n                  required\n                  value={apiKey}\n                  onChange={(e) => setApiKey(e.currentTarget.value.trim())}\n                  description={\n                    <Trans>\n                      Found in your Twitter app's Keys and tokens section\n                    </Trans>\n                  }\n                />\n\n                <PasswordInput\n                  label={<Trans>API Secret (Consumer Secret)</Trans>}\n                  // eslint-disable-next-line lingui/no-unlocalized-strings\n                  placeholder=\"Enter your API secret\"\n                  required\n                  value={apiSecret}\n                  onChange={(e) => setApiSecret(e.currentTarget.value.trim())}\n                  description={\n                    <Trans>Keep this secret and never share it publicly</Trans>\n                  }\n                />\n\n                {data?.apiKey && data?.apiSecret && (\n                  <Alert color=\"blue\" variant=\"light\">\n                    <Text size=\"sm\">\n                      <Trans>\n                        You have existing API keys. You can modify them above or\n                        proceed to the next step.\n                      </Trans>\n                    </Text>\n                  </Alert>\n                )}\n\n                <Group>\n                  <Button\n                    disabled={!keysReady}\n                    loading={isStoringKeys}\n                    onClick={() => {\n                      setIsStoringKeys(true);\n                      websitesApi\n                        .performOAuthStep<TwitterOAuthRoutes, 'setApiKeys'>(\n                          id,\n                          'setApiKeys',\n                          { apiKey, apiSecret },\n                        )\n                        .then(() => {\n                          setKeysStored(true);\n                          setActiveStep(1); // Advance to next step\n                          showSuccessNotification(\n                            <Trans>API keys saved successfully</Trans>,\n                          );\n                        })\n                        .catch(\n                          createLoginHttpErrorHandler(\n                            <Trans>Failed to store API keys</Trans>,\n                          ),\n                        )\n                        .finally(() => setIsStoringKeys(false));\n                    }}\n                  >\n                    {keysStored ? (\n                      <Trans>Update API Keys</Trans>\n                    ) : (\n                      <Trans>Save API Keys</Trans>\n                    )}\n                  </Button>\n\n                  {keysStored && (\n                    <Button variant=\"light\" onClick={() => setActiveStep(1)}>\n                      <Trans>Proceed to Authorization</Trans>\n                    </Button>\n                  )}\n                </Group>\n              </Stack>\n            </Paper>\n          </Stepper.Step>\n\n          <Stepper.Step\n            label={<Trans>Get Authorization</Trans>}\n            description={<Trans>Generate authorization link</Trans>}\n            icon={<IconExternalLink size={16} />}\n          >\n            <Paper p=\"md\" withBorder>\n              <Stack gap=\"md\">\n                <Text size=\"sm\" c=\"dimmed\">\n                  <Trans>\n                    Click the button below to get your authorization URL\n                  </Trans>\n                </Text>\n\n                <Group>\n                  <Button\n                    disabled={!canRequestToken}\n                    loading={isRequestingToken}\n                    onClick={() => {\n                      setIsRequestingToken(true);\n                      websitesApi\n                        .performOAuthStep<TwitterOAuthRoutes, 'requestToken'>(\n                          id,\n                          'requestToken',\n                          {},\n                        )\n                        .then((res) => {\n                          if (res.success) {\n                            setAuthorizationUrl(res.url || '');\n                            if (res.oauthToken) setRequestToken(res.oauthToken);\n                            setActiveStep(2); // Advance to next step\n                            showSuccessNotification(\n                              <Trans>Authorization URL generated</Trans>,\n                            );\n\n                            // Automatically open the authorization URL in the browser\n                            if (res.url) {\n                              openLink(res.url);\n                            }\n                          } else {\n                            notifyLoginFailed(res.message);\n                          }\n                        })\n                        .catch(\n                          createLoginHttpErrorHandler(\n                            <Trans>Failed to generate authorization URL</Trans>,\n                          ),\n                        )\n                        .finally(() => setIsRequestingToken(false));\n                    }}\n                  >\n                    <Trans>Get Authorization URL</Trans>\n                  </Button>\n                </Group>\n              </Stack>\n            </Paper>\n          </Stepper.Step>\n\n          <Stepper.Step\n            label={<Trans>Complete Authorization</Trans>}\n            description={<Trans>Enter the PIN from Twitter</Trans>}\n          >\n            <Paper p=\"md\" withBorder>\n              <Stack gap=\"md\">\n                {authorizationUrl && (\n                  <Alert color=\"blue\" variant=\"light\">\n                    <Group>\n                      <ExternalLink href={authorizationUrl}>\n                        <Button\n                          variant=\"light\"\n                          leftSection={<IconExternalLink size={16} />}\n                        >\n                          <Trans>Open Twitter Authorization Page</Trans>\n                        </Button>\n                      </ExternalLink>\n                    </Group>\n                    <Text size=\"sm\" mt=\"xs\">\n                      <Trans>\n                        Click the link above, authorize the app, and copy the\n                        PIN code below\n                      </Trans>\n                    </Text>\n                  </Alert>\n                )}\n\n                <TextInput\n                  label={<Trans>PIN / Verifier Code</Trans>}\n                  // eslint-disable-next-line lingui/no-unlocalized-strings\n                  placeholder=\"Enter the PIN from Twitter\"\n                  value={pin}\n                  required\n                  onChange={(e) => setPin(e.currentTarget.value.trim())}\n                  description={\n                    <Trans>\n                      You'll get this PIN after authorizing on Twitter\n                    </Trans>\n                  }\n                />\n\n                <Group>\n                  <Button\n                    disabled={!canComplete}\n                    loading={isCompleting}\n                    onClick={() => {\n                      setIsCompleting(true);\n                      websitesApi\n                        .performOAuthStep<TwitterOAuthRoutes, 'completeOAuth'>(\n                          id,\n                          'completeOAuth',\n                          { verifier: pin },\n                        )\n                        .then((res) => {\n                          if (res.success) {\n                            notifyLoginSuccess(undefined, account);\n                            setPin('');\n                            setAuthorizationUrl('');\n                            setRequestToken(undefined);\n                            setActiveStep(3); // Advance to completion step\n                            if (res.screenName) setLoggedInAs(`@${res.screenName}`);\n                          } else {\n                            notifyLoginFailed(res.message);\n                          }\n                        })\n                        .catch(\n                          createLoginHttpErrorHandler(\n                            <Trans>Failed to complete authorization</Trans>,\n                          ),\n                        )\n                        .finally(() => setIsCompleting(false));\n                    }}\n                  >\n                    <Trans>Complete Login</Trans>\n                  </Button>\n                </Group>\n              </Stack>\n            </Paper>\n          </Stepper.Step>\n        </Stepper>\n\n        {/* Navigation Controls */}\n        <Group justify=\"space-between\" mt=\"md\">\n          <Button\n            variant=\"light\"\n            leftSection={<IconArrowLeft size={16} />}\n            onClick={handleGoBack}\n            disabled={activeStep === 0}\n          >\n            <Trans>Back</Trans>\n          </Button>\n\n          <Button\n            leftSection={<IconRefresh size={16} />}\n            onClick={handleStartOver}\n            color=\"red\"\n            variant=\"light\"\n          >\n            <Trans>Start Over</Trans>\n          </Button>\n        </Group>\n      </Stack>\n    </LoginViewContainer>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/components/website-login-views/types.ts",
    "content": "/**\n * Types for website login view components.\n */\n\nimport type { AccountRecord } from '../../stores/records/account-record';\nimport type { WebsiteRecord } from '../../stores/records/website-record';\n\n/**\n * Props passed to custom login view components.\n * @template T - Type of website-specific account data\n */\nexport interface LoginViewProps<T = unknown> {\n  /** The account being logged into */\n  account: AccountRecord;\n  /** The website configuration */\n  website: WebsiteRecord;\n  /** Website-specific data stored on the account (typed) */\n  data: T | undefined;\n}\n\n/**\n * A React component that renders a custom login form for a website.\n */\nexport type LoginViewComponent<T = unknown> = React.ComponentType<\n  LoginViewProps<T>\n>;\n"
  },
  {
    "path": "apps/postybirb-ui/src/config/keybindings.ts",
    "content": "/**\n * Keybinding configuration for the PostyBirb remake UI.\n * Defines keyboard shortcuts for navigation and actions.\n */\n\n/* eslint-disable lingui/no-unlocalized-strings */\nimport {\n  formatKeybindingDisplay,\n  getActionModifier,\n  getNavigationModifier,\n} from '../shared/platform-utils';\n\nconst navMod = getNavigationModifier();\nconst actionMod = getActionModifier();\n\n/**\n * Navigation keybindings\n */\nexport const SettingsKeybinding = `${navMod}+S`;\nexport const AccountKeybinding = `${navMod}+A`;\nexport const HomeKeybinding = `${navMod}+H`;\nexport const TagGroupsKeybinding = `${navMod}+T`;\nexport const TagConvertersKeybinding = `${navMod}+C`;\nexport const UserConvertersKeybinding = `${navMod}+U`;\nexport const MessageSubmissionsKeybinding = `${navMod}+M`;\nexport const NotificationsKeybinding = `${navMod}+N`;\nexport const FileSubmissionsKeybinding = `${navMod}+F`;\nexport const CustomShortcutsKeybinding = `${navMod}+D`;\nexport const FileWatchersKeybinding = `${navMod}+W`;\nexport const TemplatesKeybinding = `${navMod}+E`;\nexport const ScheduleKeybinding = `${navMod}+L`;\n\n/**\n * Action keybindings\n */\nexport const DeleteSelectedKeybinding = 'Delete';\n\n/**\n * Convert keybinding format to tinykeys format.\n * 'Alt+S' -> 'Alt+s'\n * 'Control+K' -> 'Control+k'\n */\nexport function toTinykeysFormat(keybinding: string): string {\n  return keybinding\n    .split('+')\n    .map((part, index, arr) =>\n      index === arr.length - 1 ? part.toLowerCase() : part,\n    )\n    .join('+');\n}\n\n/**\n * Re-export formatKeybindingDisplay for convenience\n */\nexport { formatKeybindingDisplay };\n\n"
  },
  {
    "path": "apps/postybirb-ui/src/config/nav-items.tsx",
    "content": "/**\n * Navigation configuration for the PostyBirb layout.\n * Defines the main sidenav items using state-driven navigation.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport { Badge } from '@mantine/core';\nimport {\n  IconBell,\n  IconBlockquote,\n  IconBrandDiscord,\n  IconCalendar,\n  IconCoffee,\n  IconFile,\n  IconFolderSearch,\n  IconHome,\n  IconMessage,\n  IconSettings,\n  IconTags,\n  IconTemplate,\n  IconTransform,\n  IconUser,\n  IconUsers,\n} from '@tabler/icons-react';\nimport { useUnreadNotificationCount } from '../stores';\nimport type { NavigationItem } from '../types/navigation';\nimport {\n  createAccountsViewState,\n  createFileSubmissionsViewState,\n  createMessageSubmissionsViewState,\n  createTemplatesViewState,\n  defaultViewState,\n} from '../types/view-state';\nimport { openUrl } from '../utils';\nimport {\n  AccountKeybinding,\n  CustomShortcutsKeybinding,\n  FileSubmissionsKeybinding,\n  FileWatchersKeybinding,\n  HomeKeybinding,\n  MessageSubmissionsKeybinding,\n  NotificationsKeybinding,\n  ScheduleKeybinding,\n  SettingsKeybinding,\n  TagConvertersKeybinding,\n  TagGroupsKeybinding,\n  TemplatesKeybinding,\n  UserConvertersKeybinding,\n} from './keybindings';\n\n/**\n * Main navigation items displayed in the side navigation.\n * Uses view state navigation for primary sections.\n */\nexport const navItems: NavigationItem[] = [\n  // Search (spotlight) - custom action\n  // {\n  //   type: 'custom',\n  //   id: 'search',\n  //   icon: <IconSearch size={20} />,\n  //   label: <Trans>Search</Trans>,\n  //   kbd: SpotlightKeybinding,\n  //   onClick: () => {\n  //     // Spotlight will be implemented separately\n  //     // eslint-disable-next-line no-console, lingui/no-unlocalized-strings\n  //     console.log('Spotlight toggle');\n  //   },\n  // },\n\n  // Main navigation - view state items\n  {\n    type: 'view',\n    id: 'home',\n    icon: <IconHome size={20} />,\n    label: <Trans>Home</Trans>,\n    viewState: defaultViewState,\n    kbd: HomeKeybinding,\n  },\n  {\n    type: 'view',\n    id: 'accounts',\n    icon: <IconUser size={20} />,\n    label: <Trans>Accounts</Trans>,\n    viewState: createAccountsViewState(),\n    kbd: AccountKeybinding,\n  },\n  {\n    type: 'view',\n    id: 'file-submissions',\n    icon: <IconFile size={20} />,\n    label: <Trans>Post Files</Trans>,\n    viewState: createFileSubmissionsViewState(),\n    kbd: FileSubmissionsKeybinding,\n  },\n  {\n    type: 'view',\n    id: 'message-submissions',\n    icon: <IconMessage size={20} />,\n    label: <Trans>Send Messages</Trans>,\n    viewState: createMessageSubmissionsViewState(),\n    kbd: MessageSubmissionsKeybinding,\n  },\n  {\n    type: 'view',\n    id: 'templates',\n    icon: <IconTemplate size={20} />,\n    label: <Trans>Templates</Trans>,\n    viewState: createTemplatesViewState(),\n    kbd: TemplatesKeybinding,\n  },\n\n  // Divider\n  { type: 'divider', id: 'divider-1' },\n\n  // Drawer items\n  {\n    type: 'drawer',\n    id: 'schedule',\n    icon: <IconCalendar size={20} />,\n    label: <Trans>Schedule</Trans>,\n    drawerKey: 'schedule',\n    kbd: ScheduleKeybinding,\n  },\n  {\n    type: 'drawer',\n    id: 'notifications',\n    icon: <IconBell size={20} />,\n    label: (\n      <span style={{ alignItems: 'center', display: 'flex' }}>\n        <Trans>Notifications</Trans>\n        <UnreadNotificationsBadge />\n      </span>\n    ),\n    drawerKey: 'notifications',\n    kbd: NotificationsKeybinding,\n  },\n  {\n    type: 'drawer',\n    id: 'tag-groups',\n    icon: <IconTags size={20} />,\n    label: <Trans>Tag Groups</Trans>,\n    drawerKey: 'tagGroups',\n    kbd: TagGroupsKeybinding,\n  },\n  {\n    type: 'drawer',\n    id: 'tag-converters',\n    icon: <IconTransform size={20} />,\n    label: <Trans>Tag Converters</Trans>,\n    drawerKey: 'tagConverters',\n    kbd: TagConvertersKeybinding,\n  },\n  {\n    type: 'drawer',\n    id: 'user-converters',\n    icon: <IconUsers size={20} />,\n    label: <Trans>User Converters</Trans>,\n    drawerKey: 'userConverters',\n    kbd: UserConvertersKeybinding,\n  },\n  {\n    type: 'drawer',\n    id: 'custom-shortcuts',\n    icon: <IconBlockquote size={20} />,\n    label: <Trans>Custom Shortcuts</Trans>,\n    drawerKey: 'customShortcuts',\n    kbd: CustomShortcutsKeybinding,\n  },\n  {\n    type: 'drawer',\n    id: 'file-watchers',\n    icon: <IconFolderSearch size={20} />,\n    label: <Trans>File Watchers</Trans>,\n    drawerKey: 'fileWatchers',\n    kbd: FileWatchersKeybinding,\n  },\n  {\n    type: 'drawer',\n    id: 'settings',\n    icon: <IconSettings size={20} />,\n    label: <Trans>Settings</Trans>,\n    drawerKey: 'settings',\n    kbd: SettingsKeybinding,\n  },\n\n  // Divider before external links\n  { type: 'divider', id: 'divider-2' },\n\n  // Theme toggle\n  {\n    type: 'theme',\n    id: 'theme-toggle',\n  },\n\n  // Language picker\n  {\n    type: 'language',\n    id: 'language-picker',\n  },\n\n  // Divider before external links\n  { type: 'divider', id: 'divider-3' },\n\n  // Discord\n  {\n    type: 'custom',\n    id: 'discord',\n    icon: <IconBrandDiscord size={20} />,\n    label: <Trans>Discord</Trans>,\n    onClick: () => {\n      openUrl('https://discord.gg/D9ucrU8jMR');\n    },\n  },\n\n  // Ko-fi\n  {\n    type: 'custom',\n    id: 'kofi',\n    icon: <IconCoffee size={20} />,\n    label: <Trans>Ko-fi</Trans>,\n    onClick: () => {\n      openUrl('https://ko-fi.com/A81124JD');\n    },\n  },\n];\n\nfunction UnreadNotificationsBadge() {\n  const unreadCount = useUnreadNotificationCount();\n\n  return (\n    unreadCount > 0 && (\n      <Badge size=\"xs\" ml={4} variant=\"filled\" color=\"red\">\n        {unreadCount}\n      </Badge>\n    )\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/environments/environment.prod.ts",
    "content": "export const environment = {\n  production: true,\n};\n"
  },
  {
    "path": "apps/postybirb-ui/src/environments/environment.ts",
    "content": "// This file can be replaced during build by using the `fileReplacements` array.\n// When building for production, this file is replaced with `environment.prod.ts`.\n\nexport const environment = {\n  production: false,\n};\n"
  },
  {
    "path": "apps/postybirb-ui/src/hooks/index.ts",
    "content": "/**\n * Hooks barrel exports.\n */\n\nexport * from './tag-search';\nexport * from './use-keybindings';\nexport * from './use-locale';\n"
  },
  {
    "path": "apps/postybirb-ui/src/hooks/tag-search/index.ts",
    "content": "/**\n * Tag Search exports.\n */\n\nexport { TagSearchProvider } from './tag-search-provider';\nexport { TagSearchProviders } from './tag-search-providers';\nexport { useTagSearch, type UseTagSearchResult } from './use-tag-search';\n\n"
  },
  {
    "path": "apps/postybirb-ui/src/hooks/tag-search/tag-search-provider.ts",
    "content": "/**\n * Tag Search Provider - Base class for tag search functionality.\n * Providers can search external services (like e621) for tag suggestions.\n */\n\nimport { TagSearchProviderSettings } from '@postybirb/types';\n\n/**\n * Base class for tag search providers.\n * Implementations provide search and rendering functionality for tag autocomplete.\n */\nexport abstract class TagSearchProvider {\n  /**\n   * Cache for search results to avoid repeated API calls.\n   * Limited to {@link MAX_CACHE_SIZE} entries with LRU eviction.\n   */\n  private cache = new Map<string, { date: number; tags: string[] }>();\n\n  /**\n   * Maximum number of cached queries before the oldest entry is evicted.\n   */\n  private static readonly MAX_CACHE_SIZE = 200;\n\n  /**\n   * Cache eviction time in milliseconds (default: 1 hour).\n   */\n  private cacheEvictionTime = 1000 * 60 * 60;\n\n  /**\n   * Search for tags matching the query.\n   * Results are cached for efficiency.\n   */\n  async search(query: string): Promise<string[]> {\n    const cached = this.cache.get(query);\n    if (cached && Date.now() - cached.date < this.cacheEvictionTime) {\n      // Move to end for LRU ordering (Map iteration order = insertion order)\n      this.cache.delete(query);\n      this.cache.set(query, cached);\n      return cached.tags;\n    }\n\n    try {\n      const tags = await this.searchImplementation(query);\n      this.cache.set(query, { date: Date.now(), tags });\n\n      // Evict oldest entries if cache exceeds max size\n      while (this.cache.size > TagSearchProvider.MAX_CACHE_SIZE) {\n        const oldestKey = this.cache.keys().next().value;\n        if (oldestKey !== undefined) {\n          this.cache.delete(oldestKey);\n        }\n      }\n\n      return tags;\n    } catch (e) {\n      // Don't cache on error\n      // eslint-disable-next-line no-console\n      console.error(e);\n      return [];\n    }\n  }\n\n  /**\n   * Implementation-specific search logic.\n   * Override in subclasses to provide actual search functionality.\n   */\n  protected abstract searchImplementation(query: string): Promise<string[]>;\n\n  /**\n   * Render a custom search result item.\n   * Override to provide custom rendering for search results.\n   * Return null to use default rendering.\n   */\n  abstract renderSearchItem(\n    tag: string,\n    settings: TagSearchProviderSettings,\n  ): React.ReactNode;\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/hooks/tag-search/tag-search-providers.ts",
    "content": "/**\n * Tag Search Providers Registry.\n * Maps provider IDs to their implementations.\n */\n\n// Import from the legacy location for now - this provider is shared across old/new UI\n// Re-export the provider base class from local definition\nimport { e621TagSearchProvider } from '../../components/website-components/e621/e621-tag-search-provider';\nimport { TagSearchProvider } from './tag-search-provider';\n\n/**\n * Available tag search providers keyed by their ID.\n */\nexport const TagSearchProviders: Record<string, TagSearchProvider> = {\n  // Cast needed since legacy provider extends different (but compatible) base class\n  e621: e621TagSearchProvider as unknown as TagSearchProvider,\n};\n"
  },
  {
    "path": "apps/postybirb-ui/src/hooks/tag-search/use-tag-search.ts",
    "content": "/**\n * useTagSearch - Hook for searching tags with provider support.\n * Provides debounced search functionality with caching.\n */\n\nimport { useDebouncedCallback } from '@mantine/hooks';\nimport { useEffect, useRef, useState } from 'react';\nimport { useTagSearchProvider } from '../../stores/entity/settings-store';\nimport { TagSearchProvider } from './tag-search-provider';\nimport { TagSearchProviders } from './tag-search-providers';\n\nexport interface UseTagSearchResult {\n  /** Current search query value */\n  searchValue: string;\n  /** Function to update search value */\n  onSearchChange: (value: string) => void;\n  /** Search results as array of tag strings */\n  data: string[];\n  /** Whether a search is in progress */\n  isLoading: boolean;\n  /** The active search provider (if any) */\n  provider: TagSearchProvider | undefined;\n}\n\n/**\n * Hook for tag search with provider support.\n *\n * @param fieldProviderId - Optional provider ID from a field configuration.\n *                          Falls back to user settings if not provided.\n * @returns Search state and handlers\n *\n * @example\n * ```tsx\n * function TagInput() {\n *   const search = useTagSearch();\n *\n *   return (\n *     <TagsInput\n *       data={search.data}\n *       searchValue={search.searchValue}\n *       onSearchChange={search.onSearchChange}\n *     />\n *   );\n * }\n * ```\n */\nexport function useTagSearch(fieldProviderId?: string): UseTagSearchResult {\n  const tagSearchProviderSettings = useTagSearchProvider();\n\n  // Determine the provider to use: field-specific or user setting\n  const providerId = fieldProviderId ?? tagSearchProviderSettings?.id ?? '';\n  const provider: TagSearchProvider | undefined =\n    TagSearchProviders[providerId];\n\n  const [data, setData] = useState<string[]>([]);\n  const [searchValue, onSearchChange] = useState('');\n  const [isLoading, setIsLoading] = useState(false);\n  const requestId = useRef(0);\n\n  // Debounced search function with 300ms delay\n  const debouncedSearch = useDebouncedCallback(\n    (query: string) => {\n      if (query === '' || !provider) {\n        setData([]);\n        setIsLoading(false);\n        return;\n      }\n\n      const currentRequestId = ++requestId.current;\n\n      setIsLoading(true);\n      provider.search(query).then((results) => {\n        // Only update state if this is the most recent request\n        if (currentRequestId === requestId.current) {\n          setData(results);\n          setIsLoading(false);\n        }\n      });\n    },\n    { delay: 300, flushOnUnmount: false },\n  );\n\n  // Trigger search when searchValue changes\n  useEffect(() => {\n    debouncedSearch(searchValue);\n\n    return () => {\n      // Invalidate request id on unmount\n      requestId.current = -1;\n    };\n  }, [searchValue, debouncedSearch]);\n\n  return {\n    searchValue,\n    onSearchChange,\n    data,\n    isLoading,\n    provider,\n  };\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/hooks/use-keybindings.ts",
    "content": "/**\n * Keybindings hook using tinykeys for keyboard shortcuts.\n * Sets up global keyboard listeners for navigation and actions.\n */\n\nimport { useEffect, useRef } from 'react';\nimport { tinykeys } from 'tinykeys';\nimport {\n  AccountKeybinding,\n  CustomShortcutsKeybinding,\n  FileSubmissionsKeybinding,\n  FileWatchersKeybinding,\n  HomeKeybinding,\n  MessageSubmissionsKeybinding,\n  NotificationsKeybinding,\n  ScheduleKeybinding,\n  SettingsKeybinding,\n  TagConvertersKeybinding,\n  TagGroupsKeybinding,\n  TemplatesKeybinding,\n  toTinykeysFormat,\n  UserConvertersKeybinding,\n} from '../config/keybindings';\nimport { getActionModifier } from '../shared/platform-utils';\nimport { useDrawerStore } from '../stores/ui/drawer-store';\nimport {\n  useCanGoBack,\n  useCanGoForward,\n  useNavigationHistory,\n  useViewStateActions,\n} from '../stores/ui/navigation-store';\nimport {\n  createAccountsViewState,\n  createFileSubmissionsViewState,\n  createHomeViewState,\n  createMessageSubmissionsViewState,\n  createTemplatesViewState,\n} from '../types/view-state';\n\n/**\n * Hook to set up global keybindings using tinykeys.\n * Handles both navigation and drawer toggle shortcuts.\n */\nexport function useKeybindings(): void {\n  const { setViewState } = useViewStateActions();\n  const toggleDrawer = useDrawerStore((state) => state.toggleDrawer);\n  const { goBack, goForward } = useNavigationHistory();\n  const canGoBack = useCanGoBack();\n  const canGoForward = useCanGoForward();\n\n  // Store navigation state in refs so the effect doesn't re-run on every navigation.\n  // This avoids tearing down and re-creating all keyboard + mouse listeners\n  // every time canGoBack/canGoForward changes.\n  const canGoBackRef = useRef(canGoBack);\n  const canGoForwardRef = useRef(canGoForward);\n  canGoBackRef.current = canGoBack;\n  canGoForwardRef.current = canGoForward;\n\n  useEffect(() => {\n    const mod = getActionModifier();\n\n    // Mouse button handler for back/forward navigation\n    const handleMouseButton = (event: MouseEvent) => {\n      // Button 3 (back) and Button 4 (forward)\n      if (event.button === 3 && canGoBackRef.current) {\n        event.preventDefault();\n        goBack();\n      } else if (event.button === 4 && canGoForwardRef.current) {\n        event.preventDefault();\n        goForward();\n      }\n    };\n\n    // Add mouse button listener\n    window.addEventListener('mouseup', handleMouseButton);\n\n    const unsubscribe = tinykeys(window, {\n      // History navigation keybindings\n      [`${mod}+[`]: (event: KeyboardEvent) => {\n        if (canGoBackRef.current) {\n          event.preventDefault();\n          goBack();\n        }\n      },\n      [`${mod}+]`]: (event: KeyboardEvent) => {\n        if (canGoForwardRef.current) {\n          event.preventDefault();\n          goForward();\n        }\n      },\n\n      // Navigation keybindings\n      [toTinykeysFormat(HomeKeybinding)]: (event: KeyboardEvent) => {\n        event.preventDefault();\n        setViewState(createHomeViewState());\n      },\n      [toTinykeysFormat(FileSubmissionsKeybinding)]: (event: KeyboardEvent) => {\n        event.preventDefault();\n        setViewState(createFileSubmissionsViewState());\n      },\n      [toTinykeysFormat(MessageSubmissionsKeybinding)]: (\n        event: KeyboardEvent,\n      ) => {\n        event.preventDefault();\n        setViewState(createMessageSubmissionsViewState());\n      },\n      [toTinykeysFormat(AccountKeybinding)]: (event: KeyboardEvent) => {\n        event.preventDefault();\n        setViewState(createAccountsViewState());\n      },\n      [toTinykeysFormat(TemplatesKeybinding)]: (event: KeyboardEvent) => {\n        event.preventDefault();\n        setViewState(createTemplatesViewState());\n      },\n\n      // Drawer toggle keybindings\n      [toTinykeysFormat(SettingsKeybinding)]: (event: KeyboardEvent) => {\n        event.preventDefault();\n        toggleDrawer('settings');\n      },\n      [toTinykeysFormat(TagGroupsKeybinding)]: (event: KeyboardEvent) => {\n        event.preventDefault();\n        toggleDrawer('tagGroups');\n      },\n      [toTinykeysFormat(TagConvertersKeybinding)]: (event: KeyboardEvent) => {\n        event.preventDefault();\n        toggleDrawer('tagConverters');\n      },\n      [toTinykeysFormat(UserConvertersKeybinding)]: (event: KeyboardEvent) => {\n        event.preventDefault();\n        toggleDrawer('userConverters');\n      },\n      [toTinykeysFormat(NotificationsKeybinding)]: (event: KeyboardEvent) => {\n        event.preventDefault();\n        toggleDrawer('notifications');\n      },\n      [toTinykeysFormat(CustomShortcutsKeybinding)]: (event: KeyboardEvent) => {\n        event.preventDefault();\n        toggleDrawer('customShortcuts');\n      },\n      [toTinykeysFormat(FileWatchersKeybinding)]: (event: KeyboardEvent) => {\n        event.preventDefault();\n        toggleDrawer('fileWatchers');\n      },\n      [toTinykeysFormat(ScheduleKeybinding)]: (event: KeyboardEvent) => {\n        event.preventDefault();\n        toggleDrawer('schedule');\n      },\n    });\n\n    return () => {\n      unsubscribe();\n      window.removeEventListener('mouseup', handleMouseButton);\n    };\n  // canGoBack/canGoForward read from refs — not needed as dependencies\n  }, [setViewState, toggleDrawer, goBack, goForward]);\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/hooks/use-locale.ts",
    "content": "/**\n * useLocale - Centralized hook for locale-aware formatting.\n * Provides the current locale and utilities for date/time formatting.\n * Auto-subscribes to locale changes via lingui.\n */\n\nimport { useLingui } from '@lingui/react';\nimport moment from 'moment/min/moment-with-locales';\nimport { useEffect, useMemo } from 'react';\nimport {\n    calendarLanguageMap,\n    cronstrueLocaleMap,\n    dateLocaleMap,\n} from '../i18n/languages';\n\n/**\n * Return type for the useLocale hook.\n */\nexport interface UseLocaleResult {\n  /** Current app locale code (e.g., 'en', 'de', 'pt-BR') */\n  locale: string;\n  /** Locale code for date libraries (dayjs/moment) */\n  dateLocale: string;\n  /** Locale code for FullCalendar */\n  calendarLocale: string;\n  /** Locale code for cronstrue */\n  cronstrueLocale: string;\n  /** Format a date as relative time (e.g., \"2 hours ago\", \"in 3 days\") */\n  formatRelativeTime: (date: Date | string) => string;\n  /** Format a date/time for display using locale-aware formatting */\n  formatDateTime: (\n    date: Date | string,\n    options?: Intl.DateTimeFormatOptions\n  ) => string;\n  /** Format a date only (no time) for display */\n  formatDate: (\n    date: Date | string,\n    options?: Intl.DateTimeFormatOptions\n  ) => string;\n  /** Format a time only (no date) for display */\n  formatTime: (\n    date: Date | string,\n    options?: Intl.DateTimeFormatOptions\n  ) => string;\n}\n\nconst DEFAULT_DATETIME_OPTIONS: Intl.DateTimeFormatOptions = {\n  month: 'short',\n  day: 'numeric',\n  hour: 'numeric',\n  minute: '2-digit',\n};\n\nconst DEFAULT_DATE_OPTIONS: Intl.DateTimeFormatOptions = {\n  month: 'short',\n  day: 'numeric',\n  year: 'numeric',\n};\n\nconst DEFAULT_TIME_OPTIONS: Intl.DateTimeFormatOptions = {\n  hour: 'numeric',\n  minute: '2-digit',\n};\n\n/**\n * Centralized hook for locale-aware formatting.\n * Uses lingui's i18n context to automatically re-render on locale changes.\n *\n * @example\n * ```tsx\n * const { locale, formatRelativeTime, formatDateTime } = useLocale();\n *\n * // Format relative time\n * <Text>{formatRelativeTime(submission.lastModified)}</Text>\n *\n * // Format date/time\n * <Text>{formatDateTime(submission.scheduledDate)}</Text>\n * ```\n */\nexport function useLocale(): UseLocaleResult {\n  const { i18n } = useLingui();\n  const locale = i18n.locale || 'en';\n\n  // Map the app locale to library-specific locale codes\n  const dateLocale = dateLocaleMap[locale] || locale;\n  const calendarLocale = calendarLanguageMap[locale] || 'en-US';\n  const cronstrueLocale = cronstrueLocaleMap[locale] || 'en';\n\n  // Set moment locale as a proper side effect (not inside useMemo)\n  useEffect(() => {\n    moment.locale(dateLocale);\n  }, [dateLocale]);\n\n  // Memoize the formatting functions to avoid recreating on each render\n  const formatters = useMemo(() => {\n    const formatRelativeTime = (date: Date | string): string =>\n      moment(date).fromNow();\n\n    const formatDateTime = (\n      date: Date | string,\n      options: Intl.DateTimeFormatOptions = DEFAULT_DATETIME_OPTIONS\n    ): string => {\n      const d = typeof date === 'string' ? new Date(date) : date;\n      return d.toLocaleString(locale, options);\n    };\n\n    const formatDate = (\n      date: Date | string,\n      options: Intl.DateTimeFormatOptions = DEFAULT_DATE_OPTIONS\n    ): string => {\n      const d = typeof date === 'string' ? new Date(date) : date;\n      return d.toLocaleDateString(locale, options);\n    };\n\n    const formatTime = (\n      date: Date | string,\n      options: Intl.DateTimeFormatOptions = DEFAULT_TIME_OPTIONS\n    ): string => {\n      const d = typeof date === 'string' ? new Date(date) : date;\n      return d.toLocaleTimeString(locale, options);\n    };\n\n    return { formatRelativeTime, formatDateTime, formatDate, formatTime };\n  }, [locale]);\n\n  return {\n    locale,\n    dateLocale,\n    calendarLocale,\n    cronstrueLocale,\n    ...formatters,\n  };\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/i18n/languages.tsx",
    "content": "import { msg } from '@lingui/core/macro';\nimport 'cronstrue/locales/de';\nimport 'cronstrue/locales/es';\nimport 'cronstrue/locales/pt_BR';\nimport 'cronstrue/locales/ru';\nimport 'dayjs/locale/de';\nimport 'dayjs/locale/es';\nimport 'dayjs/locale/lt';\nimport 'dayjs/locale/pt-br';\nimport 'dayjs/locale/ru';\nimport 'dayjs/locale/ta';\n\nexport const languages = [\n  [msg`English`, 'en'],\n  [msg`German`, 'de'],\n  [msg`Lithuanian`, 'lt'],\n  [msg`Portuguese (Brazil)`, 'pt-BR'],\n  [msg`Russian`, 'ru'],\n  [msg`Spanish`, 'es'],\n  [msg`Tamil`, 'ta'],\n] as const;\n\nexport const supportedLocaleCodes = languages.map(([, code]) => code);\n\nexport const dateLocaleMap: Record<string, string> = {\n  en: 'en',\n  de: 'de',\n  lt: 'lt',\n  'pt-BR': 'pt-br',\n  ru: 'ru',\n  es: 'es',\n  ta: 'ta',\n};\n\nexport const calendarLanguageMap: Record<string, string> = {\n  'pt-BR': 'pt-br',\n  en: 'en-US',\n  de: 'de-DE',\n  lt: 'lt-LT',\n  ru: 'ru-RU',\n  es: 'pt-BR',\n  ta: 'ta-IN',\n};\n\n/**\n * Map app locales to cronstrue locales.\n * Unsupported locales (lt, ta) fall back to English.\n * @see https://github.com/bradymholt/cronstrue#supported-locales\n */\nexport const cronstrueLocaleMap: Record<string, string> = {\n  en: 'en',\n  de: 'de',\n  lt: 'en', // Lithuanian not supported, fallback to English\n  'pt-BR': 'pt_BR',\n  ru: 'ru',\n  es: 'es',\n  ta: 'en', // Tamil not supported, fallback to English\n};\n"
  },
  {
    "path": "apps/postybirb-ui/src/i18n/validation-translation.tsx",
    "content": "/**\n * ValidationTranslation - Translates validation message IDs to localized JSX.\n */\n\nimport { Plural, Trans } from '@lingui/react/macro';\nimport { Text } from '@mantine/core';\nimport {\n  FileType,\n  SubmissionRating,\n  ValidationMessage,\n  ValidationMessages,\n} from '@postybirb/types';\nimport { filesize } from 'filesize';\nimport { ExternalLink } from '../components/shared/external-link';\n\ntype TranslationsMap = {\n  [K in keyof ValidationMessages]: (\n    props: ValidationMessage<object, K>['values'],\n  ) => JSX.Element;\n};\n\nexport const TranslationMessages: TranslationsMap = {\n  'validation.failed': (props) => {\n    const message = props?.message;\n    return <Trans>Failed to validate submission: {message}</Trans>;\n  },\n\n  'validation.description.max-length': (props) => {\n    const maxLength = props?.maxLength ?? 0;\n    const currentLength = props?.currentLength ?? 0;\n    return (\n      <Trans>\n        Description length is greater then maximum ({currentLength} /{' '}\n        {maxLength})\n      </Trans>\n    );\n  },\n\n  'validation.description.min-length': (props) => {\n    const minLength = props?.minLength ?? 0;\n    const currentLength = props?.currentLength ?? 0;\n    return (\n      <Trans>\n        Description length is lower then minimum ({currentLength} / {minLength})\n      </Trans>\n    );\n  },\n\n  'validation.description.missing-tags': () => (\n    <Trans>\n      Tags will not be inserted. Use tags shortcut or enable 'Insert tags at\n      end'\n    </Trans>\n  ),\n  'validation.description.unexpected-tags': () => (\n    <Trans>\n      Tags are not expected in the description of this website because it has\n      dedicated tags field. Remove tags shortcut or disable 'Insert tags at end'\n    </Trans>\n  ),\n  'validation.description.missing-title': () => (\n    <Trans>\n      Title will not be inserted. Use title shortcut or enable 'Insert title at\n      start'\n    </Trans>\n  ),\n  'validation.description.unexpected-title': () => (\n    <Trans>\n      Title is not expected in the description of this website because it has\n      dedicated title field. Remove title shortcut or disable 'Insert title at\n      start'\n    </Trans>\n  ),\n\n  'validation.file.file-batch-size': (props) => {\n    const { maxBatchSize, expectedBatchesToCreate } = props;\n    return (\n      <Trans>\n        Submission will be split into {expectedBatchesToCreate} different\n        submissions with {maxBatchSize}{' '}\n        <Plural value={maxBatchSize} one=\"file each\" other=\"files each\" />.\n      </Trans>\n    );\n  },\n\n  'validation.file.text-file-no-fallback': (props) => {\n    const { fileExtension } = props;\n    return (\n      <Trans>\n        Unsupported file type {fileExtension}. Please provide fallback text.\n      </Trans>\n    );\n  },\n\n  'validation.file.invalid-mime-type': (props) => {\n    const { mimeType, acceptedMimeTypes } = props;\n    return (\n      <>\n        <Trans>Unsupported file type {mimeType}</Trans> (\n        {acceptedMimeTypes.join(', ')})\n      </>\n    );\n  },\n\n  'validation.file.all-ignored': () => (\n    <Trans>All files are marked ignored.</Trans>\n  ),\n\n  'validation.file.unsupported-file-type': (props) => {\n    const { fileType } = props;\n    let fileTypeString;\n    switch (fileType) {\n      case FileType.IMAGE:\n        fileTypeString = <Trans>Image</Trans>;\n        break;\n      case FileType.VIDEO:\n        fileTypeString = <Trans>Video</Trans>;\n        break;\n      case FileType.TEXT:\n        fileTypeString = <Trans>Text</Trans>;\n        break;\n      case FileType.AUDIO:\n        fileTypeString = <Trans>Audio</Trans>;\n        break;\n      default:\n        fileTypeString = <Trans>Unknown</Trans>;\n        break;\n    }\n    return <Trans>Unsupported submission type: {fileTypeString}</Trans>;\n  },\n\n  'validation.file.file-size': (props) => {\n    const { maxFileSize, fileSize } = props;\n    const fileSizeString = filesize(fileSize);\n    const maxFileSizeString = filesize(maxFileSize);\n    return (\n      <Trans>\n        ({fileSizeString}) is too large (max {maxFileSizeString}) and an attempt\n        will be made to reduce size when posting\n      </Trans>\n    );\n  },\n\n  'validation.file.image-resize': () => (\n    <Trans>File will be modified to support website requirements</Trans>\n  ),\n\n  'validation.tags.max-tags': (props) => {\n    const { maxLength, currentLength } = props;\n    return (\n      <Trans>\n        Tag limit reached ({currentLength} / {maxLength})\n      </Trans>\n    );\n  },\n\n  'validation.tags.min-tags': (props) => {\n    const { minLength, currentLength } = props;\n    return (\n      <>\n        <Trans>\n          Requires at least {minLength}{' '}\n          <Plural value={minLength} one=\"tag\" other=\"tags\" />\n        </Trans>\n        <Text inherit span>\n          {' '}\n          ({currentLength} / {minLength})\n        </Text>\n      </>\n    );\n  },\n\n  'validation.tags.max-tag-length': (props) => {\n    const { tags, maxLength } = props;\n    return (\n      <>\n        <Trans>Tags longer than {maxLength} characters will be skipped</Trans>:{' '}\n        <span>{tags.join(', ')}</span>\n      </>\n    );\n  },\n\n  'validation.tags.double-hashtag': (props) => {\n    const { tags } = props;\n    return (\n      <>\n        <Trans>Tags should not start with #</Trans>:{' '}\n        <span>{tags.join(', ')}</span>\n      </>\n    );\n  },\n\n  'validation.title.max-length': (props) => {\n    const { maxLength, currentLength } = props;\n    const translatedMsg = (\n      <Trans>Title is too long and will be truncated</Trans>\n    );\n    return (\n      <>\n        {translatedMsg} ({currentLength} / {maxLength})\n      </>\n    );\n  },\n\n  'validation.title.min-length': (props) => {\n    const { minLength, currentLength } = props;\n    return (\n      <>\n        <Trans>Title is too short</Trans> ({currentLength} / {minLength})\n      </>\n    );\n  },\n\n  'validation.file.itaku.must-share-feed': () => (\n    <Trans>Share on Feed is required to support posting multiple files.</Trans>\n  ),\n\n  'validation.select-field.min-selected': (props) => {\n    const { currentSelected, minSelected } = props;\n    return (\n      <Trans>\n        Requires at least {minSelected}{' '}\n        <Plural\n          value={minSelected}\n          one=\"option selected\"\n          other=\"options selected\"\n        />{' '}\n        ({currentSelected} / {minSelected})\n      </Trans>\n    );\n  },\n\n  'validation.select-field.invalid-option': (props) => {\n    const { invalidOptions } = props;\n    return (\n      <span>\n        <Trans>Unknown choice</Trans>: {invalidOptions.join(', ')}\n      </span>\n    );\n  },\n\n  'validation.field.required': () => <Trans>Required</Trans>,\n\n  'validation.datetime.invalid-format': (props) => {\n    const { value } = props;\n    return (\n      <Trans>Invalid date format: {value}. Expected ISO date string.</Trans>\n    );\n  },\n\n  'validation.datetime.min': (props) => {\n    const { currentDate, minDate } = props;\n    return (\n      <Trans>\n        Date {currentDate} is before minimum allowed date {minDate}\n      </Trans>\n    );\n  },\n\n  'validation.datetime.max': (props) => {\n    const { currentDate, maxDate } = props;\n    return (\n      <Trans>\n        Date {currentDate} is after maximum allowed date {maxDate}\n      </Trans>\n    );\n  },\n\n  'validation.datetime.range': (props) => {\n    const { currentDate, minDate, maxDate } = props;\n    return (\n      <Trans>\n        Date {currentDate} is outside allowed range ({minDate} - {maxDate})\n      </Trans>\n    );\n  },\n\n  'validation.file.bluesky.unsupported-combination-of-files': () => (\n    <Trans>\n      Supports either a set of images, a single video, or a single GIF.\n    </Trans>\n  ),\n\n  'validation.file.bluesky.gif-conversion': () => (\n    <Trans>Bluesky automatically converts GIFs to videos.</Trans>\n  ),\n\n  'validation.file.bluesky.invalid-reply-url': () => (\n    <Trans>Invalid post URL to reply to.</Trans>\n  ),\n\n  'validation.file.bluesky.rating-matches-default': () => (\n    <Trans>\n      Make sure that the default rating matches Bluesky Label Rating.\n    </Trans>\n  ),\n\n  'validation.file.e621.tags.network-error': () => (\n    <Trans>Failed to validate tags. Please check them manually</Trans>\n  ),\n\n  'validation.file.e621.tags.recommended': ({ generalTags }) => (\n    <Trans>\n      It is recommended to add at least 10 general tags{' '}\n      <strong>( {generalTags} / 10 )</strong>. See{' '}\n      <ExternalLink href=\"https://e621.net/help/tagging_checklist\">\n        tagging checklist\n      </ExternalLink>\n    </Trans>\n  ),\n\n  'validation.file.e621.user-feedback.network-error': () => (\n    <Trans>\n      Failed to get user warnings. You can check your account manually\n    </Trans>\n  ),\n\n  'validation.file.e621.user-feedback.recent': ({\n    feedback,\n    negativeOrNeutral,\n    username,\n  }) => (\n    <Trans>\n      You have recent {negativeOrNeutral} feedback: {feedback}, you can view it\n      <ExternalLink\n        href={`https://e621.net/user_feedbacks?search[user_name]=${username}`}\n      >\n        here\n      </ExternalLink>\n    </Trans>\n  ),\n\n  'validation.file.e621.tags.missing': ({ tag }) => (\n    <Trans>\n      Tag{' '}\n      <ExternalLink\n        href={`https://e621.net/wiki_pages/show_or_new?title=${tag}`}\n      >\n        {tag}\n      </ExternalLink>{' '}\n      does not exist yet or is invalid.\n    </Trans>\n  ),\n\n  'validation.file.e621.tags.missing-create': ({ tag }) => (\n    <Trans>\n      Tag{' '}\n      <ExternalLink\n        href={`https://e621.net/wiki_pages/show_or_new?title=${tag}`}\n      >\n        {tag}\n      </ExternalLink>{' '}\n      does not exist yet or is invalid. If you want to create a new tag, make a\n      post with it, then go{' '}\n      <ExternalLink href={`https://e621.net/tags?search[name]=${tag}`}>\n        here\n      </ExternalLink>\n      , press edit and select tag category\n    </Trans>\n  ),\n\n  'validation.file.e621.tags.invalid': ({ tag }) => (\n    <Trans>\n      Tag{' '}\n      <ExternalLink\n        href={`https://e621.net/wiki_pages/show_or_new?title=${tag}`}\n      >\n        {tag}\n      </ExternalLink>{' '}\n      is invalid.\n    </Trans>\n  ),\n\n  'validation.file.e621.tags.low-use': ({ tag, postCount }) => (\n    <Trans>\n      Tag {tag} has {postCount} post(s). Tag may be invalid or low use\n    </Trans>\n  ),\n\n  'validation.folder.missing-or-invalid': () => (\n    <Trans>Selected option is invalid or missing</Trans>\n  ),\n\n  'validation.file.instagram.invalid-aspect-ratio': (props) => {\n    const { fileName } = props;\n    return (\n      <Trans>\n        File &quot;{fileName}&quot; has an unsupported aspect ratio for\n        Instagram. Supported ratios: 1:1 (square), 4:5 (portrait), 1.91:1\n        (landscape).\n      </Trans>\n    );\n  },\n\n  'validation.rating.unsupported-rating': (props: {\n    rating: string;\n  }): JSX.Element => {\n    const { rating } = props;\n    let ratingLabel: JSX.Element;\n    switch (rating) {\n      case SubmissionRating.GENERAL:\n        ratingLabel = <Trans>General</Trans>;\n        break;\n      case SubmissionRating.MATURE:\n        ratingLabel = <Trans>Mature</Trans>;\n        break;\n      case SubmissionRating.ADULT:\n        ratingLabel = <Trans>Adult</Trans>;\n        break;\n      case SubmissionRating.EXTREME:\n        ratingLabel = <Trans>Extreme</Trans>;\n        break;\n      default:\n        ratingLabel = <span>{rating}</span>;\n        break;\n    }\n    return <Trans>Unsupported rating: {ratingLabel}</Trans>;\n  },\n};\n\nexport function ValidationTranslation({\n  values,\n  id,\n}: Pick<ValidationMessage, 'id' | 'values'>): JSX.Element {\n  const translation = TranslationMessages[id];\n  if (translation) {\n    return translation(\n      // @ts-expect-error Typescript does not know union type\n      values,\n    );\n  }\n\n  return <Trans>Translation {id} not found</Trans>;\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/index.tsx",
    "content": "/**\n * RemakeApp - Entry point for the remake UI with all providers.\n * Uses state-driven navigation instead of React Router.\n */\n\nimport '@mantine/core/styles.css';\nimport '@mantine/dates/styles.css';\nimport '@mantine/notifications/styles.css';\nimport './styles/layout.css';\nimport './theme/theme-styles.css';\n\nimport { MantineProvider, useMantineColorScheme } from '@mantine/core';\nimport { Notifications } from '@mantine/notifications';\nimport { useEffect, useMemo } from 'react';\nimport { QueryClient, QueryClientProvider } from 'react-query';\nimport { Disclaimer } from './components/disclaimer/disclaimer';\nimport { PageErrorBoundary } from './components/error-boundary';\nimport { Layout } from './components/layout/layout';\nimport { TourProvider } from './components/onboarding-tour';\nimport { I18nProvider } from './providers/i18n-provider';\nimport { loadAllStores } from './stores';\nimport { useColorScheme, usePrimaryColor } from './stores/ui/appearance-store';\nimport { cssVariableResolver } from './theme/css-variable-resolver';\nimport { createAppTheme } from './theme/theme';\n\nimport './components/website-components/index';\n\nconst queryClient = new QueryClient({\n  defaultOptions: {\n    queries: {\n      refetchOnWindowFocus: false,\n    },\n  },\n});\n\n/**\n * Inner app component that uses the color scheme from state.\n * Must be inside MantineProvider to use useMantineColorScheme.\n */\nfunction AppContent() {\n  const colorScheme = useColorScheme();\n  const { setColorScheme } = useMantineColorScheme();\n\n  // Sync our store's colorScheme with Mantine's colorScheme\n  useEffect(() => {\n    setColorScheme(colorScheme);\n  }, [colorScheme, setColorScheme]);\n\n  return (\n    <QueryClientProvider client={queryClient}>\n      <PageErrorBoundary>\n        <TourProvider>\n          <Layout />\n        </TourProvider>\n      </PageErrorBoundary>\n    </QueryClientProvider>\n  );\n}\n\n/**\n * Root application component for the remake UI.\n * Includes all necessary providers: Mantine, i18n, and React Query.\n * Uses state-driven navigation via UI store viewState.\n */\nexport function PostyBirb() {\n  const primaryColor = usePrimaryColor();\n\n  // Create theme with dynamic primary color\n  const dynamicTheme = useMemo(\n    () => createAppTheme(primaryColor),\n    [primaryColor],\n  );\n\n  useEffect(() => {\n    loadAllStores()\n      .then(() => {\n        // eslint-disable-next-line no-console, lingui/no-unlocalized-strings\n        console.log('All stores loaded successfully');\n      })\n      .catch((error) => {\n        // eslint-disable-next-line no-console, lingui/no-unlocalized-strings\n        console.error('Failed to load all stores', error);\n      });\n  }, []);\n\n  return (\n    <MantineProvider\n      theme={dynamicTheme}\n      cssVariablesResolver={cssVariableResolver}\n      defaultColorScheme=\"auto\"\n    >\n      <I18nProvider>\n        <Notifications zIndex=\"var(--z-notification)\" />\n        <Disclaimer>\n          <AppContent />\n        </Disclaimer>\n      </I18nProvider>\n    </MantineProvider>\n  );\n}\n\nexport default PostyBirb;\n"
  },
  {
    "path": "apps/postybirb-ui/src/main.tsx",
    "content": "import { RemoteConfig } from '@postybirb/utils/electron';\nimport { createRoot } from 'react-dom/client';\nimport { initializeAppInsightsUI } from './app-insights-ui';\nimport { PostyBirb } from './index';\n\n// Initialize Application Insights for UI error tracking\ninitializeAppInsightsUI();\n\nfunction Root() {\n  return <PostyBirb />;\n}\n\n// eslint-disable-next-line @typescript-eslint/no-non-null-assertion\ncreateRoot(document.getElementById('root')!).render(<Root />);\n\nwindow.addEventListener('keydown', (event) => {\n  if (\n    event.key === 'F5' ||\n    (event.ctrlKey && event.key.toLowerCase() === 'r')\n  ) {\n    event.preventDefault();\n    window.location.reload();\n  }\n});\n\ndeclare global {\n  interface Window {\n    electron: {\n      getAppVersion(): Promise<string>;\n      getLanIp(): Promise<string | undefined>;\n      getRemoteConfig(): RemoteConfig;\n      pickDirectory?(): Promise<string | undefined>;\n      openExternalLink(url: string): void;\n      getCookiesForAccount(accountId: string): Promise<string>;\n      quit(code?: number): void;\n      platform: NodeJS.Platform;\n      app_port: string;\n      app_version: string;\n\n      setSpellCheckerEnabled(value: boolean): void;\n      setSpellcheckerLanguages: (languages: string[]) => Promise<void>;\n      getSpellcheckerLanguages: () => Promise<string[]>;\n      getAllSpellcheckerLanguages: () => Promise<string[]>;\n      getSpellcheckerWords: () => Promise<string[]>;\n      setSpellcheckerWords: (words: string[]) => Promise<void>;\n    };\n  }\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/models/http-error-response.ts",
    "content": "export default interface HttpErrorResponse {\n  statusCode: number;\n  message: string;\n  error: string;\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/providers/i18n-provider.tsx",
    "content": "/**\n * I18nProvider - Wraps the application with Lingui i18n support.\n * Listens to language changes from the UI store.\n */\n\n/* eslint-disable lingui/no-unlocalized-strings */\nimport { i18n } from '@lingui/core';\nimport { I18nProvider as LinguiI18nProvider } from '@lingui/react';\nimport { Group, Loader } from '@mantine/core';\nimport { DatesProvider } from '@mantine/dates';\nimport moment from 'moment/min/moment-with-locales';\nimport { useCallback, useEffect, useState } from 'react';\nimport { useLanguage } from '../stores/ui/locale-store';\n\n/**\n * Provides Lingui i18n context and Mantine DatesProvider for the app.\n * Loads locale messages dynamically from the lang directory.\n * Listens to language state from UI store for reactive updates.\n */\nexport function I18nProvider({ children }: { children: React.ReactNode }) {\n  const locale = useLanguage();\n  const [loaded, setLoaded] = useState(false);\n\n  const loadLocale = useCallback(async (lang: string) => {\n    // Vite plugin lingui automatically converts .po files into plain json\n    // during production build and vite converts dynamic import into the path map.\n    // We don't need to cache these imported messages because browser's import\n    // call does it automatically.\n    // eslint-disable-next-line no-param-reassign\n    lang = lang ?? 'en';\n    const { messages } = await import(`../../../../lang/${lang}.po`);\n    i18n.loadAndActivate({ locale: lang, messages });\n    moment.locale(lang);\n    setLoaded(true);\n  }, []);\n\n  useEffect(() => {\n    let cancelled = false;\n    loadLocale(locale).catch((error) => {\n      if (!cancelled) {\n        // eslint-disable-next-line no-console\n        console.error('Failed to load locale:', locale, error);\n      }\n    });\n    return () => {\n      cancelled = true;\n    };\n  }, [locale, loadLocale]);\n\n  const [tooLongLoading, setTooLongLoading] = useState(false);\n  useEffect(() => {\n    const timeout = setTimeout(() => {\n      if (!loaded) {\n        setTooLongLoading(true);\n      }\n    }, 5000);\n    return () => clearTimeout(timeout);\n  }, [loaded]);\n\n  if (loaded) {\n    return (\n      <LinguiI18nProvider i18n={i18n}>\n        <DatesProvider settings={{ locale }}>{children}</DatesProvider>\n      </LinguiI18nProvider>\n    );\n  }\n\n  return (\n    <Group justify=\"center\" align=\"center\" style={{ minHeight: '100vh' }}>\n      <Loader />\n      <div>Loading translations...</div>\n      {tooLongLoading && (\n        <div>Loading takes too much time, please check the console for errors.</div>\n      )}\n    </Group>\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/shared/platform-utils.ts",
    "content": "/* eslint-disable lingui/no-unlocalized-strings */\n/**\n * Platform detection and cross-platform utilities.\n * Provides helpers for detecting the current OS and formatting\n * platform-appropriate keyboard shortcuts.\n */\n\n/**\n * Detect if the current platform is macOS.\n */\nexport const isMac =\n  typeof navigator !== 'undefined' &&\n  navigator.platform.toLowerCase().includes('mac');\n\n/**\n * Get the navigation modifier key for tinykeys.\n * Returns 'Control' for macOS (Option key is buggy), 'Alt' for Windows/Linux.\n */\nexport function getNavigationModifier(): 'Control' | 'Alt' {\n  return isMac ? 'Control' : 'Alt';\n}\n\n/**\n * Get the action modifier key for tinykeys.\n * Returns 'Meta' for macOS, 'Control' for Windows/Linux.\n */\nexport function getActionModifier(): 'Meta' | 'Control' {\n  return isMac ? 'Meta' : 'Control';\n}\n\n/**\n * Format a keybinding string for display.\n * Converts modifier names to platform-appropriate symbols.\n *\n * macOS: Control -> ⌃, Meta -> ⌘, Alt -> ⌥\n * Windows/Linux: Control -> Ctrl, Meta -> Win, Alt -> Alt\n *\n * @param keybinding - The keybinding string (e.g., 'Control+S', 'Alt+H')\n * @returns Formatted string for display (e.g., '⌃S' on Mac, 'Alt+S' on Windows)\n */\nexport function formatKeybindingDisplay(keybinding: string): string {\n  if (isMac) {\n    return keybinding\n      .replace(/Control\\+/g, '⌃')\n      .replace(/Meta\\+/g, '⌘')\n      .replace(/Alt\\+/g, '⌥')\n      .replace(/Shift\\+/g, '⇧');\n  }\n  return keybinding\n    .replace(/Control\\+/g, 'Ctrl+')\n    .replace(/Meta\\+/g, 'Win+');\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/stores/create-entity-store.ts",
    "content": "/* eslint-disable lingui/no-unlocalized-strings */\n/**\n * Base entity store factory for creating Zustand stores with common CRUD patterns.\n * All entity stores follow a similar pattern of loading, storing, and providing access to records.\n */\n\nimport type { EntityId } from '@postybirb/types';\nimport type { StoreApi } from 'zustand';\nimport { create } from 'zustand';\nimport { useShallow } from 'zustand/shallow';\nimport AppSocket from '../transports/websocket';\nimport type { BaseRecord } from './records/base-record';\n\n/**\n * Loading state for async operations.\n */\nexport type LoadingState = 'idle' | 'loading' | 'loaded' | 'error';\n\n/**\n * Base state interface for all entity stores.\n */\nexport interface BaseEntityState<T extends BaseRecord> {\n  /** Array of all records */\n  records: T[];\n  /** Map of records by ID for O(1) lookup */\n  recordsMap: Map<EntityId, T>;\n  /** Current loading state */\n  loadingState: LoadingState;\n  /** Error message if loading failed */\n  error: string | null;\n  /** Timestamp of last successful load */\n  lastLoadedAt: Date | null;\n}\n\n/**\n * Base actions interface for all entity stores.\n */\nexport interface BaseEntityActions<T extends BaseRecord> {\n  /** Load all records from the API */\n  loadAll: () => Promise<void>;\n  /** Set records directly (for websocket updates) */\n  setRecords: (records: T[]) => void;\n  /** Get a record by ID */\n  getById: (id: EntityId) => T | undefined;\n  /** Clear all records and reset state */\n  clear: () => void;\n}\n\n/**\n * Complete entity store type.\n */\nexport type EntityStore<T extends BaseRecord> = BaseEntityState<T> &\n  BaseEntityActions<T>;\n\n/**\n * Options for creating an entity store.\n */\nexport interface CreateEntityStoreOptions<\n  TDto = unknown,\n  TRecord extends BaseRecord = BaseRecord,\n> {\n  /** Name of the store for debugging */\n  storeName: string;\n  /** Websocket event name to subscribe to for real-time updates (optional) */\n  websocketEvent?: string;\n  /**\n   * Custom comparator to determine whether a record has changed.\n   * Receives the existing record and the incoming DTO.\n   * Return `true` if the record has changed and should be re-created.\n   * When not provided, falls back to comparing `updatedAt` timestamps.\n   */\n  hasChanged?: (existing: TRecord, newDto: TDto) => boolean;\n}\n\n// ============================================================================\n// Record-level diffing\n// ============================================================================\n\n/**\n * Compute a shallow diff between two plain objects, returning changed keys\n * with their old and new values. Nested objects are compared by JSON serialization.\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nfunction shallowDiff(\n  oldObj: Record<string, any>,\n  newObj: Record<string, any>,\n): Record<string, { old: unknown; new: unknown }> | null {\n  const changes: Record<string, { old: unknown; new: unknown }> = {};\n  const allKeys = new Set([...Object.keys(oldObj), ...Object.keys(newObj)]);\n  for (const key of allKeys) {\n    const oldVal = oldObj[key];\n    const newVal = newObj[key];\n    // Fast reference check, then fall back to JSON comparison for objects\n    if (oldVal !== newVal) {\n      const same =\n        typeof oldVal === 'object' &&\n        typeof newVal === 'object' &&\n        oldVal !== null &&\n        newVal !== null\n          ? JSON.stringify(oldVal) === JSON.stringify(newVal)\n          : false;\n      if (!same) {\n        changes[key] = { old: oldVal, new: newVal };\n      }\n    }\n  }\n  return Object.keys(changes).length > 0 ? changes : null;\n}\n\n/**\n * Diff incoming DTOs against existing records.\n * Only calls `createRecord` for genuinely new or changed entities.\n * Preserves existing record references when unchanged to prevent downstream re-renders.\n *\n * @returns `null` if nothing changed (callers should skip setState), or the new records + map.\n */\nexport function diffRecords<\n  TDto extends { id: string; updatedAt: string },\n  TRecord extends BaseRecord,\n>(\n  existingMap: Map<EntityId, TRecord>,\n  dtos: TDto[],\n  createRecord: (dto: TDto) => TRecord,\n  hasChanged?: (existing: TRecord, newDto: TDto) => boolean,\n  storeName?: string,\n): { records: TRecord[]; recordsMap: Map<EntityId, TRecord> } | null {\n  let anyChanged = false;\n  const tag = storeName ? `[${storeName}]` : '[diffRecords]';\n\n  const added: string[] = [];\n  const removed: string[] = [];\n  const updated: {\n    id: string;\n    changes: Record<string, { old: unknown; new: unknown }>;\n  }[] = [];\n\n  // Detect additions / removals by comparing size + IDs\n  if (dtos.length !== existingMap.size) {\n    anyChanged = true;\n  }\n\n  // Detect removed records (exist in map but not in incoming DTOs)\n  const incomingIds = new Set(dtos.map((d) => d.id));\n  for (const id of existingMap.keys()) {\n    if (!incomingIds.has(id)) {\n      removed.push(id);\n    }\n  }\n\n  const records: TRecord[] = [];\n  const recordsMap = new Map<EntityId, TRecord>();\n\n  for (const dto of dtos) {\n    const existing = existingMap.get(dto.id);\n\n    if (existing) {\n      // Determine if the record actually changed\n      const changed = hasChanged\n        ? hasChanged(existing, dto)\n        : dto.updatedAt !== existing.updatedAt.toISOString();\n\n      if (changed) {\n        const newRecord = createRecord(dto);\n        records.push(newRecord);\n        recordsMap.set(newRecord.id, newRecord);\n        anyChanged = true;\n\n        // Compute field-level diff for logging\n        // eslint-disable-next-line @typescript-eslint/no-explicit-any\n        const fieldDiff = shallowDiff(existing as any, newRecord as any);\n        updated.push({ id: dto.id, changes: fieldDiff ?? {} });\n      } else {\n        // Reuse existing reference — this is the key optimisation\n        records.push(existing);\n        recordsMap.set(existing.id, existing);\n      }\n    } else {\n      // New record\n      const newRecord = createRecord(dto);\n      records.push(newRecord);\n      recordsMap.set(newRecord.id, newRecord);\n      anyChanged = true;\n      added.push(dto.id);\n    }\n  }\n\n  // Log diff summary\n  if (anyChanged) {\n    // eslint-disable-next-line no-console, lingui/no-unlocalized-strings\n    console.groupCollapsed(\n      // eslint-disable-next-line lingui/no-unlocalized-strings\n      `${tag} Record diff — ${added.length} added, ${removed.length} removed, ${updated.length} updated`,\n    );\n    if (added.length > 0) {\n      // eslint-disable-next-line no-console, lingui/no-unlocalized-strings\n      console.log('Added:', added);\n    }\n    if (removed.length > 0) {\n      // eslint-disable-next-line no-console, lingui/no-unlocalized-strings\n      console.log('Removed:', removed);\n    }\n    for (const u of updated) {\n      // eslint-disable-next-line no-console, lingui/no-unlocalized-strings\n      console.groupCollapsed(`Updated: ${u.id}`);\n      for (const [key, val] of Object.entries(u.changes)) {\n        // eslint-disable-next-line no-console\n        console.log(\n          `  %c${key}`,\n          'font-weight:bold',\n          '\\n    old:',\n          val.old,\n          '\\n    new:',\n          val.new,\n        );\n      }\n      // eslint-disable-next-line no-console\n      console.groupEnd();\n    }\n    // eslint-disable-next-line no-console\n    console.groupEnd();\n  }\n\n  return anyChanged ? { records, recordsMap } : null;\n}\n\n/**\n * Factory function to create an entity store.\n *\n * @param fetchFn - Async function that fetches DTOs from the API\n * @param createRecord - Function that converts a DTO to a Record class\n * @param options - Store configuration options\n */\nexport function createEntityStore<\n  TDto extends { id: string; updatedAt: string },\n  TRecord extends BaseRecord,\n>(\n  fetchFn: () => Promise<TDto[]>,\n  createRecord: (dto: TDto) => TRecord,\n  options: CreateEntityStoreOptions<TDto, TRecord>,\n) {\n  const { storeName, websocketEvent, hasChanged } = options;\n\n  const initialState: BaseEntityState<TRecord> = {\n    records: [],\n    recordsMap: new Map(),\n    loadingState: 'idle',\n    error: null,\n    lastLoadedAt: null,\n  };\n\n  type StoreType = EntityStore<TRecord>;\n  type SetState = StoreApi<StoreType>['setState'];\n  type GetState = StoreApi<StoreType>['getState'];\n\n  const storeCreator = (set: SetState, get: GetState): StoreType => ({\n    ...initialState,\n\n    loadAll: async () => {\n      // Skip if already loading\n      if (get().loadingState === 'loading') {\n        return;\n      }\n\n      set({ loadingState: 'loading', error: null });\n\n      try {\n        const dtos = await fetchFn();\n        const { recordsMap: existingMap } = get();\n\n        // Use diffing on subsequent loads; on first load existingMap is empty so all records are new\n        const diffResult = diffRecords(\n          existingMap,\n          dtos,\n          createRecord,\n          hasChanged,\n          storeName,\n        );\n        if (diffResult) {\n          set({\n            records: diffResult.records,\n            recordsMap: diffResult.recordsMap,\n            loadingState: 'loaded',\n            lastLoadedAt: new Date(),\n          });\n        } else {\n          // No changes — just update loading state\n          set({ loadingState: 'loaded', lastLoadedAt: new Date() });\n        }\n\n        // eslint-disable-next-line no-console, lingui/no-unlocalized-strings\n        console.debug(`[${storeName}] Loaded ${dtos.length} records`);\n      } catch (err) {\n        const errorMessage =\n          // eslint-disable-next-line lingui/no-unlocalized-strings\n          err instanceof Error ? err.message : 'Unknown error';\n        set({\n          loadingState: 'error',\n          error: errorMessage,\n        });\n        // eslint-disable-next-line no-console, lingui/no-unlocalized-strings\n        console.error(`[${storeName}] Failed to load:`, err);\n      }\n    },\n\n    setRecords: (records: TRecord[]) => {\n      const recordsMap = new Map<EntityId, TRecord>();\n      records.forEach((record) => {\n        recordsMap.set(record.id, record);\n      });\n\n      set({\n        records,\n        recordsMap,\n        loadingState: 'loaded',\n        lastLoadedAt: new Date(),\n      });\n    },\n\n    getById: (id: EntityId) => get().recordsMap.get(id),\n\n    clear: () => {\n      set(initialState);\n    },\n  });\n\n  const store = create<StoreType>(storeCreator);\n\n  // Subscribe to websocket events if event name is provided\n  if (websocketEvent) {\n    AppSocket.on(websocketEvent, (dtos: TDto[]) => {\n      // eslint-disable-next-line no-console\n      console.debug(\n        `[${storeName}] Received ${dtos.length} records via websocket`,\n      );\n\n      const { recordsMap: existingMap } = store.getState();\n      const diffResult = diffRecords(\n        existingMap,\n        dtos,\n        createRecord,\n        hasChanged,\n        storeName,\n      );\n\n      if (diffResult) {\n        store.setState({\n          records: diffResult.records,\n          recordsMap: diffResult.recordsMap,\n          loadingState: 'loaded',\n          lastLoadedAt: new Date(),\n        });\n      }\n      // If diffResult is null, nothing changed — skip setState entirely\n    });\n  }\n\n  return store;\n}\n\n/**\n * Hook factory to create a selector for records array.\n */\nexport function useRecordsSelector<T extends BaseRecord>(\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  store: ReturnType<typeof createEntityStore<any, T>>,\n): T[] {\n  type StoreState = EntityStore<T>;\n  return store((state: StoreState) => state.records);\n}\n\n/**\n * Hook factory to create a selector for loading state.\n */\nexport function useLoadingStateSelector<T extends BaseRecord>(\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  store: ReturnType<typeof createEntityStore<any, T>>,\n) {\n  type StoreState = EntityStore<T>;\n  return store(\n    useShallow((state: StoreState) => ({\n      loadingState: state.loadingState,\n      error: state.error,\n      isLoading: state.loadingState === 'loading',\n      isLoaded: state.loadingState === 'loaded',\n      hasError: state.loadingState === 'error',\n    })),\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/stores/create-typed-store.ts",
    "content": "/**\n * Typed Store Factory - Creates entity stores with all standard selector hooks.\n * Reduces boilerplate from ~70 lines to ~15 lines per store.\n */\n\nimport { useShallow } from 'zustand/shallow';\nimport { createEntityStore, type EntityStore } from './create-entity-store';\nimport type { BaseRecord } from './records/base-record';\n\n/**\n * Configuration for creating a typed store.\n */\nexport interface TypedStoreConfig<TDto extends { id: string; updatedAt: string }, TRecord extends BaseRecord> {\n  /** Async function that fetches DTOs from the API */\n  fetchFn: () => Promise<TDto[]>;\n  /** Function that converts a DTO to a Record class */\n  createRecord: (dto: TDto) => TRecord;\n  /** Name of the store for debugging */\n  storeName: string;\n  /** Websocket event name to subscribe to for real-time updates (optional) */\n  websocketEvent?: string;\n  /**\n   * Custom comparator to determine whether a record has changed.\n   * Receives the existing record and the incoming DTO.\n   * Return `true` if the record has changed and should be re-created.\n   * When not provided, falls back to comparing `updatedAt` timestamps.\n   */\n  hasChanged?: (existing: TRecord, newDto: TDto) => boolean;\n}\n\n/**\n * Return type of createTypedStore.\n */\nexport interface TypedStoreResult<TRecord extends BaseRecord> {\n  /** The underlying Zustand store */\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  useStore: ReturnType<typeof createEntityStore<any, TRecord>>;\n  /** Hook to get all records */\n  useRecords: () => TRecord[];\n  /** Hook to get records map for O(1) lookup */\n  useRecordsMap: () => Map<string, TRecord>;\n  /** Hook to get loading state */\n  useLoading: () => {\n    loadingState: 'idle' | 'loading' | 'loaded' | 'error';\n    error: string | null;\n    isLoading: boolean;\n    isLoaded: boolean;\n  };\n  /** Hook to get store actions */\n  useActions: () => {\n    loadAll: () => Promise<void>;\n    setRecords: (records: TRecord[]) => void;\n    getById: (id: string) => TRecord | undefined;\n    clear: () => void;\n  };\n}\n\n/**\n * Creates an entity store with all standard selector hooks.\n * Reduces boilerplate by generating the common selector patterns automatically.\n *\n * @example\n * ```typescript\n * export const {\n *   useStore: useTagConverterStore,\n *   useRecords: useTagConverters,\n *   useRecordsMap: useTagConvertersMap,\n *   useLoading: useTagConvertersLoading,\n *   useActions: useTagConverterActions,\n * } = createTypedStore({\n *   fetchFn: () => tagConvertersApi.getAll().then((r) => r.body),\n *   createRecord: (dto) => new TagConverterRecord(dto),\n *   storeName: 'TagConverterStore',\n *   websocketEvent: TAG_CONVERTER_UPDATES,\n * });\n * ```\n */\nexport function createTypedStore<TDto extends { id: string; updatedAt: string }, TRecord extends BaseRecord>(\n  config: TypedStoreConfig<TDto, TRecord>\n): TypedStoreResult<TRecord> {\n  type StoreState = EntityStore<TRecord>;\n\n  const useStore = createEntityStore<TDto, TRecord>(\n    config.fetchFn,\n    config.createRecord,\n    {\n      storeName: config.storeName,\n      websocketEvent: config.websocketEvent,\n      hasChanged: config.hasChanged,\n    }\n  );\n\n  /**\n   * Hook to get all records.\n   * Uses shallow comparison to prevent unnecessary re-renders.\n   */\n  const useRecords = () =>\n    useStore(useShallow((state: StoreState) => state.records));\n\n  /**\n   * Hook to get records map for O(1) lookup.\n   * Reference stability is handled upstream by diffRecords.\n   */\n  const useRecordsMap = () =>\n    useStore((state: StoreState) => state.recordsMap);\n\n  /**\n   * Hook to get loading state.\n   */\n  const useLoading = () =>\n    useStore(\n      useShallow((state: StoreState) => ({\n        loadingState: state.loadingState,\n        error: state.error,\n        isLoading: state.loadingState === 'loading',\n        isLoaded: state.loadingState === 'loaded',\n      }))\n    );\n\n  /**\n   * Hook to get store actions.\n   * useShallow is required because the selector returns an object literal —\n   * without it, Zustand's Object.is check sees a new reference every render → infinite loop.\n   */\n  const useActions = () =>\n    useStore(\n      useShallow((state: StoreState) => ({\n        loadAll: state.loadAll,\n        setRecords: state.setRecords,\n        getById: state.getById,\n        clear: state.clear,\n      }))\n    );\n\n  return {\n    useStore,\n    useRecords,\n    useRecordsMap,\n    useLoading,\n    useActions,\n  };\n}\n\n/**\n * Type alias for extracting the store type from a typed store result.\n */\nexport type ExtractStoreType<T> = T extends TypedStoreResult<infer R>\n  ? EntityStore<R>\n  : never;\n"
  },
  {
    "path": "apps/postybirb-ui/src/stores/entity/account-store.ts",
    "content": "/**\n * Account Store - Zustand store for account entities.\n */\n\nimport { ACCOUNT_UPDATES } from '@postybirb/socket-events';\nimport type { AccountId, IAccountDto } from '@postybirb/types';\nimport { useShallow } from 'zustand/react/shallow';\nimport accountApi from '../../api/account.api';\nimport { createEntityStore, type EntityStore } from '../create-entity-store';\nimport { AccountRecord } from '../records';\n\n// ============================================================================\n// Account-specific change detection\n// ============================================================================\n\n/**\n * Deep change-detection for accounts.\n *\n * Checks the root `updatedAt` timestamp as well as the `state` (ILoginState)\n * fields which may change independently (e.g. login status toggled without\n * touching the account entity itself).\n */\nfunction accountHasChanged(existing: AccountRecord, dto: IAccountDto): boolean {\n  // 1. Root entity changed\n  if (dto.updatedAt !== existing.updatedAt.toISOString()) return true;\n\n  // 2. Login state changed\n  const dtoState = dto.state;\n  const existingState = existing.state;\n  if (dtoState.isLoggedIn !== existingState.isLoggedIn) return true;\n  if (dtoState.username !== existingState.username) return true;\n  if (dtoState.pending !== existingState.pending) return true;\n\n  return false;\n}\n\n/**\n * Fetch all accounts from the API.\n */\nconst fetchAccounts = async (): Promise<IAccountDto[]> => {\n  const response = await accountApi.getAll();\n  return response.body;\n};\n\n/**\n * Account store instance.\n */\nexport const useAccountStore = createEntityStore<IAccountDto, AccountRecord>(\n  fetchAccounts,\n  (dto) => new AccountRecord(dto),\n  {\n    // eslint-disable-next-line lingui/no-unlocalized-strings\n    storeName: 'AccountStore',\n    websocketEvent: ACCOUNT_UPDATES,\n    hasChanged: accountHasChanged,\n  }\n);\n\n/**\n * Type alias for the account store.\n */\nexport type AccountStoreState = EntityStore<AccountRecord>;\n\n/** @deprecated Use AccountStoreState instead */\nexport type AccountStore = AccountStoreState;\n\n// ============================================================================\n// Selector Hooks\n// ============================================================================\n\n/**\n * Select all accounts.\n * Uses useShallow for stable reference when items haven't changed.\n */\nexport const useAccounts = (): AccountRecord[] =>\n  useAccountStore(useShallow((state: AccountStoreState) => state.records));\n\n/**\n * Select accounts map for O(1) lookup.\n * Uses useShallow for stable reference when items haven't changed.\n */\nexport const useAccountsMap = () =>\n  useAccountStore(useShallow((state: AccountStoreState) => state.recordsMap));\n\n/**\n * Select account loading state.\n */\nexport const useAccountsLoading = () =>\n  useAccountStore(\n    useShallow((state: AccountStoreState) => ({\n      loadingState: state.loadingState,\n      error: state.error,\n      isLoading: state.loadingState === 'loading',\n      isLoaded: state.loadingState === 'loaded',\n    }))\n  );\n\n/**\n * Select a specific account by ID.\n */\nexport const useAccount = (id: AccountId) =>\n  useAccountStore((state: AccountStoreState) => state.recordsMap.get(id));\n\n/**\n * Select only logged-in accounts.\n * Uses useShallow for stable reference when items haven't changed.\n */\nexport const useLoggedInAccounts = (): AccountRecord[] =>\n  useAccountStore(\n    useShallow((state: AccountStoreState) =>\n      state.records.filter((acc) => acc.isLoggedIn)\n    )\n  );\n\n/**\n * Get accounts grouped by website ID.\n * This is a utility function, not a hook - use with useMemo in components.\n */\nexport const groupAccountsByWebsite = (\n  accounts: AccountRecord[]\n): Map<string, AccountRecord[]> => {\n  const grouped = new Map<string, AccountRecord[]>();\n  accounts.forEach((account) => {\n    const existing = grouped.get(account.website) ?? [];\n    existing.push(account);\n    grouped.set(account.website, existing);\n  });\n  return grouped;\n};\n\n/**\n * Select account store actions.\n * No useShallow needed — action function refs are stable.\n */\nexport const useAccountActions = () =>\n  useAccountStore(\n    useShallow((state: AccountStoreState) => ({\n      loadAll: state.loadAll,\n      setRecords: state.setRecords,\n      getById: state.getById,\n      clear: state.clear,\n    }))\n  );\n"
  },
  {
    "path": "apps/postybirb-ui/src/stores/entity/custom-shortcut-store.ts",
    "content": "/**\n * Custom Shortcut Store - Zustand store for custom shortcut entities.\n */\n\nimport { CUSTOM_SHORTCUT_UPDATES } from '@postybirb/socket-events';\nimport type { ICustomShortcutDto } from '@postybirb/types';\nimport customShortcutApi from '../../api/custom-shortcut.api';\nimport { type EntityStore } from '../create-entity-store';\nimport { createTypedStore } from '../create-typed-store';\nimport { CustomShortcutRecord } from '../records';\n\n/**\n * Custom shortcut store with all standard selector hooks.\n */\nexport const {\n  useStore: useCustomShortcutStore,\n  useRecords: useCustomShortcuts,\n  useRecordsMap: useCustomShortcutsMap,\n  useLoading: useCustomShortcutsLoading,\n  useActions: useCustomShortcutActions,\n} = createTypedStore<ICustomShortcutDto, CustomShortcutRecord>({\n  fetchFn: () => customShortcutApi.getAll().then((r) => r.body),\n  createRecord: (dto) => new CustomShortcutRecord(dto),\n  // eslint-disable-next-line lingui/no-unlocalized-strings\n  storeName: 'CustomShortcutStore',\n  websocketEvent: CUSTOM_SHORTCUT_UPDATES,\n});\n\n/**\n * Export the store for direct access outside of React components.\n * Used by BlockNote inline content specs that render outside React context.\n */\nexport const customShortcutStoreRef = useCustomShortcutStore;\n\n/**\n * Type alias for the custom shortcut store.\n */\nexport type CustomShortcutStore = EntityStore<CustomShortcutRecord>;\n"
  },
  {
    "path": "apps/postybirb-ui/src/stores/entity/directory-watcher-store.ts",
    "content": "/**\n * Directory Watcher Store - Zustand store for directory watcher entities.\n */\n\nimport { DIRECTORY_WATCHER_UPDATES } from '@postybirb/socket-events';\nimport type { DirectoryWatcherDto } from '@postybirb/types';\nimport { useShallow } from 'zustand/react/shallow';\nimport directoryWatchersApi from '../../api/directory-watchers.api';\nimport { type EntityStore } from '../create-entity-store';\nimport { createTypedStore } from '../create-typed-store';\nimport { DirectoryWatcherRecord } from '../records';\n\n/**\n * Directory watcher store with all standard selector hooks.\n */\nexport const {\n  useStore: useDirectoryWatcherStore,\n  useRecords: useDirectoryWatchers,\n  useRecordsMap: useDirectoryWatchersMap,\n  useLoading: useDirectoryWatchersLoading,\n  useActions: useDirectoryWatcherActions,\n} = createTypedStore<DirectoryWatcherDto, DirectoryWatcherRecord>({\n  fetchFn: () => directoryWatchersApi.getAll().then((r) => r.body),\n  createRecord: (dto) => new DirectoryWatcherRecord(dto),\n  // eslint-disable-next-line lingui/no-unlocalized-strings\n  storeName: 'DirectoryWatcherStore',\n  websocketEvent: DIRECTORY_WATCHER_UPDATES,\n});\n\n/**\n * Type alias for the directory watcher store.\n */\nexport type DirectoryWatcherStore = EntityStore<DirectoryWatcherRecord>;\n\n// ============================================================================\n// Additional Selector Hooks\n// ============================================================================\n\n/**\n * Select watchers with valid paths configured.\n * Uses useShallow for stable reference when items haven't changed.\n */\nexport const useActiveDirectoryWatchers = () =>\n  useDirectoryWatcherStore(\n    useShallow((state) =>\n      (state as DirectoryWatcherStore).records.filter((w) => w.hasPath)\n    )\n  );\n"
  },
  {
    "path": "apps/postybirb-ui/src/stores/entity/notification-store.ts",
    "content": "/**\n * Notification Store - Zustand store for notification entities.\n * Handles both data storage and UI notification display.\n */\n\nimport type { MantineColor } from '@mantine/core';\nimport { notifications } from '@mantine/notifications';\nimport { NOTIFICATION_UPDATES } from '@postybirb/socket-events';\nimport type { INotification } from '@postybirb/types';\nimport { useShallow } from 'zustand/react/shallow';\nimport notificationApi from '../../api/notification.api';\nimport AppSocket from '../../transports/websocket';\nimport { createEntityStore, type EntityStore } from '../create-entity-store';\nimport { NotificationRecord } from '../records';\n\n/**\n * Fetch all notifications from the API.\n */\nconst fetchNotifications = async (): Promise<INotification[]> => {\n  const response = await notificationApi.getAll();\n  return response.body;\n};\n\n/**\n * Get Mantine color based on notification type.\n */\nfunction getNotificationColor(type: INotification['type']): MantineColor {\n  switch (type) {\n    case 'error':\n      return 'red';\n    case 'success':\n      return 'green';\n    case 'warning':\n      return 'yellow';\n    case 'info':\n    default:\n      return 'blue';\n  }\n}\n\n/**\n * Display a UI notification for notifications that haven't been emitted yet.\n * Marks the notification as emitted when closed.\n */\nfunction showUINotification(notification: INotification): void {\n  if (notification.hasEmitted !== false) {\n    return;\n  }\n\n  // eslint-disable-next-line lingui/no-unlocalized-strings\n  const id = `notification-${notification.id}`;\n  const color = getNotificationColor(notification.type);\n\n  notifications.show({\n    id,\n    // eslint-disable-next-line lingui/no-unlocalized-strings\n    title: notification.title || 'Notification',\n    message: notification.message,\n    autoClose: true,\n    color,\n    onClose: () => {\n      // Mark the notification as emitted when closed\n      notificationApi\n        .update(notification.id, { ...notification, hasEmitted: true })\n        .then(() => {\n          // eslint-disable-next-line no-console, lingui/no-unlocalized-strings\n          console.debug(`Notification ${notification.id} marked as emitted`);\n        })\n        .catch((error) => {\n          // eslint-disable-next-line no-console, lingui/no-unlocalized-strings\n          console.error('Failed to update notification:', error);\n        });\n    },\n  });\n}\n\n/**\n * Subscribe to websocket updates and show UI notifications for new ones.\n */\nAppSocket.on(NOTIFICATION_UPDATES, (data: INotification[]) => {\n  data.forEach(showUINotification);\n});\n\n/**\n * Notification store instance.\n */\nexport const useNotificationStore = createEntityStore<\n  INotification,\n  NotificationRecord\n>(fetchNotifications, (dto) => new NotificationRecord(dto), {\n  // eslint-disable-next-line lingui/no-unlocalized-strings\n  storeName: 'NotificationStore',\n  websocketEvent: NOTIFICATION_UPDATES,\n});\n\n/**\n * Type alias for the notification store.\n */\nexport type NotificationStore = EntityStore<NotificationRecord>;\n\n// ============================================================================\n// Selector Hooks\n// ============================================================================\n\n/**\n * Select all notifications.\n * Uses useShallow for stable reference when items haven't changed.\n */\nexport const useNotifications = (): NotificationRecord[] =>\n  useNotificationStore(useShallow((state: NotificationStore) => state.records));\n\n/**\n * Select notifications map for O(1) lookup.\n * Uses useShallow for stable reference when items haven't changed.\n */\nexport const useNotificationsMap = (): Map<string, NotificationRecord> =>\n  useNotificationStore(useShallow((state: NotificationStore) => state.recordsMap));\n\n/**\n * Select notification loading state.\n */\nexport const useNotificationsLoading = () =>\n  useNotificationStore(\n    useShallow((state: NotificationStore) => ({\n      loadingState: state.loadingState,\n      error: state.error,\n      isLoading: state.loadingState === 'loading',\n      isLoaded: state.loadingState === 'loaded',\n    }))\n  );\n\n/**\n * Select unread notifications.\n * Uses useShallow for stable reference when items haven't changed.\n */\nexport const useUnreadNotifications = (): NotificationRecord[] =>\n  useNotificationStore(\n    useShallow((state: NotificationStore) => state.records.filter((n) => n.isUnread))\n  );\n\n/**\n * Select unread notification count.\n */\nexport const useUnreadNotificationCount = (): number =>\n  useNotificationStore((state: NotificationStore) => state.records.filter((n) => n.isUnread).length);\n\n/**\n * Select error notifications.\n * Uses useShallow for stable reference when items haven't changed.\n */\nexport const useErrorNotifications = (): NotificationRecord[] =>\n  useNotificationStore(\n    useShallow((state: NotificationStore) => state.records.filter((n) => n.isError))\n  );\n\n/**\n * Select warning notifications.\n * Uses useShallow for stable reference when items haven't changed.\n */\nexport const useWarningNotifications = (): NotificationRecord[] =>\n  useNotificationStore(\n    useShallow((state: NotificationStore) => state.records.filter((n) => n.isWarning))\n  );\n\n/**\n * Select notification store actions.\n * No useShallow needed — action function refs are stable.\n */\nexport const useNotificationActions = () =>\n  useNotificationStore(\n    useShallow((state: NotificationStore) => ({\n      loadAll: state.loadAll,\n      setRecords: state.setRecords,\n      getById: state.getById,\n      clear: state.clear,\n    }))\n  );\n"
  },
  {
    "path": "apps/postybirb-ui/src/stores/entity/settings-store.ts",
    "content": "/**\n * Settings Store - Zustand store for application settings.\n *\n * Note: Although the API returns an array, there will only ever be one settings record.\n * The store provides a convenient `useSettings()` hook that returns the single record.\n */\n\nimport { SETTINGS_UPDATES } from '@postybirb/socket-events';\nimport type { SettingsDto } from '@postybirb/types';\nimport { useShallow } from 'zustand/react/shallow';\nimport settingsApi from '../../api/settings.api';\nimport { createEntityStore, type EntityStore } from '../create-entity-store';\nimport { SettingsRecord } from '../records/settings-record';\n\n/**\n * Fetch all settings from the API.\n */\nconst fetchSettings = async (): Promise<SettingsDto[]> => {\n  const response = await settingsApi.getAll();\n  return response.body;\n};\n\n/**\n * Settings store instance.\n */\nexport const useSettingsStore = createEntityStore<SettingsDto, SettingsRecord>(\n  fetchSettings,\n  (dto) => new SettingsRecord(dto),\n  {\n    // eslint-disable-next-line lingui/no-unlocalized-strings\n    storeName: 'SettingsStore',\n    websocketEvent: SETTINGS_UPDATES,\n  },\n);\n\n/**\n * Type alias for the settings store.\n */\nexport type SettingsStore = EntityStore<SettingsRecord>;\n\n// ============================================================================\n// Selector Hooks\n// ============================================================================\n\n/**\n * Select the single settings record.\n * Returns undefined if settings haven't been loaded yet.\n */\nexport const useSettings = (): SettingsRecord | undefined =>\n  useSettingsStore((state: SettingsStore) => state.records[0]);\n\n/**\n * Select the settings options directly.\n * Useful when you only need the settings values without the record wrapper.\n */\nexport const useSettingsOptions = () =>\n  useSettingsStore((state: SettingsStore) => state.records[0]?.settings);\n\n/**\n * Select specific settings values.\n */\nexport const useLanguage = () =>\n  useSettingsStore((state: SettingsStore) => state.records[0]?.language);\n\nexport const useQueuePaused = () =>\n  useSettingsStore((state: SettingsStore) => state.records[0]?.queuePaused ?? false);\n\nexport const useHiddenWebsites = () =>\n  useSettingsStore((state: SettingsStore) => state.records[0]?.hiddenWebsites ?? []);\n\nexport const useAllowAd = () =>\n  useSettingsStore((state: SettingsStore) => state.records[0]?.allowAd ?? true);\n\nexport const useDesktopNotifications = () =>\n  useSettingsStore((state: SettingsStore) => state.records[0]?.desktopNotifications);\n\nexport const useTagSearchProvider = () =>\n  useSettingsStore((state: SettingsStore) => state.records[0]?.tagSearchProvider);\n\n/**\n * Select settings loading state.\n */\nexport const useSettingsLoading = () =>\n  useSettingsStore(\n    useShallow((state: SettingsStore) => ({\n      loadingState: state.loadingState,\n      error: state.error,\n      isLoading: state.loadingState === 'loading',\n      isLoaded: state.loadingState === 'loaded',\n    }))\n  );\n\n/**\n * Select settings actions.\n * No useShallow needed — action function refs are stable.\n */\nexport const useSettingsActions = () =>\n  useSettingsStore(\n    useShallow((state: SettingsStore) => ({\n      loadAll: state.loadAll,\n      clear: state.clear,\n    }))\n  );\n"
  },
  {
    "path": "apps/postybirb-ui/src/stores/entity/submission-store.ts",
    "content": "/**\n * Submission Store - Zustand store for submission entities.\n */\n\nimport { SUBMISSION_UPDATES } from '@postybirb/socket-events';\nimport type { ISubmissionDto, SubmissionId, SubmissionType, ValidationResult } from '@postybirb/types';\nimport { useShallow } from 'zustand/react/shallow';\nimport submissionApi from '../../api/submission.api';\nimport { createEntityStore, type EntityStore } from '../create-entity-store';\nimport { SubmissionRecord } from '../records';\n\n// ============================================================================\n// Submission-specific change detection\n// ============================================================================\n\n/**\n * Build a cheap fingerprint of a validation result's errors and warnings.\n * Skips the `account` field (large, stable) — only stringifies the small\n * error/warning arrays which are the actual changing content.\n */\nfunction validationFingerprint(v: ValidationResult): string {\n  return `${v.id}:${JSON.stringify(v.errors ?? [])}|${JSON.stringify(v.warnings ?? [])}`;\n}\n\n/**\n * Find the maximum `updatedAt` ISO string in an array of entities.\n * Returns an empty string when the array is empty.\n */\nfunction maxUpdatedAt(items: { updatedAt: string }[]): string {\n  if (items.length === 0) return '';\n  let max = items[0].updatedAt;\n  for (let i = 1; i < items.length; i++) {\n    if (items[i].updatedAt > max) max = items[i].updatedAt;\n  }\n  return max;\n}\n\n/**\n * Deep change-detection for submissions.\n *\n * A submission's root `updatedAt` does NOT reflect changes to nested entities\n * (files, options, posts are separate DB tables) and `ValidationResult` has no\n * `updatedAt` at all. This comparator checks all dimensions:\n *\n * - Root `updatedAt`\n * - File count + max file `updatedAt`\n * - Option count + max option `updatedAt`\n * - Post count + max post `updatedAt` + latest post state\n * - PostQueueRecord presence / id\n * - Validation fingerprint (JSON of errors/warnings arrays, excluding account)\n */\nfunction submissionHasChanged(existing: SubmissionRecord, dto: ISubmissionDto): boolean {\n  // 1. Root entity changed\n  if (dto.updatedAt !== existing.updatedAt.toISOString()) return true;\n\n  // 2. Files changed\n  const dtoFiles = dto.files ?? [];\n  if (dtoFiles.length !== existing.files.length) return true;\n  if (dtoFiles.length > 0 && maxUpdatedAt(dtoFiles) !== maxUpdatedAt(existing.files)) return true;\n\n  // 3. Options changed\n  const dtoOptions = dto.options ?? [];\n  if (dtoOptions.length !== existing.options.length) return true;\n  if (dtoOptions.length > 0 && maxUpdatedAt(dtoOptions) !== maxUpdatedAt(existing.options)) return true;\n\n  // 4. Posts changed (count, timestamps, or state)\n  const dtoPosts = dto.posts ?? [];\n  if (dtoPosts.length !== existing.posts.length) return true;\n  if (dtoPosts.length > 0) {\n    if (maxUpdatedAt(dtoPosts) !== maxUpdatedAt(existing.posts)) return true;\n    // Check if any post state changed (e.g. RUNNING → DONE)\n    const dtoLatestState = dtoPosts[dtoPosts.length - 1]?.state;\n    const existingLatestState = existing.posts[existing.posts.length - 1]?.state;\n    if (dtoLatestState !== existingLatestState) return true;\n  }\n\n  // 5. PostQueueRecord changed\n  const dtoQueueId = dto.postQueueRecord?.id ?? null;\n  const existingQueueId = existing.postQueueRecord?.id ?? null;\n  if (dtoQueueId !== existingQueueId) return true;\n\n  // 6. Validations changed (ephemeral — no updatedAt, use fingerprint)\n  const dtoValidations = dto.validations ?? [];\n  if (dtoValidations.length !== existing.validations.length) return true;\n  for (let i = 0; i < dtoValidations.length; i++) {\n    if (validationFingerprint(dtoValidations[i]) !== validationFingerprint(existing.validations[i])) {\n      return true;\n    }\n  }\n\n  return false;\n}\n\n/**\n * Fetch all submissions from the API.\n */\nconst fetchSubmissions = async (): Promise<ISubmissionDto[]> => {\n  const response = await submissionApi.getAll();\n  return response.body;\n};\n\n/**\n * Submission store instance.\n */\nexport const useSubmissionStore = createEntityStore<ISubmissionDto, SubmissionRecord>(\n  fetchSubmissions,\n  (dto) => new SubmissionRecord(dto),\n  {\n    // eslint-disable-next-line lingui/no-unlocalized-strings\n    storeName: 'SubmissionStore',\n    websocketEvent: SUBMISSION_UPDATES,\n    hasChanged: submissionHasChanged,\n  }\n);\n\n/**\n * Type alias for the submission store.\n */\nexport type SubmissionStoreState = EntityStore<SubmissionRecord>;\n\n/** @deprecated Use SubmissionStoreState instead */\nexport type SubmissionStore = SubmissionStoreState;\n\n// ============================================================================\n// Selector Hooks\n// ============================================================================\n\n/**\n * Select all submissions.\n * Uses useShallow for stable reference when items haven't changed.\n */\nexport const useSubmissions = (): SubmissionRecord[] =>\n  useSubmissionStore(useShallow((state: SubmissionStoreState) => state.records));\n\n/**\n * Select submissions map for O(1) lookup.\n * Uses useShallow for stable reference when items haven't changed.\n */\nexport const useSubmissionsMap = () =>\n  useSubmissionStore(useShallow((state: SubmissionStoreState) => state.recordsMap));\n\n/**\n * Select submission loading state.\n */\nexport const useSubmissionsLoading = () =>\n  useSubmissionStore(\n    useShallow((state: SubmissionStoreState) => ({\n      loadingState: state.loadingState,\n      error: state.error,\n      isLoading: state.loadingState === 'loading',\n      isLoaded: state.loadingState === 'loaded',\n    }))\n  );\n\n/**\n * Select a specific submission by ID.\n */\nexport const useSubmission = (id: SubmissionId) =>\n  useSubmissionStore((state: SubmissionStoreState) => state.recordsMap.get(id));\n\n/**\n * Select submissions by type (FILE or MESSAGE).\n * Uses useShallow for stable reference when items haven't changed.\n */\nexport const useSubmissionsByType = (type: SubmissionType): SubmissionRecord[] =>\n  useSubmissionStore(\n    useShallow((state: SubmissionStoreState) =>\n      state.records.filter((s) => s.type === type)\n    )\n  );\n\n/**\n * Select non-template, non-multi submissions (regular submissions).\n * Uses useShallow for stable reference when items haven't changed.\n */\nexport const useRegularSubmissions = (): SubmissionRecord[] =>\n  useSubmissionStore(\n    useShallow((state: SubmissionStoreState) =>\n      state.records.filter((s) => !s.isTemplate && !s.isMultiSubmission)\n    )\n  );\n\n/**\n * Select template submissions.\n * Uses useShallow for stable reference when items haven't changed.\n */\nexport const useTemplateSubmissions = (): SubmissionRecord[] =>\n  useSubmissionStore(\n    useShallow((state: SubmissionStoreState) =>\n      state.records.filter((s) => s.isTemplate)\n    )\n  );\n\n/**\n * Select scheduled submissions.\n * Uses useShallow for stable reference when items haven't changed.\n */\nexport const useScheduledSubmissions = (): SubmissionRecord[] =>\n  useSubmissionStore(\n    useShallow((state: SubmissionStoreState) =>\n      state.records.filter((s) => s.isScheduled && !s.isArchived)\n    )\n  );\n\n/**\n * Select submissions with a schedule (scheduledFor or cron, not archived/template).\n * Used by ScheduleCalendar to avoid subscribing to all submissions.\n */\nexport const useSubmissionsWithSchedule = (): SubmissionRecord[] =>\n  useSubmissionStore(\n    useShallow((state: SubmissionStoreState) =>\n      state.records.filter(\n        (s) =>\n          !s.isArchived &&\n          !s.isTemplate &&\n          (s.schedule.scheduledFor || s.schedule.cron)\n      )\n    )\n  );\n\n/**\n * Select unscheduled, non-archived, non-template, non-multi submissions.\n * Used by SubmissionList in the schedule drawer.\n */\nexport const useUnscheduledSubmissions = (): SubmissionRecord[] =>\n  useSubmissionStore(\n    useShallow((state: SubmissionStoreState) =>\n      state.records.filter(\n        (s) =>\n          !s.isArchived &&\n          !s.isTemplate &&\n          !s.isMultiSubmission &&\n          !s.schedule.scheduledFor &&\n          !s.schedule.cron\n      )\n    )\n  );\n\n/**\n * Select archived submissions.\n * Uses useShallow for stable reference when items haven't changed.\n */\nexport const useArchivedSubmissions = (): SubmissionRecord[] =>\n  useSubmissionStore(\n    useShallow((state: SubmissionStoreState) =>\n      state.records.filter((s) => s.isArchived)\n    )\n  );\n\n/**\n * Select queued submissions.\n * Uses useShallow for stable reference when items haven't changed.\n */\nexport const useQueuedSubmissions = (): SubmissionRecord[] =>\n  useSubmissionStore(\n    useShallow((state: SubmissionStoreState) =>\n      state.records.filter((s) => s.isQueued)\n    )\n  );\n\n/**\n * Select submissions with errors.\n * Uses useShallow for stable reference when items haven't changed.\n */\nexport const useSubmissionsWithErrors = (): SubmissionRecord[] =>\n  useSubmissionStore(\n    useShallow((state: SubmissionStoreState) =>\n      state.records.filter((s) => s.hasErrors)\n    )\n  );\n\n/**\n * Select submission store actions.\n * No useShallow needed — action function refs are stable.\n */\nexport const useSubmissionActions = () =>\n  useSubmissionStore(\n    useShallow((state: SubmissionStoreState) => ({\n      loadAll: state.loadAll,\n      setRecords: state.setRecords,\n      getById: state.getById,\n      clear: state.clear,\n    }))\n  );\n"
  },
  {
    "path": "apps/postybirb-ui/src/stores/entity/tag-converter-store.ts",
    "content": "/**\n * Tag Converter Store - Zustand store for tag converter entities.\n * Uses createTypedStore for reduced boilerplate.\n */\n\nimport { TAG_CONVERTER_UPDATES } from '@postybirb/socket-events';\nimport type { TagConverterDto } from '@postybirb/types';\nimport tagConvertersApi from '../../api/tag-converters.api';\nimport { type EntityStore } from '../create-entity-store';\nimport { createTypedStore } from '../create-typed-store';\nimport { TagConverterRecord } from '../records';\n\n/**\n * Tag converter store with all standard hooks.\n */\nexport const {\n  useStore: useTagConverterStore,\n  useRecords: useTagConverters,\n  useRecordsMap: useTagConvertersMap,\n  useLoading: useTagConvertersLoading,\n  useActions: useTagConverterActions,\n} = createTypedStore<TagConverterDto, TagConverterRecord>({\n  fetchFn: async () => {\n    const response = await tagConvertersApi.getAll();\n    return response.body;\n  },\n  createRecord: (dto) => new TagConverterRecord(dto),\n  // eslint-disable-next-line lingui/no-unlocalized-strings\n  storeName: 'TagConverterStore',\n  websocketEvent: TAG_CONVERTER_UPDATES,\n});\n\n/**\n * Type alias for the tag converter store.\n */\nexport type TagConverterStore = EntityStore<TagConverterRecord>;\n"
  },
  {
    "path": "apps/postybirb-ui/src/stores/entity/tag-group-store.ts",
    "content": "/**\n * Tag Group Store - Zustand store for tag group entities.\n */\n\nimport { TAG_GROUP_UPDATES } from '@postybirb/socket-events';\nimport type { TagGroupDto } from '@postybirb/types';\nimport { useShallow } from 'zustand/react/shallow';\nimport tagGroupsApi from '../../api/tag-groups.api';\nimport AppSocket from '../../transports/websocket';\nimport { type EntityStore } from '../create-entity-store';\nimport { createTypedStore } from '../create-typed-store';\nimport { TagGroupRecord } from '../records';\n\n/**\n * Tag group store with all standard selector hooks.\n */\nexport const {\n  useStore: useTagGroupStore,\n  useRecords: useTagGroups,\n  useRecordsMap: useTagGroupsMap,\n  useLoading: useTagGroupsLoading,\n  useActions: useTagGroupActions,\n} = createTypedStore<TagGroupDto, TagGroupRecord>({\n  fetchFn: () => tagGroupsApi.getAll().then((r) => r.body),\n  createRecord: (dto) => new TagGroupRecord(dto),\n  // eslint-disable-next-line lingui/no-unlocalized-strings\n  storeName: 'TagGroupStore',\n});\n\n// Subscribe to websocket updates\nAppSocket.on(TAG_GROUP_UPDATES, (payload: TagGroupDto[]) => {\n  if (Array.isArray(payload)) {\n    const records = payload.map((dto) => new TagGroupRecord(dto));\n    useTagGroupStore.getState().setRecords(records);\n  }\n});\n\n/**\n * Type alias for the tag group store.\n */\nexport type TagGroupStore = EntityStore<TagGroupRecord>;\n\n// ============================================================================\n// Additional Selector Hooks\n// ============================================================================\n\n/**\n * Select non-empty tag groups.\n * Uses shallow comparison to prevent unnecessary re-renders.\n */\nexport const useNonEmptyTagGroups = (): TagGroupRecord[] =>\n  useTagGroupStore(\n    useShallow((state) => (state as TagGroupStore).records.filter((g) => !g.isEmpty))\n  );\n"
  },
  {
    "path": "apps/postybirb-ui/src/stores/entity/user-converter-store.ts",
    "content": "/**\n * User Converter Store - Zustand store for user converter entities.\n * Uses createTypedStore for reduced boilerplate.\n */\n\nimport { USER_CONVERTER_UPDATES } from '@postybirb/socket-events';\nimport type { UserConverterDto } from '@postybirb/types';\nimport userConvertersApi from '../../api/user-converters.api';\nimport AppSocket from '../../transports/websocket';\nimport { type EntityStore } from '../create-entity-store';\nimport { createTypedStore } from '../create-typed-store';\nimport { UserConverterRecord } from '../records';\n\n/**\n * User converter store with all standard hooks.\n */\nexport const {\n  useStore: useUserConverterStore,\n  useRecords: useUserConverters,\n  useRecordsMap: useUserConvertersMap,\n  useLoading: useUserConvertersLoading,\n  useActions: useUserConverterActions,\n} = createTypedStore<UserConverterDto, UserConverterRecord>({\n  fetchFn: async () => {\n    const response = await userConvertersApi.getAll();\n    return response.body;\n  },\n  createRecord: (dto) => new UserConverterRecord(dto),\n  // eslint-disable-next-line lingui/no-unlocalized-strings\n  storeName: 'UserConverterStore',\n});\n\n// Subscribe to websocket updates\nAppSocket.on(USER_CONVERTER_UPDATES, (payload: UserConverterDto[]) => {\n  if (Array.isArray(payload)) {\n    const records = payload.map((dto) => new UserConverterRecord(dto));\n    useUserConverterStore.getState().setRecords(records);\n  }\n});\n\n/**\n * Type alias for the user converter store.\n */\nexport type UserConverterStore = EntityStore<UserConverterRecord>;\n"
  },
  {
    "path": "apps/postybirb-ui/src/stores/entity/website-store.ts",
    "content": "/**\n * Website Store - Zustand store for website info entities.\n * Handles website metadata, login types, and capabilities.\n */\n\nimport { WEBSITE_UPDATES } from '@postybirb/socket-events';\nimport type { IWebsiteInfoDto, WebsiteId } from '@postybirb/types';\nimport { create } from 'zustand';\nimport { useShallow } from 'zustand/react/shallow';\nimport websitesApi from '../../api/websites.api';\nimport AppSocket from '../../transports/websocket';\nimport type { LoadingState } from '../create-entity-store';\nimport { WebsiteRecord } from '../records';\n\n/**\n * Website store state interface.\n */\ninterface WebsiteState {\n  /** Array of all website records */\n  websites: WebsiteRecord[];\n  /** Map of websites by ID for O(1) lookup */\n  websitesMap: Map<WebsiteId, WebsiteRecord>;\n  /** Current loading state */\n  loadingState: LoadingState;\n  /** Error message if loading failed */\n  error: string | null;\n  /** Timestamp of last successful load */\n  lastLoadedAt: Date | null;\n}\n\n/**\n * Website store actions interface.\n */\ninterface WebsiteActions {\n  /** Load all websites from the API */\n  loadAll: () => Promise<void>;\n  /** Set websites directly (for websocket updates) */\n  setWebsites: (dtos: IWebsiteInfoDto[]) => void;\n  /** Get a website by ID */\n  getById: (id: WebsiteId) => WebsiteRecord | undefined;\n  /** Clear all websites and reset state */\n  clear: () => void;\n}\n\n/**\n * Complete website store type.\n */\nexport type WebsiteStore = WebsiteState & WebsiteActions;\n\n/**\n * Initial state for the website store.\n */\nconst initialState: WebsiteState = {\n  websites: [],\n  websitesMap: new Map(),\n  loadingState: 'idle',\n  error: null,\n  lastLoadedAt: null,\n};\n\n/**\n * Fetch all websites from the API.\n */\nconst fetchWebsites = async (): Promise<IWebsiteInfoDto[]> => {\n  const response = await websitesApi.getWebsiteInfo();\n  return response.body;\n};\n\n/**\n * Website store instance.\n */\nexport const useWebsiteStore = create<WebsiteStore>((set, get) => {\n  // Subscribe to websocket updates\n  AppSocket.on(WEBSITE_UPDATES, (data: IWebsiteInfoDto[]) => {\n    const records = data.map((dto) => new WebsiteRecord(dto));\n    const websitesMap = new Map<WebsiteId, WebsiteRecord>();\n    records.forEach((record) => {\n      websitesMap.set(record.id, record);\n    });\n\n    set({\n      websites: records,\n      websitesMap,\n      lastLoadedAt: new Date(),\n    });\n  });\n\n  return {\n    ...initialState,\n\n    loadAll: async () => {\n      // Skip if already loading\n      if (get().loadingState === 'loading') {\n        return;\n      }\n\n      set({ loadingState: 'loading', error: null });\n\n      try {\n        const dtos = await fetchWebsites();\n        const records = dtos.map((dto) => new WebsiteRecord(dto));\n        const websitesMap = new Map<WebsiteId, WebsiteRecord>();\n        records.forEach((record) => {\n          websitesMap.set(record.id, record);\n        });\n\n        set({\n          websites: records,\n          websitesMap,\n          loadingState: 'loaded',\n          error: null,\n          lastLoadedAt: new Date(),\n        });\n      } catch (err) {\n        const errorMessage =\n          err instanceof Error\n            ? err.message\n            : // eslint-disable-next-line lingui/no-unlocalized-strings\n              'Failed to load websites';\n        set({\n          loadingState: 'error',\n          error: errorMessage,\n        });\n        throw err;\n      }\n    },\n\n    setWebsites: (dtos: IWebsiteInfoDto[]) => {\n      const records = dtos.map((dto) => new WebsiteRecord(dto));\n      const websitesMap = new Map<WebsiteId, WebsiteRecord>();\n      records.forEach((record) => {\n        websitesMap.set(record.id, record);\n      });\n\n      set({\n        websites: records,\n        websitesMap,\n        lastLoadedAt: new Date(),\n      });\n    },\n\n    getById: (id: WebsiteId) => get().websitesMap.get(id),\n\n    clear: () => {\n      set(initialState);\n    },\n  };\n});\n\n// ============================================================================\n// Selector Hooks\n// ============================================================================\n\n/**\n * Select all websites.\n * Uses useShallow for stable reference when items haven't changed.\n */\nexport const useWebsites = (): WebsiteRecord[] =>\n  useWebsiteStore(useShallow((state: WebsiteStore) => state.websites));\n\n/**\n * Select websites map for O(1) lookup.\n * Uses useShallow for stable reference when items haven't changed.\n */\nexport const useWebsitesMap = (): Map<WebsiteId, WebsiteRecord> =>\n  useWebsiteStore(useShallow((state: WebsiteStore) => state.websitesMap));\n\n/**\n * Select website loading state.\n */\nexport const useWebsitesLoading = () =>\n  useWebsiteStore(\n    useShallow((state: WebsiteStore) => ({\n      loadingState: state.loadingState,\n      error: state.error,\n      isLoading: state.loadingState === 'loading',\n      isLoaded: state.loadingState === 'loaded',\n    }))\n  );\n\n/**\n * Select a specific website by ID.\n */\nexport const useWebsite = (id: WebsiteId): WebsiteRecord | undefined =>\n  useWebsiteStore((state: WebsiteStore) => state.websitesMap.get(id));\n\n/**\n * Select websites that support file submissions.\n * Uses useShallow for stable reference when items haven't changed.\n */\nexport const useFileWebsites = (): WebsiteRecord[] =>\n  useWebsiteStore(\n    useShallow((state: WebsiteStore) =>\n      state.websites.filter((website) => website.supportsFile)\n    )\n  );\n\n/**\n * Select websites that support message submissions.\n * Uses useShallow for stable reference when items haven't changed.\n */\nexport const useMessageWebsites = (): WebsiteRecord[] =>\n  useWebsiteStore(\n    useShallow((state: WebsiteStore) =>\n      state.websites.filter((website) => website.supportsMessage)\n    )\n  );\n\n/**\n * Select website store actions.\n * No useShallow needed — action function refs are stable.\n */\nexport const useWebsiteActions = () =>\n  useWebsiteStore(\n    useShallow((state: WebsiteStore) => ({\n      loadAll: state.loadAll,\n      setWebsites: state.setWebsites,\n      getById: state.getById,\n      clear: state.clear,\n    }))\n  );\n"
  },
  {
    "path": "apps/postybirb-ui/src/stores/index.ts",
    "content": "/**\n * Barrel exports for all stores.\n */\n\n// =============================================================================\n// Base Utilities\n// =============================================================================\n\nexport {\n    createEntityStore,\n    useLoadingStateSelector,\n    useRecordsSelector,\n    type BaseEntityActions,\n    type BaseEntityState,\n    type CreateEntityStoreOptions,\n    type EntityStore,\n    type LoadingState\n} from './create-entity-store';\n\n// Store initialization\nexport {\n    areAllStoresLoaded,\n    clearAllStores,\n    loadAllStores,\n    useInitializeStores\n} from './store-init';\n\n// =============================================================================\n// UI Stores (localStorage-persisted)\n// =============================================================================\n\n// Navigation Store\nexport {\n    useCanGoBack,\n    useCanGoForward,\n    useNavigationHistory,\n    useNavigationStore,\n    useViewState,\n    useViewStateActions,\n    type NavigationStore\n} from './ui/navigation-store';\n\n// Appearance Store\nexport {\n    MANTINE_COLORS,\n    useAppearanceActions,\n    useAppearanceStore,\n    useColorScheme,\n    useIsCompactView,\n    usePrimaryColor,\n    useSubmissionViewMode,\n    type AppearanceStore,\n    type ColorScheme,\n    type MantinePrimaryColor,\n    type SubmissionViewMode\n} from './ui/appearance-store';\n\n// Drawer Store\nexport {\n    useActiveDrawer,\n    useDrawerActions,\n    useDrawerStore,\n    useIsDrawerOpen,\n    type DrawerKey,\n    type DrawerStore\n} from './ui/drawer-store';\n\n// Submissions UI Store\nexport {\n    useFileSubmissionsFilter,\n    useMessageSubmissionsFilter,\n    useSidenavCollapsed, useSubmissionsContentPreferences,\n    useSubmissionsFilter,\n    useSubmissionsUIStore, useSubNavVisible, useToggleSectionPanel,\n    useToggleSidenav,\n    type SubmissionFilter,\n    type SubmissionsUIStore\n} from './ui/submissions-ui-store';\n\n// Accounts UI Store\nexport {\n    AccountLoginFilter,\n    useAccountsFilter,\n    useAccountsUIStore,\n    useHiddenWebsites as useUIHiddenWebsites,\n    type AccountsUIStore\n} from './ui/accounts-ui-store';\n\n// Templates UI Store\nexport {\n    useTemplatesFilter,\n    useTemplatesUIStore,\n    type TemplatesUIStore\n} from './ui/templates-ui-store';\n\n// Locale Store\nexport {\n    SUPPORTED_LOCALES,\n    useLanguageActions,\n    useLocaleStore,\n    useLanguage as useUILanguage,\n    type LocaleStore\n} from './ui/locale-store';\n\n// =============================================================================\n// Entity Stores (API-backed)\n// =============================================================================\n\n// Account Store\nexport {\n    groupAccountsByWebsite,\n    useAccount,\n    useAccountActions, useAccounts,\n    useAccountsLoading,\n    useAccountsMap, useAccountStore, useLoggedInAccounts,\n    type AccountStore\n} from './entity/account-store';\n\n// Submission Store\nexport {\n    useArchivedSubmissions,\n    useQueuedSubmissions,\n    useRegularSubmissions,\n    useScheduledSubmissions,\n    useSubmission,\n    useSubmissionActions, useSubmissions,\n    useSubmissionsByType,\n    useSubmissionsLoading,\n    useSubmissionsMap, useSubmissionStore, useSubmissionsWithErrors,\n    useTemplateSubmissions,\n    type SubmissionStore\n} from './entity/submission-store';\n\n// Custom Shortcut Store\nexport {\n    customShortcutStoreRef,\n    useCustomShortcutActions, useCustomShortcuts,\n    useCustomShortcutsLoading,\n    useCustomShortcutsMap, useCustomShortcutStore, type CustomShortcutStore\n} from './entity/custom-shortcut-store';\n\n// Directory Watcher Store\nexport {\n    useActiveDirectoryWatchers,\n    useDirectoryWatcherActions, useDirectoryWatchers,\n    useDirectoryWatchersLoading,\n    useDirectoryWatchersMap, useDirectoryWatcherStore, type DirectoryWatcherStore\n} from './entity/directory-watcher-store';\n\n// Notification Store\nexport {\n    useErrorNotifications,\n    useNotificationActions, useNotifications,\n    useNotificationsLoading,\n    useNotificationsMap, useNotificationStore, useUnreadNotificationCount,\n    useUnreadNotifications,\n    useWarningNotifications,\n    type NotificationStore\n} from './entity/notification-store';\n\n// Tag Converter Store\nexport {\n    useTagConverterActions, useTagConverters,\n    useTagConvertersLoading,\n    useTagConvertersMap, useTagConverterStore, type TagConverterStore\n} from './entity/tag-converter-store';\n\n// Tag Group Store\nexport {\n    useNonEmptyTagGroups,\n    useTagGroupActions, useTagGroups,\n    useTagGroupsLoading,\n    useTagGroupsMap, useTagGroupStore, type TagGroupStore\n} from './entity/tag-group-store';\n\n// User Converter Store\nexport {\n    useUserConverterActions, useUserConverters,\n    useUserConvertersLoading,\n    useUserConvertersMap, useUserConverterStore, type UserConverterStore\n} from './entity/user-converter-store';\n\n// Settings Store\nexport {\n    useAllowAd,\n    useDesktopNotifications,\n    useHiddenWebsites,\n    useLanguage,\n    useQueuePaused,\n    useSettings,\n    useSettingsActions,\n    useSettingsLoading,\n    useSettingsOptions,\n    useSettingsStore,\n    useTagSearchProvider,\n    type SettingsStore\n} from './entity/settings-store';\n\n// Website Store\nexport {\n    useFileWebsites,\n    useMessageWebsites,\n    useWebsite,\n    useWebsiteActions, useWebsites,\n    useWebsitesLoading,\n    useWebsitesMap, useWebsiteStore, type WebsiteStore\n} from './entity/website-store';\n\n// =============================================================================\n// Record Classes\n// =============================================================================\n\nexport * from './records';\n\n"
  },
  {
    "path": "apps/postybirb-ui/src/stores/records/account-record.ts",
    "content": "/**\n * AccountRecord - Concrete class for account data.\n */\n\nimport type { AccountId, IAccountDto, ILoginState, IWebsiteInfo } from '@postybirb/types';\nimport { BaseRecord } from './base-record';\n\n/**\n * Record class representing an account entity.\n */\nexport class AccountRecord extends BaseRecord {\n  readonly name: string;\n  readonly website: string;\n  readonly groups: string[];\n  readonly state: ILoginState;\n  readonly data: unknown;\n  readonly websiteInfo: IWebsiteInfo;\n\n  constructor(dto: IAccountDto) {\n    super(dto);\n    this.name = dto.name;\n    this.website = dto.website;\n    this.groups = dto.groups ?? [];\n    this.state = dto.state;\n    this.data = dto.data;\n    this.websiteInfo = dto.websiteInfo;\n  }\n\n  /**\n   * Get the account id with proper typing.\n   */\n  get accountId(): AccountId {\n    return this.id as AccountId;\n  }\n\n  /**\n   * Check if the account is logged in.\n   */\n  get isLoggedIn(): boolean {\n    return this.state.isLoggedIn;\n  }\n\n  /**\n   * Check if login is pending.\n   */\n  get isPending(): boolean {\n    return this.state.pending;\n  }\n\n  /**\n   * Get the username if logged in.\n   */\n  get username(): string | null {\n    return this.state.username;\n  }\n\n  /**\n   * Get the display name for the website.\n   */\n  get websiteDisplayName(): string {\n    return this.websiteInfo.websiteDisplayName;\n  }\n\n  /**\n   * Check if the account belongs to a specific group.\n   */\n  hasGroup(group: string): boolean {\n    return this.groups.includes(group);\n  }\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/stores/records/base-record.ts",
    "content": "/**\n * Base record class that all entity records extend from.\n * Provides common functionality for converting DTOs to typed record classes.\n */\n\nimport type { EntityId } from '@postybirb/types';\n\n/**\n * Base record interface for all entities.\n */\nexport interface IBaseRecord {\n  readonly id: EntityId;\n  readonly createdAt: Date;\n  readonly updatedAt: Date;\n}\n\n/**\n * Base record class providing common entity properties and utilities.\n * All record classes should extend this class.\n */\nexport abstract class BaseRecord implements IBaseRecord {\n  readonly id: EntityId;\n  readonly createdAt: Date;\n  readonly updatedAt: Date;\n\n  constructor(dto: { id: EntityId; createdAt: string; updatedAt: string }) {\n    this.id = dto.id;\n    this.createdAt = new Date(dto.createdAt);\n    this.updatedAt = new Date(dto.updatedAt);\n  }\n\n  /**\n   * Check if this record matches the given id.\n   */\n  matches(id: EntityId): boolean {\n    return this.id === id;\n  }\n\n  /**\n   * Check if this record was updated after another record.\n   */\n  isNewerThan(other: BaseRecord): boolean {\n    return this.updatedAt > other.updatedAt;\n  }\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/stores/records/custom-shortcut-record.ts",
    "content": "/**\n * CustomShortcutRecord - Concrete class for custom shortcut data.\n */\n\nimport type { Description, ICustomShortcutDto } from '@postybirb/types';\nimport { BaseRecord } from './base-record';\n\n/**\n * Record class representing a custom shortcut entity.\n */\nexport class CustomShortcutRecord extends BaseRecord {\n  readonly name: string;\n  readonly shortcut: Description;\n\n  constructor(dto: ICustomShortcutDto) {\n    super(dto);\n    this.name = dto.name;\n    this.shortcut = dto.shortcut;\n  }\n\n  /**\n   * Get the shortcut value for a specific part if it's a segmented description.\n   */\n  getShortcutValue(): string {\n    // Description can be a complex type, return string representation\n    if (typeof this.shortcut === 'string') {\n      return this.shortcut;\n    }\n    // Handle DescriptionValue format\n    return JSON.stringify(this.shortcut);\n  }\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/stores/records/directory-watcher-record.ts",
    "content": "/**\n * DirectoryWatcherRecord - Concrete class for directory watcher data.\n */\n\nimport type {\n    DirectoryWatcherDto,\n    DirectoryWatcherImportAction,\n    SubmissionId,\n} from '@postybirb/types';\nimport { BaseRecord } from './base-record';\n\n/**\n * Record class representing a directory watcher entity.\n */\nexport class DirectoryWatcherRecord extends BaseRecord {\n  readonly path?: string;\n  readonly importAction: DirectoryWatcherImportAction;\n  readonly template?: SubmissionId;\n\n  constructor(dto: DirectoryWatcherDto) {\n    super(dto);\n    this.path = dto.path;\n    this.importAction = dto.importAction;\n    this.template = dto.template;\n  }\n\n  /**\n   * Check if the watcher has a valid path configured.\n   */\n  get hasPath(): boolean {\n    return Boolean(this.path && this.path.trim().length > 0);\n  }\n\n  /**\n   * Check if a template is assigned.\n   */\n  get hasTemplate(): boolean {\n    return Boolean(this.template);\n  }\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/stores/records/index.ts",
    "content": "/**\n * Barrel exports for all record classes.\n */\n\nexport { AccountRecord } from './account-record';\nexport { BaseRecord, type IBaseRecord } from './base-record';\nexport { CustomShortcutRecord } from './custom-shortcut-record';\nexport { DirectoryWatcherRecord } from './directory-watcher-record';\nexport { NotificationRecord, type NotificationType } from './notification-record';\nexport { SettingsRecord } from './settings-record';\nexport { SubmissionRecord } from './submission-record';\nexport { TagConverterRecord } from './tag-converter-record';\nexport { TagGroupRecord } from './tag-group-record';\nexport { UserConverterRecord } from './user-converter-record';\nexport { WebsiteRecord } from './website-record';\n\n"
  },
  {
    "path": "apps/postybirb-ui/src/stores/records/notification-record.ts",
    "content": "/**\n * NotificationRecord - Concrete class for notification data.\n */\n\nimport type { INotification } from '@postybirb/types';\nimport { BaseRecord } from './base-record';\n\n/**\n * Notification type for styling purposes.\n */\nexport type NotificationType = 'warning' | 'error' | 'info' | 'success';\n\n/**\n * Record class representing a notification entity.\n */\nexport class NotificationRecord extends BaseRecord {\n  readonly title: string;\n  readonly message: string;\n  readonly tags: string[];\n  readonly data: Record<string, unknown>;\n  readonly isRead: boolean;\n  readonly hasEmitted: boolean;\n  readonly type: NotificationType;\n\n  constructor(dto: INotification) {\n    super(dto);\n    this.title = dto.title;\n    this.message = dto.message;\n    this.tags = dto.tags ?? [];\n    this.data = dto.data ?? {};\n    this.isRead = dto.isRead;\n    this.hasEmitted = dto.hasEmitted;\n    this.type = dto.type;\n  }\n\n  /**\n   * Check if the notification is unread.\n   */\n  get isUnread(): boolean {\n    return !this.isRead;\n  }\n\n  /**\n   * Check if the notification is an error type.\n   */\n  get isError(): boolean {\n    return this.type === 'error';\n  }\n\n  /**\n   * Check if the notification is a warning type.\n   */\n  get isWarning(): boolean {\n    return this.type === 'warning';\n  }\n\n  /**\n   * Check if the notification has a specific tag.\n   */\n  hasTag(tag: string): boolean {\n    return this.tags.includes(tag);\n  }\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/stores/records/settings-record.ts",
    "content": "/**\n * Settings Record - Converts SettingsDto to a typed record class.\n */\n\nimport type {\n    DesktopNotificationSettings,\n    EntityId,\n    ISettingsOptions,\n    SettingsDto,\n    TagSearchProviderSettings,\n    WebsiteId,\n} from '@postybirb/types';\nimport { BaseRecord } from './base-record';\n\n/**\n * Settings record class that wraps a SettingsDto.\n */\nexport class SettingsRecord extends BaseRecord {\n  /** The profile name for this settings record */\n  readonly profile: string;\n\n  /** The settings options */\n  readonly settings: ISettingsOptions;\n\n  constructor(dto: SettingsDto) {\n    super(dto);\n    this.profile = dto.profile;\n    this.settings = dto.settings;\n  }\n\n  // ============================================================================\n  // Convenience Getters\n  // ============================================================================\n\n  /** List of hidden website IDs */\n  get hiddenWebsites(): WebsiteId[] {\n    return this.settings.hiddenWebsites;\n  }\n\n  /** Current language setting */\n  get language(): string {\n    return this.settings.language;\n  }\n\n  /** Whether ads are allowed */\n  get allowAd(): boolean {\n    return this.settings.allowAd;\n  }\n\n  /** Whether the queue is paused */\n  get queuePaused(): boolean {\n    return this.settings.queuePaused;\n  }\n\n  /** Desktop notification settings */\n  get desktopNotifications(): DesktopNotificationSettings {\n    return this.settings.desktopNotifications;\n  }\n\n  /** Tag search provider settings */\n  get tagSearchProvider(): TagSearchProviderSettings {\n    return this.settings.tagSearchProvider;\n  }\n\n  // ============================================================================\n  // Utility Methods\n  // ============================================================================\n\n  /**\n   * Check if a website is hidden in settings.\n   */\n  isWebsiteHidden(websiteId: WebsiteId): boolean {\n    return this.hiddenWebsites.includes(websiteId);\n  }\n\n  /**\n   * Get a list of visible websites (filtering out hidden ones).\n   */\n  filterVisibleWebsites(allWebsiteIds: WebsiteId[]): WebsiteId[] {\n    return allWebsiteIds.filter((id) => !this.isWebsiteHidden(id));\n  }\n\n  /**\n   * Check if desktop notifications are enabled for a specific event type.\n   */\n  isDesktopNotificationEnabled(\n    type: keyof Omit<DesktopNotificationSettings, 'enabled'>,\n  ): boolean {\n    return this.desktopNotifications.enabled && this.desktopNotifications[type];\n  }\n\n  /**\n   * Convert back to a plain object for API updates.\n   */\n  toDto(): SettingsDto {\n    return {\n      id: this.id as EntityId,\n      createdAt: this.createdAt.toISOString(),\n      updatedAt: this.updatedAt.toISOString(),\n      profile: this.profile,\n      settings: this.settings,\n    };\n  }\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/stores/records/submission-record.ts",
    "content": "/**\n * SubmissionRecord - Concrete class for submission data.\n */\n\nimport {\n  type ISubmissionDto,\n  type ISubmissionFileDto,\n  type ISubmissionMetadata,\n  type ISubmissionScheduleInfo,\n  type IWebsiteFormFields,\n  type PostQueueRecordDto,\n  type PostRecordDto,\n  PostRecordState,\n  type SubmissionId,\n  type SubmissionType,\n  type ValidationResult,\n  type WebsiteOptionsDto\n} from '@postybirb/types';\nimport { BaseRecord } from './base-record';\n\n/**\n * Record class representing a submission entity.\n */\nexport class SubmissionRecord extends BaseRecord {\n  readonly type: SubmissionType;\n  readonly isScheduled: boolean;\n  readonly isTemplate: boolean;\n  readonly isMultiSubmission: boolean;\n  readonly isArchived: boolean;\n  readonly schedule: ISubmissionScheduleInfo;\n  readonly files: ISubmissionFileDto[];\n  readonly options: WebsiteOptionsDto[];\n  readonly posts: PostRecordDto[];\n  readonly validations: ValidationResult[];\n  readonly postQueueRecord?: PostQueueRecordDto;\n  readonly metadata: ISubmissionMetadata;\n  readonly order: number;\n\n  // Cached computed values — safe because all data is immutable after construction\n  private readonly cachedPrimaryFile: ISubmissionFileDto | undefined;\n  private readonly cachedLastModified: Date;\n  private readonly cachedSortedPosts: PostRecordDto[];\n  private readonly cachedSortedPostsDescending: PostRecordDto[];\n\n  constructor(dto: ISubmissionDto) {\n    super(dto);\n    this.type = dto.type;\n    this.isScheduled = dto.isScheduled;\n    this.isTemplate = dto.isTemplate;\n    this.isMultiSubmission = dto.isMultiSubmission;\n    this.isArchived = dto.isArchived;\n    this.schedule = dto.schedule;\n    this.files = dto.files ?? [];\n    this.options = dto.options ?? [];\n    this.posts = dto.posts ?? [];\n    this.validations = dto.validations ?? [];\n    this.postQueueRecord = dto.postQueueRecord;\n    this.metadata = dto.metadata;\n    this.order = dto.order;\n\n    // Pre-compute expensive derived values\n    this.cachedPrimaryFile = this.files.length > 0\n      ? [...this.files].sort((a, b) => a.order - b.order)[0]\n      : undefined;\n    this.cachedSortedPosts = this.posts.length > 0\n      ? [...this.posts].sort(\n          (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()\n        )\n      : [];\n    this.cachedSortedPostsDescending = this.posts.length > 0\n      ? [...this.posts].sort(\n          (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()\n        )\n      : [];\n    this.cachedLastModified = this.computeLastModified();\n  }\n\n  /**\n   * Get the submission id with proper typing.\n   */\n  get submissionId(): SubmissionId {\n    return this.id as SubmissionId;\n  }\n\n  /**\n   * Check if the submission has files.\n   */\n  get hasFiles(): boolean {\n    return this.files.length > 0;\n  }\n\n  /**\n   * Get the primary/first file if available.\n   */\n  get primaryFile(): ISubmissionFileDto | undefined {\n    return this.cachedPrimaryFile;\n  }\n\n  /**\n   * Check if the submission has validation errors.\n   */\n  get hasErrors(): boolean {\n    return this.validations.some((v) => v.errors && v.errors.length > 0);\n  }\n\n  /**\n   * Check if the submission has validation warnings.\n   */\n  get hasWarnings(): boolean {\n    return this.validations.some((v) => v.warnings && v.warnings.length > 0);\n  }\n\n  /**\n   * Check if the submission is queued for posting.\n   */\n  get isQueued(): boolean {\n    return this.postQueueRecord !== undefined;\n  }\n\n  /**\n   * Check if the submission is currently being posted.\n   */\n  get isPosting(): boolean {\n    return this.posts.some((post) => post.state === 'RUNNING');\n  }\n\n  /**\n   * Check if the submission has any website options configured\n   * (excluding the default option).\n   */\n  get hasWebsiteOptions(): boolean {\n    return this.options.some((o) => !o.isDefault);\n  }\n\n  /**\n   * Get the scheduled date if scheduled.\n   */\n  get scheduledDate(): Date | null {\n    if (!this.schedule.scheduledFor) {\n      return null;\n    }\n    return new Date(this.schedule.scheduledFor);\n  }\n\n  /**\n   * Get the default website options for this submission.\n   * The default option contains global settings like title.\n   */\n  getDefaultOptions<O extends IWebsiteFormFields>():\n    | WebsiteOptionsDto<O>\n    | undefined {\n    return this.options.find((o) => o.isDefault) as\n      | WebsiteOptionsDto<O>\n      | undefined;\n  }\n\n  /**\n   * Get the submission title.\n   * For templates, returns the template name.\n   * Otherwise returns the title from default options.\n   */\n  get title(): string {\n    if (this.isTemplate && this.metadata?.template?.name) {\n      return this.metadata.template.name;\n    }\n    const defaultOptions = this.getDefaultOptions();\n    return defaultOptions?.data?.title ?? '';\n  }\n\n  /**\n   * Get the most recent modification date across the submission,\n   * its files, and its website options.\n   */\n  get lastModified(): Date {\n    return this.cachedLastModified;\n  }\n\n  private computeLastModified(): Date {\n    let latest = this.updatedAt;\n\n    for (const file of this.files) {\n      const fileDate = new Date(file.updatedAt);\n      if (fileDate > latest) {\n        latest = fileDate;\n      }\n    }\n\n    for (const option of this.options) {\n      const optionDate = new Date(option.updatedAt);\n      if (optionDate > latest) {\n        latest = optionDate;\n      }\n    }\n\n    return latest;\n  }\n\n  /**\n   * Check if the submission has a schedule time or cron expression configured.\n   */\n  get hasScheduleTime(): boolean {\n    return Boolean(this.schedule.scheduledFor || this.schedule.cron);\n  }\n\n  // =============================================================================\n  // Post Record Methods\n  // =============================================================================\n\n  /**\n   * Get all post records sorted by creation date (oldest first).\n   * This provides a chronological view of posting attempts.\n   */\n  get sortedPosts(): PostRecordDto[] {\n    return this.cachedSortedPosts;\n  }\n\n  /**\n   * Get all post records sorted by creation date (newest first).\n   * This provides a reverse chronological view for display.\n   */\n  get sortedPostsDescending(): PostRecordDto[] {\n    return this.cachedSortedPostsDescending;\n  }\n\n  /**\n   * Get the most recent post record.\n   */\n  get latestPost(): PostRecordDto | undefined {\n    if (this.posts.length === 0) return undefined;\n    return this.sortedPosts[this.sortedPosts.length - 1];\n  }\n\n  /**\n   * Get the most recent completed post record (DONE or FAILED).\n   */\n  get latestCompletedPost(): PostRecordDto | undefined {\n    const completed = this.sortedPosts.filter(\n      (post) => post.state === PostRecordState.DONE || post.state === PostRecordState.FAILED\n    );\n    return completed[completed.length - 1];\n  }\n\n  /**\n   * Get posting statistics for this submission.\n   * Counts are based on individual post records.\n   */\n  get postingStats(): {\n    totalAttempts: number;\n    successfulAttempts: number;\n    failedAttempts: number;\n    runningAttempts: number;\n  } {\n    const successful = this.posts.filter((p) => p.state === PostRecordState.DONE);\n    const failed = this.posts.filter((p) => p.state === PostRecordState.FAILED);\n    const running = this.posts.filter((p) => p.state === PostRecordState.RUNNING);\n\n    return {\n      totalAttempts: this.posts.length,\n      successfulAttempts: successful.length,\n      failedAttempts: failed.length,\n      runningAttempts: running.length,\n    };\n  }\n\n  /**\n   * Check if this submission has ever been successfully posted.\n   */\n  get hasBeenPostedSuccessfully(): boolean {\n    return this.posts.some((p) => p.state === PostRecordState.DONE);\n  }\n\n  /**\n   * Check if this submission has any failed posting attempts.\n   */\n  get hasFailedPostingAttempts(): boolean {\n    return this.posts.some((p) => p.state === PostRecordState.FAILED);\n  }\n\n  /**\n   * Check if this submission is currently being posted.\n   */\n  get isCurrentlyPosting(): boolean {\n    return this.posts.some((p) => p.state === PostRecordState.RUNNING);\n  }\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/stores/records/tag-converter-record.ts",
    "content": "/**\n * TagConverterRecord - Concrete class for tag converter data.\n */\n\nimport type { Tag, TagConverterDto, WebsiteId } from '@postybirb/types';\nimport { BaseRecord } from './base-record';\n\n/**\n * Record class representing a tag converter entity.\n */\nexport class TagConverterRecord extends BaseRecord {\n  readonly tag: Tag;\n  readonly convertTo: Record<WebsiteId, Tag>;\n\n  constructor(dto: TagConverterDto) {\n    super(dto);\n    this.tag = dto.tag;\n    this.convertTo = dto.convertTo ?? {};\n  }\n\n  /**\n   * Get the converted tag for a specific website.\n   */\n  getConvertedTag(websiteId: WebsiteId): Tag | undefined {\n    return this.convertTo[websiteId];\n  }\n\n  /**\n   * Check if there's a conversion for a specific website.\n   */\n  hasConversionFor(websiteId: WebsiteId): boolean {\n    return websiteId in this.convertTo;\n  }\n\n  /**\n   * Get all website IDs that have conversions.\n   */\n  get websiteIds(): WebsiteId[] {\n    return Object.keys(this.convertTo) as WebsiteId[];\n  }\n\n  /**\n   * Get the number of website conversions.\n   */\n  get conversionCount(): number {\n    return Object.keys(this.convertTo).length;\n  }\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/stores/records/tag-group-record.ts",
    "content": "/**\n * TagGroupRecord - Concrete class for tag group data.\n */\n\nimport type { Tag, TagGroupDto } from '@postybirb/types';\nimport { BaseRecord } from './base-record';\n\n/**\n * Record class representing a tag group entity.\n */\nexport class TagGroupRecord extends BaseRecord {\n  readonly name: string;\n  readonly tags: Tag[];\n\n  constructor(dto: TagGroupDto) {\n    super(dto);\n    this.name = dto.name;\n    this.tags = dto.tags ?? [];\n  }\n\n  /**\n   * Check if the group contains a specific tag.\n   */\n  hasTag(tag: Tag): boolean {\n    return this.tags.includes(tag);\n  }\n\n  /**\n   * Get the number of tags in the group.\n   */\n  get tagCount(): number {\n    return this.tags.length;\n  }\n\n  /**\n   * Check if the group is empty.\n   */\n  get isEmpty(): boolean {\n    return this.tags.length === 0;\n  }\n\n  /**\n   * Get tags as a comma-separated string.\n   */\n  get tagsString(): string {\n    return this.tags.join(', ');\n  }\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/stores/records/user-converter-record.ts",
    "content": "/**\n * UserConverterRecord - Concrete class for user converter data.\n */\n\nimport type { UserConverterDto, WebsiteId } from '@postybirb/types';\nimport { BaseRecord } from './base-record';\n\n/**\n * Record class representing a user converter entity.\n */\nexport class UserConverterRecord extends BaseRecord {\n  readonly username: string;\n  readonly convertTo: Record<WebsiteId, string>;\n\n  constructor(dto: UserConverterDto) {\n    super(dto);\n    this.username = dto.username;\n    this.convertTo = dto.convertTo ?? {};\n  }\n\n  /**\n   * Get the converted username for a specific website.\n   */\n  getConvertedUsername(websiteId: WebsiteId): string | undefined {\n    return this.convertTo[websiteId];\n  }\n\n  /**\n   * Check if there's a conversion for a specific website.\n   */\n  hasConversionFor(websiteId: WebsiteId): boolean {\n    return websiteId in this.convertTo;\n  }\n\n  /**\n   * Get all website IDs that have conversions.\n   */\n  get websiteIds(): WebsiteId[] {\n    return Object.keys(this.convertTo) as WebsiteId[];\n  }\n\n  /**\n   * Get the number of website conversions.\n   */\n  get conversionCount(): number {\n    return Object.keys(this.convertTo).length;\n  }\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/stores/records/website-record.ts",
    "content": "/**\n * WebsiteRecord - Concrete class for website info data.\n * Note: Does not extend BaseRecord as IWebsiteInfoDto lacks createdAt/updatedAt.\n */\n\nimport type {\n  IAccountDto,\n  IWebsiteInfoDto,\n  IWebsiteMetadata,\n  UsernameShortcut,\n  WebsiteFileOptions,\n  WebsiteId,\n  WebsiteLoginType\n} from '@postybirb/types';\n\n/**\n * Record class representing a website entity.\n */\nexport class WebsiteRecord {\n  readonly id: WebsiteId;\n  readonly displayName: string;\n  readonly loginType: WebsiteLoginType;\n  readonly usernameShortcut?: UsernameShortcut;\n  readonly metadata: IWebsiteMetadata;\n  readonly accounts: IAccountDto[];\n  readonly fileOptions?: WebsiteFileOptions;\n  readonly supportsFile: boolean;\n  readonly supportsMessage: boolean;\n\n  // Cached computed value — safe because record data is immutable after construction\n  private readonly cachedLoggedInAccounts: IAccountDto[];\n\n  constructor(dto: IWebsiteInfoDto) {\n    this.id = dto.id;\n    this.displayName = dto.displayName;\n    this.loginType = dto.loginType;\n    this.usernameShortcut = dto.usernameShortcut;\n    this.metadata = dto.metadata;\n    this.accounts = dto.accounts;\n    this.fileOptions = dto.fileOptions;\n    this.supportsFile = dto.supportsFile;\n    this.supportsMessage = dto.supportsMessage;\n\n    // Pre-compute filtered accounts\n    this.cachedLoggedInAccounts = this.accounts.filter(\n      (account) => account.state.isLoggedIn\n    );\n  }\n\n  /**\n   * Check if this record matches the given id.\n   */\n  matches(id: WebsiteId): boolean {\n    return this.id === id;\n  }\n\n  /**\n   * Get the number of accounts for this website.\n   */\n  get accountCount(): number {\n    return this.accounts.length;\n  }\n\n  /**\n   * Get logged-in accounts for this website.\n   */\n  get loggedInAccounts(): IAccountDto[] {\n    return this.cachedLoggedInAccounts;\n  }\n\n  /**\n   * Get the number of logged-in accounts.\n   */\n  get loggedInCount(): number {\n    return this.cachedLoggedInAccounts.length;\n  }\n\n  /**\n   * Check if any accounts are logged in.\n   */\n  get hasLoggedInAccounts(): boolean {\n    return this.loggedInCount > 0;\n  }\n\n  /**\n   * Check if this website uses user/password login.\n   */\n  get isUserLogin(): boolean {\n    return this.loginType.type === 'user';\n  }\n\n  /**\n   * Check if this website uses custom login.\n   */\n  get isCustomLogin(): boolean {\n    return this.loginType.type === 'custom';\n  }\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/stores/store-init.ts",
    "content": "/**\n * Store initialization utility.\n * Provides a hook to load all entity stores when the app starts.\n */\n\nimport { useEffect, useState } from 'react';\nimport { useAccountStore } from './entity/account-store';\nimport { useCustomShortcutStore } from './entity/custom-shortcut-store';\nimport { useDirectoryWatcherStore } from './entity/directory-watcher-store';\nimport { useNotificationStore } from './entity/notification-store';\nimport { useSettingsStore } from './entity/settings-store';\nimport { useSubmissionStore } from './entity/submission-store';\nimport { useTagConverterStore } from './entity/tag-converter-store';\nimport { useTagGroupStore } from './entity/tag-group-store';\nimport { useUserConverterStore } from './entity/user-converter-store';\nimport { useWebsiteStore } from './entity/website-store';\n\n/**\n * Load all entity stores in parallel.\n * Returns a promise that resolves when all stores are loaded.\n */\nexport async function loadAllStores(): Promise<void> {\n  await Promise.all([\n    useAccountStore.getState().loadAll(),\n    useSettingsStore.getState().loadAll(),\n    useSubmissionStore.getState().loadAll(),\n    useCustomShortcutStore.getState().loadAll(),\n    useDirectoryWatcherStore.getState().loadAll(),\n    useNotificationStore.getState().loadAll(),\n    useTagConverterStore.getState().loadAll(),\n    useTagGroupStore.getState().loadAll(),\n    useUserConverterStore.getState().loadAll(),\n    useWebsiteStore.getState().loadAll(),\n  ]);\n}\n\n/**\n * Hook to initialize all stores on mount.\n * Returns loading state for initial data fetch.\n */\nexport function useInitializeStores() {\n  const [isInitialized, setIsInitialized] = useState(false);\n  const [isLoading, setIsLoading] = useState(true);\n  const [error, setError] = useState<string | null>(null);\n\n  useEffect(() => {\n    if (isInitialized) return;\n\n    setIsLoading(true);\n    loadAllStores()\n      .then(() => {\n        setIsInitialized(true);\n        setIsLoading(false);\n      })\n      .catch((err) => {\n        // eslint-disable-next-line lingui/no-unlocalized-strings\n        setError(err instanceof Error ? err.message : 'Failed to load stores');\n        setIsLoading(false);\n      });\n  }, [isInitialized]);\n\n  return { isInitialized, isLoading, error };\n}\n\n/**\n * Check if all stores are loaded.\n */\nexport function areAllStoresLoaded(): boolean {\n  return (\n    useAccountStore.getState().loadingState === 'loaded' &&\n    useSettingsStore.getState().loadingState === 'loaded' &&\n    useSubmissionStore.getState().loadingState === 'loaded' &&\n    useCustomShortcutStore.getState().loadingState === 'loaded' &&\n    useDirectoryWatcherStore.getState().loadingState === 'loaded' &&\n    useNotificationStore.getState().loadingState === 'loaded' &&\n    useTagConverterStore.getState().loadingState === 'loaded' &&\n    useTagGroupStore.getState().loadingState === 'loaded' &&\n    useUserConverterStore.getState().loadingState === 'loaded' &&\n    useWebsiteStore.getState().loadingState === 'loaded'\n  );\n}\n\n/**\n * Clear all stores and reset to initial state.\n */\nexport function clearAllStores(): void {\n  useAccountStore.getState().clear();\n  useSettingsStore.getState().clear();\n  useSubmissionStore.getState().clear();\n  useCustomShortcutStore.getState().clear();\n  useDirectoryWatcherStore.getState().clear();\n  useNotificationStore.getState().clear();\n  useTagConverterStore.getState().clear();\n  useTagGroupStore.getState().clear();\n  useUserConverterStore.getState().clear();\n  useWebsiteStore.getState().clear();\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/stores/ui/accounts-ui-store.ts",
    "content": "/**\n * Accounts UI state management using Zustand with localStorage persistence.\n * Manages account section filters and visibility preferences.\n */\n\nimport { create } from 'zustand';\nimport { createJSONStorage, persist } from 'zustand/middleware';\nimport { useShallow } from 'zustand/react/shallow';\nimport { AccountLoginFilter } from '../../types/account-filters';\n\n// Re-export AccountLoginFilter enum from types\nexport { AccountLoginFilter } from '../../types/account-filters';\n\n// ============================================================================\n// Types\n// ============================================================================\n\n/**\n * Accounts UI state interface.\n */\ninterface AccountsUIState {\n  /** Hidden website IDs */\n  hiddenWebsites: string[];\n\n  /** Accounts search query */\n  accountsSearchQuery: string;\n\n  /** Account login filter */\n  accountsLoginFilter: AccountLoginFilter;\n}\n\n/**\n * Accounts UI actions interface.\n */\ninterface AccountsUIActions {\n  /** Set hidden websites */\n  setHiddenWebsites: (websiteIds: string[]) => void;\n\n  /** Toggle visibility of a specific website */\n  toggleWebsiteVisibility: (websiteId: string) => void;\n\n  /** Set accounts search query */\n  setAccountsSearchQuery: (query: string) => void;\n\n  /** Set account login filter */\n  setAccountsLoginFilter: (filter: AccountLoginFilter) => void;\n\n  /** Reset accounts UI state */\n  resetAccountsUI: () => void;\n}\n\n/**\n * Complete Accounts UI Store type.\n */\nexport type AccountsUIStore = AccountsUIState & AccountsUIActions;\n\n// ============================================================================\n// Constants\n// ============================================================================\n\n/**\n * Storage key for localStorage persistence.\n */\nconst STORAGE_KEY = 'postybirb-accounts-ui';\n\n// ============================================================================\n// Initial State\n// ============================================================================\n\n/**\n * Initial/default accounts UI state.\n */\nconst initialState: AccountsUIState = {\n  hiddenWebsites: [],\n  accountsSearchQuery: '',\n  accountsLoginFilter: AccountLoginFilter.All,\n};\n\n// ============================================================================\n// Store\n// ============================================================================\n\n/**\n * Zustand store for accounts UI with localStorage persistence.\n */\nexport const useAccountsUIStore = create<AccountsUIStore>()(\n  persist(\n    (set) => ({\n      // Initial state\n      ...initialState,\n\n      // Actions\n      setHiddenWebsites: (hiddenWebsites) => set({ hiddenWebsites }),\n      toggleWebsiteVisibility: (websiteId) =>\n        set((state) => ({\n          hiddenWebsites: state.hiddenWebsites.includes(websiteId)\n            ? state.hiddenWebsites.filter((id) => id !== websiteId)\n            : [...state.hiddenWebsites, websiteId],\n        })),\n      setAccountsSearchQuery: (accountsSearchQuery) =>\n        set({ accountsSearchQuery }),\n      setAccountsLoginFilter: (accountsLoginFilter) =>\n        set({ accountsLoginFilter }),\n\n      // Reset to initial state\n      resetAccountsUI: () => set(initialState),\n    }),\n    {\n      name: STORAGE_KEY,\n      storage: createJSONStorage(() => localStorage),\n    },\n  ),\n);\n\n// ============================================================================\n// Selector Hooks\n// ============================================================================\n\n/** Select hidden websites */\nexport const useHiddenWebsites = () =>\n  useAccountsUIStore((state) => state.hiddenWebsites);\n\n/** Select accounts section filter state and actions */\nexport const useAccountsFilter = () =>\n  useAccountsUIStore(\n    useShallow((state) => ({\n      searchQuery: state.accountsSearchQuery,\n      loginFilter: state.accountsLoginFilter,\n      hiddenWebsites: state.hiddenWebsites,\n      setSearchQuery: state.setAccountsSearchQuery,\n      setLoginFilter: state.setAccountsLoginFilter,\n      setHiddenWebsites: state.setHiddenWebsites,\n      toggleWebsiteVisibility: state.toggleWebsiteVisibility,\n    })),\n  );\n"
  },
  {
    "path": "apps/postybirb-ui/src/stores/ui/appearance-store.ts",
    "content": "/**\n * Appearance state management using Zustand with localStorage persistence.\n * Manages color scheme, primary color, and submission view mode.\n */\n\nimport { create } from 'zustand';\nimport { createJSONStorage, persist } from 'zustand/middleware';\nimport { useShallow } from 'zustand/react/shallow';\n\n// ============================================================================\n// Types\n// ============================================================================\n\n/**\n * Color scheme options (matches Mantine's MantineColorScheme).\n */\nexport type ColorScheme = 'light' | 'dark' | 'auto';\n\n/**\n * Submission card view mode options.\n */\nexport type SubmissionViewMode = 'compact' | 'detailed';\n\n/**\n * Valid Mantine primary colors.\n */\nexport const MANTINE_COLORS = [\n  'red',\n  'pink',\n  'grape',\n  'violet',\n  'indigo',\n  'blue',\n  'cyan',\n  'teal',\n  'green',\n  'lime',\n  'yellow',\n  'orange',\n] as const;\n\nexport type MantinePrimaryColor = (typeof MANTINE_COLORS)[number];\n\n/**\n * Appearance state interface.\n */\ninterface AppearanceState {\n  /** Current color scheme (light/dark/auto) */\n  colorScheme: ColorScheme;\n\n  /** Primary color for the UI */\n  primaryColor: MantinePrimaryColor;\n\n  /** Submission card view mode (compact/detailed) */\n  submissionViewMode: SubmissionViewMode;\n}\n\n/**\n * Appearance actions interface.\n */\ninterface AppearanceActions {\n  /** Set the color scheme */\n  setColorScheme: (scheme: ColorScheme) => void;\n\n  /** Set the primary color */\n  setPrimaryColor: (color: MantinePrimaryColor) => void;\n\n  /** Set the submission view mode */\n  setSubmissionViewMode: (mode: SubmissionViewMode) => void;\n\n  /** Toggle submission view mode between compact and detailed */\n  toggleSubmissionViewMode: () => void;\n\n  /** Reset appearance state */\n  resetAppearance: () => void;\n}\n\n/**\n * Complete Appearance Store type.\n */\nexport type AppearanceStore = AppearanceState & AppearanceActions;\n\n// ============================================================================\n// Constants\n// ============================================================================\n\n/**\n * Storage key for localStorage persistence.\n */\nconst STORAGE_KEY = 'postybirb-appearance';\n\n// ============================================================================\n// Initial State\n// ============================================================================\n\n/**\n * Initial/default appearance state.\n */\nconst initialState: AppearanceState = {\n  colorScheme: 'auto',\n  primaryColor: 'teal',\n  submissionViewMode: 'detailed',\n};\n\n// ============================================================================\n// Store\n// ============================================================================\n\n/**\n * Zustand store for appearance with localStorage persistence.\n */\nexport const useAppearanceStore = create<AppearanceStore>()(\n  persist(\n    (set) => ({\n      // Initial state\n      ...initialState,\n\n      // Actions\n      setColorScheme: (colorScheme) => set({ colorScheme }),\n      setPrimaryColor: (primaryColor) => set({ primaryColor }),\n      setSubmissionViewMode: (submissionViewMode) => set({ submissionViewMode }),\n      toggleSubmissionViewMode: () =>\n        set((state) => ({\n          submissionViewMode:\n            state.submissionViewMode === 'compact' ? 'detailed' : 'compact',\n        })),\n\n      // Reset to initial state\n      resetAppearance: () => set(initialState),\n    }),\n    {\n      name: STORAGE_KEY,\n      storage: createJSONStorage(() => localStorage),\n    },\n  ),\n);\n\n// ============================================================================\n// Selector Hooks\n// ============================================================================\n\n/** Select color scheme */\nexport const useColorScheme = () =>\n  useAppearanceStore((state) => state.colorScheme);\n\n/** Select primary color */\nexport const usePrimaryColor = () =>\n  useAppearanceStore((state) => state.primaryColor);\n\n/** Select appearance actions */\nexport const useAppearanceActions = () =>\n  useAppearanceStore(\n    useShallow((state) => ({\n      colorScheme: state.colorScheme,\n      primaryColor: state.primaryColor,\n      setColorScheme: state.setColorScheme,\n      setPrimaryColor: state.setPrimaryColor,\n    })),\n  );\n\n/** Select submission view mode state and actions */\nexport const useSubmissionViewMode = () =>\n  useAppearanceStore(\n    useShallow((state) => ({\n      viewMode: state.submissionViewMode,\n      setViewMode: state.setSubmissionViewMode,\n      toggleViewMode: state.toggleSubmissionViewMode,\n    })),\n  );\n\n/** Check if submission view is compact */\nexport const useIsCompactView = () =>\n  useAppearanceStore((state) => state.submissionViewMode === 'compact');\n"
  },
  {
    "path": "apps/postybirb-ui/src/stores/ui/drawer-store.ts",
    "content": "/**\n * Drawer state management using Zustand.\n * Manages which drawer is currently open (only one at a time).\n * No persistence - drawers reset to closed on page reload.\n */\n\nimport { create } from 'zustand';\nimport { useShallow } from 'zustand/shallow';\n\n// ============================================================================\n// Types\n// ============================================================================\n\n/**\n * Drawer visibility keys.\n */\nexport type DrawerKey =\n  | 'settings'\n  | 'tagGroups'\n  | 'tagConverters'\n  | 'userConverters'\n  | 'notifications'\n  | 'customShortcuts'\n  | 'fileWatchers'\n  | 'schedule';\n\n/**\n * Drawer state interface.\n */\ninterface DrawerState {\n  /** Currently active drawer, or null if none is open */\n  activeDrawer: DrawerKey | null;\n}\n\n/**\n * Drawer actions interface.\n */\ninterface DrawerActions {\n  /** Open a specific drawer */\n  openDrawer: (drawer: DrawerKey) => void;\n\n  /** Close the currently open drawer */\n  closeDrawer: () => void;\n\n  /** Toggle a specific drawer */\n  toggleDrawer: (drawer: DrawerKey) => void;\n}\n\n/**\n * Complete Drawer Store type.\n */\nexport type DrawerStore = DrawerState & DrawerActions;\n\n// ============================================================================\n// Initial State\n// ============================================================================\n\n/**\n * Initial/default drawer state.\n */\nconst initialState: DrawerState = {\n  activeDrawer: null,\n};\n\n// ============================================================================\n// Store\n// ============================================================================\n\n/**\n * Zustand store for drawer state.\n * No persistence - drawers always start closed.\n */\nexport const useDrawerStore = create<DrawerStore>()((set) => ({\n  // Initial state\n  ...initialState,\n\n  // Actions\n  openDrawer: (drawer) => set({ activeDrawer: drawer }),\n  closeDrawer: () => set({ activeDrawer: null }),\n  toggleDrawer: (drawer) =>\n    set((state) => ({\n      activeDrawer: state.activeDrawer === drawer ? null : drawer,\n    })),\n}));\n\n// ============================================================================\n// Selector Hooks\n// ============================================================================\n\n/** Select active drawer */\nexport const useActiveDrawer = () =>\n  useDrawerStore((state) => state.activeDrawer);\n\n/** Select drawer actions — useShallow required because selector returns an object literal */\nexport const useDrawerActions = () =>\n  useDrawerStore(\n    useShallow((state) => ({\n      openDrawer: state.openDrawer,\n      closeDrawer: state.closeDrawer,\n      toggleDrawer: state.toggleDrawer,\n    }))\n  );\n\n/** Check if a specific drawer is open */\nexport const useIsDrawerOpen = (drawer: DrawerKey) =>\n  useDrawerStore((state) => state.activeDrawer === drawer);\n"
  },
  {
    "path": "apps/postybirb-ui/src/stores/ui/locale-store.ts",
    "content": "/**\n * Locale state management using Zustand with localStorage persistence.\n * Manages language/locale settings for the application.\n */\n\nimport { create } from 'zustand';\nimport { createJSONStorage, persist } from 'zustand/middleware';\nimport { useShallow } from 'zustand/react/shallow';\n\n// ============================================================================\n// Types\n// ============================================================================\n\n/**\n * Locale state interface.\n */\ninterface LocaleState {\n  /** Current language code */\n  language: string;\n}\n\n/**\n * Locale actions interface.\n */\ninterface LocaleActions {\n  /** Set the language */\n  setLanguage: (language: string) => void;\n\n  /** Reset locale state */\n  resetLocale: () => void;\n}\n\n/**\n * Complete Locale Store type.\n */\nexport type LocaleStore = LocaleState & LocaleActions;\n\n// ============================================================================\n// Constants\n// ============================================================================\n\n/**\n * Storage key for localStorage persistence.\n */\nconst STORAGE_KEY = 'postybirb-locale';\n\n/**\n * Supported locale codes for the application.\n */\nexport const SUPPORTED_LOCALES = ['en', 'de', 'lt', 'pt-BR', 'ru', 'es', 'ta'];\n\n// ============================================================================\n// Helper Functions\n// ============================================================================\n\n/**\n * Get default language from browser or fall back to English.\n * Matches browser locale against supported locales.\n */\nconst getDefaultLanguage = (): string => {\n  if (typeof navigator !== 'undefined') {\n    const browserLocale = navigator.language;\n\n    // Check for exact match first (e.g., pt-BR)\n    if (SUPPORTED_LOCALES.includes(browserLocale)) {\n      return browserLocale;\n    }\n\n    // Try base language (e.g., en-US -> en)\n    const baseLocale = browserLocale.split('-')[0];\n    if (SUPPORTED_LOCALES.includes(baseLocale)) {\n      return baseLocale;\n    }\n  }\n  return 'en';\n};\n\n// ============================================================================\n// Initial State\n// ============================================================================\n\n/**\n * Initial/default locale state.\n */\nconst initialState: LocaleState = {\n  language: getDefaultLanguage(),\n};\n\n// ============================================================================\n// Store\n// ============================================================================\n\n/**\n * Zustand store for locale with localStorage persistence.\n */\nexport const useLocaleStore = create<LocaleStore>()(\n  persist(\n    (set) => ({\n      // Initial state\n      ...initialState,\n\n      // Actions\n      setLanguage: (language) => set({ language }),\n\n      // Reset to initial state\n      resetLocale: () => set(initialState),\n    }),\n    {\n      name: STORAGE_KEY,\n      storage: createJSONStorage(() => localStorage),\n    },\n  ),\n);\n\n// ============================================================================\n// Selector Hooks\n// ============================================================================\n\n/** Select language state */\nexport const useLanguage = () => useLocaleStore((state) => state.language);\n\n/** Select language actions */\nexport const useLanguageActions = () =>\n  useLocaleStore(\n    useShallow((state) => ({\n      language: state.language,\n      setLanguage: state.setLanguage,\n    })),\n  );\n"
  },
  {
    "path": "apps/postybirb-ui/src/stores/ui/navigation-store.ts",
    "content": "/**\n * Navigation state management using Zustand with localStorage persistence.\n * Manages view state, navigation history, and view caching.\n */\n\nimport { create } from 'zustand';\nimport { createJSONStorage, persist } from 'zustand/middleware';\nimport { useShallow } from 'zustand/react/shallow';\nimport {\n  createAccountsViewState,\n  createFileSubmissionsViewState,\n  createHomeViewState,\n  createMessageSubmissionsViewState,\n  createTemplatesViewState,\n  defaultViewState,\n  type SectionId,\n  type ViewState,\n} from '../../types/view-state';\nimport { useAccountStore } from '../entity/account-store';\nimport { useSubmissionStore } from '../entity/submission-store';\n\n// ============================================================================\n// Types\n// ============================================================================\n\n/**\n * Navigation state interface.\n */\ninterface NavigationState {\n  /** Current view state - controls which section/view is active and its parameters */\n  viewState: ViewState;\n\n  /** View state cache - preserves per-view state when switching between views */\n  viewStateCache: Partial<Record<SectionId, ViewState>>;\n\n  /** Navigation history - tracks view navigation for back/forward (max 30) */\n  navigationHistory: SectionId[];\n\n  /** Current index in navigation history */\n  historyIndex: number;\n}\n\n/**\n * Navigation actions interface.\n */\ninterface NavigationActions {\n  /** Set the current view state */\n  setViewState: (viewState: ViewState) => void;\n\n  /** Navigate back in history */\n  goBack: () => void;\n\n  /** Navigate forward in history */\n  goForward: () => void;\n\n  /** Reset navigation state */\n  resetNavigation: () => void;\n}\n\n/**\n * Complete Navigation Store type.\n */\nexport type NavigationStore = NavigationState & NavigationActions;\n\n// ============================================================================\n// Constants\n// ============================================================================\n\n/**\n * Storage key for localStorage persistence.\n */\nconst STORAGE_KEY = 'postybirb-navigation';\n\n/**\n * Maximum number of entries in navigation history.\n */\nconst MAX_HISTORY_LENGTH = 30;\n\n// ============================================================================\n// Helper Functions\n// ============================================================================\n\n/**\n * Validate and clean a view state by checking if referenced entity IDs exist.\n * Silently removes invalid selections.\n */\nfunction validateViewState(viewState: ViewState): ViewState {\n  const submissionsMap = useSubmissionStore.getState().recordsMap;\n  const accountsMap = useAccountStore.getState().recordsMap;\n\n  switch (viewState.type) {\n    case 'file-submissions': {\n      const validIds = viewState.params.selectedIds.filter((id) =>\n        submissionsMap.has(id),\n      );\n      if (validIds.length !== viewState.params.selectedIds.length) {\n        return {\n          type: 'file-submissions',\n          params: {\n            ...viewState.params,\n            selectedIds: validIds,\n          },\n        };\n      }\n      return viewState;\n    }\n\n    case 'message-submissions': {\n      const validIds = viewState.params.selectedIds.filter((id) =>\n        submissionsMap.has(id),\n      );\n      if (validIds.length !== viewState.params.selectedIds.length) {\n        return {\n          type: 'message-submissions',\n          params: {\n            ...viewState.params,\n            selectedIds: validIds,\n          },\n        };\n      }\n      return viewState;\n    }\n\n    case 'accounts': {\n      const { selectedId } = viewState.params;\n      if (selectedId && !accountsMap.has(selectedId)) {\n        return {\n          type: 'accounts',\n          params: {\n            ...viewState.params,\n            selectedId: null,\n          },\n        };\n      }\n      return viewState;\n    }\n\n    case 'templates': {\n      const { selectedId } = viewState.params;\n      if (selectedId && !submissionsMap.has(selectedId)) {\n        return {\n          type: 'templates',\n          params: {\n            ...viewState.params,\n            selectedId: null,\n          },\n        };\n      }\n      return viewState;\n    }\n\n    case 'home':\n    default:\n      return viewState;\n  }\n}\n\n/**\n * Get default view state for a given section ID.\n */\nfunction getDefaultViewState(sectionId: SectionId): ViewState {\n  switch (sectionId) {\n    case 'home':\n      return createHomeViewState();\n    case 'accounts':\n      return createAccountsViewState();\n    case 'file-submissions':\n      return createFileSubmissionsViewState();\n    case 'message-submissions':\n      return createMessageSubmissionsViewState();\n    case 'templates':\n      return createTemplatesViewState();\n    default:\n      return defaultViewState;\n  }\n}\n\n// ============================================================================\n// Initial State\n// ============================================================================\n\n/**\n * Initial/default navigation state.\n */\nconst initialState: NavigationState = {\n  viewState: defaultViewState,\n  viewStateCache: {},\n  navigationHistory: ['home'],\n  historyIndex: 0,\n};\n\n// ============================================================================\n// Store\n// ============================================================================\n\n/**\n * Zustand store for navigation with localStorage persistence.\n */\nexport const useNavigationStore = create<NavigationStore>()(\n  persist(\n    (set) => ({\n      // Initial state\n      ...initialState,\n\n      // View state actions\n      setViewState: (viewState) =>\n        set((state) => {\n          const isNavigatingToNewSection =\n            state.viewState.type !== viewState.type;\n\n          // Save current view state to cache\n          const newCache = {\n            ...state.viewStateCache,\n            [state.viewState.type]: state.viewState,\n          };\n\n          let finalViewState: ViewState;\n\n          // Check if the provided viewState has explicit selections\n          const hasExplicitSelection =\n            (viewState.type === 'file-submissions' ||\n              viewState.type === 'message-submissions') &&\n            viewState.params.selectedIds.length > 0;\n\n          if (isNavigatingToNewSection && !hasExplicitSelection) {\n            // Navigating to a different section without explicit selection: restore from cache\n            const cachedState = newCache[viewState.type];\n            if (cachedState && cachedState.type === viewState.type) {\n              // Restore from cache and validate\n              finalViewState = validateViewState(cachedState);\n            } else {\n              // First visit to this section\n              finalViewState = viewState;\n            }\n          } else {\n            // Staying in the same section OR explicit selection provided: use the provided state\n            finalViewState = viewState;\n          }\n\n          // Update navigation history\n          let newHistory = [...state.navigationHistory];\n          let newIndex = state.historyIndex;\n\n          // Only add to history if navigating to a different section\n          if (isNavigatingToNewSection) {\n            // Truncate forward history when navigating from middle\n            newHistory = newHistory.slice(0, newIndex + 1);\n\n            // Add new entry (deduplicate consecutive duplicates)\n            const lastEntry = newHistory[newHistory.length - 1];\n            if (lastEntry !== finalViewState.type) {\n              newHistory.push(finalViewState.type);\n              newIndex = newHistory.length - 1;\n\n              // Cap history at max length\n              if (newHistory.length > MAX_HISTORY_LENGTH) {\n                newHistory = newHistory.slice(\n                  newHistory.length - MAX_HISTORY_LENGTH,\n                );\n                newIndex = newHistory.length - 1;\n              }\n            }\n          }\n\n          return {\n            viewState: finalViewState,\n            viewStateCache: newCache,\n            navigationHistory: newHistory,\n            historyIndex: newIndex,\n          };\n        }),\n\n      // Navigation history actions\n      goBack: () =>\n        set((state) => {\n          if (state.historyIndex <= 0) return state;\n\n          const newIndex = state.historyIndex - 1;\n          const targetSection = state.navigationHistory[newIndex];\n\n          // Get cached state or default for target section\n          const cachedState = state.viewStateCache[targetSection];\n          const targetViewState = cachedState\n            ? validateViewState(cachedState)\n            : getDefaultViewState(targetSection);\n\n          return {\n            viewState: targetViewState,\n            historyIndex: newIndex,\n          };\n        }),\n\n      goForward: () =>\n        set((state) => {\n          if (state.historyIndex >= state.navigationHistory.length - 1)\n            return state;\n\n          const newIndex = state.historyIndex + 1;\n          const targetSection = state.navigationHistory[newIndex];\n\n          // Get cached state or default for target section\n          const cachedState = state.viewStateCache[targetSection];\n          const targetViewState = cachedState\n            ? validateViewState(cachedState)\n            : getDefaultViewState(targetSection);\n\n          return {\n            viewState: targetViewState,\n            historyIndex: newIndex,\n          };\n        }),\n\n      // Reset to initial state\n      resetNavigation: () => set(initialState),\n    }),\n    {\n      name: STORAGE_KEY,\n      storage: createJSONStorage(() => localStorage),\n      // Persist only view state and cache, not history\n      partialize: (state) => ({\n        viewState: state.viewState,\n        viewStateCache: state.viewStateCache,\n      }),\n    },\n  ),\n);\n\n// ============================================================================\n// Selector Hooks\n// ============================================================================\n\n/** Select current view state */\nexport const useViewState = () =>\n  useNavigationStore((state) => state.viewState);\n\n/** Select view state with setter */\nexport const useViewStateActions = () =>\n  useNavigationStore(\n    useShallow((state) => ({\n      viewState: state.viewState,\n      setViewState: state.setViewState,\n    })),\n  );\n\n/** Select navigation history actions — useShallow required because selector returns an object literal */\nexport const useNavigationHistory = () =>\n  useNavigationStore(\n    useShallow((state) => ({\n      goBack: state.goBack,\n      goForward: state.goForward,\n    }))\n  );\n\n/** Check if navigation can go back */\nexport const useCanGoBack = () =>\n  useNavigationStore((state) => state.historyIndex > 0);\n\n/** Check if navigation can go forward */\nexport const useCanGoForward = () =>\n  useNavigationStore(\n    (state) => state.historyIndex < state.navigationHistory.length - 1,\n  );\n"
  },
  {
    "path": "apps/postybirb-ui/src/stores/ui/submissions-ui-store.ts",
    "content": "/**\n * Submissions UI state management using Zustand with localStorage persistence.\n * Manages submission filters, sidenav, and content preferences.\n */\n\nimport { SubmissionType } from '@postybirb/types';\nimport { create } from 'zustand';\nimport { createJSONStorage, persist } from 'zustand/middleware';\nimport { useShallow } from 'zustand/react/shallow';\n\n// ============================================================================\n// Types\n// ============================================================================\n\n/**\n * Sub-navigation filter options.\n */\nexport type SubmissionFilter =\n  | 'all'\n  | 'queued'\n  | 'scheduled'\n  | 'posted'\n  | 'failed';\n\n/**\n * Submissions UI state interface.\n */\ninterface SubmissionsUIState {\n  /** Whether the sidenav is collapsed */\n  sidenavCollapsed: boolean;\n\n  /** File submissions filter */\n  fileSubmissionsFilter: SubmissionFilter;\n\n  /** File submissions search query */\n  fileSubmissionsSearchQuery: string;\n\n  /** Message submissions filter */\n  messageSubmissionsFilter: SubmissionFilter;\n\n  /** Message submissions search query */\n  messageSubmissionsSearchQuery: string;\n\n  /** Whether sub-nav is visible */\n  subNavVisible: boolean;\n\n  /** Whether to prefer multi-edit in submissions primary content */\n  submissionsPreferMultiEdit: boolean;\n}\n\n/**\n * Submissions UI actions interface.\n */\ninterface SubmissionsUIActions {\n  /** Toggle sidenav collapsed state */\n  toggleSidenav: () => void;\n\n  /** Set sidenav collapsed state */\n  setSidenavCollapsed: (collapsed: boolean) => void;\n\n  /** Set file submissions filter */\n  setFileSubmissionsFilter: (filter: SubmissionFilter) => void;\n\n  /** Set file submissions search query */\n  setFileSubmissionsSearchQuery: (query: string) => void;\n\n  /** Set message submissions filter */\n  setMessageSubmissionsFilter: (filter: SubmissionFilter) => void;\n\n  /** Set message submissions search query */\n  setMessageSubmissionsSearchQuery: (query: string) => void;\n\n  /** Set sub-nav visibility */\n  setSubNavVisible: (visible: boolean) => void;\n\n  /** Toggle sub-nav visibility */\n  toggleSubNavVisible: () => void;\n\n  /** Set multi-edit preference */\n  setSubmissionsPreferMultiEdit: (preferMultiEdit: boolean) => void;\n\n  /** Reset submissions UI state */\n  resetSubmissionsUI: () => void;\n}\n\n/**\n * Complete Submissions UI Store type.\n */\nexport type SubmissionsUIStore = SubmissionsUIState & SubmissionsUIActions;\n\n// ============================================================================\n// Constants\n// ============================================================================\n\n/**\n * Storage key for localStorage persistence.\n */\nconst STORAGE_KEY = 'postybirb-submissions-ui';\n\n// ============================================================================\n// Initial State\n// ============================================================================\n\n/**\n * Initial/default submissions UI state.\n */\nconst initialState: SubmissionsUIState = {\n  sidenavCollapsed: false,\n  fileSubmissionsFilter: 'all',\n  fileSubmissionsSearchQuery: '',\n  messageSubmissionsFilter: 'all',\n  messageSubmissionsSearchQuery: '',\n  subNavVisible: true,\n  submissionsPreferMultiEdit: false,\n};\n\n// ============================================================================\n// Store\n// ============================================================================\n\n/**\n * Zustand store for submissions UI with localStorage persistence.\n */\nexport const useSubmissionsUIStore = create<SubmissionsUIStore>()(\n  persist(\n    (set) => ({\n      // Initial state\n      ...initialState,\n\n      // Sidenav actions\n      toggleSidenav: () =>\n        set((state) => ({ sidenavCollapsed: !state.sidenavCollapsed })),\n      setSidenavCollapsed: (collapsed) => set({ sidenavCollapsed: collapsed }),\n\n      // Filter actions\n      setFileSubmissionsFilter: (filter) =>\n        set({ fileSubmissionsFilter: filter }),\n      setFileSubmissionsSearchQuery: (query) =>\n        set({ fileSubmissionsSearchQuery: query }),\n      setMessageSubmissionsFilter: (filter) =>\n        set({ messageSubmissionsFilter: filter }),\n      setMessageSubmissionsSearchQuery: (query) =>\n        set({ messageSubmissionsSearchQuery: query }),\n\n      // Sub-nav actions\n      setSubNavVisible: (visible) => set({ subNavVisible: visible }),\n      toggleSubNavVisible: () =>\n        set((state) => ({ subNavVisible: !state.subNavVisible })),\n\n      // Content preferences\n      setSubmissionsPreferMultiEdit: (submissionsPreferMultiEdit) =>\n        set({ submissionsPreferMultiEdit }),\n\n      // Reset to initial state\n      resetSubmissionsUI: () => set(initialState),\n    }),\n    {\n      name: STORAGE_KEY,\n      storage: createJSONStorage(() => localStorage),\n      // Don't persist search queries — they should reset between sessions\n      partialize: (state) => {\n        const { fileSubmissionsSearchQuery, messageSubmissionsSearchQuery, ...rest } = state;\n        return rest;\n      },\n    },\n  ),\n);\n\n// ============================================================================\n// Selector Hooks\n// ============================================================================\n\n/** Select sidenav collapsed state */\nexport const useSidenavCollapsed = () =>\n  useSubmissionsUIStore((state) => state.sidenavCollapsed);\n\n/** Select sidenav toggle action */\nexport const useToggleSidenav = () =>\n  useSubmissionsUIStore((state) => state.toggleSidenav);\n\n/** Select file submissions filter and search query */\nexport const useFileSubmissionsFilter = () =>\n  useSubmissionsUIStore(\n    useShallow((state) => ({\n      filter: state.fileSubmissionsFilter,\n      searchQuery: state.fileSubmissionsSearchQuery,\n      setFilter: state.setFileSubmissionsFilter,\n      setSearchQuery: state.setFileSubmissionsSearchQuery,\n    })),\n  );\n\n/** Select message submissions filter and search query */\nexport const useMessageSubmissionsFilter = () =>\n  useSubmissionsUIStore(\n    useShallow((state) => ({\n      filter: state.messageSubmissionsFilter,\n      searchQuery: state.messageSubmissionsSearchQuery,\n      setFilter: state.setMessageSubmissionsFilter,\n      setSearchQuery: state.setMessageSubmissionsSearchQuery,\n    })),\n  );\n\n/** Generic submissions filter hook - returns filter state based on submission type */\nexport const useSubmissionsFilter = (type: SubmissionType) =>\n  useSubmissionsUIStore(\n    useShallow((state) =>\n      type === SubmissionType.FILE\n        ? {\n            filter: state.fileSubmissionsFilter,\n            searchQuery: state.fileSubmissionsSearchQuery,\n            setFilter: state.setFileSubmissionsFilter,\n            setSearchQuery: state.setFileSubmissionsSearchQuery,\n          }\n        : {\n            filter: state.messageSubmissionsFilter,\n            searchQuery: state.messageSubmissionsSearchQuery,\n            setFilter: state.setMessageSubmissionsFilter,\n            setSearchQuery: state.setMessageSubmissionsSearchQuery,\n          },\n    ),\n  );\n\n/** Select sub-nav visibility */\nexport const useSubNavVisible = () =>\n  useSubmissionsUIStore(\n    useShallow((state) => ({\n      visible: state.subNavVisible,\n      setVisible: state.setSubNavVisible,\n    })),\n  );\n\n/** Toggle section panel visibility hook */\nexport const useToggleSectionPanel = () =>\n  useSubmissionsUIStore((state) => state.toggleSubNavVisible);\n\n/** Select submissions primary content preferences */\nexport const useSubmissionsContentPreferences = () =>\n  useSubmissionsUIStore(\n    useShallow((state) => ({\n      preferMultiEdit: state.submissionsPreferMultiEdit,\n      setPreferMultiEdit: state.setSubmissionsPreferMultiEdit,\n    })),\n  );\n"
  },
  {
    "path": "apps/postybirb-ui/src/stores/ui/templates-ui-store.ts",
    "content": "/**\n * Templates UI state management using Zustand with localStorage persistence.\n * Manages template section filters and tab preferences.\n */\n\nimport { SubmissionType } from '@postybirb/types';\nimport { create } from 'zustand';\nimport { createJSONStorage, persist } from 'zustand/middleware';\nimport { useShallow } from 'zustand/react/shallow';\n\n// ============================================================================\n// Types\n// ============================================================================\n\n/**\n * Templates UI state interface.\n */\ninterface TemplatesUIState {\n  /** Currently selected template tab type */\n  templatesTabType: SubmissionType;\n\n  /** Templates search query */\n  templatesSearchQuery: string;\n}\n\n/**\n * Templates UI actions interface.\n */\ninterface TemplatesUIActions {\n  /** Set templates tab type */\n  setTemplatesTabType: (type: SubmissionType) => void;\n\n  /** Set templates search query */\n  setTemplatesSearchQuery: (query: string) => void;\n\n  /** Reset templates UI state */\n  resetTemplatesUI: () => void;\n}\n\n/**\n * Complete Templates UI Store type.\n */\nexport type TemplatesUIStore = TemplatesUIState & TemplatesUIActions;\n\n// ============================================================================\n// Constants\n// ============================================================================\n\n/**\n * Storage key for localStorage persistence.\n */\nconst STORAGE_KEY = 'postybirb-templates-ui';\n\n// ============================================================================\n// Initial State\n// ============================================================================\n\n/**\n * Initial/default templates UI state.\n */\nconst initialState: TemplatesUIState = {\n  templatesTabType: SubmissionType.FILE,\n  templatesSearchQuery: '',\n};\n\n// ============================================================================\n// Store\n// ============================================================================\n\n/**\n * Zustand store for templates UI with localStorage persistence.\n */\nexport const useTemplatesUIStore = create<TemplatesUIStore>()(\n  persist(\n    (set) => ({\n      // Initial state\n      ...initialState,\n\n      // Actions\n      setTemplatesTabType: (templatesTabType) => set({ templatesTabType }),\n      setTemplatesSearchQuery: (templatesSearchQuery) =>\n        set({ templatesSearchQuery }),\n\n      // Reset to initial state\n      resetTemplatesUI: () => set(initialState),\n    }),\n    {\n      name: STORAGE_KEY,\n      storage: createJSONStorage(() => localStorage),\n    },\n  ),\n);\n\n// ============================================================================\n// Selector Hooks\n// ============================================================================\n\n/** Select templates section filter state and actions */\nexport const useTemplatesFilter = () =>\n  useTemplatesUIStore(\n    useShallow((state) => ({\n      tabType: state.templatesTabType,\n      searchQuery: state.templatesSearchQuery,\n      setTabType: state.setTemplatesTabType,\n      setSearchQuery: state.setTemplatesSearchQuery,\n    })),\n  );\n"
  },
  {
    "path": "apps/postybirb-ui/src/stores/ui/tour-store.ts",
    "content": "/**\n * Tour UI state management using Zustand with localStorage persistence.\n * Tracks which tours have been completed/skipped and which tour is active.\n */\n\nimport { create } from 'zustand';\nimport { createJSONStorage, persist } from 'zustand/middleware';\nimport { useShallow } from 'zustand/react/shallow';\n\n// ============================================================================\n// Types\n// ============================================================================\n\ninterface TourState {\n  /** Map of tour ID to completion status */\n  completedTours: Record<string, boolean>;\n\n  /** Currently active tour ID, or null if no tour is running */\n  activeTourId: string | null;\n\n  /** Whether the active tour is currently started/running */\n  tourStarted: boolean;\n}\n\ninterface TourActions {\n  /** Start a specific tour by ID */\n  startTour: (tourId: string) => void;\n\n  /** Mark a tour as completed (user finished all steps) */\n  completeTour: (tourId: string) => void;\n\n  /** Mark a tour as skipped (user dismissed early) */\n  skipTour: (tourId: string) => void;\n\n  /** End the currently running tour (cleanup) */\n  endTour: () => void;\n\n  /** Reset a specific tour so it can be re-triggered */\n  resetTour: (tourId: string) => void;\n\n  /** Reset all tours */\n  resetAllTours: () => void;\n}\n\ntype TourStore = TourState & TourActions;\n\n// ============================================================================\n// Constants\n// ============================================================================\n\nconst STORAGE_KEY = 'postybirb-tour-state';\n\n// ============================================================================\n// Initial State\n// ============================================================================\n\nconst initialState: TourState = {\n  completedTours: {},\n  activeTourId: null,\n  tourStarted: false,\n};\n\n// ============================================================================\n// Store\n// ============================================================================\n\nexport const useTourStore = create<TourStore>()(\n  persist(\n    (set) => ({\n      ...initialState,\n\n      startTour: (tourId) =>\n        set({ activeTourId: tourId, tourStarted: true }),\n\n      completeTour: (tourId) =>\n        set((state) => ({\n          completedTours: { ...state.completedTours, [tourId]: true },\n          activeTourId: null,\n          tourStarted: false,\n        })),\n\n      skipTour: (tourId) =>\n        set((state) => ({\n          completedTours: { ...state.completedTours, [tourId]: true },\n          activeTourId: null,\n          tourStarted: false,\n        })),\n\n      endTour: () =>\n        set({ activeTourId: null, tourStarted: false }),\n\n      resetTour: (tourId) =>\n        set((state) => {\n          const { [tourId]: _, ...rest } = state.completedTours;\n          return { completedTours: rest };\n        }),\n\n      resetAllTours: () =>\n        set({ completedTours: {}, activeTourId: null, tourStarted: false }),\n    }),\n    {\n      name: STORAGE_KEY,\n      storage: createJSONStorage(() => localStorage),\n      // Only persist completedTours — active tour state resets between sessions\n      partialize: (state) => ({\n        completedTours: state.completedTours,\n      }),\n    },\n  ),\n);\n\n// ============================================================================\n// Selector Hooks\n// ============================================================================\n\nexport function useActiveTourId() {\n  return useTourStore((state) => state.activeTourId);\n}\n\nexport function useTourStarted() {\n  return useTourStore((state) => state.tourStarted);\n}\n\nexport function useIsTourCompleted(tourId: string) {\n  return useTourStore((state) => !!state.completedTours[tourId]);\n}\n\nexport function useTourActions() {\n  return useTourStore(\n    useShallow((state) => ({\n      startTour: state.startTour,\n      completeTour: state.completeTour,\n      skipTour: state.skipTour,\n      endTour: state.endTour,\n      resetTour: state.resetTour,\n      resetAllTours: state.resetAllTours,\n    })),\n  );\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/styles/layout.css",
    "content": "/**\n * Layout-specific styles for the PostyBirb UI.\n * Custom flexbox layout (no AppShell) with transitions.\n * Naming convention: postybirb__snake_case\n */\n\n/* =============================================================================\n   Z-Index Scale - Centralized z-index values for consistent layering\n   ============================================================================= */\n\n:root {\n  /* Base layers */\n  --z-base: 0;\n  --z-section-panel: 1;\n  --z-sticky: 10;\n  \n  /* Navigation */\n  --z-sidenav: 100;\n  \n  /* Drawers */\n  --z-drawer-overlay: 200;\n  --z-drawer: 201;\n  \n  /* Modals */\n  --z-modal-overlay: 300;\n  --z-modal: 301;\n  --z-modal-nested: 400;\n  \n  /* Floating elements */\n  --z-popover: 500;\n  --z-tooltip: 600;\n  \n  /* Always on top */\n  --z-notification: 700;\n}\n\n/* =============================================================================\n   Utility Classes - Reusable across components\n   ============================================================================= */\n\n/* Flexbox utilities */\n.postybirb__flex_row {\n  display: flex;\n  flex-direction: row;\n}\n\n.postybirb__flex_center {\n  display: flex;\n  align-items: center;\n}\n\n.postybirb__flex_between {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  width: 100%;\n}\n\n/* Gap utilities */\n.postybirb__gap_xs {\n  gap: 4px;\n}\n\n.postybirb__gap_sm {\n  gap: 8px;\n}\n\n.postybirb__gap_md {\n  gap: 12px;\n}\n\n/* Nav item label - used in sidenav, theme picker, language picker */\n.postybirb__nav_item_label {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  width: 100%;\n}\n\n/* Tooltip content with kbd shortcut */\n.postybirb__tooltip_content {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n}\n\n/* Kbd element alignment fix */\n.postybirb__kbd_aligned {\n  display: flex;\n  align-items: center;\n}\n\n/* NavLink border radius - applied via CSS instead of inline */\n.postybirb__sidenav_nav .mantine-NavLink-root {\n  border-radius: var(--mantine-radius-sm);\n}\n\n/* =============================================================================\n   Layout Container\n   ============================================================================= */\n\n/* Layout container - full viewport height */\n.postybirb__layout {\n  display: flex;\n  min-height: 100vh;\n  width: 100%;\n  overflow: hidden;\n}\n\n/* Side navigation */\n.postybirb__sidenav {\n  display: flex;\n  flex-direction: column;\n  width: 280px;\n  min-width: 280px;\n  height: 100vh;\n  position: fixed;\n  left: 0;\n  top: 0;\n  background-color: var(--mantine-color-body);\n  border-right: 1px solid var(--mantine-color-default-border);\n  transition:\n    width 200ms ease,\n    min-width 200ms ease;\n  z-index: var(--z-sidenav);\n  overflow: hidden;\n}\n\n.postybirb__sidenav--collapsed {\n  width: 60px;\n  min-width: 60px;\n}\n\n/* Sidenav header (contains collapse button) */\n.postybirb__sidenav_header {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  padding: 12px;\n  border-bottom: 1px solid var(--mantine-color-default-border);\n  min-height: var(--postybirb-header-height);\n}\n\n.postybirb__sidenav--collapsed .postybirb__sidenav_header {\n  justify-content: center;\n  padding: 12px 8px;\n}\n\n/* Sidenav scroll area - fills remaining space */\n.postybirb__sidenav_scroll {\n  flex: 1;\n  overflow: hidden;\n}\n\n/* Sidenav scroll area - primary color scrollbar */\n.mantine-ScrollArea-scrollbar {\n  --scrollbar-color: color-mix(\n    in srgb,\n    var(--mantine-primary-color-filled) 40%,\n    transparent\n  );\n}\n\n.mantine-ScrollArea-scrollbar:hover {\n  --scrollbar-color: color-mix(\n    in srgb,\n    var(--mantine-primary-color-filled) 65%,\n    transparent\n  );\n}\n\n.mantine-ScrollArea-thumb {\n  background-color: var(--scrollbar-color);\n}\n\n/* Sidenav navigation items container */\n.postybirb__sidenav_nav {\n  padding: 8px;\n}\n\n/* Center nav link icons when sidenav is collapsed */\n.postybirb__sidenav--collapsed .postybirb__sidenav_nav .mantine-NavLink-root {\n  justify-content: center;\n}\n\n.postybirb__sidenav--collapsed\n  .postybirb__sidenav_nav\n  .mantine-NavLink-section {\n  margin: 0;\n}\n\n/* Nav item label visibility */\n.postybirb__nav_label {\n  display: block;\n}\n\n.postybirb__sidenav--collapsed .postybirb__nav_label {\n  display: none;\n}\n\n/* Main content wrapper - adjusts for sidenav */\n.postybirb__main {\n  display: flex;\n  flex-direction: column;\n  flex: 1;\n  margin-left: 280px;\n  min-height: 100vh;\n  transition: margin-left 200ms ease;\n}\n\n.postybirb__main--sidenav_collapsed {\n  margin-left: 60px;\n}\n\n/* Content navbar (with pagination) */\n.postybirb__content_navbar {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  height: 60px;\n  min-height: 60px;\n  padding: 0 16px;\n  background-color: var(--mantine-color-body);\n  border-bottom: 1px solid var(--mantine-color-default-border);\n}\n\n.postybirb__content_navbar_title {\n  font-weight: 500;\n  font-size: 14px;\n}\n\n.postybirb__content_navbar_center {\n  display: flex;\n  justify-content: center;\n  flex: 1;\n}\n\n.postybirb__content_navbar_actions {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n}\n\n/* =============================================================================\n   Split Content Layout - Master/Detail Pattern\n   ============================================================================= */\n\n/* Container for split content (section panel + primary content) */\n.postybirb__content_split {\n  display: flex;\n  flex: 1;\n  overflow: hidden;\n  position: relative; /* Positioning context for section drawer */\n}\n\n/* Section Panel (Master) - left side list */\n.postybirb__section_panel {\n  display: flex;\n  flex-direction: column;\n  height: 100%;\n  background-color: var(--mantine-color-body);\n  border-right: 1px solid var(--mantine-color-default-border);\n  box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);\n  overflow: hidden;\n  z-index: var(--z-section-panel);\n  max-height: 100vh;\n}\n\n/* Primary Content (Detail) - right side detail view */\n.postybirb__primary_content {\n  display: flex;\n  flex-direction: column;\n  flex: 1;\n  overflow: hidden;\n  background-color: var(--mantine-color-body);\n  max-height: 100vh;\n}\n\n/* Primary content scrollable area (below navbar) */\n.postybirb__primary_content_area {\n  flex: 1;\n  overflow: hidden; /* Children manage their own scrolling */\n  position: relative;\n}\n\n/* =============================================================================\n   Section Drawer - Custom drawer that slides from section panel area\n   ============================================================================= */\n\n/* Drawer overlay - covers content_split area */\n.postybirb__section_drawer_overlay {\n  position: absolute;\n  inset: 0;\n  background-color: rgba(0, 0, 0, 0.4);\n  z-index: var(--z-drawer-overlay);\n  opacity: 0;\n  pointer-events: none;\n  transition: opacity 200ms ease;\n}\n\n.postybirb__section_drawer_overlay--visible {\n  opacity: 1;\n  pointer-events: auto;\n}\n\n/* Drawer panel container */\n.postybirb__section_drawer {\n  position: absolute;\n  top: 0;\n  left: 0;\n  bottom: 0;\n  width: 360px;\n  max-width: 90%;\n  z-index: var(--z-drawer);\n  display: flex;\n  flex-direction: column;\n  background-color: var(--mantine-color-body);\n  border-right: 1px solid var(--mantine-color-default-border);\n  transform: translateX(-100%);\n  transition: transform 200ms ease;\n}\n\n.postybirb__section_drawer--open {\n  transform: translateX(0);\n}\n\n/* Drawer header */\n.postybirb__section_drawer_header {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  padding: 12px 16px;\n  border-bottom: 1px solid var(--mantine-color-default-border);\n  height: var(--postybirb-header-height);\n  min-height: var(--postybirb-header-height);\n}\n\n/* Drawer body - scrollable content area */\n.postybirb__section_drawer_body {\n  flex: 1;\n  overflow-y: auto;\n  padding: 16px;\n}\n\n/* Section drawer scroll area - primary color scrollbar */\n.postybirb__section_drawer_body::-webkit-scrollbar-thumb {\n  background: var(--scrollbar-thumb-color);\n}\n\n.postybirb__section_drawer_body::-webkit-scrollbar-thumb:hover {\n  background: var(--scrollbar-thumb-hover-color);\n}\n\n/* Primary content area - scrollable (legacy, may be removed) */\n.postybirb__content_area {\n  flex: 1;\n  overflow-y: auto;\n  padding: 16px;\n  position: relative;\n}\n\n/* Loading overlay for content area */\n.postybirb__content_area_loading {\n  position: absolute;\n  inset: 0;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background-color: rgba(255, 255, 255, 0.8);\n  z-index: var(--z-sticky);\n}\n\n[data-mantine-color-scheme='dark'] .postybirb__content_area_loading {\n  background-color: rgba(0, 0, 0, 0.6);\n}\n\n/* =============================================================================\n   Custom Scrollbars - Elegant, slim design using Mantine's primary color\n   ============================================================================= */\n\n:root {\n  --scrollbar-width: 6px;\n  --scrollbar-track-color: transparent;\n  /* Uses primary color with transparency for subtle, cohesive look */\n  --scrollbar-thumb-color: color-mix(\n    in srgb,\n    var(--mantine-primary-color-filled) 40%,\n    transparent\n  );\n  --scrollbar-thumb-hover-color: color-mix(\n    in srgb,\n    var(--mantine-primary-color-filled) 65%,\n    transparent\n  );\n  --scrollbar-thumb-active-color: var(--mantine-primary-color-filled);\n}\n\n/* WebKit browsers (Chrome, Safari, Edge) */\n::-webkit-scrollbar {\n  width: var(--scrollbar-width);\n  height: var(--scrollbar-width);\n}\n\n::-webkit-scrollbar-track {\n  background: var(--scrollbar-track-color);\n  border-radius: var(--mantine-radius-sm);\n}\n\n::-webkit-scrollbar-thumb {\n  background: var(--scrollbar-thumb-color);\n  border-radius: var(--mantine-radius-sm);\n  transition: background 150ms ease;\n}\n\n::-webkit-scrollbar-thumb:hover {\n  background: var(--scrollbar-thumb-hover-color);\n}\n\n::-webkit-scrollbar-thumb:active {\n  background: var(--scrollbar-thumb-active-color);\n}\n\n/* Hide scrollbar corner (where vertical and horizontal scrollbars meet) */\n::-webkit-scrollbar-corner {\n  background: transparent;\n}\n\n/* Firefox */\n* {\n  scrollbar-width: thin;\n  scrollbar-color: var(--scrollbar-thumb-color) var(--scrollbar-track-color);\n}\n\n/* =============================================================================\n    CSS Variables - Global layout measurements\n    ============================================================================= */\n\n:root {\n  --postybirb-header-height: 60px;\n}\n\n/* =============================================================================\n    Stat Card - Dashboard stat card styles\n    ============================================================================= */\n\n.postybirb__stat-card--clickable {\n  cursor: pointer;\n  transition: transform 0.1s ease, box-shadow 0.1s ease;\n}\n\n.postybirb__stat-card--clickable:hover {\n  transform: translateY(-2px);\n  box-shadow: var(--mantine-shadow-sm);\n}\n\n.postybirb__stat-card--clickable:active {\n  transform: translateY(0);\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/styles.css",
    "content": "/* You can add global styles to this file, and also import other style files */\n@tailwind components;\n@tailwind base;\n@tailwind utilities;\n"
  },
  {
    "path": "apps/postybirb-ui/src/theme/css-variable-resolver.ts",
    "content": "import { CSSVariablesResolver } from \"@mantine/core\";\n\nexport const cssVariableResolver: CSSVariablesResolver = () => ({\n    variables: {\n      //  variables that do not depend on color scheme\n    },\n    light: {\n      // variables for light color scheme only\n    },\n    dark: {\n      // variables for dark color scheme only\n    },\n  });"
  },
  {
    "path": "apps/postybirb-ui/src/theme/theme-styles.css",
    "content": ""
  },
  {
    "path": "apps/postybirb-ui/src/theme/theme.ts",
    "content": "import type { MantineThemeOverride } from '@mantine/core';\nimport {\n  Card,\n  Container,\n  createTheme,\n  LoadingOverlay,\n  Modal,\n  Overlay,\n  Paper,\n  Popover,\n  rem,\n  ScrollArea,\n  Select,\n  Tooltip,\n} from '@mantine/core';\nimport type { MantinePrimaryColor } from '../stores/ui/appearance-store';\n\n// ============================================================================\n// Z-Index Scale (mirrors CSS variables in layout.css)\n// ============================================================================\nconst Z_INDEX = {\n  sticky: 10,\n  modalOverlay: 300,\n  modal: 301,\n  popover: 500,\n  tooltip: 600,\n} as const;\n\nconst CONTAINER_SIZES: Record<string, string> = {\n  xxs: rem('200px'),\n  xs: rem('300px'),\n  sm: rem('400px'),\n  md: rem('500px'),\n  lg: rem('600px'),\n  xl: rem('1400px'),\n  xxl: rem('1600px'),\n};\n\n/**\n * Create a Mantine theme with the specified primary color.\n */\nexport function createAppTheme(\n  primaryColor: MantinePrimaryColor = 'red',\n): MantineThemeOverride {\n  return createTheme({\n    /** Put your mantine theme override here */\n    fontSizes: {\n      xs: rem('12px'),\n      sm: rem('14px'),\n      md: rem('16px'),\n      lg: rem('18px'),\n      xl: rem('20px'),\n      '2xl': rem('24px'),\n      '3xl': rem('30px'),\n      '4xl': rem('36px'),\n      '5xl': rem('48px'),\n    },\n    spacing: {\n      '3xs': rem('4px'),\n      '2xs': rem('8px'),\n      xs: rem('10px'),\n      sm: rem('12px'),\n      md: rem('16px'),\n      lg: rem('20px'),\n      xl: rem('24px'),\n      '2xl': rem('28px'),\n      '3xl': rem('32px'),\n    },\n    primaryColor,\n    components: {\n      /** Put your mantine component override here */\n      Container: Container.extend({\n        vars: (_, { size, fluid }) => ({\n          root: {\n            '--container-size': fluid\n              ? '100%'\n              : size !== undefined && size in CONTAINER_SIZES\n                ? CONTAINER_SIZES[size]\n                : rem(size),\n          },\n        }),\n      }),\n      Paper: Paper.extend({\n        defaultProps: {\n          p: 'md',\n          shadow: 'xl',\n          radius: 'md',\n          withBorder: true,\n        },\n      }),\n\n      Card: Card.extend({\n        defaultProps: {\n          p: 'md',\n          shadow: 'sm',\n          radius: 'var(--mantine-radius-default)',\n          withBorder: true,\n        },\n      }),\n      Select: Select.extend({\n        defaultProps: {\n          checkIconPosition: 'right',\n        },\n      }),\n      ScrollArea: ScrollArea.extend({\n        defaultProps: {\n          scrollbarSize: '6px',\n        },\n      }),\n\n      // Z-Index defaults for overlay components\n      Modal: Modal.extend({\n        defaultProps: {\n          zIndex: Z_INDEX.modal,\n        },\n      }),\n      Tooltip: Tooltip.extend({\n        defaultProps: {\n          zIndex: Z_INDEX.tooltip,\n        },\n      }),\n      Popover: Popover.extend({\n        defaultProps: {\n          zIndex: Z_INDEX.popover,\n        },\n      }),\n      Overlay: Overlay.extend({\n        defaultProps: {\n          zIndex: Z_INDEX.sticky,\n        },\n      }),\n      LoadingOverlay: LoadingOverlay.extend({\n        defaultProps: {\n          zIndex: Z_INDEX.sticky,\n        },\n      }),\n    },\n    other: {\n      style: 'mantine',\n    },\n  });\n}\n\n/**\n * Default theme instance (for backwards compatibility).\n */\nexport const theme = createAppTheme('teal');\n"
  },
  {
    "path": "apps/postybirb-ui/src/transports/http-client.ts",
    "content": "/* eslint-disable lingui/no-unlocalized-strings */\n/* eslint-disable @typescript-eslint/no-explicit-any */\n/* eslint-disable max-classes-per-file */\n// eslint-disable-next-line no-restricted-globals\n\nexport const REMOTE_PASSWORD_KEY = 'remote_password';\nexport const REMOTE_HOST_KEY = 'remote_host';\nexport const REMOTE_MODE_KEY = 'remote_mode';\n\nexport const defaultTargetPath = `https://localhost:${window.electron.app_port}`;\n\n// ---------------------------------------------------------------------------\n// Cached localStorage config\n// Values are read once on module load and refreshed on `storage` events or\n// explicit calls to `refreshRemoteConfig()`. This avoids synchronous\n// `localStorage.getItem` calls on every HTTP request.\n// ---------------------------------------------------------------------------\ninterface RemoteConfig {\n  host: string | null;\n  password: string | null;\n  mode: 'client' | 'host' | null;\n}\n\nlet cachedConfig: RemoteConfig = {\n  host: localStorage.getItem(REMOTE_HOST_KEY),\n  password: localStorage.getItem(REMOTE_PASSWORD_KEY),\n  mode: localStorage.getItem(REMOTE_MODE_KEY) as 'client' | 'host' | null,\n};\n\nexport const getRemoteConfig = (): RemoteConfig => cachedConfig;\n\n/**\n * Re-read remote host / password from localStorage.\n * Call this after programmatically writing to localStorage so the cached\n * values stay in sync (the `storage` event only fires for *other* tabs).\n */\nexport const refreshRemoteConfig = () => {\n  cachedConfig = {\n    host: localStorage.getItem(REMOTE_HOST_KEY),\n    password: localStorage.getItem(REMOTE_PASSWORD_KEY),\n    mode: localStorage.getItem(REMOTE_MODE_KEY) as 'client' | 'host' | null,\n  };\n};\n\n// Keep the cache in sync when another tab/window changes the values.\nwindow.addEventListener('storage', (e) => {\n  if (e.key === REMOTE_HOST_KEY || e.key === REMOTE_PASSWORD_KEY) {\n    refreshRemoteConfig();\n  }\n});\n\nexport const defaultTargetProvider = () => {\n  const remoteUrl = cachedConfig.host;\n  if (remoteUrl?.trim()) {\n    return `https://${remoteUrl}`;\n  }\n\n  return defaultTargetPath;\n};\n\nexport const getRemotePassword = () => {\n  const remotePassword = cachedConfig.password;\n  const electronRemotePassword = window.electron?.getRemoteConfig()?.password;\n  return remotePassword?.trim() || electronRemotePassword?.trim();\n};\n\ntype FetchMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';\n\n/**\n * Error thrown when a network-level failure occurs (no response received)\n */\nexport class NetworkError extends Error {\n  constructor(\n    message: string,\n    public readonly cause?: Error,\n  ) {\n    super(message);\n    this.name = 'NetworkError';\n  }\n}\n\n/**\n * Error thrown when an HTTP request returns a non-2xx status code\n */\nexport class HttpError<T = unknown> extends Error {\n  public readonly response: HttpResponse<T>;\n  public readonly statusCode: number;\n\n  constructor(response: HttpResponse<T>) {\n    super(`HTTP ${response.status}: ${response.statusText}`);\n    this.name = 'HttpError';\n    this.response = {\n      ...response,\n      error: response.body as unknown as ErrorResponse,\n    };\n    this.statusCode = response.status;\n  }\n}\n\nexport type ErrorResponse<T = string> = {\n  error: string;\n  message: T;\n  statusCode: number;\n};\nexport type HttpResponse<T> = {\n  body: T;\n  status: number;\n  statusText: string;\n  error: ErrorResponse;\n};\n\n// eslint-disable-next-line @typescript-eslint/ban-types\ntype RequestBody = Object | BodyInit | undefined;\ntype SearchBody = string | object | undefined;\ntype HttpOptions = {\n  headers?: Record<string, string>;\n  /** Number of retry attempts for failed requests (default: 3) */\n  retries?: number;\n  /** Base delay in ms between retries, uses exponential backoff (default: 1000) */\n  retryDelay?: number;\n};\n\nexport class HttpClient {\n  private static readonly DEFAULT_RETRIES = 3;\n  private static readonly DEFAULT_RETRY_DELAY = 1000;\n\n  constructor(\n    private readonly basePath: string,\n    private readonly targetProvider: () => string = defaultTargetProvider,\n  ) {}\n\n  public get<T = any>(\n    path = '',\n    searchParams: SearchBody = undefined,\n    options: HttpOptions = {},\n  ): Promise<HttpResponse<T>> {\n    return this.performRequest<T>('GET', path, searchParams, options ?? {});\n  }\n\n  public post<T = any>(\n    path = '',\n    body: RequestBody = undefined,\n    options: HttpOptions = {},\n  ): Promise<HttpResponse<T>> {\n    return this.performRequest<T>('POST', path, body, options ?? {});\n  }\n\n  public put<T = any>(\n    path = '',\n    searchParams: SearchBody = undefined,\n    options: HttpOptions = {},\n  ): Promise<HttpResponse<T>> {\n    return this.performRequest<T>('PUT', path, searchParams, options ?? {});\n  }\n\n  public patch<T = any>(\n    path = '',\n    body: RequestBody = undefined,\n    options: HttpOptions = {},\n  ): Promise<HttpResponse<T>> {\n    return this.performRequest<T>('PATCH', path, body, options ?? {});\n  }\n\n  public delete<T = any>(\n    path = '',\n    searchParams: SearchBody = undefined,\n    options: HttpOptions = {},\n  ): Promise<HttpResponse<T>> {\n    return this.performRequest<T>('DELETE', path, searchParams, options ?? {});\n  }\n\n  /**\n   * Determines if a request should be retried based on status code\n   */\n  private shouldRetry(status: number): boolean {\n    // Retry on server errors (5xx) and certain client errors\n    // Don't retry on 400 (bad request), 401 (unauthorized), 403 (forbidden), 404 (not found)\n    // as these are unlikely to succeed on retry\n    if (status >= 500) return true;\n    // Retry on 408 (timeout), 429 (too many requests)\n    if (status === 408 || status === 429) return true;\n    return false;\n  }\n\n  /**\n   * Waits for exponential backoff delay\n   */\n  private async delay(attempt: number, baseDelay: number): Promise<void> {\n    const delayMs = baseDelay * 2 ** attempt;\n    return new Promise((resolve) => {\n      setTimeout(resolve, delayMs);\n    });\n  }\n\n  private async performRequest<T = any>(\n    method: FetchMethod,\n    path: string,\n    bodyOrSearchParams: RequestBody | SearchBody,\n    options: HttpOptions,\n  ): Promise<HttpResponse<T>> {\n    const maxRetries = options.retries ?? HttpClient.DEFAULT_RETRIES;\n    const retryDelay = options.retryDelay ?? HttpClient.DEFAULT_RETRY_DELAY;\n\n    const shouldUseBody = this.supportsBody(method);\n    const url = this.createPath(\n      path,\n      shouldUseBody ? undefined : (bodyOrSearchParams as SearchBody),\n    );\n\n    // Build headers - let browser set Content-Type for FormData\n    const isFormData = bodyOrSearchParams instanceof FormData;\n    let headers: Record<string, string> = {};\n\n    // Only set Content-Type for non-FormData requests\n    if (!isFormData && shouldUseBody) {\n      headers['Content-Type'] = 'application/json';\n    }\n\n    // Add remote password if configured\n    const pw = getRemotePassword();\n    if (pw) {\n      headers['X-Remote-Password'] = pw;\n    }\n\n    // Merge custom headers (custom headers take precedence)\n    if (options.headers) {\n      headers = { ...headers, ...options.headers };\n    }\n\n    const fetchOptions: RequestInit = {\n      method,\n      body: shouldUseBody\n        ? this.handleRequestData(bodyOrSearchParams as RequestBody)\n        : undefined,\n      headers,\n    };\n\n    let lastError: Error | undefined;\n    let lastResponse: HttpResponse<T> | undefined;\n\n    for (let attempt = 0; attempt <= maxRetries; attempt++) {\n      try {\n        const res = await fetch(url, fetchOptions);\n        const resObj = await this.buildResponse<T>(res);\n\n        if (!res.ok) {\n          // Check if we should retry this error\n          if (this.shouldRetry(res.status) && attempt < maxRetries) {\n            lastResponse = resObj;\n            await this.delay(attempt, retryDelay);\n            continue;\n          }\n          // Non-retryable error, throw with response details\n          throw new HttpError(resObj);\n        }\n\n        return resObj;\n      } catch (error) {\n        // Handle network-level errors (no response received)\n        if (\n          error instanceof TypeError ||\n          (error as Error)?.name === 'TypeError'\n        ) {\n          lastError = error as Error;\n          if (attempt < maxRetries) {\n            await this.delay(attempt, retryDelay);\n            continue;\n          }\n          throw new NetworkError(\n            `Network request failed after ${maxRetries + 1} attempts: ${(error as Error).message}`,\n            error as Error,\n          );\n        }\n        // Re-throw HTTP errors (already have response)\n        throw error;\n      }\n    }\n\n    // Should not reach here, but handle edge case\n    if (lastResponse) {\n      throw new HttpError(lastResponse);\n    }\n    throw lastError ?? new Error('Request failed');\n  }\n\n  /**\n   * Builds HttpResponse object from fetch Response, handling parse errors\n   */\n  private async buildResponse<T>(res: Response): Promise<HttpResponse<T>> {\n    let body: T;\n    try {\n      body = await this.processResponse<T>(res);\n    } catch (parseError) {\n      // If we can't parse the response, use empty object or error message\n      body = (\n        res.ok\n          ? {}\n          : { error: 'Parse error', message: 'Failed to parse response body' }\n      ) as T;\n    }\n\n    return {\n      body,\n      status: res.status,\n      statusText: res.statusText,\n      error: { error: '', statusCode: 0, message: '' },\n    };\n  }\n\n  private createPath(path: string, searchBody: SearchBody): URL {\n    const url = new URL(`api/${this.basePath}/${path}`, this.targetProvider());\n    if (typeof searchBody === 'string') {\n      url.search = searchBody;\n    } else if (searchBody && typeof searchBody === 'object') {\n      Object.entries(searchBody).forEach(([key, value]) => {\n        if (Array.isArray(value)) {\n          value.forEach((v) => {\n            url.searchParams.append(\n              key,\n              typeof v === 'string' ? v : JSON.stringify(v),\n            );\n          });\n        } else if (typeof value === 'object') {\n          url.searchParams.append(key, JSON.stringify(value));\n        } else {\n          url.searchParams.append(key, value as string);\n        }\n      });\n    }\n\n    return url;\n  }\n\n  private handleRequestData(body: RequestBody): BodyInit | undefined {\n    if (body instanceof FormData) {\n      return body;\n    }\n\n    if (typeof body === 'object') {\n      return JSON.stringify(body);\n    }\n\n    if (typeof body === 'string') {\n      return body;\n    }\n\n    return body;\n  }\n\n  private supportsBody(method: FetchMethod): boolean {\n    switch (method) {\n      case 'POST':\n      case 'PATCH':\n        return true;\n      case 'PUT':\n      case 'GET':\n      case 'DELETE':\n      default:\n        return false;\n    }\n  }\n\n  private async processResponse<T>(res: Response): Promise<T> {\n    if (res.headers.get('Content-Type')?.includes('application/json')) {\n      return this.processJson<T>(res);\n    }\n\n    return this.processText(res);\n  }\n\n  private async processJson<T>(res: Response): Promise<T> {\n    return (await res.json()) as T;\n  }\n\n  private async processText<T>(res: Response): Promise<T> {\n    return (await res.text()) as unknown as T;\n  }\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/transports/websocket.ts",
    "content": "/* eslint-disable no-console */\n/* eslint-disable lingui/no-unlocalized-strings */\nimport io, { ManagerOptions, SocketOptions } from 'socket.io-client';\nimport { defaultTargetProvider, getRemotePassword } from './http-client';\n\n// Retry configuration\nconst INITIAL_RETRY_DELAY = 1000; // 1 second\nconst MAX_RETRY_DELAY = 30000; // 30 seconds\nconst BACKOFF_MULTIPLIER = 1.5;\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nconst socketSettings: Partial<ManagerOptions & SocketOptions> = {\n  // Automatically try to reconnect\n  reconnection: true,\n  // Number of reconnection attempts before giving up (Infinity = never give up)\n  reconnectionAttempts: Infinity,\n  // Initial reconnection delay\n  reconnectionDelay: INITIAL_RETRY_DELAY,\n  // Maximum delay between reconnection attempts\n  reconnectionDelayMax: MAX_RETRY_DELAY,\n  // Randomization factor for reconnection delay (0 = no randomization, 1 = 100% randomization)\n  randomizationFactor: 0.5,\n};\n\nconst remotePassword = getRemotePassword();\nif (remotePassword) {\n  socketSettings.transportOptions = {\n    polling: {\n      extraHeaders: {\n        Authorization: remotePassword,\n      },\n    },\n  };\n}\n\nconst AppSocket = io(defaultTargetProvider(), socketSettings);\n\n// Connection event handlers\nAppSocket.on('connect', () => {\n  console.log('Websocket connected successfully');\n});\n\nAppSocket.on('connect_error', (error) => {\n  console.warn('Websocket connection error, will retry...', error.message);\n});\n\nAppSocket.on('reconnect_attempt', (attemptNumber) => {\n  console.log(`Websocket reconnection attempt #${attemptNumber}`);\n});\n\nAppSocket.on('reconnect', (attemptNumber) => {\n  console.log(`Websocket reconnected after ${attemptNumber} attempts`);\n});\n\nAppSocket.on('disconnect', (reason) => {\n  console.log('Websocket disconnected:', reason);\n  if (reason === 'io server disconnect') {\n    // The server forcefully disconnected, manually reconnect\n    AppSocket.connect();\n  }\n  // Otherwise, socket.io will automatically try to reconnect\n});\n\nexport default AppSocket;\n"
  },
  {
    "path": "apps/postybirb-ui/src/types/account-filters.ts",
    "content": "/**\n * Account filter types and enums.\n */\n\n/**\n * Enum for filtering accounts by login status.\n */\nexport enum AccountLoginFilter {\n  All = 'all',\n  LoggedIn = 'logged-in',\n  NotLoggedIn = 'not-logged-in',\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/types/navigation.ts",
    "content": "/**\n * Navigation-related type definitions for the remake layout.\n * @module remake/types/navigation\n */\n\nimport type { ReactNode } from 'react';\nimport type { DrawerKey } from '../stores/ui/drawer-store';\nimport type { ViewState } from './view-state';\n\n// =============================================================================\n// Navigation Item Types\n// =============================================================================\n\n/**\n * Base properties shared by standard navigation items (link, drawer, custom, view).\n */\ninterface NavigationItemBase {\n  /** Unique identifier for the navigation item */\n  id: string;\n\n  /** Display label shown when sidenav is expanded */\n  label: ReactNode;\n\n  /** Icon component to display (always visible, even when collapsed) */\n  icon: ReactNode;\n\n  /** Optional keyboard shortcut */\n  kbd?: string;\n\n  /** Whether this item is disabled */\n  disabled?: boolean;\n}\n\n/**\n * Navigation item that sets a view state.\n * This is the primary navigation type for state-driven navigation.\n */\nexport interface NavigationViewItem extends NavigationItemBase {\n  type: 'view';\n  /** View state to set when clicked */\n  viewState: ViewState;\n}\n\n/**\n * Navigation item that links to a route (legacy/external use).\n */\nexport interface NavigationLinkItem extends NavigationItemBase {\n  type: 'link';\n  /** Route path this item navigates to */\n  path: string;\n}\n\n/**\n * Navigation item that toggles a drawer.\n */\nexport interface NavigationDrawerItem extends NavigationItemBase {\n  type: 'drawer';\n  /** Key in the global drawer state to toggle */\n  drawerKey: DrawerKey;\n}\n\n/**\n * Navigation item with custom click behavior.\n */\nexport interface NavigationCustomItem extends NavigationItemBase {\n  type: 'custom';\n  /** Click handler */\n  onClick: () => void;\n}\n\n/**\n * Base for special navigation items (no label/icon - handled internally).\n */\ninterface NavigationSpecialItemBase {\n  /** Unique identifier */\n  id: string;\n  /** Optional keyboard shortcut */\n  kbd?: string;\n}\n\n/**\n * Navigation item for theme toggle.\n * Icon and label are handled dynamically based on current theme.\n */\nexport interface NavigationThemeItem extends NavigationSpecialItemBase {\n  type: 'theme';\n}\n\n/**\n * Navigation item for language picker.\n * Icon and label are handled dynamically based on current locale.\n */\nexport interface NavigationLanguageItem extends NavigationSpecialItemBase {\n  type: 'language';\n}\n\n/**\n * Represents a divider in the navigation list.\n */\nexport interface NavigationDivider {\n  type: 'divider';\n  id: string;\n}\n\n/**\n * Union type for all navigation item types.\n */\nexport type NavigationItem =\n  | NavigationViewItem\n  | NavigationLinkItem\n  | NavigationDrawerItem\n  | NavigationCustomItem\n  | NavigationThemeItem\n  | NavigationLanguageItem\n  | NavigationDivider;\n\n/**\n * Type guard to check if a navigation item has standard properties (label, icon).\n */\nexport function isStandardNavItem(\n  item: NavigationItem\n): item is NavigationViewItem | NavigationLinkItem | NavigationDrawerItem | NavigationCustomItem {\n  return item.type === 'view' || item.type === 'link' || item.type === 'drawer' || item.type === 'custom';\n}\n\n/**\n * Type guard to check if a navigation item is a view state item.\n */\nexport function isViewNavItem(\n  item: NavigationItem\n): item is NavigationViewItem {\n  return item.type === 'view';\n}\n\n/**\n * Type guard to check if a navigation item is a special item (theme, language).\n */\nexport function isSpecialNavItem(\n  item: NavigationItem\n): item is NavigationThemeItem | NavigationLanguageItem {\n  return item.type === 'theme' || item.type === 'language';\n}\n\n// =============================================================================\n// Sub-Navigation Types\n// =============================================================================\n\n// =============================================================================\n// Content & Pagination Types\n// =============================================================================\n\n/**\n * Pagination state for content area.\n */\nexport interface PaginationState {\n  /** Current page number (1-indexed) */\n  currentPage: number;\n\n  /** Total number of pages */\n  totalPages: number;\n\n  /** Optional: items per page */\n  itemsPerPage?: number;\n\n  /** Optional: total item count */\n  totalItems?: number;\n}\n\n/**\n * Configuration for the content navbar.\n */\nexport interface ContentNavbarConfig {\n  /** Whether to show pagination controls */\n  showPagination: boolean;\n\n  /** Pagination state (required if showPagination is true) */\n  pagination?: PaginationState;\n\n  /** Optional title to display in navbar */\n  title?: string;\n\n  /** Optional actions to render in the navbar */\n  actions?: ReactNode;\n}\n\n// =============================================================================\n// Component Props Types\n// =============================================================================\n\n/**\n * Props for the Layout component.\n */\nexport interface LayoutProps {\n  /** Children rendered in the main content area (typically <Outlet />) */\n  children: ReactNode;\n}\n\n/**\n * Props for the SideNav component.\n */\nexport interface SideNavProps {\n  /** Navigation items to display */\n  items: NavigationItem[];\n\n  /** Currently active item id (optional, auto-detected from route if not provided) */\n  activeId?: string;\n\n  /** Whether sidenav is collapsed */\n  collapsed: boolean;\n\n  /** Callback when collapse state changes */\n  onCollapsedChange: (collapsed: boolean) => void;\n}\n\n/**\n * Props for the ContentNavbar component.\n */\nexport interface ContentNavbarProps {\n  /** Configuration for navbar content */\n  config: ContentNavbarConfig;\n\n  /** Callback when page changes */\n  onPageChange?: (page: number) => void;\n}\n\n/**\n * Props for the ContentArea component.\n */\nexport interface ContentAreaProps {\n  /** Content to render */\n  children: ReactNode;\n\n  /** Optional loading state */\n  loading?: boolean;\n}\n\n// =============================================================================\n// Layout Constants\n// =============================================================================\n\n/**\n * Layout dimensions and constants.\n */\nexport const LAYOUT_CONSTANTS = {\n  SIDENAV_WIDTH_EXPANDED: 280,\n  SIDENAV_WIDTH_COLLAPSED: 60,\n  SUBNAV_HEIGHT: 48,\n  CONTENT_NAVBAR_HEIGHT: 48,\n  TRANSITION_DURATION: 200,\n} as const;\n"
  },
  {
    "path": "apps/postybirb-ui/src/types/view-state.ts",
    "content": "/**\n * View state types for state-driven navigation.\n * Uses discriminated unions with type-specific parameters.\n */\n\nimport type { SubmissionType } from '@postybirb/types';\n\n// =============================================================================\n// Section IDs - All possible section/view types\n// =============================================================================\n\n/**\n * All valid section identifiers in the application.\n * Only includes sections that render as full views, not drawers.\n */\nexport type SectionId =\n  | 'home'\n  | 'accounts'\n  | 'file-submissions'\n  | 'message-submissions'\n  | 'templates';\n\n// =============================================================================\n// Section-Specific Parameters\n// =============================================================================\n\n/**\n * Home section has no parameters.\n */\n// eslint-disable-next-line @typescript-eslint/no-empty-interface\nexport interface HomeParams {\n  // Empty - home has no specific state\n}\n\n/**\n * Parameters for accounts view.\n */\nexport interface AccountsParams {\n  /** Selected account ID for detail view */\n  selectedId: string | null;\n  /** Filter by website ID */\n  websiteFilter: string | null;\n}\n\n/**\n * Parameters for file submissions view.\n */\nexport interface FileSubmissionsParams {\n  /** Selected submission IDs */\n  selectedIds: string[];\n  /** Selection mode */\n  mode: 'single' | 'multi';\n  /** Submission type filter (always FILE for this section, but useful for shared components) */\n  submissionType: typeof SubmissionType.FILE;\n}\n\n/**\n * Parameters for message submissions view.\n */\nexport interface MessageSubmissionsParams {\n  /** Selected submission IDs */\n  selectedIds: string[];\n  /** Selection mode */\n  mode: 'single' | 'multi';\n  /** Submission type filter (always MESSAGE for this section) */\n  submissionType: typeof SubmissionType.MESSAGE;\n}\n\n/**\n * Parameters for templates view.\n */\nexport interface TemplatesParams {\n  /** Currently selected template ID */\n  selectedId: string | null;\n}\n\n// =============================================================================\n// View State - Discriminated Union\n// =============================================================================\n\n/**\n * Home view state.\n */\nexport interface HomeViewState {\n  type: 'home';\n  params: HomeParams;\n}\n\n/**\n * Accounts view state.\n */\nexport interface AccountsViewState {\n  type: 'accounts';\n  params: AccountsParams;\n}\n\n/**\n * File submissions view state.\n */\nexport interface FileSubmissionsViewState {\n  type: 'file-submissions';\n  params: FileSubmissionsParams;\n}\n\n/**\n * Message submissions view state.\n */\nexport interface MessageSubmissionsViewState {\n  type: 'message-submissions';\n  params: MessageSubmissionsParams;\n}\n\n/**\n * Templates view state.\n */\nexport interface TemplatesViewState {\n  type: 'templates';\n  params: TemplatesParams;\n}\n\n/**\n * Union type of all possible view states.\n */\nexport type ViewState =\n  | HomeViewState\n  | AccountsViewState\n  | FileSubmissionsViewState\n  | MessageSubmissionsViewState\n  | TemplatesViewState;\n\n// =============================================================================\n// Section Panel Configuration\n// =============================================================================\n\n/**\n * Configuration for section panel visibility and behavior.\n */\nexport interface SectionPanelConfig {\n  /** Whether this section has a section panel */\n  hasPanel: boolean;\n  /** Default width of the panel (if applicable) */\n  defaultWidth?: number;\n}\n\n/**\n * Section panel configurations per section type.\n */\nexport const sectionPanelConfigs: Record<SectionId, SectionPanelConfig> = {\n  home: { hasPanel: false },\n  accounts: { hasPanel: true, defaultWidth: 320 },\n  'file-submissions': { hasPanel: true, defaultWidth: 345 },\n  'message-submissions': { hasPanel: true, defaultWidth: 345 },\n  templates: { hasPanel: true, defaultWidth: 320 },\n};\n\n/**\n * Get section panel config for a given view state.\n */\nexport function getSectionPanelConfig(viewState: ViewState): SectionPanelConfig {\n  return sectionPanelConfigs[viewState.type];\n}\n\n// =============================================================================\n// Default View States\n// =============================================================================\n\n/**\n * Default view state for the application.\n */\nexport const defaultViewState: ViewState = {\n  type: 'home',\n  params: {},\n};\n\n/**\n * Create a home view state.\n */\nexport function createHomeViewState(): HomeViewState {\n  return {\n    type: 'home',\n    params: {},\n  };\n}\n\n/**\n * Create a default accounts view state.\n */\nexport function createAccountsViewState(\n  overrides?: Partial<AccountsParams>\n): AccountsViewState {\n  return {\n    type: 'accounts',\n    params: {\n      selectedId: null,\n      websiteFilter: null,\n      ...overrides,\n    },\n  };\n}\n\n/**\n * Create a default file submissions view state.\n */\nexport function createFileSubmissionsViewState(\n  overrides?: Partial<FileSubmissionsParams>\n): FileSubmissionsViewState {\n  return {\n    type: 'file-submissions',\n    params: {\n      selectedIds: [],\n      mode: 'single',\n      submissionType: 'FILE' as typeof SubmissionType.FILE,\n      ...overrides,\n    },\n  };\n}\n\n/**\n * Create a default message submissions view state.\n */\nexport function createMessageSubmissionsViewState(\n  overrides?: Partial<MessageSubmissionsParams>\n): MessageSubmissionsViewState {\n  return {\n    type: 'message-submissions',\n    params: {\n      selectedIds: [],\n      mode: 'single',\n      submissionType: 'MESSAGE' as typeof SubmissionType.MESSAGE,\n      ...overrides,\n    },\n  };\n}\n\n/**\n * Create a default templates view state.\n */\nexport function createTemplatesViewState(\n  overrides?: Partial<TemplatesParams>\n): TemplatesViewState {\n  return {\n    type: 'templates',\n    params: {\n      selectedId: null,\n      ...overrides,\n    },\n  };\n}\n\n// =============================================================================\n// Type Guards\n// =============================================================================\n\n/**\n * Check if view state is home.\n */\nexport function isHomeViewState(state: ViewState): state is HomeViewState {\n  return state.type === 'home';\n}\n\n/**\n * Check if view state is accounts.\n */\nexport function isAccountsViewState(\n  state: ViewState\n): state is AccountsViewState {\n  return state.type === 'accounts';\n}\n\n/**\n * Check if view state is file submissions.\n */\nexport function isFileSubmissionsViewState(\n  state: ViewState\n): state is FileSubmissionsViewState {\n  return state.type === 'file-submissions';\n}\n\n/**\n * Check if view state is message submissions.\n */\nexport function isMessageSubmissionsViewState(\n  state: ViewState\n): state is MessageSubmissionsViewState {\n  return state.type === 'message-submissions';\n}\n\n/**\n * Check if view state is templates.\n */\nexport function isTemplatesViewState(\n  state: ViewState\n): state is TemplatesViewState {\n  return state.type === 'templates';\n}\n\n/**\n * Check if view state has a section panel.\n */\nexport function hasSectionPanel(state: ViewState): boolean {\n  return getSectionPanelConfig(state).hasPanel;\n}\n\n// =============================================================================\n// Navigation Helpers\n// =============================================================================\n\n/**\n * Type-safe navigation helper object.\n * Provides convenient methods for creating view states with parameters.\n * \n * @example\n * ```ts\n * // Navigate to home\n * setViewState(navigateTo.home());\n * \n * // Navigate to accounts with selection\n * setViewState(navigateTo.accounts('account-123'));\n * \n * // Navigate to file submissions with multiple selections\n * setViewState(navigateTo.fileSubmissions(['id1', 'id2'], 'multi'));\n * ```\n */\nexport const navigateTo = {\n  /**\n   * Navigate to home view.\n   */\n  home: () => createHomeViewState(),\n\n  /**\n   * Navigate to accounts view.\n   * @param selectedId - Optional account ID to select\n   * @param websiteFilter - Optional website ID to filter by\n   */\n  accounts: (selectedId?: string | null, websiteFilter?: string | null) =>\n    createAccountsViewState({\n      selectedId: selectedId ?? null,\n      websiteFilter: websiteFilter ?? null,\n    }),\n\n  /**\n   * Navigate to file submissions view.\n   * @param selectedIds - Optional array of submission IDs to select\n   * @param mode - Optional selection mode ('single' or 'multi')\n   */\n  fileSubmissions: (selectedIds?: string[], mode?: 'single' | 'multi') =>\n    createFileSubmissionsViewState({\n      selectedIds: selectedIds ?? [],\n      mode: mode ?? 'single',\n    }),\n\n  /**\n   * Navigate to message submissions view.\n   * @param selectedIds - Optional array of submission IDs to select\n   * @param mode - Optional selection mode ('single' or 'multi')\n   */\n  messageSubmissions: (selectedIds?: string[], mode?: 'single' | 'multi') =>\n    createMessageSubmissionsViewState({\n      selectedIds: selectedIds ?? [],\n      mode: mode ?? 'single',\n    }),\n\n  /**\n   * Navigate to templates view.\n   * @param selectedId - Optional template ID to select\n   */\n  templates: (selectedId?: string | null) =>\n    createTemplatesViewState({\n      selectedId: selectedId ?? null,\n    }),\n} as const;\n"
  },
  {
    "path": "apps/postybirb-ui/src/utils/class-names.ts",
    "content": "/**\n * Utility for generating dynamic class names with conditional support.\n *\n * @example\n * // Basic usage with static classes\n * cn(['class1', 'class2']) // => \"class1 class2\"\n *\n * // With conditional classes\n * cn(['static-class'], { 'is-active': true, 'is-disabled': false })\n * // => \"static-class is-active\"\n *\n * // Only conditionals\n * cn({ 'is-collapsed': isCollapsed, 'is-expanded': !isCollapsed })\n *\n * // Mixed with undefined/null filtering\n * cn(['base', undefined, null, 'valid'], { 'conditional': true })\n * // => \"base valid conditional\"\n */\n\ntype ConditionalClasses = Record<string, boolean | undefined | null>;\ntype StaticClasses = (string | undefined | null)[];\n\n/**\n * Generates a className string from static and conditional class names.\n *\n * @param staticOrConditional - Array of static class names, or conditional object if no statics\n * @param conditional - Object mapping class names to boolean conditions\n * @returns Combined className string\n */\nexport function cn(\n  staticOrConditional: StaticClasses | ConditionalClasses,\n  conditional?: ConditionalClasses,\n): string {\n  const classes: string[] = [];\n\n  // Handle first argument\n  if (Array.isArray(staticOrConditional)) {\n    // It's an array of static classes\n    for (const cls of staticOrConditional) {\n      if (cls) {\n        classes.push(cls);\n      }\n    }\n  } else {\n    // It's a conditional object (no static classes provided)\n    for (const [cls, condition] of Object.entries(staticOrConditional)) {\n      if (condition) {\n        classes.push(cls);\n      }\n    }\n  }\n\n  // Handle conditional argument if provided\n  if (conditional) {\n    for (const [cls, condition] of Object.entries(conditional)) {\n      if (condition) {\n        classes.push(cls);\n      }\n    }\n  }\n\n  return classes.join(' ');\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/utils/environment.ts",
    "content": "/**\n * Checks if the application is running in an Electron environment.\n * @returns {boolean} True if running in Electron, false otherwise.\n */\nexport function isElectron(): boolean {\n    // Check if running in a browser environment\n    if (typeof window === 'undefined') {\n        return false;\n    }\n\n    // Check for Electron-specific properties\n    const userAgent = navigator.userAgent.toLowerCase();\n    if (userAgent.includes('electron')) {\n        return true;\n    }\n\n    return false;\n}"
  },
  {
    "path": "apps/postybirb-ui/src/utils/index.ts",
    "content": "/**\n * Utility exports for the PostyBirb UI.\n */\n\nexport { cn } from './class-names';\nexport { isElectron } from './environment';\nexport {\n  showConnectionErrorNotification,\n  showConnectionSuccessNotification,\n  showCopiedNotification,\n  showCreateErrorNotification,\n  showCreatedNotification,\n  showDeletedNotification,\n  showDeleteErrorNotification,\n  showDuplicateErrorNotification,\n  showErrorNotification,\n  showErrorWithContext,\n  showErrorWithTitleNotification,\n  showInfoNotification,\n  showPostErrorNotification,\n  showRestoredNotification,\n  showRestoreErrorNotification,\n  showSaveErrorNotification,\n  showScheduleUpdatedNotification,\n  showSuccessNotification,\n  showUpdatedNotification,\n  showUpdateErrorNotification,\n  showUploadErrorNotification,\n  showUploadSuccessNotification,\n  showWarningNotification,\n} from './notifications';\nexport { openUrl } from './open-url';\n\n"
  },
  {
    "path": "apps/postybirb-ui/src/utils/notifications.tsx",
    "content": "/**\n * Standardized notification messages for CRUD operations and common actions.\n * Reduces translation burden by using consistent message patterns.\n * All notifications include consistent icons for visual clarity.\n */\n\nimport { Trans } from '@lingui/react/macro';\nimport { notifications } from '@mantine/notifications';\nimport {\n  IconAlertTriangle,\n  IconCheck,\n  IconInfoCircle,\n  IconX,\n} from '@tabler/icons-react';\n\n// -----------------------------------------------------------------------------\n// Success Notifications\n// -----------------------------------------------------------------------------\n\n/**\n * Show a generic success notification.\n */\nexport function showSuccessNotification(message: React.ReactNode) {\n  notifications.show({\n    message,\n    color: 'green',\n    icon: <IconCheck size={16} />,\n  });\n}\n\n/**\n * Show a success notification for item creation.\n */\nexport function showCreatedNotification(itemName?: string) {\n  notifications.show({\n    title: itemName,\n    message: <Trans>Created successfully</Trans>,\n    color: 'green',\n    icon: <IconCheck size={16} />,\n  });\n}\n\n/**\n * Show a success notification for item update.\n */\nexport function showUpdatedNotification(itemName?: string) {\n  notifications.show({\n    title: itemName,\n    message: <Trans>Updated successfully</Trans>,\n    color: 'green',\n    icon: <IconCheck size={16} />,\n  });\n}\n\n/**\n * Show a success notification for item deletion.\n * @param count - Number of items deleted\n */\nexport function showDeletedNotification(count = 1) {\n  notifications.show({\n    message:\n      count === 1 ? (\n        <Trans>Deleted successfully</Trans>\n      ) : (\n        <Trans>{count} items deleted</Trans>\n      ),\n    color: 'green',\n    icon: <IconCheck size={16} />,\n  });\n}\n\n/**\n * Show a success notification for copying to clipboard.\n */\nexport function showCopiedNotification() {\n  notifications.show({\n    message: <Trans>Copied to clipboard</Trans>,\n    color: 'green',\n    icon: <IconCheck size={16} />,\n  });\n}\n\n/**\n * Show a success notification for item duplication.\n */\nexport function showDuplicatedNotification(itemName?: string) {\n  notifications.show({\n    title: itemName,\n    message: <Trans>Duplicated successfully</Trans>,\n    color: 'green',\n    icon: <IconCheck size={16} />,\n  });\n}\n\n/**\n * Show a success notification for restoring/unarchiving an item.\n */\nexport function showRestoredNotification() {\n  notifications.show({\n    message: <Trans>Restored successfully</Trans>,\n    color: 'green',\n    icon: <IconCheck size={16} />,\n  });\n}\n\n/**\n * Show a success notification for file upload.\n */\nexport function showUploadSuccessNotification() {\n  notifications.show({\n    message: <Trans>Files uploaded successfully</Trans>,\n    color: 'green',\n    icon: <IconCheck size={16} />,\n  });\n}\n\n/**\n * Show a success notification for successful connection.\n */\nexport function showConnectionSuccessNotification(message?: React.ReactNode) {\n  notifications.show({\n    message: message ?? <Trans>Connected successfully</Trans>,\n    color: 'green',\n    icon: <IconCheck size={16} />,\n  });\n}\n\n/**\n * Show a success notification for schedule updates.\n */\nexport function showScheduleUpdatedNotification(itemName?: string) {\n  notifications.show({\n    title: <Trans>Schedule updated</Trans>,\n    message: itemName,\n    color: 'green',\n    icon: <IconCheck size={16} />,\n  });\n}\n\n// -----------------------------------------------------------------------------\n// Error Notifications\n// -----------------------------------------------------------------------------\n\n/**\n * Show an error notification for failed creation.\n */\nexport function showCreateErrorNotification(itemName?: string) {\n  notifications.show({\n    title: itemName,\n    message: <Trans>Failed to create</Trans>,\n    color: 'red',\n    icon: <IconX size={16} />,\n  });\n}\n\n/**\n * Show an error notification for failed update.\n */\nexport function showUpdateErrorNotification(itemName?: string) {\n  notifications.show({\n    title: itemName,\n    message: <Trans>Failed to update</Trans>,\n    color: 'red',\n    icon: <IconX size={16} />,\n  });\n}\n\n/**\n * Show an error notification for failed deletion.\n */\nexport function showDeleteErrorNotification() {\n  notifications.show({\n    message: <Trans>Failed to delete</Trans>,\n    color: 'red',\n    icon: <IconX size={16} />,\n  });\n}\n\n/**\n * Show a generic error notification.\n */\nexport function showErrorNotification(message?: React.ReactNode) {\n  notifications.show({\n    title: <Trans>Error</Trans>,\n    message: message ?? <Trans>An error occurred</Trans>,\n    color: 'red',\n    icon: <IconX size={16} />,\n  });\n}\n\n/**\n * Show an error notification with title and message.\n * Useful for displaying API errors with status codes.\n */\nexport function showErrorWithTitleNotification(\n  title: React.ReactNode,\n  message: React.ReactNode\n) {\n  notifications.show({\n    title,\n    message,\n    color: 'red',\n    icon: <IconX size={16} />,\n  });\n}\n\n/**\n * Show an error notification, extracting message from Error objects.\n * Handles the common pattern of `error instanceof Error ? error.message : fallback`.\n */\nexport function showErrorWithContext(\n  error: unknown,\n  fallbackMessage: React.ReactNode\n) {\n  const message = error instanceof Error ? error.message : fallbackMessage;\n  notifications.show({\n    message,\n    color: 'red',\n    icon: <IconX size={16} />,\n  });\n}\n\n/**\n * Show a notification for failed duplication.\n */\nexport function showDuplicateErrorNotification() {\n  notifications.show({\n    message: <Trans>Failed to duplicate</Trans>,\n    color: 'red',\n    icon: <IconX size={16} />,\n  });\n}\n\n/**\n * Show a notification for failed posting.\n */\nexport function showPostErrorNotification() {\n  notifications.show({\n    message: <Trans>Failed to post submission</Trans>,\n    color: 'red',\n    icon: <IconX size={16} />,\n  });\n}\n\n/**\n * Show an error notification for failed restore/unarchive.\n */\nexport function showRestoreErrorNotification() {\n  notifications.show({\n    message: <Trans>Failed to restore</Trans>,\n    color: 'red',\n    icon: <IconX size={16} />,\n  });\n}\n\n/**\n * Show an error notification for failed file upload.\n */\nexport function showUploadErrorNotification(message?: React.ReactNode) {\n  notifications.show({\n    message: message ?? <Trans>Failed to upload files</Trans>,\n    color: 'red',\n    icon: <IconX size={16} />,\n  });\n}\n\n/**\n * Show an error notification for failed connection.\n */\nexport function showConnectionErrorNotification(\n  title: React.ReactNode,\n  message: React.ReactNode\n) {\n  notifications.show({\n    title,\n    message,\n    color: 'red',\n    icon: <IconX size={16} />,\n  });\n}\n\n/**\n * Show an error notification for failed save operation.\n */\nexport function showSaveErrorNotification(message?: React.ReactNode) {\n  notifications.show({\n    message: message ?? <Trans>Failed to save</Trans>,\n    color: 'red',\n    icon: <IconX size={16} />,\n  });\n}\n\n// -----------------------------------------------------------------------------\n// Info & Warning Notifications\n// -----------------------------------------------------------------------------\n\n/**\n * Show an informational notification.\n */\nexport function showInfoNotification(\n  message: React.ReactNode,\n  title?: React.ReactNode\n) {\n  notifications.show({\n    title,\n    message,\n    color: 'blue',\n    icon: <IconInfoCircle size={16} />,\n  });\n}\n\n/**\n * Show a warning notification.\n */\nexport function showWarningNotification(\n  message: React.ReactNode,\n  title?: React.ReactNode\n) {\n  notifications.show({\n    title,\n    message,\n    color: 'orange',\n    icon: <IconAlertTriangle size={16} />,\n  });\n}\n"
  },
  {
    "path": "apps/postybirb-ui/src/utils/open-url.ts",
    "content": "import { isElectron } from './environment';\n\nexport function openUrl(url: string): void {\n  if (isElectron()) {\n    window.electron.openExternalLink(url);\n  } else {\n    window.open(url, '_blank');\n  }\n}\n"
  },
  {
    "path": "apps/postybirb-ui/tailwind.config.js",
    "content": "// eslint-disable-next-line import/no-extraneous-dependencies\nconst { createGlobPatternsForDependencies } = require('@nx/react/tailwind');\nconst { join } = require('path');\n\nmodule.exports = {\n  content: [\n    join(__dirname, 'src/**/*.{js,ts,jsx,tsx}'),\n    ...createGlobPatternsForDependencies(__dirname),\n  ],\n  corePlugins: {\n    preflight: false,\n  },\n  theme: {\n    extend: {},\n  },\n  plugins: [],\n};\n"
  },
  {
    "path": "apps/postybirb-ui/tsconfig.app.json",
    "content": "{\n  \"extends\": \"./tsconfig.json\",\n  \"compilerOptions\": {\n    \"outDir\": \"../../dist/out-tsc\"\n  },\n  \"files\": [\n    \"../../node_modules/@nx/react/typings/cssmodule.d.ts\",\n    \"../../node_modules/@nx/react/typings/image.d.ts\"\n  ],\n  \"exclude\": [\n    \"jest.config.ts\",\n    \"**/*.spec.ts\",\n    \"**/*.test.ts\",\n    \"**/*.spec.tsx\",\n    \"**/*.test.tsx\",\n    \"**/*.spec.js\",\n    \"**/*.test.js\",\n    \"**/*.spec.jsx\",\n    \"**/*.test.jsx\"\n  ],\n  \"include\": [\"src\", \"**/*.js\", \"**/*.jsx\", \"**/*.ts\", \"**/*.tsx\"]\n}\n"
  },
  {
    "path": "apps/postybirb-ui/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.base.json\",\n  \"compilerOptions\": {\n    \"target\": \"ESNext\",\n    \"useDefineForClassFields\": true,\n    \"module\": \"ESNext\",\n    \"strict\": true,\n    \"moduleResolution\": \"Node\",\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"types\": [\"vite/client\"],\n    \"noEmit\": true,\n    \"lib\": [\"DOM\", \"DOM.Iterable\", \"ESNext\"],\n    \"allowJs\": false,\n    \"esModuleInterop\": false,\n    \"skipLibCheck\": true,\n    \"allowSyntheticDefaultImports\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"jsx\": \"react-jsx\"\n  },\n  \"files\": [],\n  \"include\": [],\n  \"references\": [\n    {\n      \"path\": \"./tsconfig.app.json\"\n    },\n    {\n      \"path\": \"./tsconfig.spec.json\"\n    }\n  ]\n}\n"
  },
  {
    "path": "apps/postybirb-ui/tsconfig.spec.json",
    "content": "{\n  \"extends\": \"./tsconfig.json\",\n  \"compilerOptions\": {\n    \"outDir\": \"../../dist/out-tsc\",\n    \"module\": \"commonjs\",\n    \"types\": [\n      \"jest\",\n      \"node\",\n      \"@nx/react/typings/cssmodule.d.ts\",\n      \"@nx/react/typings/image.d.ts\"\n    ]\n  },\n  \"include\": [\n    \"src\",\n    \"jest.config.ts\",\n    \"**/*.test.ts\",\n    \"**/*.spec.ts\",\n    \"**/*.test.tsx\",\n    \"**/*.spec.tsx\",\n    \"**/*.test.js\",\n    \"**/*.spec.js\",\n    \"**/*.test.jsx\",\n    \"**/*.spec.jsx\",\n    \"**/*.d.ts\"\n  ]\n}\n"
  },
  {
    "path": "apps/postybirb-ui/vite.config.ts",
    "content": "import { lingui } from '@lingui/vite-plugin';\nimport { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';\nimport react from '@vitejs/plugin-react-swc';\nimport path from 'path';\nimport { defineConfig } from 'vite';\n\nexport default defineConfig({\n  root: __dirname,\n  cacheDir: '../../node_modules/.vite/postybirb-ui',\n\n  resolve: {\n    alias: {\n      // The @tiptap/react/menus subpath export causes a circular import\n      // that Vite's dev server can't resolve. Point directly at the file.\n      '@tiptap/react/menus': path.resolve(\n        __dirname,\n        '../../node_modules/@tiptap/react/dist/menus/index.js',\n      ),\n    },\n  },\n  server: {\n    port: 4200,\n    host: '127.0.0.1',\n    fs: {\n      // blocknote loads fonts from node_modules folder which\n      // is at the ../../ because for vite cwd is postybitb-ui workspace\n      allow: ['../../node_modules/'],\n    },\n  },\n\n  build: {\n    outDir: '../../dist/apps/postybirb-ui',\n    reportCompressedSize: true,\n    commonjsOptions: { transformMixedEsModules: true },\n\n    // Because we are loading files from file:// protocol\n    // in production we dont really need to care about this\n    chunkSizeWarningLimit: 10000,\n\n    minify: false,\n  },\n\n  preview: {\n    port: 4300,\n    host: 'localhost',\n  },\n\n  plugins: [\n    // replaceFiles([\n    //   {\n    //     replace: 'apps/postybirb-ui/src/environments/environment.ts',\n    //     with: 'apps/postybirb-ui/src/environments/environment.prod.ts',\n    //   },\n    // ]),\n    react({\n      plugins: [['@lingui/swc-plugin', {}]],\n    }),\n    nxViteTsPaths(),\n    lingui(),\n  ],\n});\n"
  },
  {
    "path": "babel.config.js",
    "content": "// Despite the fact that we use @swc/jest babel is still used by jest for instrumenting files with code coverage\n// Also by default this config won't load, so we even had to patch jest-coverage\nmodule.exports = {\n  presets: ['@nx/react/babel'],\n  plugins: [\n    ['@babel/plugin-proposal-decorators', { legacy: true }],\n    ['@babel/plugin-transform-class-properties', { loose: true }],\n  ],\n};\n"
  },
  {
    "path": "commitlint.config.js",
    "content": "module.exports = {\n  extends: ['@commitlint/config-conventional'],\n  rules: {\n    'type-enum': [\n      2,\n      'always',\n      [\n        'build',\n        'chore',\n        'ci',\n        'docs',\n        'feat',\n        'fix',\n        'perf',\n        'refactor',\n        'release',\n        'revert',\n        'style',\n        'test',\n      ],\n    ],\n  },\n};\n"
  },
  {
    "path": "compose.yml",
    "content": "services:\n  postybirb:\n    image: ghcr.io/mvdicarlo/postybirb:latest\n    ports:\n      - 8080:8080\n    volumes:\n      # Contains database, submissions, tags etc\n      - ./my-path/storage:/root/PostyBirb\n      # Contains startup options, remote config, partitions etc\n      - ./my-path/appData:/root/.config/postybirb\n"
  },
  {
    "path": "docs/DOCKER.md",
    "content": "### Docker setup\n\n1. Use [this compose as an example](../compose.yml)\n2. At the start, application will print the password needed for the remote access (in the very beginning at the logs)\n3. Download client application at the releases, start it and at the settings open Remote access, type in IP and password\n"
  },
  {
    "path": "docs/POST_QUEUE_FLOWS.md",
    "content": "# Post Queue Flow Diagrams\n\nThis document provides comprehensive flow diagrams for submissions going through the post queue system in PostyBirb.\n\n## 1. High-Level Overview\n\n```mermaid\nflowchart TD\n    subgraph Entry Points\n        A[User Manually Enqueues Submission]\n        B[Scheduled Submission Triggers]\n    end\n\n    A --> C[PostQueueService.enqueue]\n    B -->|CRON: Every 30s| C\n\n    C --> D{Queue Entry<br/>Already Exists?}\n    D -->|Yes| E[Skip - First-in wins]\n    D -->|No| F[Create PostRecord via<br/>PostRecordFactory]\n\n    F --> G[Determine Resume Mode]\n    G --> H[Insert PostQueueRecord]\n\n    H --> I[Queue Processing Loop<br/>CRON: Every 1s]\n\n    I --> J{Queue Paused?}\n    J -->|Yes| K[Skip Execution]\n    J -->|No| L[PostQueueService.peek]\n\n    L --> M{Top Record State?}\n    M -->|DONE/FAILED| N[Dequeue & Remove]\n    M -->|PENDING/RUNNING| O{Manager Already<br/>Posting?}\n\n    O -->|Yes| P[Wait for completion]\n    O -->|No| Q[PostManagerRegistry.startPost]\n\n    Q --> R[Post Execution<br/>See Detailed Flow]\n    R --> M\n```\n\n## 2. Resume Mode Logic\n\n```mermaid\nflowchart TD\n    A[enqueue called with submissionId] --> AA{Any PENDING or<br/>RUNNING PostRecord?}\n\n    AA -->|Yes| AB[Throw InvalidPostChainError<br/>reason: in_progress]\n\n    AA -->|No| B{Any Prior<br/>PostRecord?}\n\n    B -->|No| C[Create Fresh PostRecord<br/>resumeMode = NEW<br/>originPostRecordId = null]\n\n    B -->|Yes| D{Most Recent<br/>PostRecord State?}\n\n    D -->|DONE| E[Create Fresh PostRecord<br/>resumeMode = NEW<br/>originPostRecordId = null<br/>Previous was successful]\n\n    D -->|FAILED| F{resumeMode<br/>provided?}\n\n    F -->|Yes| G[Use Provided Mode]\n    F -->|No| H[Default to CONTINUE]\n\n    G --> I[PostRecordFactory.create]\n    H --> I\n\n    I --> J{resumeMode = NEW?}\n    J -->|Yes| K[originPostRecordId = null<br/>This record IS the origin]\n    J -->|No| L[Find most recent NEW record]\n\n    L --> M{Origin found?}\n    M -->|No| N[Throw InvalidPostChainError<br/>reason: no_origin]\n    M -->|Yes| MA{Origin state<br/>= DONE?}\n    MA -->|Yes| MB[Throw InvalidPostChainError<br/>reason: origin_done]\n    MA -->|No| O[originPostRecordId = origin.id<br/>Chain to origin]\n\n    subgraph Resume Modes\n        P[NEW]\n        Q[CONTINUE]\n        R[CONTINUE_RETRY]\n    end\n\n    P --> S[Start completely fresh<br/>Ignore all prior progress]\n    Q --> T[Continue from where it left off<br/>Skip successfully posted files/accounts]\n    R --> U[Retry failed websites<br/>but remember successful posts]\n\n    subgraph InvalidPostChainError Reasons\n        ERR1[in_progress: PENDING/RUNNING record exists]\n        ERR2[no_origin: No prior NEW record for CONTINUE/RETRY]\n        ERR3[origin_done: Origin NEW record already DONE]\n    end\n\n    subgraph Chain Example\n        V[\"#1 NEW (origin)\"]\n        W[\"#2 CONTINUE → origin=#1\"]\n        X[\"#3 CONTINUE → origin=#1\"]\n        Y[\"#4 NEW (new origin)\"]\n        Z[\"#5 RETRY → origin=#4\"]\n    end\n```\n\n## 3. PostManagerRegistry & Manager Selection\n\n```mermaid\nflowchart TD\n    A[PostManagerRegistry.startPost] --> B{Get Manager by<br/>Submission Type}\n\n    B --> C{SubmissionType.FILE}\n    B --> D{SubmissionType.MESSAGE}\n\n    C --> E[FileSubmissionPostManager]\n    D --> F[MessageSubmissionPostManager]\n\n    E --> G{Manager Already<br/>Posting?}\n    F --> G\n\n    G -->|Yes| H[Return - Cannot start<br/>new post]\n    G -->|No| I[Build Resume Context]\n\n    I --> J[postRecordFactory.buildResumeContext]\n    J --> K[Query chain via originPostRecordId<br/>Aggregate events from all records]\n\n    K --> L[BasePostManager.startPost<br/>with ResumeContext]\n```\n\n## 4. Post Execution Flow (BasePostManager)\n\n```mermaid\nflowchart TD\n    A[startPost called] --> B{Currently<br/>Posting?}\n\n    B -->|Yes| C[Return early]\n    B -->|No| D[Create CancellableToken]\n\n    D --> E[Set PostRecord state<br/>to RUNNING]\n\n    E --> F[Get Post Order<br/>Group websites into batches]\n\n    F --> G[Batch 1: Standard Websites<br/>post concurrently]\n    G --> H[Batch 2: Websites that Accept<br/>External Source URLs<br/>post concurrently]\n\n    H --> I[finishPost]\n\n    I --> J{Check PostEvents<br/>for failures}\n    J -->|All Success| K[State = DONE]\n    J -->|Any Failures| L[State = FAILED]\n\n    K --> M[Archive submission if<br/>non-recurring schedule]\n    L --> N[Create failure notification]\n\n    M --> O[Cleanup & End]\n    N --> O\n\n    subgraph Post Order Logic\n        P[Standard Websites First]\n        Q[Then External Source Websites]\n        R[So source URLs can propagate]\n    end\n```\n\n## 5. Website Posting Flow (per website)\n\n```mermaid\nflowchart TD\n    A[postToWebsite] --> B[Emit POST_ATTEMPT_STARTED<br/>event]\n\n    B --> C{Account<br/>Logged In?}\n    C -->|No| D[Throw: Not logged in]\n\n    C -->|Yes| E{Website Supports<br/>Submission Type?}\n    E -->|No| F[Throw: Type not supported]\n\n    E -->|Yes| G[Prepare Post Data<br/>Parse descriptions, tags]\n    G --> H[Validate Submission]\n\n    H --> I{Validation<br/>Passed?}\n    I -->|No| J[Throw: Validation errors]\n\n    I -->|Yes| K[attemptToPost<br/>Type-specific logic]\n\n    K --> L{Post<br/>Successful?}\n\n    L -->|Yes| M[Emit POST_ATTEMPT_COMPLETED<br/>event]\n    L -->|No| N[handlePostFailure<br/>Emit POST_ATTEMPT_FAILED event]\n\n    M --> O[Track success metrics]\n    N --> P[Create error notification]\n\n    D --> N\n    F --> N\n    J --> N\n```\n\n## 6. File Submission Flow (FileSubmissionPostManager)\n\n```mermaid\nflowchart TD\n    A[attemptToPost] --> B{Website is<br/>FileWebsite?}\n    B -->|No| C[Throw Error]\n\n    B -->|Yes| D[Get Files to Post]\n    D --> E[Filter: Not ignored for this website]\n    E --> F[Filter: Not already posted<br/>based on resumeContext]\n    F --> G[Sort by order]\n\n    G --> H{Any files<br/>to post?}\n    H -->|No| I[Return - Nothing to post]\n\n    H -->|Yes| J[Split into batches<br/>based on fileBatchSize]\n\n    J --> K[For Each Batch]\n\n    K --> L[Collect source URLs<br/>from other accounts]\n    L --> M[Process files:<br/>Resize/Convert if needed]\n    M --> N[Verify file types supported]\n\n    N --> O[Wait for posting interval]\n    O --> P[cancelToken.throwIfCancelled]\n\n    P --> Q[website.onPostFileSubmission]\n\n    Q --> R{Result has<br/>exception?}\n\n    R -->|Yes| S[Emit FILE_FAILED events<br/>for each file in batch]\n    S --> T[Stop posting to<br/>this website]\n\n    R -->|No| U[Emit FILE_POSTED events<br/>for each file in batch]\n    U --> V{More Batches?}\n\n    V -->|Yes| K\n    V -->|No| W[Complete]\n```\n\n## 7. Message Submission Flow (MessageSubmissionPostManager)\n\n```mermaid\nflowchart TD\n    A[attemptToPost] --> B[Wait for posting interval]\n    B --> C[cancelToken.throwIfCancelled]\n\n    C --> D[website.onPostMessageSubmission]\n\n    D --> E{Result has<br/>exception?}\n\n    E -->|Yes| F[Emit MESSAGE_FAILED event]\n    F --> G[Throw exception]\n\n    E -->|No| H[Emit MESSAGE_POSTED event<br/>with sourceUrl]\n    H --> I[Complete]\n```\n\n## 8. File Processing Pipeline\n\n```mermaid\nflowchart TD\n    A[SubmissionFile] --> B{File Type?}\n\n    B -->|IMAGE| C{Can convert to<br/>accepted format?}\n    C -->|Yes| D[FileConverterService.convert]\n    C -->|No| E[Keep original]\n\n    D --> F[Calculate resize parameters]\n    E --> F\n\n    F --> G{User defined<br/>dimensions?}\n    G -->|Yes| H[Apply user dimensions]\n    G -->|No| I[Use website limits]\n\n    H --> J[PostFileResizerService.resize]\n    I --> J\n\n    J --> K[PostingFile]\n\n    B -->|TEXT| L{Has alt file &<br/>original not accepted?}\n    L -->|Yes| M[Use alt file]\n    L -->|No| N[Use original]\n\n    M --> O[Convert if needed]\n    N --> K\n    O --> K\n\n    K --> P[Add source URLs<br/>from other websites]\n    P --> Q[Ready for posting]\n```\n\n## 9. Crash Recovery Flow\n\n```mermaid\nflowchart TD\n    A[Application Startup] --> B[PostQueueService.onModuleInit]\n\n    B --> C[Find all PostRecords<br/>with state = RUNNING]\n\n    C --> D{Any RUNNING<br/>records found?}\n\n    D -->|No| E[Normal startup]\n\n    D -->|Yes| F[For each RUNNING record]\n\n    F --> G[Log: Resuming interrupted PostRecord]\n    G --> H[PostManagerRegistry.startPost]\n\n    H --> I[Build resume context<br/>using originPostRecordId chain]\n\n    I --> J{Resume Mode?}\n\n    J -->|NEW + RUNNING| K[Aggregate own events<br/>for crash recovery]\n    J -->|CONTINUE| L[Skip completed accounts<br/>& posted files]\n    J -->|CONTINUE_RETRY| M[Skip completed accounts<br/>Retry failed]\n\n    K --> N[Resume posting]\n    L --> N\n    M --> N\n```\n\n## 10. Complete End-to-End Flow\n\n```mermaid\nflowchart TD\n    subgraph User/Scheduler\n        A[Enqueue Request]\n    end\n\n    subgraph PostQueueService\n        B[enqueue]\n        C[Queue Loop - 1s CRON]\n        D[peek - get next item]\n    end\n\n    subgraph PostRecordFactory\n        E[create PostRecord]\n        F[buildResumeContext]\n    end\n\n    subgraph PostManagerRegistry\n        G[startPost]\n        H[Route to appropriate manager]\n    end\n\n    subgraph PostManagers\n        I[FileSubmissionPostManager]\n        J[MessageSubmissionPostManager]\n    end\n\n    subgraph Website\n        K[onPostFileSubmission]\n        L[onPostMessageSubmission]\n    end\n\n    A --> B\n    B --> E\n    E --> B\n    B --> C\n    C --> D\n    D --> G\n    G --> F\n    F --> H\n    H --> I\n    H --> J\n    I --> K\n    J --> L\n    K --> M{Success?}\n    L --> M\n    M -->|Yes| N[Emit success events]\n    M -->|No| O[Emit failure events]\n    N --> P[Update PostRecord state]\n    O --> P\n    P --> Q[Dequeue]\n```\n\n## Key Classes & Files Reference\n\n| Component           | File                                                                                                  |\n| ------------------- | ----------------------------------------------------------------------------------------------------- |\n| Queue Management    | `apps/client-server/src/app/post/services/post-queue/post-queue.service.ts`                           |\n| Manager Registry    | `apps/client-server/src/app/post/services/post-manager-v2/post-manager-registry.service.ts`           |\n| Base Manager        | `apps/client-server/src/app/post/services/post-manager-v2/base-post-manager.service.ts`               |\n| File Posting        | `apps/client-server/src/app/post/services/post-manager-v2/file-submission-post-manager.service.ts`    |\n| Message Posting     | `apps/client-server/src/app/post/services/post-manager-v2/message-submission-post-manager.service.ts` |\n| Record Factory      | `apps/client-server/src/app/post/services/post-record-factory/post-record-factory.service.ts`         |\n| Chain Error         | `apps/client-server/src/app/post/errors/invalid-post-chain.error.ts`                                  |\n| Legacy Manager      | `apps/client-server/src/app/post/services/post-manager/post-manager.service.ts`                       |\n\n## PostRecord Chain Model\n\nPostRecords are linked via `originPostRecordId` to form posting chains:\n\n```text\nChain 1 (Group):\n┌─────────────────────────────────────────────────────────────────┐\n│  #1 NEW                    ← origin (originPostRecordId: null)  │\n│  #2 CONTINUE → origin=#1   ← chains to #1                       │\n│  #3 CONTINUE → origin=#1   ← chains to #1 (DONE closes chain)   │\n└─────────────────────────────────────────────────────────────────┘\n\nChain 2 (Self-contained):\n┌─────────────────────────────────────────────────────────────────┐\n│  #4 NEW (DONE)             ← origin, completed in one attempt   │\n└─────────────────────────────────────────────────────────────────┘\n\nChain 3 (Active):\n┌─────────────────────────────────────────────────────────────────┐\n│  #5 NEW                    ← origin (originPostRecordId: null)  │\n│  #6 RETRY → origin=#5      ← chains to #5                       │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n**Query for chain aggregation:**\n\n```sql\nSELECT * FROM post_record\nWHERE id = :originId OR originPostRecordId = :originId\nORDER BY createdAt ASC\n```\n\n## PostRecord Creation Guards\n\nBefore creating a new PostRecord, the factory performs the following validations:\n\n| Guard                 | Condition                                    | Error Reason    |\n| --------------------- | -------------------------------------------- | --------------- |\n| In-Progress Check     | PENDING or RUNNING record exists             | `in_progress`   |\n| Origin Exists         | CONTINUE/RETRY without prior NEW record      | `no_origin`     |\n| Origin Open           | CONTINUE/RETRY when origin is DONE           | `origin_done`   |\n\nThese guards ensure:\n\n1. **No concurrent posting** - Only one PostRecord can be PENDING/RUNNING per submission at a time\n2. **Valid chain linkage** - CONTINUE/RETRY modes must have a valid origin to chain from\n3. **Chain closure respected** - Once an origin is DONE, a new chain (NEW mode) must be started\n"
  },
  {
    "path": "docs/app-insights/APP_INSIGHTS_QUERIES.md",
    "content": "# Azure App Insights - Complete Query Reference\n\n> This file contains all Application Insights queries for PostyBirb, organized by category.\n> Copy and paste these queries directly into Azure Portal → Application Insights → Logs.\n\n## Quick Start\n\n1. Open **Azure Portal** → Your Application Insights resource\n2. Click **Logs** in the left sidebar\n3. Copy any query from this file\n4. Paste into the query editor\n5. Click **Run** to execute\n\n## Table of Contents\n\n- [HTTP Dependencies](#http-dependencies)\n  - [Performance](#performance)\n  - [Reliability](#reliability)\n  - [Volume & Trends](#volume--trends)\n- [Posting Events](#posting-events)\n  - [Success Rate](#success-rate)\n  - [Recent Activity](#recent-activity)\n  - [Metrics](#metrics)\n- [Exceptions](#exceptions)\n  - [By Source](#by-source)\n  - [React Errors](#react-errors)\n  - [HTTP Errors](#http-errors)\n- [Correlation Queries](#correlation-queries)\n- [Version Tracking](#version-tracking)\n- [Alerts](#alerts)\n- [Dashboard Tiles](#dashboard-tiles)\n\n---\n\n## HTTP Dependencies\n\n### Performance\n```kusto\n// Average response time by website\ndependencies\n| where type == \"HTTP\"\n| summarize AvgDuration = avg(duration), Calls = count() by target\n| order by AvgDuration desc\n```\n\n```kusto\n// 95th percentile response time\ndependencies\n| where type == \"HTTP\"\n| summarize P95 = percentile(duration, 95) by target\n| order by P95 desc\n```\n\n```kusto\n// Slowest recent requests\ndependencies\n| where type == \"HTTP\"\n| order by duration desc\n| take 100\n| project timestamp, name, target, duration, resultCode\n```\n\n### Reliability\n```kusto\n// Failure rate by website\ndependencies\n| where type == \"HTTP\"\n| summarize\n    Total = count(),\n    Failures = countif(success == false),\n    FailureRate = 100.0 * countif(success == false) / count()\n    by target\n| order by FailureRate desc\n```\n\n```kusto\n// Recent failures\ndependencies\n| where type == \"HTTP\"\n| where success == false\n| order by timestamp desc\n| take 50\n| project timestamp, name, target, resultCode, duration\n```\n\n```kusto\n// Status code distribution\ndependencies\n| where type == \"HTTP\"\n| summarize count() by resultCode, target\n| order by count_ desc\n```\n\n### Volume & Trends\n```kusto\n// Request volume by website\ndependencies\n| where type == \"HTTP\"\n| summarize count() by target\n| order by count_ desc\n```\n\n```kusto\n// Request volume over time\ndependencies\n| where type == \"HTTP\"\n| summarize count() by bin(timestamp, 1h), target\n| render timechart\n```\n\n```kusto\n// Most called endpoints\ndependencies\n| where type == \"HTTP\"\n| summarize count() by name, target\n| top 20 by count_\n```\n\n## Posting Events\n\n### Success Rate\n```kusto\n// Overall success rate by website\ncustomEvents\n| where name in (\"PostSuccess\", \"PostFailure\")\n| summarize\n    Total = count(),\n    Successes = countif(name == \"PostSuccess\"),\n    SuccessRate = 100.0 * countif(name == \"PostSuccess\") / count()\n    by Website = tostring(customDimensions.website)\n| order by Total desc\n```\n\n```kusto\n// Success rate over time\ncustomEvents\n| where name in (\"PostSuccess\", \"PostFailure\")\n| summarize\n    Successes = countif(name == \"PostSuccess\"),\n    Failures = countif(name == \"PostFailure\"),\n    SuccessRate = 100.0 * countif(name == \"PostSuccess\") / count()\n    by bin(timestamp, 1h)\n| render timechart\n```\n\n### Recent Activity\n```kusto\n// Recent post failures\ncustomEvents\n| where name == \"PostFailure\"\n| order by timestamp desc\n| take 50\n| project\n    timestamp,\n    Website = tostring(customDimensions.website),\n    Error = tostring(customDimensions.errorMessage),\n    Stage = tostring(customDimensions.stage)\n```\n\n```kusto\n// Recent post failures (detailed)\ncustomEvents\n| where name == \"PostFailure\"\n| order by timestamp desc\n| take 50\n| project\n    timestamp,\n    Website = tostring(customDimensions.website),\n    ErrorMessage = tostring(customDimensions.errorMessage),\n    Stage = tostring(customDimensions.stage),\n    SubmissionType = tostring(customDimensions.submissionType),\n    SubmissionId = tostring(customDimensions.submissionId),\n    AccountId = tostring(customDimensions.accountId)\n```\n\n```kusto\n// Recent post successes\ncustomEvents\n| where name == \"PostSuccess\"\n| order by timestamp desc\n| take 50\n| project\n    timestamp,\n    Website = tostring(customDimensions.website),\n    SubmissionType = tostring(customDimensions.submissionType),\n    FileCount = tostring(customDimensions.fileCount)\n```\n\n```kusto\n// Posting trends over time\ncustomEvents\n| where name in (\"PostSuccess\", \"PostFailure\")\n| summarize\n    Successes = countif(name == \"PostSuccess\"),\n    Failures = countif(name == \"PostFailure\")\n    by bin(timestamp, 1h), Website = tostring(customDimensions.website)\n| render timechart\n```\n\n### Metrics\n```kusto\n// Post success metrics by website\ncustomMetrics\n| where name startswith \"post.success.\"\n| extend Website = replace(@\"post\\.success\\.\", \"\", name)\n| summarize Total = sum(value) by Website\n| order by Total desc\n```\n\n```kusto\n// Post failure metrics by website\ncustomMetrics\n| where name startswith \"post.failure.\"\n| extend Website = replace(@\"post\\.failure\\.\", \"\", name)\n| summarize Total = sum(value) by Website\n| order by Total desc\n```\n\n## Exceptions\n\n### By Source\n```kusto\n// Exceptions by component\nexceptions\n| summarize count() by Source = tostring(customDimensions.source)\n| order by count_ desc\n```\n\n```kusto\n// Recent exceptions\nexceptions\n| order by timestamp desc\n| take 50\n| project\n    timestamp,\n    Source = tostring(customDimensions.source),\n    Type = type,\n    Message = outerMessage\n```\n\n```kusto\n// Uncaught exceptions\nexceptions\n| where customDimensions.type in (\"uncaughtException\", \"unhandledRejection\")\n| order by timestamp desc\n| take 50\n| project\n    timestamp,\n    Source = tostring(customDimensions.source),\n    Type = tostring(customDimensions.type),\n    Message = outerMessage,\n    Details = details\n```\n\n```kusto\n// Exceptions by website\nexceptions\n| where customDimensions.website != \"\"\n| summarize Count = count() by\n    Website = tostring(customDimensions.website),\n    ErrorType = type,\n    Message = outerMessage\n| order by Count desc\n```\n\n### React Errors\n```kusto\n// React Error Boundary catches\nexceptions\n| where customDimensions.source == \"error-boundary\"\n| order by timestamp desc\n| take 50\n| project\n    timestamp,\n    Component = tostring(customDimensions.component),\n    Level = tostring(customDimensions.level),\n    Message = outerMessage\n```\n\n```kusto\n// React Error Boundary catches (detailed)\nexceptions\n| where customDimensions.source == \"error-boundary\"\n| order by timestamp desc\n| take 50\n| project\n    timestamp,\n    Component = tostring(customDimensions.component),\n    Level = tostring(customDimensions.level),\n    Message = outerMessage,\n    ComponentStack = tostring(customDimensions.componentStack)\n```\n\n```kusto\n// Most common React component failures\nexceptions\n| where customDimensions.source == \"error-boundary\"\n| summarize\n    Count = count(),\n    UniqueErrors = dcount(outerMessage)\n    by Component = tostring(customDimensions.component)\n| order by Count desc\n```\n\n```kusto\n// Most common React components failing (detailed)\nexceptions\n| where customDimensions.source == \"error-boundary\"\n| summarize\n    ErrorCount = count(),\n    UniqueErrors = dcount(outerMessage),\n    LatestOccurrence = max(timestamp)\n    by Component = tostring(customDimensions.component)\n| order by ErrorCount desc\n```\n\n### HTTP Errors\n```kusto\n// HTTP request exceptions\nexceptions\n| where customDimensions.source == \"http-dependency\"\n| order by timestamp desc\n| take 50\n| project\n    timestamp,\n    Method = tostring(customDimensions.method),\n    Domain = tostring(customDimensions.domain),\n    StatusCode = tostring(customDimensions.statusCode),\n    Message = outerMessage\n```\n\n## Correlation Queries\n\n### HTTP Failures + Post Failures\n```kusto\nunion\n    (dependencies\n     | where type == \"HTTP\"\n     | where success == false\n     | extend EventType = \"HTTPFailure\", Website = target, Details = strcat(\"Status: \", resultCode)),\n    (customEvents\n     | where name == \"PostFailure\"\n     | extend EventType = \"PostFailure\", Website = tostring(customDimensions.website), Details = tostring(customDimensions.errorMessage))\n| order by timestamp desc\n| project timestamp, EventType, Website, Details\n```\n\n### Slow HTTP + Post Failures\n```kusto\nlet slowRequests = dependencies\n    | where type == \"HTTP\"\n    | where duration > 10000\n    | summarize SlowCalls = count() by Website = target;\nlet postFailures = customEvents\n    | where name == \"PostFailure\"\n    | summarize Failures = count() by Website = tostring(customDimensions.website);\nslowRequests\n| join kind=inner (postFailures) on Website\n| project Website, SlowCalls, Failures\n| order by SlowCalls desc\n```\n\n### Exceptions + Post Failures\n```kusto\nunion\n    (exceptions\n     | extend EventType = \"Exception\", Website = tostring(customDimensions.website)),\n    (customEvents\n     | where name == \"PostFailure\"\n     | extend EventType = \"PostFailure\", Website = tostring(customDimensions.website))\n| where Website != \"\"\n| order by timestamp desc\n| project timestamp, EventType, Website, Message = outerMessage\n```\n\n## Version Tracking\n\n### Success Rate by Version\n```kusto\ncustomEvents\n| where name in (\"PostSuccess\", \"PostFailure\")\n| extend Version = application_Version\n| summarize\n    Total = count(),\n    Successes = countif(name == \"PostSuccess\"),\n    SuccessRate = 100.0 * countif(name == \"PostSuccess\") / count()\n    by Version\n| order by Version desc\n```\n\n```kusto\n// Success rate by version (detailed)\ncustomEvents\n| where name in (\"PostSuccess\", \"PostFailure\")\n| extend Version = tostring(application_Version)\n| summarize\n    Total = count(),\n    Successes = countif(name == \"PostSuccess\"),\n    SuccessRate = 100.0 * countif(name == \"PostSuccess\") / count()\n    by Version\n| order by Version desc\n```\n\n### Exceptions by Version\n```kusto\nexceptions\n| summarize\n    Count = count(),\n    UniqueErrors = dcount(outerMessage)\n    by Version = application_Version\n| order by Count desc\n```\n\n```kusto\n// Exceptions by version (detailed)\nexceptions\n| summarize\n    Count = count(),\n    UniqueErrors = dcount(outerMessage)\n    by Version = tostring(application_Version)\n| order by Count desc\n```\n\n### HTTP Performance by Version\n```kusto\ndependencies\n| where type == \"HTTP\"\n| summarize\n    TotalCalls = count(),\n    AvgDuration = avg(duration),\n    FailureRate = 100.0 * countif(success == false) / count()\n    by Version = application_Version\n| order by Version desc\n```\n\n## Alerts\n\n### High HTTP Failure Rate\n```kusto\ndependencies\n| where type == \"HTTP\"\n| where timestamp > ago(15m)\n| summarize\n    Total = count(),\n    Failures = countif(success == false),\n    FailureRate = 100.0 * countif(success == false) / count()\n    by target\n| where FailureRate > 10  // Alert if >10%\n```\n\n### Slow HTTP Requests\n```kusto\ndependencies\n| where type == \"HTTP\"\n| where timestamp > ago(15m)\n| summarize AvgDuration = avg(duration) by target\n| where AvgDuration > 30000  // Alert if >30 seconds\n```\n\n### High Post Failure Rate\n```kusto\ncustomEvents\n| where name in (\"PostSuccess\", \"PostFailure\")\n| where timestamp > ago(15m)\n| summarize\n    Total = count(),\n    FailureRate = 100.0 * countif(name == \"PostFailure\") / count()\n    by Website = tostring(customDimensions.website)\n| where FailureRate > 20  // Alert if >20%\n```\n\n### Spike in Exceptions\n```kusto\nexceptions\n| where timestamp > ago(15m)\n| summarize Count = count()\n| where Count > 50  // Alert if >50 exceptions in 15 min\n```\n\n## Dashboard Tiles\n\n### Key Metrics\n```kusto\n// Total HTTP requests (last 24h)\ndependencies\n| where type == \"HTTP\"\n| where timestamp > ago(24h)\n| summarize count()\n```\n\n```kusto\n// HTTP success rate (last 24h)\ndependencies\n| where type == \"HTTP\"\n| where timestamp > ago(24h)\n| summarize SuccessRate = 100.0 * countif(success == true) / count()\n```\n\n```kusto\n// Average HTTP duration (last 24h)\ndependencies\n| where type == \"HTTP\"\n| where timestamp > ago(24h)\n| summarize avg(duration)\n```\n\n```kusto\n// Total posts (last 24h)\ncustomEvents\n| where name == \"PostCompleted\"\n| where timestamp > ago(24h)\n| summarize count()\n```\n\n```kusto\n// Post success rate (last 24h)\ncustomEvents\n| where name in (\"PostSuccess\", \"PostFailure\")\n| where timestamp > ago(24h)\n| summarize SuccessRate = 100.0 * countif(name == \"PostSuccess\") / count()\n```\n\n```kusto\n// Total exceptions (last 24h)\nexceptions\n| where timestamp > ago(24h)\n| summarize count()\n```\n\n## Tips\n\n- Use `bin(timestamp, 1h)` for hourly aggregation\n- Use `percentile(duration, 95)` for P95 latency\n- Use `render timechart` for time-series visualization\n- Use `render barchart` for distributions\n- Filter by `application_Version` to compare versions\n- Use `ago(24h)` for last 24 hours, `ago(7d)` for last 7 days\n- Combine with `| take 100` to limit large result sets\n\n## Query Syntax Reference\n\n### Common Filters\n```kusto\n// Time range\n| where timestamp > ago(24h)\n| where timestamp between (datetime(2025-01-01) .. datetime(2025-01-31))\n\n// String matching\n| where name == \"PostSuccess\"\n| where name in (\"PostSuccess\", \"PostFailure\")\n| where name contains \"Post\"\n| where name startswith \"post.\"\n\n// Numeric comparisons\n| where duration > 5000\n| where resultCode == 200\n| where resultCode in (200, 201, 204)\n```\n\n### Common Aggregations\n```kusto\n// Count\n| summarize count()\n| summarize count() by Website\n\n// Average and percentiles\n| summarize avg(duration)\n| summarize percentile(duration, 95)\n\n// Success rate\n| summarize SuccessRate = 100.0 * countif(success == true) / count()\n\n// Multiple metrics\n| summarize \n    Total = count(),\n    Avg = avg(duration),\n    Max = max(duration),\n    Min = min(duration)\n```\n\n### Common Operations\n```kusto\n// Sort\n| order by timestamp desc\n| order by count_ desc\n\n// Limit results\n| take 50\n| top 20 by duration\n\n// Project (select columns)\n| project timestamp, name, duration\n\n// Extend (add calculated columns)\n| extend DurationSeconds = duration / 1000\n\n// Render charts\n| render timechart\n| render barchart\n| render piechart\n```\n\n## Additional Resources\n\n- **Main Setup Guide**: [APP_INSIGHTS_SETUP.md](./APP_INSIGHTS_SETUP.md)\n- **HTTP Tracking Guide**: [APP_INSIGHTS_HTTP_TRACKING.md](./APP_INSIGHTS_HTTP_TRACKING.md)\n- **Azure Documentation**: [Kusto Query Language (KQL)](https://docs.microsoft.com/en-us/azure/data-explorer/kusto/query/)\n\n---\n\n**Total Queries**: 50+ ready-to-use queries covering all PostyBirb telemetry\n\n**Last Updated**: October 2, 2025\n\n"
  },
  {
    "path": "docs/app-insights/APP_INSIGHTS_SETUP.md",
    "content": "# Azure Application Insights Implementation\n\n## Overview\n\nAzure Application Insights has been successfully integrated into PostyBirb to track:\n\n- **Uncaught exceptions** in Electron main process, NestJS backend, and React UI\n- **Posting success/failure events** with granular website-level tracking\n- **Error-level logs** from Winston (to reduce noise)\n- **Application version** - Automatically injected into all telemetry for version tracking\n\n## What's Being Tracked\n\n### 1. Uncaught Exceptions\n\n**Electron Main Process:**\n\n- Uncaught exceptions\n- Unhandled promise rejections\n- Tagged with `source: 'electron-main'`\n\n**NestJS Backend:**\n\n- Uncaught exceptions\n- Unhandled promise rejections\n- Tagged with `source: 'nestjs-backend'`\n\n**React UI:**\n\n- Window errors\n- Unhandled promise rejections\n- **React Error Boundary catches** - Component rendering failures with component name and stack\n- Tagged with `source: 'window.onerror'`, `'unhandledrejection'`, or `'error-boundary'`\n\n### 2. HTTP Dependencies ⭐ NEW\n\n**All outgoing HTTP requests are tracked as dependencies:**\n\n- Populates the **Application Map** - Visual representation of all external services\n- Populates the **Dependencies view** - Performance and reliability metrics\n- Tracked details:\n  - HTTP method (GET, POST, PATCH)\n  - Target domain and path\n  - Full URL (truncated to 500 chars)\n  - Status code\n  - Request duration (ms)\n  - Success/failure status\n  - Protocol (http/https)\n\n**Benefits:**\n- Visual service discovery in Application Map\n- Identify slow APIs and bottlenecks\n- Track failure rates per website\n- Correlate HTTP errors with posting failures\n\nSee [HTTP Dependency Tracking Guide](./APP_INSIGHTS_HTTP_TRACKING.md) for detailed documentation.\n\n### 3. Posting Events\n\n**PostSuccess Event:**\n\n- Tracked when a post succeeds to a website\n- Properties:\n  - `website` - The website name (e.g., \"FurAffinity\", \"Twitter\")\n  - `accountId` - The account ID\n  - `submissionId` - The submission ID\n  - `submissionType` - File or Message\n  - `hasSourceUrl` - Whether a source URL was returned\n  - `completed` - Whether fully completed\n  - `isFileBatch` - Whether this was a file batch\n  - `fileCount` - Number of files in batch\n\n**PostFailure Event:**\n\n- Tracked when a post fails to a website\n- Properties:\n  - `website` - The website name\n  - `accountId` - The account ID\n  - `submissionId` - The submission ID\n  - `submissionType` - File or Message\n  - `errorMessage` - The error message\n  - `stage` - What stage the error occurred\n  - `hasException` - Whether an exception was thrown\n  - `isFileBatch` - Whether this was a file batch\n  - `fileCount` - Number of files in batch\n\n**PostCompleted Event:**\n\n- Tracked when an entire post (all websites) completes\n- Properties:\n  - `submissionId` - The submission ID\n  - `submissionType` - File or Message\n  - `state` - DONE or FAILED\n  - `websiteCount` - Total websites attempted\n  - `successCount` - Successful posts\n  - `failureCount` - Failed posts\n\n### 4. Metrics\n\n**Success Metrics:**\n\n- `post.success.{websiteName}` - Counter for each successful post per website\n\n**Failure Metrics:**\n\n- `post.failure.{websiteName}` - Counter for each failed post per website\n\n### 5. Winston Logs\n\n**Only ERROR level logs** are sent to Application Insights to reduce noise:\n\n- All error logs from Winston are tracked as traces\n- Errors with exceptions are also tracked as exceptions\n\n## Querying Data in Azure Portal\n\nAll Azure Application Insights queries are available in the [APP_INSIGHTS_QUERIES.md](./APP_INSIGHTS_QUERIES.md) file, organized by category:\n\n- **HTTP Dependencies** - Performance, reliability, and volume metrics\n- **Posting Events** - Success rates, recent activity, and metrics\n- **Exceptions** - By source, React errors, and HTTP errors\n- **Correlation Queries** - HTTP failures + post failures, slow requests, etc.\n- **Version Tracking** - Compare metrics across versions\n- **Alerts** - Ready-to-use alert queries\n- **Dashboard Tiles** - Key metrics for monitoring dashboards\n\nSee [APP_INSIGHTS_QUERIES.md](./APP_INSIGHTS_QUERIES.md) for 50+ ready-to-use Kusto queries.\n\n## Architecture\n\n### Cloud Role\n\nThe implementation uses a single cloud role **`postybirb`** for all components:\n\n- Electron main process\n- NestJS backend\n- React UI\n\nThis simplifies queries and avoids duplicate initialization issues in the Electron process where the backend runs in the same Node.js runtime.\n\nYou can still distinguish the source of errors/events using the **`source`** property in custom dimensions:\n\n- `source: 'electron-main'` - Electron main process exceptions\n- `source: 'nestjs-backend'` - Backend exceptions\n- `source: 'window.onerror'` - UI window errors\n- `source: 'unhandledrejection'` - Promise rejections\n- `source: 'error-boundary'` - React Error Boundary catches\n\nExample query filtering by source:\n\n```kusto\nexceptions\n| where customDimensions.source == \"electron-main\"\n| order by timestamp desc\n```\n\n### Application Version\n\nThe application version is automatically injected into all telemetry:\n\n- **Electron main process** - Uses `environment.version` from the build\n- **NestJS backend** - Uses `process.env.POSTYBIRB_VERSION`\n- **React UI** - Uses `window.electron.app_version`\n\nThis allows you to:\n\n- Track which versions have errors\n- Compare metrics across versions\n- Filter telemetry by version in Azure Portal\n- Create alerts for specific version issues\n\nExample query to see errors by version:\n\n```kusto\nexceptions\n| summarize Count = count() by Version = tostring(application_Version)\n| order by Count desc\n```\n\n### Data Flow\n\n```\n┌─────────────────────┐\n│  Electron Main      │──► Application Insights\n│  (main.ts)          │    (Exceptions, Events)\n└─────────────────────┘\n\n┌─────────────────────┐\n│  NestJS Backend     │──► Application Insights\n│  (post-manager)     │    (Posting Events, Errors)\n└─────────────────────┘\n\n┌─────────────────────┐\n│  React UI           │──► Application Insights\n│  (app-insights-ui)  │    (UI Errors, Error Boundary)\n└─────────────────────┘\n\n┌─────────────────────┐\n│  Winston Logger     │──► Application Insights\n│  (error level only) │    (Error Logs)\n└─────────────────────┘\n```\n\n│ React UI │──► Application Insights\n│ (app-insights-ui) │ (UI Errors)\n└─────────────────────┘\n\n┌─────────────────────┐\n│ Winston Logger │──► Application Insights\n│ (error level only) │ (Error Logs)\n└─────────────────────┘\n\n```\n\n```\n"
  },
  {
    "path": "docs/contributing/add-a-website/README.md",
    "content": "# How to add a Website to PostyBirb\n\nThis guide walks you through the basic steps of setting up a new PostyBirb website implementation.\n\nFor the purposes of examples, website `Foo` will be used.\n\nIt is not likely to be an exhaustive reference and should be seen as a living document that I will\ndo my best to keep updated as things change.\n\n## Starting Out\n\nTo start out you should locate the website directory located [here](../apps/client-server/src/app/websites/implementations/)\nfor your own use later.\n\n### Quick Start\n\nFirst consider a valid dash-case name of the website you are adding.\n\n**Examples**\n\n- Google -> google\n- NewYork -> new-york or newyork\n\nFrom the base `postybirb` path, run the command.\n\n> `node scripts/add-website.js`\n\n#### Sample\n\n```ts\nimport { ILoginState } from '@postybirb/types';\nimport { WebsiteMetadata } from '../../decorators/website-metadata.decorator';\nimport { DataPropertyAccessibility } from '../../models/data-property-accessibility';\nimport { Website } from '../../website';\n\nexport type FooAccountData = {\n  sensitiveProperty: string;\n  nonSensitiveProperty: string[];\n};\n\n@WebsiteMetadata({\n  name: 'foo',\n  displayName: 'Foo',\n})\nexport default class Foo extends Website<FooAccountData> {\n  protected BASE_URL: string;\n\n  public externallyAccessibleWebsiteDataProperties: DataPropertyAccessibility<FooAccountData> = {\n    nonSensitiveProperty: true,\n    sensitiveProperty: false,\n  };\n\n  public async onLogin(): Promise<ILoginState> {\n    if (this.account.name === 'test') {\n      this.loginState.logout();\n    }\n\n    await this.websiteDataStore.setData({\n      sensitiveProperty: '<SECRET-API-KEY>',\n      nonSensitiveProperty: ['folder1', 'folder2'],\n    });\n    return this.loginState.setLogin(true, 'TestUser');\n  }\n}\n```\n\n### Sample Explained\n\n#### Base Website Class\n\nAll websites within PostyBirb must extend the [Website](../apps/client-server/src/app/websites/website.ts)\nclass as it contains important logic used elsewhere within the application.\n\n#### WebsiteMetadata Decorator\n\nMetadata for a website is set through the use of one or more decorators. The only required one at\nthe moment is `@WebsiteMetadata` to set an Identifying `name` property and a `displayName` that is\nused within the UI.\n\nOnce a website is officially published the `name` property will likely never be changed\nas it is used to connect pieces of data together for logged in accounts.\n\n#### Additional Decorators\n\nOther metadata decorators can be found [here](../apps/client-server/src/app/websites/decorators/).\nIt is likely many will be used in every website implementation.\n\n#### Base Url\n\nThe `BASE_URL` field is one of the few semi-required fields to be used as a common\nURL for outgoing requests.\n\n#### ExternallyAccessibleWebsiteDataProperties\n\nThis will likely be moved into an optional interface in the future. But for now\nit is used to define which properties defined in `Website<TAccountData>` are\nallowed to be returned to the UI as we don't really want to pass sensitive API\nkeys or secrets if we can avoid it.\n\n#### onLogin\n\nThe `onLogin` function is called whenever a user interacts with an account in\nthe UI or whenever the `refreshInterval` occurs (as defined in the WebsiteMetadata\ndecorator). It also runs once at startup.\n\nThis is where login state and additional data retrieval is intended to occur\nby interacting with the underlying website `loginState` (in memory)\nand `websiteDataStore` (in database).\n\n## How To\n\n- [Login and Authenticate Users](./sections/authenticate-a-user.md)\n- [Post Files](./sections/file-website.md)\n- [Post Messages](./sections/message-website.md)\n- [Validate User Input](./sections/validation.md)\n- [Description Parsing](./sections/description-parsing.md)\n- [Specifying User Input / UI Form Specification](./sections/defining-submission-data.md)\n\n## Relevant Differences from V3 (PostyBirb+)\n\n- Each user account receives its own object instance so developers no longer\n  need to worry about overwriting data shared with another logged in account.\n- Most metadata is set through custom decorators.\n- Descriptions are no longer collected as plain HTML and for now (semi-experimental) are\n  using BlockNote.\n- There are many common validations that can occur with the assistance of decorators\n  reducing the need for tons of duplicate code for validations.\n"
  },
  {
    "path": "docs/contributing/add-a-website/sections/authenticate-a-user.md",
    "content": "# Login / Authenticate A User\n\nLogging in a user and retrieving specific details on the account is the first thing that needs to\nbe figured out when adding a new website.\n\n## OnLogin\n\nAll websites are required to implement the `onLogin` function, returning a `ILoginState` value.\n\nThis `onLogin` method is responsible for handling all login state for a user and is called in the\nfollowing scenarios:\n\n- User closes the UI login panel\n- Startup of the application\n- When the `refreshInterval` is reached (default 1 hour)\n\n> [!IMPORTANT]\n> Implementations of onLogin should catch its own errors, log, and update state appropriately.\n\n### Sample\n\n```ts\n  public async onLogin(): Promise<ILoginState> {\n    try {\n      const res = await Http.get<string>(\n        `${this.BASE_URL}/user`,\n        { partition: this.accountId },\n      );\n\n      if (res.body.includes('logout-link')) {\n        const $ = load(res.body);\n        return this.loginState.setLogin(\n          true,\n          $('.loggedin_user_avatar').attr('alt'),\n        );\n      }\n\n      return this.loginState.setLogin(false, null);\n    } catch (e) {\n      this.logger.error('Failed to login', e);\n      return this.loginState.setLogin(false, null);\n    }\n  }\n```\n\n## How To Set Up A Login Flow For Users\n\n```ts\nimport { UserLoginFlow } from '../../decorators/login-flow.decorator';\n\n@UserLoginFlow('https://foo.com/login')\nexport default class Foo extends Website<FooAccountData> {\n  // Class Impl\n}\n```\n\nPostyBirb supports 3 ways to login a user to their accounts.\n\n1. Direct browser-like login (webview) using the `@UserLoginFlow(url)` decorator\n   to automatically hook up login within the UI.\n2. Custom Login Flow using the `@CustomLoginFlow(name(optional))` which requires the\n   creation of a custom React component to handle retrieval of certain information for login.\n   A simple example of this can be seen used by the Discord implementation\n   [here](../../../apps/postybirb-ui/src/website-components/discord/).\n3. OAuth flows. Currently this is still being defined and this documentation will be updated\n   once a more stable example has been created.\n\n## Storing Account / Login Information\n\n### Short Term (In-Memory)\n\nYou can store information in the object itself that only lives as long as the app is\nrunning however you like.\n\n### Long Term (Database)\n\nAll details that need to be stored long-term such as API keys should be stored within\neach class' `websiteDataStore`. These can be set at any time, though the preference would\nbe during `onLogin` calls or from a UI component calling `accountApi.setWebsiteData`.\n\nStoring things within the data store also allows for injection of data into the form\ngenerator by default which is elaborated on in other sections.\n"
  },
  {
    "path": "docs/contributing/add-a-website/sections/defining-submission-data.md",
    "content": "# Defining Submission / Form Data\n\nTo reduce the amount of files that are required to be generated during the implementation\nof a website V4 attempts to solve this issue through the specification of user inputs\nthrough classes implementing the `IWebsiteFormFields` or `FileWebsiteFormFields` interfaces.\n\nThis is further enhanced through custom decorators that define metadata that the UI can use\nto automatically generate the form the user sees.\n\n## Supported Fields Types\n\nThis is likely to be a growing list with more rich features as more websites with\nmore dynamic requirements are implemented.\n\n- **@BooleanField** - Checkbox\n- **@DescriptionField** - Standard description field to be used for description retrieval.\n- **@RadioField** - Radio Input (best used when only a few choices are available)\n- **@RatingField** - Radio input used for the specific selection of a submission rating.\n- **@SelectField** - Dropdown selection (best used when more than 5 choices are available)\n- **@TagField** - Standard tag field to be used for tag retrieval.\n- **@TextField** - Text / Text Area\n- **@TitleField** - Text\n\n## Defining Layout\n\nDefining the general layout of the form generated is done with row / col fields to\nsupport a simple grid system. It may need to be played around with to get a look you\nare happy with.\n\nAs standard practice, please keep the common fields that most websites share in a similar\nlayout and at the top (description, rating, tag, title).\n\n## Adding Translation for Custom Labels\n\n```ts\n  @BooleanField({ label: 'feature', defaultValue: true })\n  feature: boolean;\n```\n\nWhen you need to add a custom named field, you will also need to provide a simple english\ntranslation for it as well. `label` in this case is more of a translation identifier.\n\nThis is the most convenient solution until a better one can be found.\n\nTo add the `feature` label you need to update the following files.\n\n**[field-translations](../../../libs/types/src/models/submission/field-translation.type.ts)**\n\n```ts\nexport interface FieldTranslations {\n  // ...\n  feature: true; // Add this to the properties\n  // ...\n}\n```\n\n**[field-translations (ui)](../../../apps/postybirb-ui/src/components/translations/field-translations.ts)**\n\n```ts\nexport const FieldLabelTranslations: {\n  [K in keyof FieldTranslations]: MessageDescriptor;\n} = {\n  // ...\n  feature: msg`Feature`, // Add this to the properties\n  // ...\n};\n```\n\n## Sample Submission Class\n\n```ts\nclass FooFileSubmission extends BaseWebsiteOptions {\n  @TitleField({ required: true, row: 0, col: 1 })\n  title: string;\n\n  @TagField({ row: 2, col: 1 })\n  tags: TagValue;\n\n  @DescriptionField({ row: 3, col: 1 })\n  description: DescriptionValue;\n\n  @RatingField({ required: true, row: 0, col: 0 })\n  rating: SubmissionRating;\n\n  // This is a custom field that requires translation of the label 'feature'\n  @BooleanField({ label: 'feature', defaultValue: true })\n  feature: boolean;\n}\n\nclass Foo extends Website<FooAccountData> implements FileWebsite<FooFileSubmission> {\n  createFileModel(): FooFileSubmission {\n    return new FooFileSubmission();\n  }\n}\n```\n"
  },
  {
    "path": "docs/contributing/add-a-website/sections/description-parsing.md",
    "content": "# Description Parsing\n\n> [!WARNING]\n> This section is volatile at the moment until it has been more properly battle-tested\n> during actual website implementation, so please bear with me on any gaps that may exist.\n\nSome websites may need to customize their description outputs to be more than the\nout-of-the-box implementations of Markdown, HTML, or Plaintext.\n\nCurrently, description data is collected from BlockNote (object format) and custom\nparsers build the official description.\n\n## Sample\n\n```ts\n@SupportsDescription(DescriptionType.HTML)\nexport default class Foo extends Website<FooAccountData> {\n  // ...\n}\n```\n\n## Custom Parsing Sample\n\nIt should be very infrequent, but there is the option to support entirely new description\ntypes not provided in the `DescriptionType` choices.\n\nTo do this you can add the `WithCustomDescriptionParser` interface and `@SupportsDescription`\ndecorator.\n\nUnfortunately, this has not really been battle-tested yet and I honestly don't remember\nhow I intended the onDescriptionParse playing out so please bear with me until I have\na chance to implement BBCode through this tooling.\n\n```ts\n@SupportsDescription(DescriptionType.CUSTOM)\nexport default class Foo extends Website<FooAccountData> implements WithCustomDescriptionParser {\n  onDescriptionParse(node: DescriptionNode) {\n    return node.toHtmlString();\n  }\n\n  onAfterDescriptionParse(description: string): string {\n    return description.replace('<div>', '<p>').replace('</div>', '</p>');\n  }\n}\n```\n"
  },
  {
    "path": "docs/contributing/add-a-website/sections/file-website.md",
    "content": "# Support Posting Files\n\nAll website implementation that need to post files to their respective websites must\nimplement the `FileWebsite<T>` interface.\n\n## Sample\n\n```ts\nclass FooFileSubmission extends BaseWebsiteOptions {\n  @TitleField({ 'title', required: true, row: 0, col: 1 })\n  title: string;\n\n  @TagField({ row: 2, col: 1 })\n  tags: TagValue;\n\n  @DescriptionField({ row: 3, col: 1 })\n  description: DescriptionValue;\n\n  @RatingField({ required: true, row: 0, col: 0 })\n  rating: SubmissionRating;\n}\n\n@WebsiteMetadata({\n  name: 'foo',\n  displayName: 'Foo',\n})\n@UserLoginFlow('https://foo.net/login')\n@SupportsFiles({\n  acceptedMimeTypes: ['image/png', 'image/jpeg'], // Limits to only these mime types\n  fileBatchSize: 2,\n})\nexport default class Foo extends Website<FooAccountData> implements FileWebsite<FooFileSubmission> {\n  protected BASE_URL = 'https://foo.net';\n\n  createFileModel(): FooFileSubmission {\n    return new FooFileSubmission();\n  }\n\n  calculateImageResize(file: ISubmissionFile): ImageResizeProps | undefined {\n    return undefined;\n  }\n\n  onPostFileSubmission(postData: PostData<FileSubmission, FooFileSubmission>, files: PostingFile[], batchIndex: number, cancellationToken: CancellableToken): Promise<PostResponse> {\n    throw new Error('Method not implemented.');\n  }\n\n  async onValidateFileSubmission(postData: PostData<FileSubmission, FooFileSubmission>): Promise<SimpleValidationResult> {\n    // Stub\n  }\n}\n```\n\n### Sample Explained\n\n#### FooFileSubmission\n\nThe class that you pass into the `FileWebsite<T>` is responsible for the population of data\nand fields that users can fill in to customize their submission. You can read more about\nhow to specify form data [here](./defining-submission-data.md).\n\nThe general rule of thumb is to try and emulate most of the options provided to a user\nin the actual form.\n\n#### createFileModel\n\nThis is the method responsible for returning an object of the generic type passed into\nthe `FileWebsite` interface. This method is called when a user adds a website into their\nsubmission. In most cases this method will be pretty boiler-plate, but also allows for\nthe injection of account specific data if needed or convenient on form data creation.\n\n#### calculateImageResize\n\nThis method is called just before image files are processed for posting and during\nsubmission validation. This should be used to correct images dimensions or to reduce\noverall file size.\n\n#### onPostFileSubmission\n\nThis is where the actual logic to submit a submission to any particular website is.\nThis section will be better filled once a standard pattern has been written across\nmultiple website implementations.\n\n#### onValidateFileSubmission\n\nThis method is where any additional validations can occur that are not fulfilled by\ndecorator-based validations. You can read more about validations [here](./validation.md).\n"
  },
  {
    "path": "docs/contributing/add-a-website/sections/message-website.md",
    "content": "# Support Sending Messages\n\nAll website implementation that need to post files to their respective websites must\nimplement the `MessageWebsite<T>` interface.\n\n## Sample\n\n```ts\nclass FooMessageSubmission implements IWebsiteFormFields {\n  @TextField({ label: 'title', required: true, row: 0, col: 1 })\n  title: string;\n\n  @DescriptionField({ row: 3, col: 1 })\n  description: DescriptionValue;\n\n  @RatingField({ required: true, row: 0, col: 0 })\n  rating: SubmissionRating;\n}\n\n@WebsiteMetadata({\n  name: 'foo',\n  displayName: 'Foo',\n})\n@UserLoginFlow('https://foo.net/login')\nexport default class Foo extends Website<FooAccountData> implements MessageWebsite<FooMessageSubmission> {\n  protected BASE_URL = 'https://foo.net';\n\n  createFileModel(): FooMessageSubmission {\n    return new FooMessageSubmission();\n  }\n\n  calculateImageResize(file: ISubmissionFile): ImageResizeProps | undefined {\n    return undefined;\n  }\n\n  onPostFileSubmission(postData: PostData<FileSubmission, FooMessageSubmission>, files: PostingFile[], batchIndex: number, cancellationToken: CancellableToken): Promise<PostResponse> {\n    throw new Error('Method not implemented.');\n  }\n\n  async onValidateFileSubmission(postData: PostData<MessageSubmission, FooMessageSubmission>): Promise<SimpleValidationResult> {\n    // Stub\n  }\n}\n```\n\n### Sample Explained\n\n#### FooMessageSubmission\n\nThe class that you pass into the `MessageWebsite<T>` is responsible for the population of data\nand fields that users can fill in to customize their submission. You can read more about\nhow to specify form data [here](./defining-submission-data.md).\n\nThe general rule of thumb is to try and emulate most of the options provided to a user\nin the actual form.\n\n#### createMessageModel\n\nThis is the method responsible for returning an object of the generic type passed into\nthe `MessageWebsite` interface. This method is called when a user adds a website into their\nsubmission. In most cases this method will be pretty boiler-plate, but also allows for\nthe injection of account specific data if needed or convenient on form data creation.\n\n#### onPostMessageSubmission\n\nThis is where the actual logic to submit a submission to any particular website is.\nThis section will be better filled once a standard pattern has been written across\nmultiple website implementations.\n\n#### onValidateMessageSubmission\n\nThis method is where any additional validations can occur that are not fulfilled by\ndecorator-based validations. You can read more about validations [here](./validation.md).\n"
  },
  {
    "path": "docs/contributing/add-a-website/sections/validation.md",
    "content": "# Add User Input Validations\n\nIn either scenario of supporting Message or File posting to a website, a validation method\nmust be implemented for the sake of checking that user provided data is within expected\nlimits. For simple forms, this can largely be a pass-thru as the application attempts to\nhandle many common scenarios out of the box through the use of validation-based decorators.\n\n## Available Validation Decorators\n\n- [Description](../../../apps/client-server/src/app/websites/decorators/supports-description.decorator.ts)\n- [Files](../../../apps/client-server/src/app/websites/decorators/supports-files.decorator.ts)\n- [Title](../../../apps/client-server/src/app/websites/decorators/supports-title.decorator.ts)\n\nThese can be appended to a class to get a lot of default behavior.\nIf you are curious about the implementation validators associated with these decorators\nyou can see the [validators](../../../apps/client-server/src/app/validation/validators/)\nsection.\n\n## Validation Method Implementation\n\n### Sample\n\n```ts\n// Basic sample with custom validation\nclass FooMessageSubmission implements IWebsiteFormFields {\n  @TextField({\n    label: 'hexColor',\n    defaultValue: '#fff',\n  })\n  hexColor: string;\n}\n\nclass Foo extends Website implements MessageWebsite<FooMessageSubmission> {\n  async onValidateMessageSubmission(postData: PostData<MessageSubmission, FooMessageSubmission>): Promise<SimpleValidationResult> {\n    const result: SimpleValidationResult<FooMessageSubmission> = {\n      warnings: [],\n      errors: [],\n    };\n\n    if (postData.options.hexColor.length !== 7) {\n      result.errors.push({\n        field: 'hexColor', // If you provide the field, it will automatically pair the validation in the UI near that field\n        id: 'foo.validation.hex-color.invalid-length',\n        values: {\n          currentLength: postData.options.hexColor.length,\n        },\n      });\n    }\n\n    return result;\n  }\n}\n```\n\n```ts\n// Pass-thru sample where no additional validations are needed\nclass Foo extends Website implements MessageWebsite<FooMessageSubmission> {\n  async onValidateMessageSubmission(postData: PostData<MessageSubmission, FooMessageSubmission>): Promise<SimpleValidationResult> {\n    return {};\n  }\n```\n\n### The Difference Between Warning and Error\n\nValidators can return two different types of validations that have different effects\nwithin the application.\n\n#### Warning\n\nWarnings through validation are intended to inform the user that they may be missing\nsomething optional that may be beneficial or to inform of the behavior of the application\npotentially altering the outputs, such as needing to truncate a title down.\n\nThese do not block a post from being attempted.\n\n#### Error\n\nErrors through validation are determinations that some user input is missing or invalid.\nThis may be having an empty value in a required field. The presence of a validation error\nwill cause a post to fail.\n\n### Custom Validation Translation Requirements\n\n> [!IMPORTANT]\n> If you do add a translation to the application, please run `yarn lingui:extract` before\n> pushing to help ensure translations stay up-to-date.\n\nPostyBirb attempts to make sure that everything can be translated for better user\nexperiences. It is not expected of developers to update translations across all\nsupported languages. So the aim is to only require modifying a few files when a custom\nvalidation is required.\n\nTo support a new custom validation for translation you can follow the samples below.\n\n```ts\n// Within a validation method\nresult.errors.push({\n  field: 'hexColor',\n  id: 'foo.validation.hex-color.invalid-length',\n  values: {\n    currentLength: postData.options.hexColor.length,\n  },\n});\n```\n\nUpdate [Known Validations](../../../libs/types/src/models/submission/validation-result.type.ts).\n\n```ts\nexport interface ValidationMessages {\n  // ...\n  'foo.validation.hex-color.invalid-length': {\n    currentLength: number;\n  };\n}\n```\n\nAdd english translation to [Validation Translations](../../../apps/postybirb-ui/src/components/translations/validation-translation.tsx)\n\n```ts\n  'foo.validation.hex-color.invalid-length': (props) => {\n    const currentLength = props.values?.currentLength ?? 0;\n    return (\n      <Trans>Hex is greater than {currentLength} characters long</Trans>\n    );\n  },\n```\n"
  },
  {
    "path": "drizzle.config.ts",
    "content": "import { defineConfig } from 'drizzle-kit';\n\nexport default defineConfig({\n  dialect: 'sqlite', // Database dialect\n  schema: './libs/database/src/lib/schemas/index.ts', // Path to your schema definitions\n  out: './apps/postybirb/src/migrations', // Directory for migrations\n});\n"
  },
  {
    "path": "electron-builder.yml",
    "content": "appId: com.mvdicarlo.postybirb\ncopyright: Copyright (c) 2024 Michael DiCarlo\nproductName: PostyBirb\nexecutableName: PostyBirb\nartifactName: '${productName}-${version}-${os}-${arch}.${ext}'\n\npublish:\n  provider: github\n  owner: mvdicarlo\n  repo: postybirb\n\ndirectories:\n  buildResources: packaging-resources\n  output: release\n\ncompression: maximum\nremovePackageScripts: true\n\n# Unpack native dependencies\nasarUnpack:\n  - 'node_modules/better-sqlite3/**/*'\n  - 'node_modules/@img/**/*'\n  - 'node_modules/bufferutil/**/*'\n  - 'node_modules/utf-8-validate/**/*'\n  - '**/*.node'\n  - '**/*.dylib'\n  - '**/*.so'\n\nmac:\n  category: public.app-category.productivity\n  entitlements: packaging-resources/entitlements.mac.plist\n  entitlementsInherit: packaging-resources/entitlements.mac.plist\n  hardenedRuntime: true\n  type: distribution\n  icon: icons/icon.icns\n  gatekeeperAssess: false\n  # This ensures all binaries including native modules are signed\n  signIgnore: []\n  target:\n    - target: dmg\n  notarize:\n    teamId: 'ZRKS9CNUQZ'\n\nlinux:\n  category: Network\n  icon: icons/icon_256x256x32.png\n  synopsis: 'PostyBirb is a desktop application for posting to multiple websites'\n  description: 'PostyBirb helps artists post art and other multimedia to multiple websites more quickly.'\n  target:\n    - target: AppImage\n    - target: snap\n    - target: deb\n    - target: rpm\n\nsnap:\n  publish:\n    provider: github\n  summary: 'PostyBirb - Multi-site posting application'\n  description: 'PostyBirb helps artists post art and other multimedia to multiple websites more quickly.'\n  grade: stable\n  confinement: strict\n  plugs:\n    - default\n  artifactName: '${productName}-${version}-linux-snap-${arch}.${ext}'\n\ndeb:\n  priority: optional\n  depends:\n    - libnotify4\n    - libxtst6\n    - libnss3\n  artifactName: '${productName}-${version}-linux-deb-${arch}.${ext}'\n\nrpm:\n  depends:\n    - libnotify\n    - libappindicator\n    - libXtst\n    - nss\n  artifactName: '${productName}-${version}-linux-rpm-${arch}.${ext}'\n\nappImage:\n  artifactName: '${productName}-${version}-linux-${arch}.${ext}'\n\n# Generate separate update files for different Linux formats\n\nnsis:\n  deleteAppDataOnUninstall: true\n  oneClick: false\n  allowToChangeInstallationDirectory: true\n  allowElevation: true\n  createDesktopShortcut: true\n  createStartMenuShortcut: true\n  shortcutName: PostyBirb\n  include: packaging-resources/installer.nsh\n  installerLanguages:\n    - en_US\n  language: 1033\n\nwin:\n  icon: icons/icon.ico\n  requestedExecutionLevel: asInvoker\n  publisherName:\n    - \"Michael DiCarlo\"\n  target:\n    - target: nsis\n      arch:\n        - x64\n    - target: portable\n      arch:\n        - x64\n\nportable:\n  artifactName: '${productName}-${version}-portable-${arch}.${ext}'\n\nfiles:\n  - 'dist/**/*'\n  - 'node_modules/**/*'\n  - 'package.json'\n  - '!**/*.log'\n  - '!**/*.md'\n  - '!**/README*'\n  - '!**/CHANGELOG*'\n  - '!**/LICENSE*'\n  - '!**/*.d.ts'\n  - '!**/test/**/*'\n  - '!**/tests/**/*'\n  - '!**/.git/**/*'\n  - '!**/.DS_Store'\n  - '!*.{ts,js,yml,yaml,md}'\n  - '!{babel.config.js,commitlint.config.js,drizzle.config.ts,jest.*,lingui.config.ts,nx.json,tsconfig.*}'\n"
  },
  {
    "path": "entrypoint.sh",
    "content": "#! /usr/bin/bash\nset -o pipefail\nxvfb-run --auto-servernum --server-args=\"-screen 0 1280x960x24\" ./PostyBirb --headless --no-sandbox --port=8080 |& grep -v -E \"ERROR:viz_main_impl\\.cc\\(183\\)|ERROR:object_proxy\\.cc\\(576\\)|ERROR:bus\\.cc\\(408\\)|ERROR:browser_main_loop\\.cc\\(276\\)|ERROR:gles2_cmd_decoder_passthrough\\.cc\\(1094\\)|ERROR:gl_utils\\.cc\\(431\\)\""
  },
  {
    "path": "jest.config.ts",
    "content": "import { getJestProjects } from '@nx/jest';\nimport type { Config } from 'jest';\n\nconst config: Config = {\n  projects: getJestProjects(),\n  collectCoverage: true,\n  coverageDirectory: '<rootDir>/coverage',\n  coverageReporters: ['html', 'text', 'lcov'],\n};\n\nexport default config;\n"
  },
  {
    "path": "jest.preset.js",
    "content": "// @ts-check\n// @ts-expect-error No types for this import\nconst { transform: _, ...nxPreset } = require('@nx/jest/preset').default;\nconst { join } = require('path');\nconst basePath = __dirname.split(/(app|lib)/)[0];\n\n/** @type {import('jest').Config} */\nconst config = {\n  ...nxPreset,\n  setupFiles: [join(basePath, 'jest.setup.ts')],\n  prettierPath: require.resolve('prettier-2'),\n  reporters: ['summary', join(basePath, 'jest.reporter.js')],\n  slowTestThreshold: 7000,\n  cacheDirectory: join(process.cwd(), '.jest'),\n  transformIgnorePatterns: [], // There is a lot of esm packages and swc is fast enough to transform everything\n  transform: {\n    '^.+\\\\.(ts|tsx|jsx|js|html)$': [\n      '@swc/jest',\n      {\n        jsc: {\n          // https://github.com/swc-project/swc/discussions/5151#discussioncomment-3149154\n          experimental: { plugins: [['swc_mut_cjs_exports', {}]] },\n          loose: true,\n          target: 'es2020',\n          parser: {\n            syntax: 'typescript',\n            tsx: true,\n            decorators: true,\n            decoratorsBeforeExport: true,\n          },\n          transform: {\n            legacyDecorator: true,\n            decoratorMetadata: true,\n          },\n        },\n        sourceMaps: 'inline',\n        inlineSourcesContent: true,\n      },\n    ],\n  },\n};\n\nmodule.exports = config;\n"
  },
  {
    "path": "jest.reporter.js",
    "content": "// @ts-check\n\n// Custom reporter used to print console only for failed tests\n// Source: https://github.com/jestjs/jest/issues/4156#issuecomment-757376195\n\nconst { DefaultReporter } = require('@jest/reporters');\n\nclass Reporter extends DefaultReporter {\n  /**\n   * @param {string} _testPath\n   * @param {import('@jest/reporters').Config.ProjectConfig} _config\n   * @param {import('@jest/reporters').TestResult} result\n   */\n  printTestFileHeader(_testPath, _config, result) {\n    const console = result.console;\n\n    if (result.numFailingTests === 0 && !result.testExecError) {\n      result.console = undefined;\n    }\n\n    super.printTestFileHeader(_testPath, _config, result);\n    result.console = console;\n  }\n}\n\nmodule.exports = Reporter;\n"
  },
  {
    "path": "jest.setup.ts",
    "content": "process.env.NODE_ENV = 'test';\n"
  },
  {
    "path": "lang/de.po",
    "content": "msgid \"\"\nmsgstr \"\"\n\"Project-Id-Version: \\n\"\n\"Report-Msgid-Bugs-To: \\n\"\n\"POT-Creation-Date: \\n\"\n\"PO-Revision-Date: \\n\"\n\"Last-Translator: \\n\"\n\"Language: \\n\"\n\"Language-Team: \\n\"\n\"Content-Type: \\n\"\n\"Content-Transfer-Encoding: \\n\"\n\"Plural-Forms: \\n\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"({fileSizeString}) is too large (max {maxFileSizeString}) and an attempt will be made to reduce size when posting\"\nmsgstr \"\"\n\n#. placeholder {0}: tag.post_count\n#: apps/postybirb-ui/src/remake/components/website-components/e621/e621-tag-search-provider.tsx\nmsgid \"{0, plural, one {# post} other {# posts}}\"\nmsgstr \"\"\n\n#. placeholder {0}: selectedOptions.length\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/tree-select.tsx\nmsgid \"{0} selected\"\nmsgstr \"\"\n\n#. placeholder {0}: value.length\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker.tsx\nmsgid \"{0} submissions selected\"\nmsgstr \"\"\n\n#. placeholder {0}: account?.websiteDisplayName\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\nmsgid \"{0}: Login successful\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"{count} items deleted\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"{count} selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-visibility-picker.tsx\nmsgid \"{hiddenCount} hidden\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"{selectedCount} of {totalCount} selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"{validCount} of {totalSelectedCount} selected submission(s) are ready to post. Submissions without websites or with validation errors will be skipped.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"{validCount} submission(s) will be posted in the order shown below.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"<0>Tip:</0> If your handle doesn't work, try using the email linked to your account\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"2FA enabled, password required. It won't be stored.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"A shortcut with this name already exists\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/tag-converter-drawer/tag-converter-drawer.tsx\nmsgid \"A tag converter with this tag already exists\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/user-converter-drawer/user-converter-drawer.tsx\nmsgid \"A user converter with this username already exists\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"Accept and Continue\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Access Tiers\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/selected-accounts-forms.tsx\nmsgid \"account\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Account\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-section.tsx\nmsgid \"Account data cleared\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Account name\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Account Options\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"Account Status\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/selected-accounts-forms.tsx\nmsgid \"accounts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/account-section-header.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Accounts\"\nmsgstr \"Konten\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\nmsgid \"Activate\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Activate schedule\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\nmsgid \"Active\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/recent-activity-panel.tsx\nmsgid \"Activity will appear here as you use the app\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Add account\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"Add an account to start posting\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-submission-modal.tsx\nmsgid \"Add File Submission\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"Add shortcut\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\nmsgid \"Add tags...\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Add to Portfolio\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\nmsgid \"Add website\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-preview-panel.tsx\nmsgid \"Add website accounts to preview the description output.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Adjust dimensions while maintaining aspect ratio\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/rating-input/rating-input.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Adult\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Adult themes\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"After authorizing, copy the code and paste it below\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"AI generated\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/account-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\nmsgid \"All\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"All files are marked ignored.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"All healthy\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\nmsgid \"All submissions are ready to post\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\nmsgid \"All Websites\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow comments\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow commercial use\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow free download\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow modifications of your work\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow other users to edit tags\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/description-settings-section.tsx\nmsgid \"Allow PostyBirb to self-advertise at the end of descriptions\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow reblogging\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Alt Text\"\nmsgstr \"\"\n\n#. Bluesky login form\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"An <0>app password</0> (not your main password)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"An error occurred\"\nmsgstr \"\"\n\n#. placeholder {0}: dtext.length - text.length\n#: apps/postybirb-ui/src/remake/components/website-components/e621/e621-dtext-renderer.tsx\nmsgid \"and {0} more\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"and {remainingCount} more\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"API Key (Consumer Key)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"API keys saved successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"API Secret (Consumer Secret)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\nmsgid \"App\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"App API Hash\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"App API ID\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/app-settings-section.tsx\nmsgid \"App Folder\"\nmsgstr \"App-Ordner\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"App Password\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"App registered successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/app-settings-section.tsx\nmsgid \"App Server Port\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"App view URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\nmsgid \"Appearance\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Applied template options to {successCount} submissions\"\nmsgstr \"\"\n\n#. placeholder {0}: submissionIds.length\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/save-to-many-action.tsx\nmsgid \"Applied to {0} submission(s)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Applied to {successCount} submissions, {failedCount} failed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Apply\"\nmsgstr \"Anwenden\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/apply-template-action.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Apply template\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\nmsgid \"Archive\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/header/submission-edit-card-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-section.tsx\nmsgid \"Archived\"\nmsgstr \"\"\n\n#. placeholder {0}: selectedIds.length\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-section.tsx\nmsgid \"Are you sure you want to delete {0} submission(s)? This action cannot be undone.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Are you sure you want to reset all remote connection settings to their defaults? This will clear the saved host URL and password.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Are you sure you want to watch this folder?\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Artist name\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Artist URL (Off-Site only)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Aspect Ratio\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Attach Images as Attachments\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Audience\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Audio\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Authenticate\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Authorization Code\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Authorization URL generated\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Authorize\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Authorized viewers\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/appearance-settings-section.tsx\nmsgid \"Auto\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Automatically import new files from specified folders\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Back\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Block guests\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Blog\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Bluesky automatically converts GIFs to videos.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/app-settings-section.tsx\nmsgid \"Browse\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Builder\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"By using this application, you acknowledge that you are solely responsible for how you use it and for any content you create, upload, distribute, or interact with. The authors and maintainers provide this software as is without warranties of any kind and are not liable for any damages or losses resulting from its use.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/confirm-action-modal/confirm-action-modal.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/components/insert-media-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Cancel\"\nmsgstr \"Abbrechen\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"Cancel posting\"\nmsgstr \"\"\n\n#. placeholder {0}: getFileTypeLabel(newFileType)\n#. placeholder {1}: getFileTypeLabel(existingFileType)\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-uploader.tsx\nmsgid \"Cannot add {0} files to a submission containing {1} files. All files in a submission must be of the same type.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-card.tsx\nmsgid \"Cannot delete the only file\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Category\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Channel\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Characters\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Charge Patrons\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\nmsgid \"Check login status\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Check that handle and password are valid or try using email instead of handle.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Check your telegram messages\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\nmsgid \"Checking...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-content.tsx\nmsgid \"Choose an account from the list on the left\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgctxt \"template.picker-modal-header\"\nmsgid \"Choose Templates\"\nmsgstr \"Vorlagen auswählen\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Click here to authorize PostyBirb\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Click the button below to get your authorization URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Click the link above, authorize the app, and copy the PIN code below\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Client\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Cloud Password\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Code\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Code invalid, ensure it matches the code sent from telegram\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Code sent\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-card.tsx\nmsgid \"Collapse\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Collections\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Comment permissions\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Commercial use\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Compact view\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Complete Authorization\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Complete Login\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Configuration Error\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Configure API Keys\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/confirm-action-modal/confirm-action-modal.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Confirm\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Connected successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\nmsgid \"Connection error\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Connection Failed\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Contains content\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Content Blur\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/content-warning-shortcut.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Content warning\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Continue from where it left off\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/copy-to-clipboard/copy-to-clipboard.tsx\nmsgid \"Copied\"\nmsgstr \"Kopiert\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Copied to clipboard\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/copy-to-clipboard/copy-to-clipboard.tsx\n#: apps/postybirb-ui/src/remake/components/shared/copy-to-clipboard/copy-to-clipboard.tsx\nmsgid \"Copy\"\nmsgstr \"Kopieren\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Create\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Create a File or Message submission to start posting\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Create a file watcher to automatically import new files from a folder\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Create an app password in Bluesky Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Create Message\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Create new file watcher\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Create new submission\"\nmsgstr \"Neue Einreichung erstellen\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Create Submission\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgctxt \"telegram.app-id-help\"\nmsgid \"Create telegram app to retrieve api_id and api_hash\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Created successfully\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Creative Commons\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Creative Commons license\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"CRON Expression\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Crop from primary\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Ctrl+Enter to apply\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Custom\"\nmsgstr \"\"\n\n#. placeholder {0}: website.displayName\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/custom-login-placeholder.tsx\nmsgid \"Custom login for {0}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Custom PDS or AppView\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Custom Shortcuts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/spellchecker-settings-section.tsx\nmsgid \"Custom words\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Daily\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/appearance-settings-section.tsx\nmsgid \"Dark\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/theme-picker/theme-picker.tsx\n#: apps/postybirb-ui/src/remake/components/theme-picker/theme-picker.tsx\nmsgid \"Dark Mode\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Dashboard\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Date {currentDate} is after maximum allowed date {maxDate}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Date {currentDate} is before minimum allowed date {minDate}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Date {currentDate} is outside allowed range ({minDate} - {maxDate})\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Date and Time\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Date is in the past\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Day of Month\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Days\"\nmsgstr \"Tage\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"Decline and Exit\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/radio-field.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/default-shortcut.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Default\"\nmsgstr \"Standard\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/defaults-form/defaults-form.tsx\nmsgid \"Defaults\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\nmsgid \"Defaults saved successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-preview.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-section.tsx\nmsgid \"Delete\"\nmsgstr \"Löschen\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\nmsgid \"Delete all existing website options and use only those specified in the source.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-card.tsx\nmsgid \"Delete file\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/archived-submission-card.tsx\nmsgid \"Delete permanently\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-section.tsx\nmsgid \"Delete Submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Deleted successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: apps/postybirb-ui/src/remake/components/website-components/e621/e621-tag-search-provider.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Description\"\nmsgstr \"Beschreibung\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Description length is greater then maximum ({currentLength} / {maxLength})\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Description length is lower then minimum ({currentLength} / {minLength})\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/description-settings-section.tsx\nmsgid \"Description Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-select.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-selection-form.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\nmsgid \"Deselect all\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"Desktop Notifications\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Detailed view\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Dimensions\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Disable comments\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Discard all progress from the previous attempt and start as if this were the first time posting.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Discord\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Display resolution\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Double-click to toggle crop/move mode\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Download Update\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Downloading...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/reorderable-submission-list/reorderable-submission-list.tsx\nmsgid \"Drag or use arrow keys to reorder\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/submission-list.tsx\nmsgid \"Drag submissions onto the calendar to schedule them\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-manager.tsx\nmsgid \"Drag to reorder\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-dropzone.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-list.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-uploader.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Drop files here or click to browse\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Drug / Alcohol\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\nmsgid \"Duplicate\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Duplicated successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/e621/e621-login-view.tsx\nmsgid \"e621 requires API credentials for posting. You'll need to generate an API key from your account settings to authenticate PostyBirb.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-dropzone.tsx\nmsgid \"Each file becomes a separate submission\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Early Access\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\nmsgid \"Edit\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-preview.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Edit image\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-card.tsx\nmsgid \"Edit metadata\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-preview.tsx\nmsgid \"Edit title\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"Enable desktop notifications\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\nmsgid \"Enable global tag autocomplete from sites like e621.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/spellchecker-settings-section.tsx\nmsgid \"Enabled\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"English\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Ensure the IP is correct\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Enter Instance URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Enter the host URL and password to connect to a remote PostyBirb instance.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Enter the PIN from Twitter\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Enter the URL of your Mastodon, Pleroma, or Pixelfed instance (e.g., \\\"mastodon.social\\\" or \\\"pixelfed.social\\\")\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Enter title...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Enter your Twitter app credentials\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-option-row.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/selected-accounts-forms.tsx\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Error\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Error while authenticating Telegram\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\nmsgid \"errors\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Errors\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"Expand\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Explicit text\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/rating-input/rating-input.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Extreme\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"failed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Failed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"Failed Posts Detected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Failed to apply template options\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-section.tsx\nmsgid \"Failed to clear account data\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Failed to complete authorization\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Failed to connect to remote host\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\nmsgid \"Failed to connect to the server\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to create\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\nmsgid \"Failed to create template\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to delete\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-card.tsx\nmsgid \"Failed to delete file\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to duplicate\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Failed to generate authorization URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Failed to get user warnings. You can check your account manually\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Failed to load primary file for cropping.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to post submission\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Failed to register with instance\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Failed to replace file\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to restore\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to save\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/form-fields-context.tsx\nmsgid \"Failed to save changes\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-alt-text-editor.tsx\nmsgid \"Failed to save fallback text\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Failed to save metadata\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Failed to schedule submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Failed to send code to begin authentication\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Failed to store API keys\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to update\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\nmsgid \"Failed to update template name\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to upload files\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Failed to validate submission: {message}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Failed to validate tags. Please check them manually\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-alt-text-editor.tsx\nmsgid \"Fallback Text\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Feature\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Fediverse Authentication\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Female\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\nmsgid \"File\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-content.tsx\nmsgid \"File Submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"File types do not match. Please upload a file of the same type.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"File Watcher Failure\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"File Watcher Success\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"File Watchers\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"File will be modified to support website requirements\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-list.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-manager.tsx\nmsgid \"Files\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Files uploaded successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Flip\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Flip horizontal\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Flip vertical\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Folder\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Folder Contains Files\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Folder Path\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/specialized-error-boundaries.tsx\nmsgid \"Form Error\"\nmsgstr \"\"\n\n#. Bluesky login form\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Format should be <0>handle.bsky.social</0> or <1>domain.ext</1> for custom domains\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Format: minute hour day month weekday\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Found in your Twitter app's Keys and tokens section\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Fr\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Frequency\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Friends only\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Furry\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Gender\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/rating-input/rating-input.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"General\"\nmsgstr \"\"\n\n#. E621 login form\n#: apps/postybirb-ui/src/remake/components/website-login-views/e621/e621-login-view.tsx\nmsgid \"Generate an API key from your <0>account settings</0>\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Generate authorization link\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"German\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Get Authorization\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Get authorization code\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Get Authorization URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Get started by adding your accounts and creating your first submission.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\nmsgid \"Global tag search provider\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Go back\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\nmsgid \"Go forward\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Go to Accounts to add and log into your websites\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Gore\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Has sexual content\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Has validation errors\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Has validation warnings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/components/insert-media-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/resizable-image.tsx\nmsgid \"Height\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-selection-form.tsx\nmsgid \"Hide unselected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\nmsgid \"Higher boost levels allow larger file uploads\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\nmsgid \"Hold to delete\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\nmsgid \"Hold to delete {count} item(s)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"Hold to delete permanently\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"Hold to post\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Home\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Host\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Host URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Hours\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\nmsgctxt \"discord.webhook-help\"\nmsgid \"How to create a webhook\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"I have read, understand, and accept the disclaimer.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"If you are using pds other then bsky.social\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"If you do not agree with these terms, you must decline and exit the application.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/tag-field.tsx\nmsgctxt \"override-default\"\nmsgid \"Ignore default tags\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Image\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/resizable-image.tsx\nmsgid \"Image Size\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-uploader.tsx\nmsgid \"Images, videos, audio, and text files up to 100MB\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\nmsgid \"Import\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Import Action\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\nmsgid \"Import completed successfully!\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\nmsgid \"Import encountered errors:\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\nmsgid \"Import from PostyBirb Plus\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\nmsgid \"Import your data from PostyBirb Plus. Select which types of data you want to import.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\nmsgid \"Inactive\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Info\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/components/insert-media-modal.tsx\nmsgid \"Insert\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/components/insert-media-modal.tsx\nmsgid \"Insert Media\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-field.tsx\nmsgid \"Insert tags at end\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-field.tsx\nmsgid \"Insert title at start\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Instance URL\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Intended as advertisement\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Invalid CRON expression\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Invalid date format: {value}. Expected ISO date string.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Invalid post URL to reply to.\"\nmsgstr \"\"\n\n#. Bluesky login form\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Invalid url. Format should be <0>https://bsky.app/</0>\"\nmsgstr \"\"\n\n#. Bluesky login form\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Invalid url. Format should be <0>https://bsky.social/</0>\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"It is recommended to add at least 10 general tags <0>( {generalTags} / 10 )</0>. See <1>tagging checklist</1>\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Keep this secret and never share it publicly\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Ko-fi\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"LAN IP\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/language-picker/language-picker.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Language\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/spellchecker-settings-section.tsx\nmsgid \"Languages to check\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Later\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-field.tsx\nmsgid \"Legacy Shortcuts Detected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"Legal Notice & Disclaimer\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/appearance-settings-section.tsx\nmsgid \"Light\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/theme-picker/theme-picker.tsx\n#: apps/postybirb-ui/src/remake/components/theme-picker/theme-picker.tsx\nmsgid \"Light Mode\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"Lithuanian\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Log in to different instance\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"logged in\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/account-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Logged in\"\nmsgstr \"\"\n\n#. placeholder {0}: username ? ` as ${username}` : ''\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\nmsgid \"Logged in{0}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Login\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\nmsgid \"Login failed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Make sure that the default rating matches Bluesky Label Rating.\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Male\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"Manage accounts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Mark as read\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Mark as unread\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Mark as work in progress\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Marked as read\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-content.tsx\nmsgid \"Mass Edit\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/rating-input/rating-input.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Mature\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Mature content\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Media\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\nmsgid \"Merge (recommended)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\nmsgid \"Merge Mode\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\nmsgid \"Message\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-content.tsx\nmsgid \"Message Submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Minutes\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Mo\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Modification\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Modified\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Monthly\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/header/submission-edit-card-header.tsx\nmsgid \"Multi Edit\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\nmsgid \"Name\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"Name is required\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"need attention\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\nmsgid \"New\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"New account\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"New Message\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\nmsgid \"New template name...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"New version:\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"No AI\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"No changes\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"No custom dimensions set\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/defaults-form/defaults-form.tsx\nmsgid \"No default options available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-list.tsx\nmsgid \"No files added yet\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"No folder selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/empty-state/empty-state.tsx\nmsgid \"No notifications\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/tree-select.tsx\nmsgid \"No options available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"No options selected to apply\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-preview-panel.tsx\nmsgid \"No preview available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/empty-state/empty-state.tsx\nmsgid \"No records yet\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/empty-state/empty-state.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-select.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/tree-select.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker.tsx\nmsgid \"No results found\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"No schedule configured\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/reorderable-submission-list/reorderable-submission-list.tsx\nmsgid \"No submissions selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\nmsgid \"No validation issues\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"No website posts found\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"No websites\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-visibility-picker.tsx\nmsgid \"No websites available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"No websites selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-components/e621/e621-tag-search-provider.tsx\nmsgid \"No wiki page available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\nmsgid \"None\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\nmsgid \"Not all tag providers support this feature.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/account-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-option-row.tsx\n#: apps/postybirb-ui/src/remake/components/shared/account-picker/account-picker.tsx\nmsgid \"Not logged in\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Not scheduled\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/empty-state/empty-state.tsx\nmsgid \"Nothing selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Notifications\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Notify followers\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Nudity\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Offer hi-res download (gold only)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Offline\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Once\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Online\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Only set scheduled date (don't activate)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Open CRON helper\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Open full calendar\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\nmsgid \"Open Inkbunny Account Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/app-settings-section.tsx\nmsgid \"Open PostyBirb on computer startup\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Open Twitter Authorization Page\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Optional template to apply to imported files\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Options\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Original work\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Other\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\nmsgid \"Overwrite overlapping website options only. Keeps existing website options that are not specified in the source.\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Parent ID\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\nmsgid \"Password\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Password is invalid\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-dropzone.tsx\nmsgid \"Paste from clipboard\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-dropzone.tsx\nmsgid \"Paste image from clipboard (Ctrl+V)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\nmsgid \"Pause\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/queue-control-card.tsx\nmsgid \"Pause Posting\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\nmsgid \"Paused\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"PDS (Personal Data Server)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Pending\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Per-Account Dimensions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Phone Number\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgctxt \"telegram.phone-number-help\"\nmsgid \"Phone number must be in international format\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"PIN / Verifier Code\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Pixel perfect display\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\nmsgid \"Please check your credentials and try again\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"Portuguese (Brazil)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Post\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Post Data (JSON)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"Post Failure\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Post Files\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"Post Submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"Post Success\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\nmsgid \"PostyBirb uses Discord webhooks to post content to your server. Make sure you have the necessary permissions to create webhooks in your target channel.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-preview-panel.tsx\nmsgid \"Preview\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Primary\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Privacy\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Private\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Proceed to Authorization\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Profanity\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Public viewers\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Queued\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Quick tips to get started:\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Racism\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Rating\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Read\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Ready\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-button.tsx\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Ready to Install\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Ready to post\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/recent-activity-panel.tsx\nmsgid \"Recent Activity\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Recurring\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Recurring schedule (active)\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Reference\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\nmsgid \"Refresh page\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Register Instance\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\nmsgid \"Reload page\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\nmsgid \"Remote\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Remote Connection\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Remote host URL is not configured\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Remote password is not configured\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\nmsgid \"Remove\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/resizable-image.tsx\nmsgid \"Remove image\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\nmsgid \"Rename\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\nmsgid \"Replace All\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgctxt \"import.override-description\"\nmsgid \"Replace description\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Replace file\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Replace the current description with the selected template's description\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Replace the current title with the selected template's title\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgctxt \"import.override-title\"\nmsgid \"Replace title\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Reply to post URL\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Request critique\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Required\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Required if 2FA is enabled.\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Required tag\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Requires at least {minLength} {minLength, plural, one {tag} other {tags}}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Requires at least {minSelected} {minSelected, plural, one {option selected} other {options selected}} ({currentSelected} / {minSelected})\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Reset\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Reset account data\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Reset account?\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Reset Remote Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Restart Now\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/archived-submission-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/archived-submission-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"Restore\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Restored successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Resume Failed Posting\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/queue-control-card.tsx\nmsgid \"Resume Posting\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Retry all failed or unattempted websites\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Retry posting to all websites that failed or were not attempted. All files will be re-uploaded.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Rotate\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Rotate left 90°\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Rotate right 90°\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Running\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"Russian\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Sa\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/e621/e621-login-view.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\nmsgid \"Save\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Save API Keys\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\nmsgid \"Save as default\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Save changes\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\nmsgid \"Save Selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Save to file\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/save-to-many-action.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/save-to-many-action.tsx\nmsgid \"Save to submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Schedule\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/upcoming-posts-panel.tsx\nmsgid \"Schedule a submission to see it here\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Schedule configured (inactive)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Schedule posts to automatically publish at specific times\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Schedule Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Schedule Submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Schedule updated\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Scheduled\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Scheduled (active)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Scroll to zoom\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/submission-list.tsx\n#: apps/postybirb-ui/src/remake/components/shared/search-input.tsx\nmsgid \"Search...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-content.tsx\nmsgid \"Select a submission from the list to view details\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker.tsx\nmsgid \"Select a template\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-content.tsx\nmsgid \"Select a template from the list to view or edit it\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-select.tsx\nmsgid \"Select accounts...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-select.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-selection-form.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\nmsgid \"Select all\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-content.tsx\nmsgid \"Select an account to log in\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\nmsgid \"Select fields to save as defaults for this account.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\nmsgid \"Select items to delete\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/language-picker/language-picker.tsx\nmsgid \"Select language\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker.tsx\nmsgid \"Select submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Select templates or submissions to import\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\nmsgid \"Select websites to apply this shortcut to\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\nmsgid \"Select websites to apply usernames to\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Select which template to use for each account\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/tree-select.tsx\nmsgid \"Select...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Selected option is invalid or missing\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Send Code\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Send Messages\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Send to scraps\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Sensitive content\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\nmsgid \"Server Boost Level\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Server unreachable\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Set the interval between each submission.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Settings\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Sexual Content\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Share on feed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Share on Feed is required to support posting multiple files.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Share your LAN IP and password with clients to allow them to connect.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"shortcut\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"Shortcut name\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\nmsgid \"Show wiki page related to tag on hover\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-visibility-picker.tsx\nmsgid \"Show/Hide Websites\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Silent\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Sketch\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Skip Accounts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"Skip websites that posted successfully and only attempt failed or unattempted ones.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Skip websites that posted successfully and only attempt failed or unattempted ones. Continues from the last successful batch of files.\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Software\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"Some accounts need to be logged in before you can post to them.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-uploader.tsx\nmsgid \"Some files were rejected. Check file type and size (max 100MB).\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"Some submissions have failed posting attempts. Choose how to handle them:\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\nmsgid \"Something went wrong\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Source URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Source URLs\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"Spanish\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Species\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Specify your Fediverse instance\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\nmsgid \"Spellchecker\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/spellchecker-settings-section.tsx\nmsgid \"Spellchecker advanced configuration is controlled by system on MacOS.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/spellchecker-settings-section.tsx\nmsgid \"Spellchecker Settings\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Spoiler\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Spoiler Text\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Spoilers\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Start completely fresh\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Start Date\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Start Over\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/app-settings-section.tsx\nmsgid \"Startup Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Status\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Su\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"Submission has validation errors\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Submission Order\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\nmsgid \"Submission unscheduled\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Submission will be posted automatically\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Submission will be split into {expectedBatchesToCreate} different submissions with {maxBatchSize} {maxBatchSize, plural, one {file each} other {files each}}.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-section.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"success\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Success\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Successful\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Successfully connected to the remote host\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Successfully logged in as {loggedInAs}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Successfully logged in as @{loggedInAs}\"\nmsgstr \"\"\n\n#. placeholder {0}: orderedSubmissions.length\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Successfully scheduled ${0} submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Supports either a set of images, a single video, or a single GIF.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tag {tag} has {postCount} post(s). Tag may be invalid or low use\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tag <0>{tag}</0> does not exist yet or is invalid.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tag <0>{tag}</0> does not exist yet or is invalid. If you want to create a new tag, make a post with it, then go <1>here</1>, press edit and select tag category\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tag <0>{tag}</0> is invalid.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/tag-converter-drawer/tag-converter-drawer.tsx\nmsgid \"tag converter\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-converter-drawer/tag-converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Tag Converters\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Tag Groups\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tag limit reached ({currentLength} / {maxLength})\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Tag permissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/simple-tag-input/simple-tag-input.tsx\nmsgid \"tags\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/tags-shortcut.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Tags\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tags longer than {maxLength} characters will be skipped\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\nmsgid \"Tags Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tags should not start with #\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"Tamil\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Teaser\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Telegram requires API credentials and phone number authentication. You'll need to create a Telegram app and verify your phone number.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker.tsx\nmsgid \"Template\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\nmsgid \"Template created\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-content.tsx\nmsgid \"Template Editor\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-content.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Templates\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Test Connection\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Text\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Th\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"The folder \\\"{folderName}\\\" contains {fileCount} files.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/specialized-error-boundaries.tsx\nmsgid \"The form encountered an error and couldn't be displayed properly.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"The last posting attempt failed. How would you like to proceed?\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"The update has been downloaded and is ready to install. Click \\\"Restart Now\\\" to apply the update.\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Theme\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\nmsgid \"These defaults apply to future submissions.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\nmsgid \"This component encountered an error and couldn't render properly.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\nmsgid \"This is a forum channel\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"This post record has no events or is still processing. Check the JSON data below for more details.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-content.tsx\nmsgid \"This website has an unsupported login configuration\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/custom-login-placeholder.tsx\nmsgid \"This website uses a custom login form. Support coming soon.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"This will clear all account data and cookies.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Thumbnail\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Thumbnail must be an image file.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Time\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Time Taken\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Tips\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/title-shortcut.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Title\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Title is too long and will be truncated\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Title is too short\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-visibility-picker.tsx\nmsgid \"Toggle website visibility\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Total\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Translation {id} not found\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\n#: apps/postybirb-ui/src/remake/components/error-boundary/specialized-error-boundaries.tsx\nmsgid \"Try again\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Tu\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Twitter / X Authentication\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/description-editor.tsx\nmsgid \"Type / for commands or @, ` or '{' for shortcuts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Unknown\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Unknown choice\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-content.tsx\nmsgid \"Unknown login type\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Unread\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\nmsgid \"Unschedule\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/submission-list.tsx\nmsgid \"Unscheduled Submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Unsupported file type {fileExtension}. Please provide fallback text.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Unsupported file type {mimeType}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Unsupported rating: {ratingLabel}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Unsupported submission type: {fileTypeString}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/upcoming-posts-panel.tsx\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/header/submission-edit-card-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\n#: apps/postybirb-ui/src/remake/components/shared/reorderable-submission-list/reorderable-submission-list.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Untitled\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/submission-list.tsx\nmsgid \"Untitled Submission\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Untitled Template\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/upcoming-posts-panel.tsx\nmsgid \"Upcoming Posts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Update API Keys\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-button.tsx\n#: apps/postybirb-ui/src/remake/components/update-button/update-button.tsx\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Update available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Update Error\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Update Failed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-button.tsx\nmsgid \"Update ready\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Updated successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-submission-modal.tsx\nmsgid \"Upload\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Upload thumbnail\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/components/insert-media-modal.tsx\nmsgid \"URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-field.tsx\nmsgid \"Use custom description\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Use templates to save time on repeated posts\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Use thumbnail as cover art\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Use title\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Use your Bluesky handle or email along with an app password. App passwords are more secure than your main password and can be revoked at any time.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Used for other sites (like e621) as source url base. Independent from custom pds.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/user-converter-drawer/user-converter-drawer.tsx\nmsgid \"user converter\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/user-converter-drawer/user-converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"User Converters\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/e621/e621-login-view.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\nmsgid \"Username\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Username or Email\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\nmsgid \"Validation Issues\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Video\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"View\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/archived-submission-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/archived-submission-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"View history\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"View permissions\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Violence\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Visibility\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-option-row.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/selected-accounts-forms.tsx\nmsgid \"Warning\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\nmsgid \"warnings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Warnings\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Watermark\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"We\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\nmsgid \"We encountered an unexpected error. Please try refreshing the page.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Website\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/selected-accounts-forms.tsx\nmsgid \"Website Options\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-select.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-selection-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/account-picker/account-picker.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\nmsgid \"Websites\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Weekly\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Welcome to PostyBirb!\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"What to do when new files are detected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"What's New\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Who can reply\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/components/insert-media-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/resizable-image.tsx\nmsgid \"Width\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"You agree to comply with all applicable laws, terms of service, and policies of any third party platforms you connect to or interact with through this application. Do not use this software to infringe intellectual property rights, violate privacy, or circumvent platform rules.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"You have existing API keys. You can modify them above or proceed to the next step.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"You have recent {negativeOrNeutral} feedback: {feedback}, you can view it<0>here</0>\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\nmsgid \"You must first enable API access in your account settings under \\\"API (External Scripting)\\\" before you can authenticate with PostyBirb.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"You need to create a Twitter app to get API keys. <0>Visit Twitter Developer Portal</0>\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"You'll get this PIN after authorizing on Twitter\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-field.tsx\nmsgid \"Your description contains legacy shortcut syntax. These are no longer supported. Please use the new shortcut menu (type @, &lbrace; or `) to insert shortcuts.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Your handle (e.g. <0>yourname.bsky.social</0>) or custom domain (e.g. <1>username.ext</1>)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\nmsgid \"Your password will not be stored\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Zoom\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Zoom in\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Zoom out\"\nmsgstr \"\"\n"
  },
  {
    "path": "lang/en.po",
    "content": "msgid \"\"\nmsgstr \"\"\n\"POT-Creation-Date: 2025-06-28 08:44+0300\\n\"\n\"MIME-Version: 1.0\\n\"\n\"Content-Type: text/plain; charset=utf-8\\n\"\n\"Content-Transfer-Encoding: 8bit\\n\"\n\"X-Generator: @lingui/cli\\n\"\n\"Language: en\\n\"\n\"Project-Id-Version: \\n\"\n\"Report-Msgid-Bugs-To: \\n\"\n\"PO-Revision-Date: \\n\"\n\"Last-Translator: \\n\"\n\"Language-Team: \\n\"\n\"Plural-Forms: \\n\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"({fileSizeString}) is too large (max {maxFileSizeString}) and an attempt will be made to reduce size when posting\"\nmsgstr \"\"\n\n#. placeholder {0}: tag.post_count\n#: apps/postybirb-ui/src/remake/components/website-components/e621/e621-tag-search-provider.tsx\nmsgid \"{0, plural, one {# post} other {# posts}}\"\nmsgstr \"\"\n\n#. placeholder {0}: selectedOptions.length\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/tree-select.tsx\nmsgid \"{0} selected\"\nmsgstr \"\"\n\n#. placeholder {0}: value.length\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker.tsx\nmsgid \"{0} submissions selected\"\nmsgstr \"\"\n\n#. placeholder {0}: account?.websiteDisplayName\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\nmsgid \"{0}: Login successful\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"{count} items deleted\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"{count} selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-visibility-picker.tsx\nmsgid \"{hiddenCount} hidden\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"{selectedCount} of {totalCount} selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"{validCount} of {totalSelectedCount} selected submission(s) are ready to post. Submissions without websites or with validation errors will be skipped.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"{validCount} submission(s) will be posted in the order shown below.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"<0>Tip:</0> If your handle doesn't work, try using the email linked to your account\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"2FA enabled, password required. It won't be stored.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"A shortcut with this name already exists\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/tag-converter-drawer/tag-converter-drawer.tsx\nmsgid \"A tag converter with this tag already exists\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/user-converter-drawer/user-converter-drawer.tsx\nmsgid \"A user converter with this username already exists\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"Accept and Continue\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Access Tiers\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/selected-accounts-forms.tsx\nmsgid \"account\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Account\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-section.tsx\nmsgid \"Account data cleared\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Account name\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Account Options\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"Account Status\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/selected-accounts-forms.tsx\nmsgid \"accounts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/account-section-header.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Accounts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\nmsgid \"Activate\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Activate schedule\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\nmsgid \"Active\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/recent-activity-panel.tsx\nmsgid \"Activity will appear here as you use the app\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Add account\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"Add an account to start posting\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-submission-modal.tsx\nmsgid \"Add File Submission\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"Add shortcut\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\nmsgid \"Add tags...\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Add to Portfolio\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\nmsgid \"Add website\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-preview-panel.tsx\nmsgid \"Add website accounts to preview the description output.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Adjust dimensions while maintaining aspect ratio\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/rating-input/rating-input.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Adult\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Adult themes\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"After authorizing, copy the code and paste it below\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"AI generated\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/account-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\nmsgid \"All\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"All files are marked ignored.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"All healthy\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\nmsgid \"All submissions are ready to post\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\nmsgid \"All Websites\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow comments\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow commercial use\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow free download\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow modifications of your work\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow other users to edit tags\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/description-settings-section.tsx\nmsgid \"Allow PostyBirb to self-advertise at the end of descriptions\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow reblogging\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Alt Text\"\nmsgstr \"\"\n\n#. Bluesky login form\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"An <0>app password</0> (not your main password)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"An error occurred\"\nmsgstr \"\"\n\n#. placeholder {0}: dtext.length - text.length\n#: apps/postybirb-ui/src/remake/components/website-components/e621/e621-dtext-renderer.tsx\nmsgid \"and {0} more\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"and {remainingCount} more\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"API Key (Consumer Key)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"API keys saved successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"API Secret (Consumer Secret)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\nmsgid \"App\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"App API Hash\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"App API ID\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/app-settings-section.tsx\nmsgid \"App Folder\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"App Password\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"App registered successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/app-settings-section.tsx\nmsgid \"App Server Port\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"App view URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\nmsgid \"Appearance\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Applied template options to {successCount} submissions\"\nmsgstr \"\"\n\n#. placeholder {0}: submissionIds.length\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/save-to-many-action.tsx\nmsgid \"Applied to {0} submission(s)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Applied to {successCount} submissions, {failedCount} failed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Apply\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/apply-template-action.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Apply template\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\nmsgid \"Archive\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/header/submission-edit-card-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-section.tsx\nmsgid \"Archived\"\nmsgstr \"\"\n\n#. placeholder {0}: selectedIds.length\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-section.tsx\nmsgid \"Are you sure you want to delete {0} submission(s)? This action cannot be undone.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Are you sure you want to reset all remote connection settings to their defaults? This will clear the saved host URL and password.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Are you sure you want to watch this folder?\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Artist name\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Artist URL (Off-Site only)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Aspect Ratio\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Attach Images as Attachments\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Audience\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Audio\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Authenticate\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Authorization Code\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Authorization URL generated\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Authorize\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Authorized viewers\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/appearance-settings-section.tsx\nmsgid \"Auto\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Automatically import new files from specified folders\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Back\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Block guests\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Blog\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Bluesky automatically converts GIFs to videos.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/app-settings-section.tsx\nmsgid \"Browse\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Builder\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"By using this application, you acknowledge that you are solely responsible for how you use it and for any content you create, upload, distribute, or interact with. The authors and maintainers provide this software as is without warranties of any kind and are not liable for any damages or losses resulting from its use.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/confirm-action-modal/confirm-action-modal.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/components/insert-media-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Cancel\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"Cancel posting\"\nmsgstr \"\"\n\n#. placeholder {0}: getFileTypeLabel(newFileType)\n#. placeholder {1}: getFileTypeLabel(existingFileType)\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-uploader.tsx\nmsgid \"Cannot add {0} files to a submission containing {1} files. All files in a submission must be of the same type.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-card.tsx\nmsgid \"Cannot delete the only file\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Category\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Channel\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Characters\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Charge Patrons\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\nmsgid \"Check login status\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Check that handle and password are valid or try using email instead of handle.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Check your telegram messages\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\nmsgid \"Checking...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-content.tsx\nmsgid \"Choose an account from the list on the left\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgctxt \"template.picker-modal-header\"\nmsgid \"Choose Templates\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Click here to authorize PostyBirb\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Click the button below to get your authorization URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Click the link above, authorize the app, and copy the PIN code below\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Client\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Cloud Password\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Code\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Code invalid, ensure it matches the code sent from telegram\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Code sent\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-card.tsx\nmsgid \"Collapse\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Collections\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Comment permissions\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Commercial use\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Compact view\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Complete Authorization\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Complete Login\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Configuration Error\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Configure API Keys\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/confirm-action-modal/confirm-action-modal.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Confirm\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Connected successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\nmsgid \"Connection error\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Connection Failed\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Contains content\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Content Blur\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/content-warning-shortcut.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Content warning\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Continue from where it left off\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/copy-to-clipboard/copy-to-clipboard.tsx\nmsgid \"Copied\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Copied to clipboard\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/copy-to-clipboard/copy-to-clipboard.tsx\n#: apps/postybirb-ui/src/remake/components/shared/copy-to-clipboard/copy-to-clipboard.tsx\nmsgid \"Copy\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Create\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Create a File or Message submission to start posting\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Create a file watcher to automatically import new files from a folder\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Create an app password in Bluesky Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Create Message\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Create new file watcher\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Create new submission\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Create Submission\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgctxt \"telegram.app-id-help\"\nmsgid \"Create telegram app to retrieve api_id and api_hash\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Created successfully\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Creative Commons\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Creative Commons license\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"CRON Expression\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Crop from primary\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Ctrl+Enter to apply\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Custom\"\nmsgstr \"\"\n\n#. placeholder {0}: website.displayName\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/custom-login-placeholder.tsx\nmsgid \"Custom login for {0}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Custom PDS or AppView\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Custom Shortcuts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/spellchecker-settings-section.tsx\nmsgid \"Custom words\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Daily\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/appearance-settings-section.tsx\nmsgid \"Dark\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/theme-picker/theme-picker.tsx\n#: apps/postybirb-ui/src/remake/components/theme-picker/theme-picker.tsx\nmsgid \"Dark Mode\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Dashboard\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Date {currentDate} is after maximum allowed date {maxDate}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Date {currentDate} is before minimum allowed date {minDate}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Date {currentDate} is outside allowed range ({minDate} - {maxDate})\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Date and Time\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Date is in the past\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Day of Month\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Days\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"Decline and Exit\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/radio-field.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/default-shortcut.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Default\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/defaults-form/defaults-form.tsx\nmsgid \"Defaults\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\nmsgid \"Defaults saved successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-preview.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-section.tsx\nmsgid \"Delete\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\nmsgid \"Delete all existing website options and use only those specified in the source.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-card.tsx\nmsgid \"Delete file\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/archived-submission-card.tsx\nmsgid \"Delete permanently\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-section.tsx\nmsgid \"Delete Submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Deleted successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: apps/postybirb-ui/src/remake/components/website-components/e621/e621-tag-search-provider.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Description\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Description length is greater then maximum ({currentLength} / {maxLength})\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Description length is lower then minimum ({currentLength} / {minLength})\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/description-settings-section.tsx\nmsgid \"Description Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-select.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-selection-form.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\nmsgid \"Deselect all\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"Desktop Notifications\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Detailed view\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Dimensions\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Disable comments\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Discard all progress from the previous attempt and start as if this were the first time posting.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Discord\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Display resolution\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Double-click to toggle crop/move mode\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Download Update\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Downloading...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/reorderable-submission-list/reorderable-submission-list.tsx\nmsgid \"Drag or use arrow keys to reorder\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/submission-list.tsx\nmsgid \"Drag submissions onto the calendar to schedule them\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-manager.tsx\nmsgid \"Drag to reorder\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-dropzone.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-list.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-uploader.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Drop files here or click to browse\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Drug / Alcohol\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\nmsgid \"Duplicate\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Duplicated successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/e621/e621-login-view.tsx\nmsgid \"e621 requires API credentials for posting. You'll need to generate an API key from your account settings to authenticate PostyBirb.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-dropzone.tsx\nmsgid \"Each file becomes a separate submission\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Early Access\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\nmsgid \"Edit\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-preview.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Edit image\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-card.tsx\nmsgid \"Edit metadata\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-preview.tsx\nmsgid \"Edit title\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"Enable desktop notifications\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\nmsgid \"Enable global tag autocomplete from sites like e621.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/spellchecker-settings-section.tsx\nmsgid \"Enabled\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"English\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Ensure the IP is correct\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Enter Instance URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Enter the host URL and password to connect to a remote PostyBirb instance.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Enter the PIN from Twitter\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Enter the URL of your Mastodon, Pleroma, or Pixelfed instance (e.g., \\\"mastodon.social\\\" or \\\"pixelfed.social\\\")\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Enter title...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Enter your Twitter app credentials\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-option-row.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/selected-accounts-forms.tsx\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Error\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Error while authenticating Telegram\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\nmsgid \"errors\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Errors\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"Expand\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Explicit text\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/rating-input/rating-input.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Extreme\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"failed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Failed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"Failed Posts Detected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Failed to apply template options\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-section.tsx\nmsgid \"Failed to clear account data\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Failed to complete authorization\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Failed to connect to remote host\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\nmsgid \"Failed to connect to the server\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to create\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\nmsgid \"Failed to create template\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to delete\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-card.tsx\nmsgid \"Failed to delete file\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to duplicate\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Failed to generate authorization URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Failed to get user warnings. You can check your account manually\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Failed to load primary file for cropping.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to post submission\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Failed to register with instance\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Failed to replace file\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to restore\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to save\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/form-fields-context.tsx\nmsgid \"Failed to save changes\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-alt-text-editor.tsx\nmsgid \"Failed to save fallback text\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Failed to save metadata\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Failed to schedule submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Failed to send code to begin authentication\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Failed to store API keys\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to update\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\nmsgid \"Failed to update template name\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to upload files\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Failed to validate submission: {message}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Failed to validate tags. Please check them manually\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-alt-text-editor.tsx\nmsgid \"Fallback Text\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Feature\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Fediverse Authentication\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Female\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\nmsgid \"File\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-content.tsx\nmsgid \"File Submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"File types do not match. Please upload a file of the same type.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"File Watcher Failure\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"File Watcher Success\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"File Watchers\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"File will be modified to support website requirements\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-list.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-manager.tsx\nmsgid \"Files\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Files uploaded successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Flip\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Flip horizontal\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Flip vertical\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Folder\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Folder Contains Files\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Folder Path\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/specialized-error-boundaries.tsx\nmsgid \"Form Error\"\nmsgstr \"\"\n\n#. Bluesky login form\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Format should be <0>handle.bsky.social</0> or <1>domain.ext</1> for custom domains\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Format: minute hour day month weekday\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Found in your Twitter app's Keys and tokens section\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Fr\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Frequency\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Friends only\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Furry\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Gender\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/rating-input/rating-input.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"General\"\nmsgstr \"\"\n\n#. E621 login form\n#: apps/postybirb-ui/src/remake/components/website-login-views/e621/e621-login-view.tsx\nmsgid \"Generate an API key from your <0>account settings</0>\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Generate authorization link\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"German\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Get Authorization\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Get authorization code\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Get Authorization URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Get started by adding your accounts and creating your first submission.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\nmsgid \"Global tag search provider\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Go back\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\nmsgid \"Go forward\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Go to Accounts to add and log into your websites\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Gore\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Has sexual content\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Has validation errors\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Has validation warnings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/components/insert-media-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/resizable-image.tsx\nmsgid \"Height\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-selection-form.tsx\nmsgid \"Hide unselected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\nmsgid \"Higher boost levels allow larger file uploads\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\nmsgid \"Hold to delete\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\nmsgid \"Hold to delete {count} item(s)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"Hold to delete permanently\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"Hold to post\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Home\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Host\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Host URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Hours\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\nmsgctxt \"discord.webhook-help\"\nmsgid \"How to create a webhook\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"I have read, understand, and accept the disclaimer.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"If you are using pds other then bsky.social\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"If you do not agree with these terms, you must decline and exit the application.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/tag-field.tsx\nmsgctxt \"override-default\"\nmsgid \"Ignore default tags\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Image\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/resizable-image.tsx\nmsgid \"Image Size\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-uploader.tsx\nmsgid \"Images, videos, audio, and text files up to 100MB\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\nmsgid \"Import\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Import Action\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\nmsgid \"Import completed successfully!\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\nmsgid \"Import encountered errors:\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\nmsgid \"Import from PostyBirb Plus\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\nmsgid \"Import your data from PostyBirb Plus. Select which types of data you want to import.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\nmsgid \"Inactive\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Info\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/components/insert-media-modal.tsx\nmsgid \"Insert\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/components/insert-media-modal.tsx\nmsgid \"Insert Media\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-field.tsx\nmsgid \"Insert tags at end\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-field.tsx\nmsgid \"Insert title at start\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Instance URL\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Intended as advertisement\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Invalid CRON expression\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Invalid date format: {value}. Expected ISO date string.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Invalid post URL to reply to.\"\nmsgstr \"\"\n\n#. Bluesky login form\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Invalid url. Format should be <0>https://bsky.app/</0>\"\nmsgstr \"\"\n\n#. Bluesky login form\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Invalid url. Format should be <0>https://bsky.social/</0>\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"It is recommended to add at least 10 general tags <0>( {generalTags} / 10 )</0>. See <1>tagging checklist</1>\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Keep this secret and never share it publicly\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Ko-fi\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"LAN IP\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/language-picker/language-picker.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Language\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/spellchecker-settings-section.tsx\nmsgid \"Languages to check\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Later\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-field.tsx\nmsgid \"Legacy Shortcuts Detected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"Legal Notice & Disclaimer\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/appearance-settings-section.tsx\nmsgid \"Light\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/theme-picker/theme-picker.tsx\n#: apps/postybirb-ui/src/remake/components/theme-picker/theme-picker.tsx\nmsgid \"Light Mode\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"Lithuanian\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Log in to different instance\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"logged in\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/account-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Logged in\"\nmsgstr \"\"\n\n#. placeholder {0}: username ? ` as ${username}` : ''\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\nmsgid \"Logged in{0}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Login\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\nmsgid \"Login failed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Make sure that the default rating matches Bluesky Label Rating.\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Male\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"Manage accounts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Mark as read\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Mark as unread\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Mark as work in progress\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Marked as read\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-content.tsx\nmsgid \"Mass Edit\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/rating-input/rating-input.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Mature\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Mature content\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Media\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\nmsgid \"Merge (recommended)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\nmsgid \"Merge Mode\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\nmsgid \"Message\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-content.tsx\nmsgid \"Message Submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Minutes\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Mo\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Modification\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Modified\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Monthly\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/header/submission-edit-card-header.tsx\nmsgid \"Multi Edit\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\nmsgid \"Name\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"Name is required\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"need attention\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\nmsgid \"New\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"New account\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"New Message\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\nmsgid \"New template name...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"New version:\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"No AI\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"No changes\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"No custom dimensions set\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/defaults-form/defaults-form.tsx\nmsgid \"No default options available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-list.tsx\nmsgid \"No files added yet\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"No folder selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/empty-state/empty-state.tsx\nmsgid \"No notifications\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/tree-select.tsx\nmsgid \"No options available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"No options selected to apply\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-preview-panel.tsx\nmsgid \"No preview available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/empty-state/empty-state.tsx\nmsgid \"No records yet\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/empty-state/empty-state.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-select.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/tree-select.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker.tsx\nmsgid \"No results found\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"No schedule configured\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/reorderable-submission-list/reorderable-submission-list.tsx\nmsgid \"No submissions selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\nmsgid \"No validation issues\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"No website posts found\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"No websites\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-visibility-picker.tsx\nmsgid \"No websites available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"No websites selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-components/e621/e621-tag-search-provider.tsx\nmsgid \"No wiki page available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\nmsgid \"None\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\nmsgid \"Not all tag providers support this feature.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/account-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-option-row.tsx\n#: apps/postybirb-ui/src/remake/components/shared/account-picker/account-picker.tsx\nmsgid \"Not logged in\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Not scheduled\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/empty-state/empty-state.tsx\nmsgid \"Nothing selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Notifications\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Notify followers\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Nudity\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Offer hi-res download (gold only)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Offline\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Once\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Online\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Only set scheduled date (don't activate)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Open CRON helper\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Open full calendar\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\nmsgid \"Open Inkbunny Account Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/app-settings-section.tsx\nmsgid \"Open PostyBirb on computer startup\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Open Twitter Authorization Page\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Optional template to apply to imported files\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Options\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Original work\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Other\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\nmsgid \"Overwrite overlapping website options only. Keeps existing website options that are not specified in the source.\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Parent ID\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\nmsgid \"Password\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Password is invalid\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-dropzone.tsx\nmsgid \"Paste from clipboard\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-dropzone.tsx\nmsgid \"Paste image from clipboard (Ctrl+V)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\nmsgid \"Pause\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/queue-control-card.tsx\nmsgid \"Pause Posting\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\nmsgid \"Paused\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"PDS (Personal Data Server)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Pending\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Per-Account Dimensions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Phone Number\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgctxt \"telegram.phone-number-help\"\nmsgid \"Phone number must be in international format\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"PIN / Verifier Code\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Pixel perfect display\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\nmsgid \"Please check your credentials and try again\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"Portuguese (Brazil)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Post\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Post Data (JSON)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"Post Failure\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Post Files\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"Post Submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"Post Success\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\nmsgid \"PostyBirb uses Discord webhooks to post content to your server. Make sure you have the necessary permissions to create webhooks in your target channel.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-preview-panel.tsx\nmsgid \"Preview\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Primary\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Privacy\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Private\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Proceed to Authorization\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Profanity\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Public viewers\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Queued\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Quick tips to get started:\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Racism\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Rating\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Read\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Ready\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-button.tsx\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Ready to Install\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Ready to post\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/recent-activity-panel.tsx\nmsgid \"Recent Activity\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Recurring\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Recurring schedule (active)\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Reference\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\nmsgid \"Refresh page\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Register Instance\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\nmsgid \"Reload page\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\nmsgid \"Remote\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Remote Connection\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Remote host URL is not configured\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Remote password is not configured\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\nmsgid \"Remove\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/resizable-image.tsx\nmsgid \"Remove image\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\nmsgid \"Rename\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\nmsgid \"Replace All\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgctxt \"import.override-description\"\nmsgid \"Replace description\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Replace file\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Replace the current description with the selected template's description\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Replace the current title with the selected template's title\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgctxt \"import.override-title\"\nmsgid \"Replace title\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Reply to post URL\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Request critique\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Required\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Required if 2FA is enabled.\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Required tag\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Requires at least {minLength} {minLength, plural, one {tag} other {tags}}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Requires at least {minSelected} {minSelected, plural, one {option selected} other {options selected}} ({currentSelected} / {minSelected})\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Reset\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Reset account data\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Reset account?\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Reset Remote Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Restart Now\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/archived-submission-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/archived-submission-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"Restore\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Restored successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Resume Failed Posting\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/queue-control-card.tsx\nmsgid \"Resume Posting\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Retry all failed or unattempted websites\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Retry posting to all websites that failed or were not attempted. All files will be re-uploaded.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Rotate\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Rotate left 90°\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Rotate right 90°\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Running\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"Russian\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Sa\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/e621/e621-login-view.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\nmsgid \"Save\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Save API Keys\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\nmsgid \"Save as default\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Save changes\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\nmsgid \"Save Selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Save to file\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/save-to-many-action.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/save-to-many-action.tsx\nmsgid \"Save to submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Schedule\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/upcoming-posts-panel.tsx\nmsgid \"Schedule a submission to see it here\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Schedule configured (inactive)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Schedule posts to automatically publish at specific times\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Schedule Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Schedule Submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Schedule updated\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Scheduled\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Scheduled (active)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Scroll to zoom\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/submission-list.tsx\n#: apps/postybirb-ui/src/remake/components/shared/search-input.tsx\nmsgid \"Search...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-content.tsx\nmsgid \"Select a submission from the list to view details\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker.tsx\nmsgid \"Select a template\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-content.tsx\nmsgid \"Select a template from the list to view or edit it\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-select.tsx\nmsgid \"Select accounts...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-select.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-selection-form.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\nmsgid \"Select all\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-content.tsx\nmsgid \"Select an account to log in\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\nmsgid \"Select fields to save as defaults for this account.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\nmsgid \"Select items to delete\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/language-picker/language-picker.tsx\nmsgid \"Select language\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker.tsx\nmsgid \"Select submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Select templates or submissions to import\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\nmsgid \"Select websites to apply this shortcut to\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\nmsgid \"Select websites to apply usernames to\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Select which template to use for each account\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/tree-select.tsx\nmsgid \"Select...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Selected option is invalid or missing\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Send Code\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Send Messages\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Send to scraps\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Sensitive content\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\nmsgid \"Server Boost Level\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Server unreachable\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Set the interval between each submission.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Settings\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Sexual Content\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Share on feed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Share on Feed is required to support posting multiple files.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Share your LAN IP and password with clients to allow them to connect.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"shortcut\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"Shortcut name\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\nmsgid \"Show wiki page related to tag on hover\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-visibility-picker.tsx\nmsgid \"Show/Hide Websites\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Silent\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Sketch\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Skip Accounts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"Skip websites that posted successfully and only attempt failed or unattempted ones.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Skip websites that posted successfully and only attempt failed or unattempted ones. Continues from the last successful batch of files.\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Software\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"Some accounts need to be logged in before you can post to them.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-uploader.tsx\nmsgid \"Some files were rejected. Check file type and size (max 100MB).\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"Some submissions have failed posting attempts. Choose how to handle them:\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\nmsgid \"Something went wrong\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Source URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Source URLs\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"Spanish\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Species\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Specify your Fediverse instance\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\nmsgid \"Spellchecker\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/spellchecker-settings-section.tsx\nmsgid \"Spellchecker advanced configuration is controlled by system on MacOS.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/spellchecker-settings-section.tsx\nmsgid \"Spellchecker Settings\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Spoiler\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Spoiler Text\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Spoilers\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Start completely fresh\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Start Date\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Start Over\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/app-settings-section.tsx\nmsgid \"Startup Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Status\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Su\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"Submission has validation errors\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Submission Order\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\nmsgid \"Submission unscheduled\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Submission will be posted automatically\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Submission will be split into {expectedBatchesToCreate} different submissions with {maxBatchSize} {maxBatchSize, plural, one {file each} other {files each}}.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-section.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"success\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Success\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Successful\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Successfully connected to the remote host\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Successfully logged in as {loggedInAs}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Successfully logged in as @{loggedInAs}\"\nmsgstr \"\"\n\n#. placeholder {0}: orderedSubmissions.length\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Successfully scheduled ${0} submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Supports either a set of images, a single video, or a single GIF.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tag {tag} has {postCount} post(s). Tag may be invalid or low use\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tag <0>{tag}</0> does not exist yet or is invalid.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tag <0>{tag}</0> does not exist yet or is invalid. If you want to create a new tag, make a post with it, then go <1>here</1>, press edit and select tag category\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tag <0>{tag}</0> is invalid.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/tag-converter-drawer/tag-converter-drawer.tsx\nmsgid \"tag converter\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-converter-drawer/tag-converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Tag Converters\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Tag Groups\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tag limit reached ({currentLength} / {maxLength})\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Tag permissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/simple-tag-input/simple-tag-input.tsx\nmsgid \"tags\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/tags-shortcut.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Tags\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tags longer than {maxLength} characters will be skipped\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\nmsgid \"Tags Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tags should not start with #\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"Tamil\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Teaser\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Telegram requires API credentials and phone number authentication. You'll need to create a Telegram app and verify your phone number.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker.tsx\nmsgid \"Template\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\nmsgid \"Template created\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-content.tsx\nmsgid \"Template Editor\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-content.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Templates\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Test Connection\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Text\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Th\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"The folder \\\"{folderName}\\\" contains {fileCount} files.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/specialized-error-boundaries.tsx\nmsgid \"The form encountered an error and couldn't be displayed properly.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"The last posting attempt failed. How would you like to proceed?\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"The update has been downloaded and is ready to install. Click \\\"Restart Now\\\" to apply the update.\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Theme\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\nmsgid \"These defaults apply to future submissions.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\nmsgid \"This component encountered an error and couldn't render properly.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\nmsgid \"This is a forum channel\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"This post record has no events or is still processing. Check the JSON data below for more details.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-content.tsx\nmsgid \"This website has an unsupported login configuration\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/custom-login-placeholder.tsx\nmsgid \"This website uses a custom login form. Support coming soon.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"This will clear all account data and cookies.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Thumbnail\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Thumbnail must be an image file.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Time\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Time Taken\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Tips\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/title-shortcut.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Title\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Title is too long and will be truncated\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Title is too short\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-visibility-picker.tsx\nmsgid \"Toggle website visibility\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Total\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Translation {id} not found\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\n#: apps/postybirb-ui/src/remake/components/error-boundary/specialized-error-boundaries.tsx\nmsgid \"Try again\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Tu\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Twitter / X Authentication\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/description-editor.tsx\nmsgid \"Type / for commands or @, ` or '{' for shortcuts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Unknown\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Unknown choice\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-content.tsx\nmsgid \"Unknown login type\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Unread\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\nmsgid \"Unschedule\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/submission-list.tsx\nmsgid \"Unscheduled Submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Unsupported file type {fileExtension}. Please provide fallback text.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Unsupported file type {mimeType}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Unsupported rating: {ratingLabel}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Unsupported submission type: {fileTypeString}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/upcoming-posts-panel.tsx\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/header/submission-edit-card-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\n#: apps/postybirb-ui/src/remake/components/shared/reorderable-submission-list/reorderable-submission-list.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Untitled\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/submission-list.tsx\nmsgid \"Untitled Submission\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Untitled Template\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/upcoming-posts-panel.tsx\nmsgid \"Upcoming Posts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Update API Keys\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-button.tsx\n#: apps/postybirb-ui/src/remake/components/update-button/update-button.tsx\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Update available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Update Error\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Update Failed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-button.tsx\nmsgid \"Update ready\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Updated successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-submission-modal.tsx\nmsgid \"Upload\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Upload thumbnail\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/components/insert-media-modal.tsx\nmsgid \"URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-field.tsx\nmsgid \"Use custom description\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Use templates to save time on repeated posts\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Use thumbnail as cover art\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Use title\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Use your Bluesky handle or email along with an app password. App passwords are more secure than your main password and can be revoked at any time.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Used for other sites (like e621) as source url base. Independent from custom pds.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/user-converter-drawer/user-converter-drawer.tsx\nmsgid \"user converter\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/user-converter-drawer/user-converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"User Converters\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/e621/e621-login-view.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\nmsgid \"Username\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Username or Email\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\nmsgid \"Validation Issues\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Video\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"View\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/archived-submission-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/archived-submission-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"View history\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"View permissions\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Violence\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Visibility\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-option-row.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/selected-accounts-forms.tsx\nmsgid \"Warning\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\nmsgid \"warnings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Warnings\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Watermark\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"We\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\nmsgid \"We encountered an unexpected error. Please try refreshing the page.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Website\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/selected-accounts-forms.tsx\nmsgid \"Website Options\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-select.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-selection-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/account-picker/account-picker.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\nmsgid \"Websites\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Weekly\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Welcome to PostyBirb!\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"What to do when new files are detected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"What's New\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Who can reply\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/components/insert-media-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/resizable-image.tsx\nmsgid \"Width\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"You agree to comply with all applicable laws, terms of service, and policies of any third party platforms you connect to or interact with through this application. Do not use this software to infringe intellectual property rights, violate privacy, or circumvent platform rules.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"You have existing API keys. You can modify them above or proceed to the next step.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"You have recent {negativeOrNeutral} feedback: {feedback}, you can view it<0>here</0>\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\nmsgid \"You must first enable API access in your account settings under \\\"API (External Scripting)\\\" before you can authenticate with PostyBirb.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"You need to create a Twitter app to get API keys. <0>Visit Twitter Developer Portal</0>\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"You'll get this PIN after authorizing on Twitter\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-field.tsx\nmsgid \"Your description contains legacy shortcut syntax. These are no longer supported. Please use the new shortcut menu (type @, &lbrace; or `) to insert shortcuts.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Your handle (e.g. <0>yourname.bsky.social</0>) or custom domain (e.g. <1>username.ext</1>)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\nmsgid \"Your password will not be stored\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Zoom\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Zoom in\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Zoom out\"\nmsgstr \"\"\n"
  },
  {
    "path": "lang/es.po",
    "content": "msgid \"\"\nmsgstr \"\"\n\"Project-Id-Version: \\n\"\n\"Report-Msgid-Bugs-To: \\n\"\n\"POT-Creation-Date: \\n\"\n\"PO-Revision-Date: \\n\"\n\"Last-Translator: \\n\"\n\"Language: \\n\"\n\"Language-Team: \\n\"\n\"Content-Type: \\n\"\n\"Content-Transfer-Encoding: \\n\"\n\"Plural-Forms: \\n\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"({fileSizeString}) is too large (max {maxFileSizeString}) and an attempt will be made to reduce size when posting\"\nmsgstr \"({fileSizeString}) es demasiado grande (max. {maxFileSizeString}) y se intentará reducir el tamaño al publicarlo\"\n\n#. placeholder {0}: tag.post_count\n#: apps/postybirb-ui/src/remake/components/website-components/e621/e621-tag-search-provider.tsx\nmsgid \"{0, plural, one {# post} other {# posts}}\"\nmsgstr \"\"\n\n#. placeholder {0}: selectedOptions.length\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/tree-select.tsx\nmsgid \"{0} selected\"\nmsgstr \"\"\n\n#. placeholder {0}: value.length\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker.tsx\nmsgid \"{0} submissions selected\"\nmsgstr \"\"\n\n#. placeholder {0}: account?.websiteDisplayName\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\nmsgid \"{0}: Login successful\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"{count} items deleted\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"{count} selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-visibility-picker.tsx\nmsgid \"{hiddenCount} hidden\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"{selectedCount} of {totalCount} selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"{validCount} of {totalSelectedCount} selected submission(s) are ready to post. Submissions without websites or with validation errors will be skipped.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"{validCount} submission(s) will be posted in the order shown below.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"<0>Tip:</0> If your handle doesn't work, try using the email linked to your account\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"2FA enabled, password required. It won't be stored.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"A shortcut with this name already exists\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/tag-converter-drawer/tag-converter-drawer.tsx\nmsgid \"A tag converter with this tag already exists\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/user-converter-drawer/user-converter-drawer.tsx\nmsgid \"A user converter with this username already exists\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"Accept and Continue\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Access Tiers\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/selected-accounts-forms.tsx\nmsgid \"account\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Account\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-section.tsx\nmsgid \"Account data cleared\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Account name\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Account Options\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"Account Status\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/selected-accounts-forms.tsx\nmsgid \"accounts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/account-section-header.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Accounts\"\nmsgstr \"Cuentas\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\nmsgid \"Activate\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Activate schedule\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\nmsgid \"Active\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/recent-activity-panel.tsx\nmsgid \"Activity will appear here as you use the app\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Add account\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"Add an account to start posting\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-submission-modal.tsx\nmsgid \"Add File Submission\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"Add shortcut\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\nmsgid \"Add tags...\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Add to Portfolio\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\nmsgid \"Add website\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-preview-panel.tsx\nmsgid \"Add website accounts to preview the description output.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Adjust dimensions while maintaining aspect ratio\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/rating-input/rating-input.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Adult\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Adult themes\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"After authorizing, copy the code and paste it below\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"AI generated\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/account-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\nmsgid \"All\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"All files are marked ignored.\"\nmsgstr \"Todos los archivos están marcados como ignorados.\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"All healthy\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\nmsgid \"All submissions are ready to post\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\nmsgid \"All Websites\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow comments\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow commercial use\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow free download\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow modifications of your work\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow other users to edit tags\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/description-settings-section.tsx\nmsgid \"Allow PostyBirb to self-advertise at the end of descriptions\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow reblogging\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Alt Text\"\nmsgstr \"Texto alternativo\"\n\n#. Bluesky login form\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"An <0>app password</0> (not your main password)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"An error occurred\"\nmsgstr \"\"\n\n#. placeholder {0}: dtext.length - text.length\n#: apps/postybirb-ui/src/remake/components/website-components/e621/e621-dtext-renderer.tsx\nmsgid \"and {0} more\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"and {remainingCount} more\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"API Key (Consumer Key)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"API keys saved successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"API Secret (Consumer Secret)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\nmsgid \"App\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"App API Hash\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"App API ID\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/app-settings-section.tsx\nmsgid \"App Folder\"\nmsgstr \"Carpeta de aplicación\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"App Password\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"App registered successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/app-settings-section.tsx\nmsgid \"App Server Port\"\nmsgstr \"Puerto del Servidor de la Aplicación\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"App view URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\nmsgid \"Appearance\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Applied template options to {successCount} submissions\"\nmsgstr \"\"\n\n#. placeholder {0}: submissionIds.length\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/save-to-many-action.tsx\nmsgid \"Applied to {0} submission(s)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Applied to {successCount} submissions, {failedCount} failed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Apply\"\nmsgstr \"Aplicar\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/apply-template-action.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Apply template\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\nmsgid \"Archive\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/header/submission-edit-card-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-section.tsx\nmsgid \"Archived\"\nmsgstr \"\"\n\n#. placeholder {0}: selectedIds.length\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-section.tsx\nmsgid \"Are you sure you want to delete {0} submission(s)? This action cannot be undone.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Are you sure you want to reset all remote connection settings to their defaults? This will clear the saved host URL and password.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Are you sure you want to watch this folder?\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Artist name\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Artist URL (Off-Site only)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Aspect Ratio\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Attach Images as Attachments\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Audience\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Audio\"\nmsgstr \"Audio\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Authenticate\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Authorization Code\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Authorization URL generated\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Authorize\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Authorized viewers\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/appearance-settings-section.tsx\nmsgid \"Auto\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Automatically import new files from specified folders\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Back\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Block guests\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Blog\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Bluesky automatically converts GIFs to videos.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/app-settings-section.tsx\nmsgid \"Browse\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Builder\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"By using this application, you acknowledge that you are solely responsible for how you use it and for any content you create, upload, distribute, or interact with. The authors and maintainers provide this software as is without warranties of any kind and are not liable for any damages or losses resulting from its use.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/confirm-action-modal/confirm-action-modal.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/components/insert-media-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Cancel\"\nmsgstr \"Cancelar\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"Cancel posting\"\nmsgstr \"\"\n\n#. placeholder {0}: getFileTypeLabel(newFileType)\n#. placeholder {1}: getFileTypeLabel(existingFileType)\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-uploader.tsx\nmsgid \"Cannot add {0} files to a submission containing {1} files. All files in a submission must be of the same type.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-card.tsx\nmsgid \"Cannot delete the only file\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Category\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Channel\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Characters\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Charge Patrons\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\nmsgid \"Check login status\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Check that handle and password are valid or try using email instead of handle.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Check your telegram messages\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\nmsgid \"Checking...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-content.tsx\nmsgid \"Choose an account from the list on the left\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgctxt \"template.picker-modal-header\"\nmsgid \"Choose Templates\"\nmsgstr \"Elegir plantilla\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Click here to authorize PostyBirb\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Click the button below to get your authorization URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Click the link above, authorize the app, and copy the PIN code below\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Client\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Cloud Password\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Code\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Code invalid, ensure it matches the code sent from telegram\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Code sent\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-card.tsx\nmsgid \"Collapse\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Collections\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Comment permissions\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Commercial use\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Compact view\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Complete Authorization\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Complete Login\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Configuration Error\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Configure API Keys\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/confirm-action-modal/confirm-action-modal.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Confirm\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Connected successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\nmsgid \"Connection error\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Connection Failed\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Contains content\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Content Blur\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/content-warning-shortcut.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Content warning\"\nmsgstr \"Advertencia de contenido\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Continue from where it left off\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/copy-to-clipboard/copy-to-clipboard.tsx\nmsgid \"Copied\"\nmsgstr \"Copiado\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Copied to clipboard\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/copy-to-clipboard/copy-to-clipboard.tsx\n#: apps/postybirb-ui/src/remake/components/shared/copy-to-clipboard/copy-to-clipboard.tsx\nmsgid \"Copy\"\nmsgstr \"Copiar\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Create\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Create a File or Message submission to start posting\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Create a file watcher to automatically import new files from a folder\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Create an app password in Bluesky Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Create Message\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Create new file watcher\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Create new submission\"\nmsgstr \"Crear nueva publicación\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Create Submission\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgctxt \"telegram.app-id-help\"\nmsgid \"Create telegram app to retrieve api_id and api_hash\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Created successfully\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Creative Commons\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Creative Commons license\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"CRON Expression\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Crop from primary\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Ctrl+Enter to apply\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Custom\"\nmsgstr \"\"\n\n#. placeholder {0}: website.displayName\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/custom-login-placeholder.tsx\nmsgid \"Custom login for {0}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Custom PDS or AppView\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Custom Shortcuts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/spellchecker-settings-section.tsx\nmsgid \"Custom words\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Daily\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/appearance-settings-section.tsx\nmsgid \"Dark\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/theme-picker/theme-picker.tsx\n#: apps/postybirb-ui/src/remake/components/theme-picker/theme-picker.tsx\nmsgid \"Dark Mode\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Dashboard\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Date {currentDate} is after maximum allowed date {maxDate}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Date {currentDate} is before minimum allowed date {minDate}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Date {currentDate} is outside allowed range ({minDate} - {maxDate})\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Date and Time\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Date is in the past\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Day of Month\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Days\"\nmsgstr \"Días\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"Decline and Exit\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/radio-field.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/default-shortcut.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Default\"\nmsgstr \"Por defecto\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/defaults-form/defaults-form.tsx\nmsgid \"Defaults\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\nmsgid \"Defaults saved successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-preview.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-section.tsx\nmsgid \"Delete\"\nmsgstr \"Borrar\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\nmsgid \"Delete all existing website options and use only those specified in the source.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-card.tsx\nmsgid \"Delete file\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/archived-submission-card.tsx\nmsgid \"Delete permanently\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-section.tsx\nmsgid \"Delete Submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Deleted successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: apps/postybirb-ui/src/remake/components/website-components/e621/e621-tag-search-provider.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Description\"\nmsgstr \"Descripción\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Description length is greater then maximum ({currentLength} / {maxLength})\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Description length is lower then minimum ({currentLength} / {minLength})\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/description-settings-section.tsx\nmsgid \"Description Settings\"\nmsgstr \"Ajustes de la descripción\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-select.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-selection-form.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\nmsgid \"Deselect all\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"Desktop Notifications\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Detailed view\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Dimensions\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Disable comments\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Discard all progress from the previous attempt and start as if this were the first time posting.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Discord\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Display resolution\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Double-click to toggle crop/move mode\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Download Update\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Downloading...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/reorderable-submission-list/reorderable-submission-list.tsx\nmsgid \"Drag or use arrow keys to reorder\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/submission-list.tsx\nmsgid \"Drag submissions onto the calendar to schedule them\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-manager.tsx\nmsgid \"Drag to reorder\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-dropzone.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-list.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-uploader.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Drop files here or click to browse\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Drug / Alcohol\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\nmsgid \"Duplicate\"\nmsgstr \"Duplicar\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Duplicated successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/e621/e621-login-view.tsx\nmsgid \"e621 requires API credentials for posting. You'll need to generate an API key from your account settings to authenticate PostyBirb.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-dropzone.tsx\nmsgid \"Each file becomes a separate submission\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Early Access\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\nmsgid \"Edit\"\nmsgstr \"Editar\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-preview.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Edit image\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-card.tsx\nmsgid \"Edit metadata\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-preview.tsx\nmsgid \"Edit title\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"Enable desktop notifications\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\nmsgid \"Enable global tag autocomplete from sites like e621.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/spellchecker-settings-section.tsx\nmsgid \"Enabled\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"English\"\nmsgstr \"Inglés\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Ensure the IP is correct\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Enter Instance URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Enter the host URL and password to connect to a remote PostyBirb instance.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Enter the PIN from Twitter\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Enter the URL of your Mastodon, Pleroma, or Pixelfed instance (e.g., \\\"mastodon.social\\\" or \\\"pixelfed.social\\\")\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Enter title...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Enter your Twitter app credentials\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-option-row.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/selected-accounts-forms.tsx\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Error\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Error while authenticating Telegram\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\nmsgid \"errors\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Errors\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"Expand\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Explicit text\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/rating-input/rating-input.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Extreme\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"failed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Failed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"Failed Posts Detected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Failed to apply template options\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-section.tsx\nmsgid \"Failed to clear account data\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Failed to complete authorization\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Failed to connect to remote host\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\nmsgid \"Failed to connect to the server\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to create\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\nmsgid \"Failed to create template\"\nmsgstr \"No se pudo crear la plantilla\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to delete\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-card.tsx\nmsgid \"Failed to delete file\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to duplicate\"\nmsgstr \"No se pudo duplicar\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Failed to generate authorization URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Failed to get user warnings. You can check your account manually\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Failed to load primary file for cropping.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to post submission\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Failed to register with instance\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Failed to replace file\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to restore\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to save\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/form-fields-context.tsx\nmsgid \"Failed to save changes\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-alt-text-editor.tsx\nmsgid \"Failed to save fallback text\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Failed to save metadata\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Failed to schedule submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Failed to send code to begin authentication\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Failed to store API keys\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to update\"\nmsgstr \"Error al actualizar\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\nmsgid \"Failed to update template name\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to upload files\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Failed to validate submission: {message}\"\nmsgstr \"No se pudo validar el envío: {message}\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Failed to validate tags. Please check them manually\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-alt-text-editor.tsx\nmsgid \"Fallback Text\"\nmsgstr \"Texto de respaldo\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Feature\"\nmsgstr \"Característica\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Fediverse Authentication\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Female\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\nmsgid \"File\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-content.tsx\nmsgid \"File Submissions\"\nmsgstr \"Cargas de Archivos\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"File types do not match. Please upload a file of the same type.\"\nmsgstr \"Los tipos de archivo no coinciden. Por favor, cargue un archivo del mismo tipo.\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"File Watcher Failure\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"File Watcher Success\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"File Watchers\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"File will be modified to support website requirements\"\nmsgstr \"El archivo se modificará para cumplir los requisitos de la página web\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-list.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-manager.tsx\nmsgid \"Files\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Files uploaded successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Flip\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Flip horizontal\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Flip vertical\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Folder\"\nmsgstr \"Carpeta\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Folder Contains Files\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Folder Path\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/specialized-error-boundaries.tsx\nmsgid \"Form Error\"\nmsgstr \"\"\n\n#. Bluesky login form\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Format should be <0>handle.bsky.social</0> or <1>domain.ext</1> for custom domains\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Format: minute hour day month weekday\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Found in your Twitter app's Keys and tokens section\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Fr\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Frequency\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Friends only\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Furry\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Gender\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/rating-input/rating-input.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"General\"\nmsgstr \"\"\n\n#. E621 login form\n#: apps/postybirb-ui/src/remake/components/website-login-views/e621/e621-login-view.tsx\nmsgid \"Generate an API key from your <0>account settings</0>\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Generate authorization link\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"German\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Get Authorization\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Get authorization code\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Get Authorization URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Get started by adding your accounts and creating your first submission.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\nmsgid \"Global tag search provider\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Go back\"\nmsgstr \"Regresar\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\nmsgid \"Go forward\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Go to Accounts to add and log into your websites\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Gore\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Has sexual content\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Has validation errors\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Has validation warnings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/components/insert-media-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/resizable-image.tsx\nmsgid \"Height\"\nmsgstr \"Altura\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-selection-form.tsx\nmsgid \"Hide unselected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\nmsgid \"Higher boost levels allow larger file uploads\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\nmsgid \"Hold to delete\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\nmsgid \"Hold to delete {count} item(s)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"Hold to delete permanently\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"Hold to post\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Home\"\nmsgstr \"Página principal\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Host\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Host URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Hours\"\nmsgstr \"Horas\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\nmsgctxt \"discord.webhook-help\"\nmsgid \"How to create a webhook\"\nmsgstr \"Cómo crear un webhook\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"I have read, understand, and accept the disclaimer.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"If you are using pds other then bsky.social\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"If you do not agree with these terms, you must decline and exit the application.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/tag-field.tsx\nmsgctxt \"override-default\"\nmsgid \"Ignore default tags\"\nmsgstr \"Ignorar etiquetas predeterminadas\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Image\"\nmsgstr \"Imagen\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/resizable-image.tsx\nmsgid \"Image Size\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-uploader.tsx\nmsgid \"Images, videos, audio, and text files up to 100MB\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\nmsgid \"Import\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Import Action\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\nmsgid \"Import completed successfully!\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\nmsgid \"Import encountered errors:\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\nmsgid \"Import from PostyBirb Plus\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\nmsgid \"Import your data from PostyBirb Plus. Select which types of data you want to import.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\nmsgid \"Inactive\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Info\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/components/insert-media-modal.tsx\nmsgid \"Insert\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/components/insert-media-modal.tsx\nmsgid \"Insert Media\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-field.tsx\nmsgid \"Insert tags at end\"\nmsgstr \"Insertar etiquetas al final\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-field.tsx\nmsgid \"Insert title at start\"\nmsgstr \"Insertar título al inicio\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Instance URL\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Intended as advertisement\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Invalid CRON expression\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Invalid date format: {value}. Expected ISO date string.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Invalid post URL to reply to.\"\nmsgstr \"\"\n\n#. Bluesky login form\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Invalid url. Format should be <0>https://bsky.app/</0>\"\nmsgstr \"\"\n\n#. Bluesky login form\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Invalid url. Format should be <0>https://bsky.social/</0>\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"It is recommended to add at least 10 general tags <0>( {generalTags} / 10 )</0>. See <1>tagging checklist</1>\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Keep this secret and never share it publicly\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Ko-fi\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"LAN IP\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/language-picker/language-picker.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Language\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/spellchecker-settings-section.tsx\nmsgid \"Languages to check\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Later\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-field.tsx\nmsgid \"Legacy Shortcuts Detected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"Legal Notice & Disclaimer\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/appearance-settings-section.tsx\nmsgid \"Light\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/theme-picker/theme-picker.tsx\n#: apps/postybirb-ui/src/remake/components/theme-picker/theme-picker.tsx\nmsgid \"Light Mode\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"Lithuanian\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Log in to different instance\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"logged in\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/account-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Logged in\"\nmsgstr \"\"\n\n#. placeholder {0}: username ? ` as ${username}` : ''\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\nmsgid \"Logged in{0}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Login\"\nmsgstr \"Conectarse\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\nmsgid \"Login failed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Make sure that the default rating matches Bluesky Label Rating.\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Male\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"Manage accounts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Mark as read\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Mark as unread\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Mark as work in progress\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Marked as read\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-content.tsx\nmsgid \"Mass Edit\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/rating-input/rating-input.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Mature\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Mature content\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Media\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\nmsgid \"Merge (recommended)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\nmsgid \"Merge Mode\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\nmsgid \"Message\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-content.tsx\nmsgid \"Message Submissions\"\nmsgstr \"Publicaciones de mensajes\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Minutes\"\nmsgstr \"Minutos\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Mo\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Modification\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Modified\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Monthly\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/header/submission-edit-card-header.tsx\nmsgid \"Multi Edit\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\nmsgid \"Name\"\nmsgstr \"Usuario\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"Name is required\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"need attention\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\nmsgid \"New\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"New account\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"New Message\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\nmsgid \"New template name...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"New version:\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"No AI\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"No changes\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"No custom dimensions set\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/defaults-form/defaults-form.tsx\nmsgid \"No default options available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-list.tsx\nmsgid \"No files added yet\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"No folder selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/empty-state/empty-state.tsx\nmsgid \"No notifications\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/tree-select.tsx\nmsgid \"No options available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"No options selected to apply\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-preview-panel.tsx\nmsgid \"No preview available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/empty-state/empty-state.tsx\nmsgid \"No records yet\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/empty-state/empty-state.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-select.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/tree-select.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker.tsx\nmsgid \"No results found\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"No schedule configured\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/reorderable-submission-list/reorderable-submission-list.tsx\nmsgid \"No submissions selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\nmsgid \"No validation issues\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"No website posts found\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"No websites\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-visibility-picker.tsx\nmsgid \"No websites available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"No websites selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-components/e621/e621-tag-search-provider.tsx\nmsgid \"No wiki page available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\nmsgid \"None\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\nmsgid \"Not all tag providers support this feature.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/account-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-option-row.tsx\n#: apps/postybirb-ui/src/remake/components/shared/account-picker/account-picker.tsx\nmsgid \"Not logged in\"\nmsgstr \"Sesión no iniciada\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Not scheduled\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/empty-state/empty-state.tsx\nmsgid \"Nothing selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Notifications\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Notify followers\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Nudity\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Offer hi-res download (gold only)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Offline\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Once\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Online\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Only set scheduled date (don't activate)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Open CRON helper\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Open full calendar\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\nmsgid \"Open Inkbunny Account Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/app-settings-section.tsx\nmsgid \"Open PostyBirb on computer startup\"\nmsgstr \"Abrir PostyBirb al iniciar el ordenador\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Open Twitter Authorization Page\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Optional template to apply to imported files\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Options\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Original work\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Other\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\nmsgid \"Overwrite overlapping website options only. Keeps existing website options that are not specified in the source.\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Parent ID\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\nmsgid \"Password\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Password is invalid\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-dropzone.tsx\nmsgid \"Paste from clipboard\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-dropzone.tsx\nmsgid \"Paste image from clipboard (Ctrl+V)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\nmsgid \"Pause\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/queue-control-card.tsx\nmsgid \"Pause Posting\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\nmsgid \"Paused\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"PDS (Personal Data Server)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Pending\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Per-Account Dimensions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Phone Number\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgctxt \"telegram.phone-number-help\"\nmsgid \"Phone number must be in international format\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"PIN / Verifier Code\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Pixel perfect display\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\nmsgid \"Please check your credentials and try again\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"Portuguese (Brazil)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Post\"\nmsgstr \"Publicar\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Post Data (JSON)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"Post Failure\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Post Files\"\nmsgstr \"Publicar archivos\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"Post Submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"Post Success\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\nmsgid \"PostyBirb uses Discord webhooks to post content to your server. Make sure you have the necessary permissions to create webhooks in your target channel.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-preview-panel.tsx\nmsgid \"Preview\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Primary\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Privacy\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Private\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Proceed to Authorization\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Profanity\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Public viewers\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Queued\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Quick tips to get started:\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Racism\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Rating\"\nmsgstr \"Valoración\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Read\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Ready\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-button.tsx\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Ready to Install\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Ready to post\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/recent-activity-panel.tsx\nmsgid \"Recent Activity\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Recurring\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Recurring schedule (active)\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Reference\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\nmsgid \"Refresh page\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Register Instance\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\nmsgid \"Reload page\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\nmsgid \"Remote\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Remote Connection\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Remote host URL is not configured\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Remote password is not configured\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\nmsgid \"Remove\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/resizable-image.tsx\nmsgid \"Remove image\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\nmsgid \"Rename\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\nmsgid \"Replace All\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgctxt \"import.override-description\"\nmsgid \"Replace description\"\nmsgstr \"Reemplazar descripción\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Replace file\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Replace the current description with the selected template's description\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Replace the current title with the selected template's title\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgctxt \"import.override-title\"\nmsgid \"Replace title\"\nmsgstr \"Reemplazar Título\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Reply to post URL\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Request critique\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Required\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Required if 2FA is enabled.\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Required tag\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Requires at least {minLength} {minLength, plural, one {tag} other {tags}}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Requires at least {minSelected} {minSelected, plural, one {option selected} other {options selected}} ({currentSelected} / {minSelected})\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Reset\"\nmsgstr \"Restablecer\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Reset account data\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Reset account?\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Reset Remote Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Restart Now\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/archived-submission-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/archived-submission-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"Restore\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Restored successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Resume Failed Posting\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/queue-control-card.tsx\nmsgid \"Resume Posting\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Retry all failed or unattempted websites\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Retry posting to all websites that failed or were not attempted. All files will be re-uploaded.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Rotate\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Rotate left 90°\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Rotate right 90°\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Running\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"Russian\"\nmsgstr \"Ruso\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Sa\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/e621/e621-login-view.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\nmsgid \"Save\"\nmsgstr \"Guardar\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Save API Keys\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\nmsgid \"Save as default\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Save changes\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\nmsgid \"Save Selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Save to file\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/save-to-many-action.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/save-to-many-action.tsx\nmsgid \"Save to submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Schedule\"\nmsgstr \"Programar\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/upcoming-posts-panel.tsx\nmsgid \"Schedule a submission to see it here\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Schedule configured (inactive)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Schedule posts to automatically publish at specific times\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Schedule Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Schedule Submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Schedule updated\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Scheduled\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Scheduled (active)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Scroll to zoom\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/submission-list.tsx\n#: apps/postybirb-ui/src/remake/components/shared/search-input.tsx\nmsgid \"Search...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-content.tsx\nmsgid \"Select a submission from the list to view details\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker.tsx\nmsgid \"Select a template\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-content.tsx\nmsgid \"Select a template from the list to view or edit it\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-select.tsx\nmsgid \"Select accounts...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-select.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-selection-form.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\nmsgid \"Select all\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-content.tsx\nmsgid \"Select an account to log in\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\nmsgid \"Select fields to save as defaults for this account.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\nmsgid \"Select items to delete\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/language-picker/language-picker.tsx\nmsgid \"Select language\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker.tsx\nmsgid \"Select submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Select templates or submissions to import\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\nmsgid \"Select websites to apply this shortcut to\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\nmsgid \"Select websites to apply usernames to\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Select which template to use for each account\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/tree-select.tsx\nmsgid \"Select...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Selected option is invalid or missing\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Send Code\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Send Messages\"\nmsgstr \"Enviar mensajes\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Send to scraps\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Sensitive content\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\nmsgid \"Server Boost Level\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Server unreachable\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Set the interval between each submission.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Settings\"\nmsgstr \"Ajustes\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Sexual Content\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Share on feed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Share on Feed is required to support posting multiple files.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Share your LAN IP and password with clients to allow them to connect.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"shortcut\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"Shortcut name\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\nmsgid \"Show wiki page related to tag on hover\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-visibility-picker.tsx\nmsgid \"Show/Hide Websites\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Silent\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Sketch\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Skip Accounts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"Skip websites that posted successfully and only attempt failed or unattempted ones.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Skip websites that posted successfully and only attempt failed or unattempted ones. Continues from the last successful batch of files.\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Software\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"Some accounts need to be logged in before you can post to them.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-uploader.tsx\nmsgid \"Some files were rejected. Check file type and size (max 100MB).\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"Some submissions have failed posting attempts. Choose how to handle them:\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\nmsgid \"Something went wrong\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Source URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Source URLs\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"Spanish\"\nmsgstr \"Español\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Species\"\nmsgstr \"Especies\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Specify your Fediverse instance\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\nmsgid \"Spellchecker\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/spellchecker-settings-section.tsx\nmsgid \"Spellchecker advanced configuration is controlled by system on MacOS.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/spellchecker-settings-section.tsx\nmsgid \"Spellchecker Settings\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Spoiler\"\nmsgstr \"Spoiler\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Spoiler Text\"\nmsgstr \"Texto del spoiler\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Spoilers\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Start completely fresh\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Start Date\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Start Over\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/app-settings-section.tsx\nmsgid \"Startup Settings\"\nmsgstr \"Configuración de inicio\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Status\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Su\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"Submission has validation errors\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Submission Order\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\nmsgid \"Submission unscheduled\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Submission will be posted automatically\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Submission will be split into {expectedBatchesToCreate} different submissions with {maxBatchSize} {maxBatchSize, plural, one {file each} other {files each}}.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-section.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Submissions\"\nmsgstr \"Publicaciones\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"success\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Success\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Successful\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Successfully connected to the remote host\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Successfully logged in as {loggedInAs}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Successfully logged in as @{loggedInAs}\"\nmsgstr \"\"\n\n#. placeholder {0}: orderedSubmissions.length\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Successfully scheduled ${0} submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Supports either a set of images, a single video, or a single GIF.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tag {tag} has {postCount} post(s). Tag may be invalid or low use\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tag <0>{tag}</0> does not exist yet or is invalid.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tag <0>{tag}</0> does not exist yet or is invalid. If you want to create a new tag, make a post with it, then go <1>here</1>, press edit and select tag category\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tag <0>{tag}</0> is invalid.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/tag-converter-drawer/tag-converter-drawer.tsx\nmsgid \"tag converter\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-converter-drawer/tag-converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Tag Converters\"\nmsgstr \"Convertidores de Etiquetas\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Tag Groups\"\nmsgstr \"Grupos de Etiquetas\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tag limit reached ({currentLength} / {maxLength})\"\nmsgstr \"Se alcanzó el límite de etiquetas ({currentLength} / {maxLength})\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Tag permissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/simple-tag-input/simple-tag-input.tsx\nmsgid \"tags\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/tags-shortcut.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Tags\"\nmsgstr \"Etiquetas\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tags longer than {maxLength} characters will be skipped\"\nmsgstr \"Se omitirán las etiquetas con más de {maxLength} caracteres\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\nmsgid \"Tags Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tags should not start with #\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"Tamil\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Teaser\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Telegram requires API credentials and phone number authentication. You'll need to create a Telegram app and verify your phone number.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker.tsx\nmsgid \"Template\"\nmsgstr \"Plantilla\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\nmsgid \"Template created\"\nmsgstr \"Plantilla creada\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-content.tsx\nmsgid \"Template Editor\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-content.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Templates\"\nmsgstr \"Plantillas\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Test Connection\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Text\"\nmsgstr \"Texto\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Th\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"The folder \\\"{folderName}\\\" contains {fileCount} files.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/specialized-error-boundaries.tsx\nmsgid \"The form encountered an error and couldn't be displayed properly.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"The last posting attempt failed. How would you like to proceed?\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"The update has been downloaded and is ready to install. Click \\\"Restart Now\\\" to apply the update.\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Theme\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\nmsgid \"These defaults apply to future submissions.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\nmsgid \"This component encountered an error and couldn't render properly.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\nmsgid \"This is a forum channel\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"This post record has no events or is still processing. Check the JSON data below for more details.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-content.tsx\nmsgid \"This website has an unsupported login configuration\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/custom-login-placeholder.tsx\nmsgid \"This website uses a custom login form. Support coming soon.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"This will clear all account data and cookies.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Thumbnail\"\nmsgstr \"Miniatura\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Thumbnail must be an image file.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Time\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Time Taken\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Tips\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/title-shortcut.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Title\"\nmsgstr \"Título\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Title is too long and will be truncated\"\nmsgstr \"El título es demasiado largo y se truncará\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Title is too short\"\nmsgstr \"El título es demasiado corto\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-visibility-picker.tsx\nmsgid \"Toggle website visibility\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Total\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Translation {id} not found\"\nmsgstr \"Traducción {id} no encontrada\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\n#: apps/postybirb-ui/src/remake/components/error-boundary/specialized-error-boundaries.tsx\nmsgid \"Try again\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Tu\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Twitter / X Authentication\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/description-editor.tsx\nmsgid \"Type / for commands or @, ` or '{' for shortcuts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Unknown\"\nmsgstr \"Desconocido\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Unknown choice\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-content.tsx\nmsgid \"Unknown login type\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Unread\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\nmsgid \"Unschedule\"\nmsgstr \"Sin programar\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/submission-list.tsx\nmsgid \"Unscheduled Submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Unsupported file type {fileExtension}. Please provide fallback text.\"\nmsgstr \"Tipo de archivo no compatible {fileExtension}. Proporcione un texto alternativo.\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Unsupported file type {mimeType}\"\nmsgstr \"Archivo no compatible {mimeType}\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Unsupported rating: {ratingLabel}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Unsupported submission type: {fileTypeString}\"\nmsgstr \"Tipo de envío no soportado : {fileTypeString}\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/upcoming-posts-panel.tsx\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/header/submission-edit-card-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\n#: apps/postybirb-ui/src/remake/components/shared/reorderable-submission-list/reorderable-submission-list.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Untitled\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/submission-list.tsx\nmsgid \"Untitled Submission\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Untitled Template\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/upcoming-posts-panel.tsx\nmsgid \"Upcoming Posts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Update API Keys\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-button.tsx\n#: apps/postybirb-ui/src/remake/components/update-button/update-button.tsx\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Update available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Update Error\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Update Failed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-button.tsx\nmsgid \"Update ready\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Updated successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-submission-modal.tsx\nmsgid \"Upload\"\nmsgstr \"Cargar\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Upload thumbnail\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/components/insert-media-modal.tsx\nmsgid \"URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-field.tsx\nmsgid \"Use custom description\"\nmsgstr \"Utilizar una descripción personalizada\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Use templates to save time on repeated posts\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Use thumbnail as cover art\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Use title\"\nmsgstr \"Usar título\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Use your Bluesky handle or email along with an app password. App passwords are more secure than your main password and can be revoked at any time.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Used for other sites (like e621) as source url base. Independent from custom pds.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/user-converter-drawer/user-converter-drawer.tsx\nmsgid \"user converter\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/user-converter-drawer/user-converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"User Converters\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/e621/e621-login-view.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\nmsgid \"Username\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Username or Email\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\nmsgid \"Validation Issues\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Video\"\nmsgstr \"Vídeo\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"View\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/archived-submission-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/archived-submission-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"View history\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"View permissions\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Violence\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Visibility\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-option-row.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/selected-accounts-forms.tsx\nmsgid \"Warning\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\nmsgid \"warnings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Warnings\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Watermark\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"We\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\nmsgid \"We encountered an unexpected error. Please try refreshing the page.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Website\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/selected-accounts-forms.tsx\nmsgid \"Website Options\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-select.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-selection-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/account-picker/account-picker.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\nmsgid \"Websites\"\nmsgstr \"Páginas web\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Weekly\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Welcome to PostyBirb!\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"What to do when new files are detected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"What's New\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Who can reply\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/components/insert-media-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/resizable-image.tsx\nmsgid \"Width\"\nmsgstr \"Ancho\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"You agree to comply with all applicable laws, terms of service, and policies of any third party platforms you connect to or interact with through this application. Do not use this software to infringe intellectual property rights, violate privacy, or circumvent platform rules.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"You have existing API keys. You can modify them above or proceed to the next step.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"You have recent {negativeOrNeutral} feedback: {feedback}, you can view it<0>here</0>\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\nmsgid \"You must first enable API access in your account settings under \\\"API (External Scripting)\\\" before you can authenticate with PostyBirb.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"You need to create a Twitter app to get API keys. <0>Visit Twitter Developer Portal</0>\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"You'll get this PIN after authorizing on Twitter\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-field.tsx\nmsgid \"Your description contains legacy shortcut syntax. These are no longer supported. Please use the new shortcut menu (type @, &lbrace; or `) to insert shortcuts.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Your handle (e.g. <0>yourname.bsky.social</0>) or custom domain (e.g. <1>username.ext</1>)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\nmsgid \"Your password will not be stored\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Zoom\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Zoom in\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Zoom out\"\nmsgstr \"\"\n"
  },
  {
    "path": "lang/lt.po",
    "content": "msgid \"\"\nmsgstr \"\"\n\"Project-Id-Version: \\n\"\n\"Report-Msgid-Bugs-To: \\n\"\n\"POT-Creation-Date: \\n\"\n\"PO-Revision-Date: \\n\"\n\"Last-Translator: \\n\"\n\"Language: \\n\"\n\"Language-Team: \\n\"\n\"Content-Type: \\n\"\n\"Content-Transfer-Encoding: \\n\"\n\"Plural-Forms: \\n\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"({fileSizeString}) is too large (max {maxFileSizeString}) and an attempt will be made to reduce size when posting\"\nmsgstr \"({fileSizeString}) yra per didelis (didžiausias {maxFileSizeString}) ir bus bandoma sumažinti jo dydį siunčiant.\"\n\n#. placeholder {0}: tag.post_count\n#: apps/postybirb-ui/src/remake/components/website-components/e621/e621-tag-search-provider.tsx\nmsgid \"{0, plural, one {# post} other {# posts}}\"\nmsgstr \"\"\n\n#. placeholder {0}: selectedOptions.length\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/tree-select.tsx\nmsgid \"{0} selected\"\nmsgstr \"\"\n\n#. placeholder {0}: value.length\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker.tsx\nmsgid \"{0} submissions selected\"\nmsgstr \"\"\n\n#. placeholder {0}: account?.websiteDisplayName\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\nmsgid \"{0}: Login successful\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"{count} items deleted\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"{count} selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-visibility-picker.tsx\nmsgid \"{hiddenCount} hidden\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"{selectedCount} of {totalCount} selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"{validCount} of {totalSelectedCount} selected submission(s) are ready to post. Submissions without websites or with validation errors will be skipped.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"{validCount} submission(s) will be posted in the order shown below.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"<0>Tip:</0> If your handle doesn't work, try using the email linked to your account\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"2FA enabled, password required. It won't be stored.\"\nmsgstr \"2FA įjungtas, slaptažodis privalomas. Jis nebus saugomas.\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"A shortcut with this name already exists\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/tag-converter-drawer/tag-converter-drawer.tsx\nmsgid \"A tag converter with this tag already exists\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/user-converter-drawer/user-converter-drawer.tsx\nmsgid \"A user converter with this username already exists\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"Accept and Continue\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Access Tiers\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/selected-accounts-forms.tsx\nmsgid \"account\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Account\"\nmsgstr \"Paskyra\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-section.tsx\nmsgid \"Account data cleared\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Account name\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Account Options\"\nmsgstr \"Paskyros parinktys\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"Account Status\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/selected-accounts-forms.tsx\nmsgid \"accounts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/account-section-header.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Accounts\"\nmsgstr \"Paskyros\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\nmsgid \"Activate\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Activate schedule\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\nmsgid \"Active\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/recent-activity-panel.tsx\nmsgid \"Activity will appear here as you use the app\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Add account\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"Add an account to start posting\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-submission-modal.tsx\nmsgid \"Add File Submission\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"Add shortcut\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\nmsgid \"Add tags...\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Add to Portfolio\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\nmsgid \"Add website\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-preview-panel.tsx\nmsgid \"Add website accounts to preview the description output.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Adjust dimensions while maintaining aspect ratio\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/rating-input/rating-input.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Adult\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Adult themes\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"After authorizing, copy the code and paste it below\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"AI generated\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/account-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\nmsgid \"All\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"All files are marked ignored.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"All healthy\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\nmsgid \"All submissions are ready to post\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\nmsgid \"All Websites\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow comments\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow commercial use\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow free download\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow modifications of your work\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow other users to edit tags\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/description-settings-section.tsx\nmsgid \"Allow PostyBirb to self-advertise at the end of descriptions\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow reblogging\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Alt Text\"\nmsgstr \"Alternatyvusis tekstas\"\n\n#. Bluesky login form\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"An <0>app password</0> (not your main password)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"An error occurred\"\nmsgstr \"\"\n\n#. placeholder {0}: dtext.length - text.length\n#: apps/postybirb-ui/src/remake/components/website-components/e621/e621-dtext-renderer.tsx\nmsgid \"and {0} more\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"and {remainingCount} more\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"API Key (Consumer Key)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"API keys saved successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"API Secret (Consumer Secret)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\nmsgid \"App\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"App API Hash\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"App API ID\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/app-settings-section.tsx\nmsgid \"App Folder\"\nmsgstr \"Programos aplankas\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"App Password\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"App registered successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/app-settings-section.tsx\nmsgid \"App Server Port\"\nmsgstr \"Programos serverio prievadas\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"App view URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\nmsgid \"Appearance\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Applied template options to {successCount} submissions\"\nmsgstr \"\"\n\n#. placeholder {0}: submissionIds.length\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/save-to-many-action.tsx\nmsgid \"Applied to {0} submission(s)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Applied to {successCount} submissions, {failedCount} failed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Apply\"\nmsgstr \"Taikyti\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/apply-template-action.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Apply template\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\nmsgid \"Archive\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/header/submission-edit-card-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-section.tsx\nmsgid \"Archived\"\nmsgstr \"\"\n\n#. placeholder {0}: selectedIds.length\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-section.tsx\nmsgid \"Are you sure you want to delete {0} submission(s)? This action cannot be undone.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Are you sure you want to reset all remote connection settings to their defaults? This will clear the saved host URL and password.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Are you sure you want to watch this folder?\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Artist name\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Artist URL (Off-Site only)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Aspect Ratio\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Attach Images as Attachments\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Audience\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Audio\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Authenticate\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Authorization Code\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Authorization URL generated\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Authorize\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Authorized viewers\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/appearance-settings-section.tsx\nmsgid \"Auto\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Automatically import new files from specified folders\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Back\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Block guests\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Blog\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Bluesky automatically converts GIFs to videos.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/app-settings-section.tsx\nmsgid \"Browse\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Builder\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"By using this application, you acknowledge that you are solely responsible for how you use it and for any content you create, upload, distribute, or interact with. The authors and maintainers provide this software as is without warranties of any kind and are not liable for any damages or losses resulting from its use.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/confirm-action-modal/confirm-action-modal.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/components/insert-media-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Cancel\"\nmsgstr \"Atšaukti\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"Cancel posting\"\nmsgstr \"\"\n\n#. placeholder {0}: getFileTypeLabel(newFileType)\n#. placeholder {1}: getFileTypeLabel(existingFileType)\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-uploader.tsx\nmsgid \"Cannot add {0} files to a submission containing {1} files. All files in a submission must be of the same type.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-card.tsx\nmsgid \"Cannot delete the only file\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Category\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Channel\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Characters\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Charge Patrons\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\nmsgid \"Check login status\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Check that handle and password are valid or try using email instead of handle.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Check your telegram messages\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\nmsgid \"Checking...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-content.tsx\nmsgid \"Choose an account from the list on the left\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgctxt \"template.picker-modal-header\"\nmsgid \"Choose Templates\"\nmsgstr \"Pasirinkite šablonus\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Click here to authorize PostyBirb\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Click the button below to get your authorization URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Click the link above, authorize the app, and copy the PIN code below\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Client\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Cloud Password\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Code\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Code invalid, ensure it matches the code sent from telegram\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Code sent\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-card.tsx\nmsgid \"Collapse\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Collections\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Comment permissions\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Commercial use\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Compact view\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Complete Authorization\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Complete Login\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Configuration Error\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Configure API Keys\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/confirm-action-modal/confirm-action-modal.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Confirm\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Connected successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\nmsgid \"Connection error\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Connection Failed\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Contains content\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Content Blur\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/content-warning-shortcut.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Content warning\"\nmsgstr \"Turinio įspėjimas\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Continue from where it left off\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/copy-to-clipboard/copy-to-clipboard.tsx\nmsgid \"Copied\"\nmsgstr \"Nukopijuota\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Copied to clipboard\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/copy-to-clipboard/copy-to-clipboard.tsx\n#: apps/postybirb-ui/src/remake/components/shared/copy-to-clipboard/copy-to-clipboard.tsx\nmsgid \"Copy\"\nmsgstr \"Kopijuoti\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Create\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Create a File or Message submission to start posting\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Create a file watcher to automatically import new files from a folder\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Create an app password in Bluesky Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Create Message\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Create new file watcher\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Create new submission\"\nmsgstr \"Kurti naują pateikimą\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Create Submission\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgctxt \"telegram.app-id-help\"\nmsgid \"Create telegram app to retrieve api_id and api_hash\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Created successfully\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Creative Commons\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Creative Commons license\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"CRON Expression\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Crop from primary\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Ctrl+Enter to apply\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Custom\"\nmsgstr \"\"\n\n#. placeholder {0}: website.displayName\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/custom-login-placeholder.tsx\nmsgid \"Custom login for {0}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Custom PDS or AppView\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Custom Shortcuts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/spellchecker-settings-section.tsx\nmsgid \"Custom words\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Daily\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/appearance-settings-section.tsx\nmsgid \"Dark\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/theme-picker/theme-picker.tsx\n#: apps/postybirb-ui/src/remake/components/theme-picker/theme-picker.tsx\nmsgid \"Dark Mode\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Dashboard\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Date {currentDate} is after maximum allowed date {maxDate}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Date {currentDate} is before minimum allowed date {minDate}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Date {currentDate} is outside allowed range ({minDate} - {maxDate})\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Date and Time\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Date is in the past\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Day of Month\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Days\"\nmsgstr \"Dienos (-ų)\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"Decline and Exit\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/radio-field.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/default-shortcut.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Default\"\nmsgstr \"Numatytasis\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/defaults-form/defaults-form.tsx\nmsgid \"Defaults\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\nmsgid \"Defaults saved successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-preview.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-section.tsx\nmsgid \"Delete\"\nmsgstr \"Ištrinti\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\nmsgid \"Delete all existing website options and use only those specified in the source.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-card.tsx\nmsgid \"Delete file\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/archived-submission-card.tsx\nmsgid \"Delete permanently\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-section.tsx\nmsgid \"Delete Submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Deleted successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: apps/postybirb-ui/src/remake/components/website-components/e621/e621-tag-search-provider.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Description\"\nmsgstr \"Aprašas\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Description length is greater then maximum ({currentLength} / {maxLength})\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Description length is lower then minimum ({currentLength} / {minLength})\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/description-settings-section.tsx\nmsgid \"Description Settings\"\nmsgstr \"Aprašo nustatymai\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-select.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-selection-form.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\nmsgid \"Deselect all\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"Desktop Notifications\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Detailed view\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Dimensions\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Disable comments\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Discard all progress from the previous attempt and start as if this were the first time posting.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Discord\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Display resolution\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Double-click to toggle crop/move mode\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Download Update\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Downloading...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/reorderable-submission-list/reorderable-submission-list.tsx\nmsgid \"Drag or use arrow keys to reorder\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/submission-list.tsx\nmsgid \"Drag submissions onto the calendar to schedule them\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-manager.tsx\nmsgid \"Drag to reorder\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-dropzone.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-list.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-uploader.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Drop files here or click to browse\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Drug / Alcohol\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\nmsgid \"Duplicate\"\nmsgstr \"Dubliuoti\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Duplicated successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/e621/e621-login-view.tsx\nmsgid \"e621 requires API credentials for posting. You'll need to generate an API key from your account settings to authenticate PostyBirb.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-dropzone.tsx\nmsgid \"Each file becomes a separate submission\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Early Access\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\nmsgid \"Edit\"\nmsgstr \"Redaguoti\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-preview.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Edit image\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-card.tsx\nmsgid \"Edit metadata\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-preview.tsx\nmsgid \"Edit title\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"Enable desktop notifications\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\nmsgid \"Enable global tag autocomplete from sites like e621.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/spellchecker-settings-section.tsx\nmsgid \"Enabled\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"English\"\nmsgstr \"anglų\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Ensure the IP is correct\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Enter Instance URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Enter the host URL and password to connect to a remote PostyBirb instance.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Enter the PIN from Twitter\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Enter the URL of your Mastodon, Pleroma, or Pixelfed instance (e.g., \\\"mastodon.social\\\" or \\\"pixelfed.social\\\")\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Enter title...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Enter your Twitter app credentials\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-option-row.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/selected-accounts-forms.tsx\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Error\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Error while authenticating Telegram\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\nmsgid \"errors\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Errors\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"Expand\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Explicit text\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/rating-input/rating-input.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Extreme\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"failed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Failed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"Failed Posts Detected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Failed to apply template options\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-section.tsx\nmsgid \"Failed to clear account data\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Failed to complete authorization\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Failed to connect to remote host\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\nmsgid \"Failed to connect to the server\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to create\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\nmsgid \"Failed to create template\"\nmsgstr \"Nepavyko sukurti šablono.\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to delete\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-card.tsx\nmsgid \"Failed to delete file\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to duplicate\"\nmsgstr \"Nepavyko dubliuoti.\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Failed to generate authorization URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Failed to get user warnings. You can check your account manually\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Failed to load primary file for cropping.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to post submission\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Failed to register with instance\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Failed to replace file\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to restore\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to save\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/form-fields-context.tsx\nmsgid \"Failed to save changes\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-alt-text-editor.tsx\nmsgid \"Failed to save fallback text\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Failed to save metadata\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Failed to schedule submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Failed to send code to begin authentication\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Failed to store API keys\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to update\"\nmsgstr \"Nepavyko atnaujinti.\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\nmsgid \"Failed to update template name\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to upload files\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Failed to validate submission: {message}\"\nmsgstr \"Nepavyko patvirtinti pateikimo: {message}.\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Failed to validate tags. Please check them manually\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-alt-text-editor.tsx\nmsgid \"Fallback Text\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Feature\"\nmsgstr \"Rodyti\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Fediverse Authentication\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Female\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\nmsgid \"File\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-content.tsx\nmsgid \"File Submissions\"\nmsgstr \"Failų pateikimai\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"File types do not match. Please upload a file of the same type.\"\nmsgstr \"Failų tipai nesutampa. Įkelkite to paties tipo failą.\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"File Watcher Failure\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"File Watcher Success\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"File Watchers\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"File will be modified to support website requirements\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-list.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-manager.tsx\nmsgid \"Files\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Files uploaded successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Flip\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Flip horizontal\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Flip vertical\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Folder\"\nmsgstr \"Aplankas\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Folder Contains Files\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Folder Path\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/specialized-error-boundaries.tsx\nmsgid \"Form Error\"\nmsgstr \"\"\n\n#. Bluesky login form\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Format should be <0>handle.bsky.social</0> or <1>domain.ext</1> for custom domains\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Format: minute hour day month weekday\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Found in your Twitter app's Keys and tokens section\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Fr\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Frequency\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Friends only\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Furry\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Gender\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/rating-input/rating-input.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"General\"\nmsgstr \"\"\n\n#. E621 login form\n#: apps/postybirb-ui/src/remake/components/website-login-views/e621/e621-login-view.tsx\nmsgid \"Generate an API key from your <0>account settings</0>\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Generate authorization link\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"German\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Get Authorization\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Get authorization code\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Get Authorization URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Get started by adding your accounts and creating your first submission.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\nmsgid \"Global tag search provider\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Go back\"\nmsgstr \"Eiti atgal\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\nmsgid \"Go forward\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Go to Accounts to add and log into your websites\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Gore\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Has sexual content\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Has validation errors\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Has validation warnings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/components/insert-media-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/resizable-image.tsx\nmsgid \"Height\"\nmsgstr \"Aukštis\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-selection-form.tsx\nmsgid \"Hide unselected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\nmsgid \"Higher boost levels allow larger file uploads\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\nmsgid \"Hold to delete\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\nmsgid \"Hold to delete {count} item(s)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"Hold to delete permanently\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"Hold to post\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Home\"\nmsgstr \"Pagrindinis\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Host\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Host URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Hours\"\nmsgstr \"Valandos (-ų)\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\nmsgctxt \"discord.webhook-help\"\nmsgid \"How to create a webhook\"\nmsgstr \"Kaip sukurti saityno gaudyklę\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"I have read, understand, and accept the disclaimer.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"If you are using pds other then bsky.social\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"If you do not agree with these terms, you must decline and exit the application.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/tag-field.tsx\nmsgctxt \"override-default\"\nmsgid \"Ignore default tags\"\nmsgstr \"Nepaisyti numatytąsias žymes\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Image\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/resizable-image.tsx\nmsgid \"Image Size\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-uploader.tsx\nmsgid \"Images, videos, audio, and text files up to 100MB\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\nmsgid \"Import\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Import Action\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\nmsgid \"Import completed successfully!\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\nmsgid \"Import encountered errors:\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\nmsgid \"Import from PostyBirb Plus\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\nmsgid \"Import your data from PostyBirb Plus. Select which types of data you want to import.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\nmsgid \"Inactive\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Info\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/components/insert-media-modal.tsx\nmsgid \"Insert\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/components/insert-media-modal.tsx\nmsgid \"Insert Media\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-field.tsx\nmsgid \"Insert tags at end\"\nmsgstr \"Įterpti žymes pabaigoje\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-field.tsx\nmsgid \"Insert title at start\"\nmsgstr \"Įterpti pavadinimą pradžioje\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Instance URL\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Intended as advertisement\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Invalid CRON expression\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Invalid date format: {value}. Expected ISO date string.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Invalid post URL to reply to.\"\nmsgstr \"\"\n\n#. Bluesky login form\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Invalid url. Format should be <0>https://bsky.app/</0>\"\nmsgstr \"\"\n\n#. Bluesky login form\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Invalid url. Format should be <0>https://bsky.social/</0>\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"It is recommended to add at least 10 general tags <0>( {generalTags} / 10 )</0>. See <1>tagging checklist</1>\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Keep this secret and never share it publicly\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Ko-fi\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"LAN IP\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/language-picker/language-picker.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Language\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/spellchecker-settings-section.tsx\nmsgid \"Languages to check\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Later\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-field.tsx\nmsgid \"Legacy Shortcuts Detected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"Legal Notice & Disclaimer\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/appearance-settings-section.tsx\nmsgid \"Light\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/theme-picker/theme-picker.tsx\n#: apps/postybirb-ui/src/remake/components/theme-picker/theme-picker.tsx\nmsgid \"Light Mode\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"Lithuanian\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Log in to different instance\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"logged in\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/account-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Logged in\"\nmsgstr \"\"\n\n#. placeholder {0}: username ? ` as ${username}` : ''\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\nmsgid \"Logged in{0}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Login\"\nmsgstr \"Prisijungti\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\nmsgid \"Login failed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Make sure that the default rating matches Bluesky Label Rating.\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Male\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"Manage accounts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Mark as read\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Mark as unread\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Mark as work in progress\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Marked as read\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-content.tsx\nmsgid \"Mass Edit\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/rating-input/rating-input.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Mature\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Mature content\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Media\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\nmsgid \"Merge (recommended)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\nmsgid \"Merge Mode\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\nmsgid \"Message\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-content.tsx\nmsgid \"Message Submissions\"\nmsgstr \"Žinutės pateikimai\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Minutes\"\nmsgstr \"Minutės (-čių)\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Mo\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Modification\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Modified\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Monthly\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/header/submission-edit-card-header.tsx\nmsgid \"Multi Edit\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\nmsgid \"Name\"\nmsgstr \"Pavadinimas\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"Name is required\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"need attention\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\nmsgid \"New\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"New account\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"New Message\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\nmsgid \"New template name...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"New version:\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"No AI\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"No changes\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"No custom dimensions set\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/defaults-form/defaults-form.tsx\nmsgid \"No default options available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-list.tsx\nmsgid \"No files added yet\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"No folder selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/empty-state/empty-state.tsx\nmsgid \"No notifications\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/tree-select.tsx\nmsgid \"No options available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"No options selected to apply\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-preview-panel.tsx\nmsgid \"No preview available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/empty-state/empty-state.tsx\nmsgid \"No records yet\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/empty-state/empty-state.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-select.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/tree-select.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker.tsx\nmsgid \"No results found\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"No schedule configured\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/reorderable-submission-list/reorderable-submission-list.tsx\nmsgid \"No submissions selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\nmsgid \"No validation issues\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"No website posts found\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"No websites\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-visibility-picker.tsx\nmsgid \"No websites available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"No websites selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-components/e621/e621-tag-search-provider.tsx\nmsgid \"No wiki page available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\nmsgid \"None\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\nmsgid \"Not all tag providers support this feature.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/account-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-option-row.tsx\n#: apps/postybirb-ui/src/remake/components/shared/account-picker/account-picker.tsx\nmsgid \"Not logged in\"\nmsgstr \"Neprisijungta\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Not scheduled\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/empty-state/empty-state.tsx\nmsgid \"Nothing selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Notifications\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Notify followers\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Nudity\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Offer hi-res download (gold only)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Offline\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Once\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Online\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Only set scheduled date (don't activate)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Open CRON helper\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Open full calendar\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\nmsgid \"Open Inkbunny Account Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/app-settings-section.tsx\nmsgid \"Open PostyBirb on computer startup\"\nmsgstr \"Atverti „PostyBirb“ kompiuterio paleidimo metu\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Open Twitter Authorization Page\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Optional template to apply to imported files\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Options\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Original work\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Other\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\nmsgid \"Overwrite overlapping website options only. Keeps existing website options that are not specified in the source.\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Parent ID\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\nmsgid \"Password\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Password is invalid\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-dropzone.tsx\nmsgid \"Paste from clipboard\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-dropzone.tsx\nmsgid \"Paste image from clipboard (Ctrl+V)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\nmsgid \"Pause\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/queue-control-card.tsx\nmsgid \"Pause Posting\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\nmsgid \"Paused\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"PDS (Personal Data Server)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Pending\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Per-Account Dimensions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Phone Number\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgctxt \"telegram.phone-number-help\"\nmsgid \"Phone number must be in international format\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"PIN / Verifier Code\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Pixel perfect display\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\nmsgid \"Please check your credentials and try again\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"Portuguese (Brazil)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Post\"\nmsgstr \"Skelbti\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Post Data (JSON)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"Post Failure\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Post Files\"\nmsgstr \"Skelbti failus\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"Post Submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"Post Success\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\nmsgid \"PostyBirb uses Discord webhooks to post content to your server. Make sure you have the necessary permissions to create webhooks in your target channel.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-preview-panel.tsx\nmsgid \"Preview\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Primary\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Privacy\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Private\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Proceed to Authorization\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Profanity\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Public viewers\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Queued\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Quick tips to get started:\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Racism\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Rating\"\nmsgstr \"Vertinimas\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Read\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Ready\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-button.tsx\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Ready to Install\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Ready to post\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/recent-activity-panel.tsx\nmsgid \"Recent Activity\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Recurring\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Recurring schedule (active)\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Reference\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\nmsgid \"Refresh page\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Register Instance\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\nmsgid \"Reload page\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\nmsgid \"Remote\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Remote Connection\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Remote host URL is not configured\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Remote password is not configured\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\nmsgid \"Remove\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/resizable-image.tsx\nmsgid \"Remove image\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\nmsgid \"Rename\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\nmsgid \"Replace All\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgctxt \"import.override-description\"\nmsgid \"Replace description\"\nmsgstr \"Pakeisti aprašą\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Replace file\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Replace the current description with the selected template's description\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Replace the current title with the selected template's title\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgctxt \"import.override-title\"\nmsgid \"Replace title\"\nmsgstr \"Pakeisti pavadinimą\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Reply to post URL\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Request critique\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Required\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Required if 2FA is enabled.\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Required tag\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Requires at least {minLength} {minLength, plural, one {tag} other {tags}}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Requires at least {minSelected} {minSelected, plural, one {option selected} other {options selected}} ({currentSelected} / {minSelected})\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Reset\"\nmsgstr \"Atkurti\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Reset account data\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Reset account?\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Reset Remote Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Restart Now\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/archived-submission-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/archived-submission-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"Restore\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Restored successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Resume Failed Posting\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/queue-control-card.tsx\nmsgid \"Resume Posting\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Retry all failed or unattempted websites\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Retry posting to all websites that failed or were not attempted. All files will be re-uploaded.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Rotate\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Rotate left 90°\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Rotate right 90°\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Running\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"Russian\"\nmsgstr \"rusų\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Sa\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/e621/e621-login-view.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\nmsgid \"Save\"\nmsgstr \"Išsaugoti\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Save API Keys\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\nmsgid \"Save as default\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Save changes\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\nmsgid \"Save Selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Save to file\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/save-to-many-action.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/save-to-many-action.tsx\nmsgid \"Save to submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Schedule\"\nmsgstr \"Planuoti\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/upcoming-posts-panel.tsx\nmsgid \"Schedule a submission to see it here\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Schedule configured (inactive)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Schedule posts to automatically publish at specific times\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Schedule Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Schedule Submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Schedule updated\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Scheduled\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Scheduled (active)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Scroll to zoom\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/submission-list.tsx\n#: apps/postybirb-ui/src/remake/components/shared/search-input.tsx\nmsgid \"Search...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-content.tsx\nmsgid \"Select a submission from the list to view details\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker.tsx\nmsgid \"Select a template\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-content.tsx\nmsgid \"Select a template from the list to view or edit it\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-select.tsx\nmsgid \"Select accounts...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-select.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-selection-form.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\nmsgid \"Select all\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-content.tsx\nmsgid \"Select an account to log in\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\nmsgid \"Select fields to save as defaults for this account.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\nmsgid \"Select items to delete\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/language-picker/language-picker.tsx\nmsgid \"Select language\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker.tsx\nmsgid \"Select submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Select templates or submissions to import\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\nmsgid \"Select websites to apply this shortcut to\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\nmsgid \"Select websites to apply usernames to\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Select which template to use for each account\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/tree-select.tsx\nmsgid \"Select...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Selected option is invalid or missing\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Send Code\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Send Messages\"\nmsgstr \"Siųsti žinutes\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Send to scraps\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Sensitive content\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\nmsgid \"Server Boost Level\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Server unreachable\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Set the interval between each submission.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Settings\"\nmsgstr \"Nustatymai\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Sexual Content\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Share on feed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Share on Feed is required to support posting multiple files.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Share your LAN IP and password with clients to allow them to connect.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"shortcut\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"Shortcut name\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\nmsgid \"Show wiki page related to tag on hover\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-visibility-picker.tsx\nmsgid \"Show/Hide Websites\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Silent\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Sketch\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Skip Accounts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"Skip websites that posted successfully and only attempt failed or unattempted ones.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Skip websites that posted successfully and only attempt failed or unattempted ones. Continues from the last successful batch of files.\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Software\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"Some accounts need to be logged in before you can post to them.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-uploader.tsx\nmsgid \"Some files were rejected. Check file type and size (max 100MB).\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"Some submissions have failed posting attempts. Choose how to handle them:\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\nmsgid \"Something went wrong\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Source URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Source URLs\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"Spanish\"\nmsgstr \"ispanų\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Species\"\nmsgstr \"Rūšys\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Specify your Fediverse instance\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\nmsgid \"Spellchecker\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/spellchecker-settings-section.tsx\nmsgid \"Spellchecker advanced configuration is controlled by system on MacOS.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/spellchecker-settings-section.tsx\nmsgid \"Spellchecker Settings\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Spoiler\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Spoiler Text\"\nmsgstr \"Atskleidžiamas tekstas\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Spoilers\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Start completely fresh\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Start Date\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Start Over\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/app-settings-section.tsx\nmsgid \"Startup Settings\"\nmsgstr \"Paleidimo nustatymai\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Status\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Su\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"Submission has validation errors\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Submission Order\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\nmsgid \"Submission unscheduled\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Submission will be posted automatically\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Submission will be split into {expectedBatchesToCreate} different submissions with {maxBatchSize} {maxBatchSize, plural, one {file each} other {files each}}.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-section.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Submissions\"\nmsgstr \"Pateikimai\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"success\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Success\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Successful\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Successfully connected to the remote host\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Successfully logged in as {loggedInAs}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Successfully logged in as @{loggedInAs}\"\nmsgstr \"\"\n\n#. placeholder {0}: orderedSubmissions.length\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Successfully scheduled ${0} submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Supports either a set of images, a single video, or a single GIF.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tag {tag} has {postCount} post(s). Tag may be invalid or low use\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tag <0>{tag}</0> does not exist yet or is invalid.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tag <0>{tag}</0> does not exist yet or is invalid. If you want to create a new tag, make a post with it, then go <1>here</1>, press edit and select tag category\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tag <0>{tag}</0> is invalid.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/tag-converter-drawer/tag-converter-drawer.tsx\nmsgid \"tag converter\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-converter-drawer/tag-converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Tag Converters\"\nmsgstr \"Žymių konverteriai\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Tag Groups\"\nmsgstr \"Žymių grupės\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tag limit reached ({currentLength} / {maxLength})\"\nmsgstr \"Žymių riba pasiekta ({currentLength} / {maxLength}).\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Tag permissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/simple-tag-input/simple-tag-input.tsx\nmsgid \"tags\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/tags-shortcut.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Tags\"\nmsgstr \"Žymės\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tags longer than {maxLength} characters will be skipped\"\nmsgstr \"Žymės ilgesnės nei {maxLength} simbolių bus praleistos.\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\nmsgid \"Tags Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tags should not start with #\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"Tamil\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Teaser\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Telegram requires API credentials and phone number authentication. You'll need to create a Telegram app and verify your phone number.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker.tsx\nmsgid \"Template\"\nmsgstr \"Šablonas\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\nmsgid \"Template created\"\nmsgstr \"Šablonas sukurtas\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-content.tsx\nmsgid \"Template Editor\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-content.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Templates\"\nmsgstr \"Šablonai\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Test Connection\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Text\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Th\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"The folder \\\"{folderName}\\\" contains {fileCount} files.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/specialized-error-boundaries.tsx\nmsgid \"The form encountered an error and couldn't be displayed properly.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"The last posting attempt failed. How would you like to proceed?\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"The update has been downloaded and is ready to install. Click \\\"Restart Now\\\" to apply the update.\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Theme\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\nmsgid \"These defaults apply to future submissions.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\nmsgid \"This component encountered an error and couldn't render properly.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\nmsgid \"This is a forum channel\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"This post record has no events or is still processing. Check the JSON data below for more details.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-content.tsx\nmsgid \"This website has an unsupported login configuration\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/custom-login-placeholder.tsx\nmsgid \"This website uses a custom login form. Support coming soon.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"This will clear all account data and cookies.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Thumbnail\"\nmsgstr \"Miniatiūra\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Thumbnail must be an image file.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Time\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Time Taken\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Tips\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/title-shortcut.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Title\"\nmsgstr \"Pavadinimas\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Title is too long and will be truncated\"\nmsgstr \"Pavadinimas per ilgas ir bus sutrumpintas.\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Title is too short\"\nmsgstr \"Pavadinimas per trumpas.\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-visibility-picker.tsx\nmsgid \"Toggle website visibility\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Total\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Translation {id} not found\"\nmsgstr \"Vertimas {id} nerastas.\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\n#: apps/postybirb-ui/src/remake/components/error-boundary/specialized-error-boundaries.tsx\nmsgid \"Try again\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Tu\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Twitter / X Authentication\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/description-editor.tsx\nmsgid \"Type / for commands or @, ` or '{' for shortcuts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Unknown\"\nmsgstr \"Nežinomas\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Unknown choice\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-content.tsx\nmsgid \"Unknown login type\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Unread\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\nmsgid \"Unschedule\"\nmsgstr \"Naikinti planavimą\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/submission-list.tsx\nmsgid \"Unscheduled Submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Unsupported file type {fileExtension}. Please provide fallback text.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Unsupported file type {mimeType}\"\nmsgstr \"Nepalaikomas failo tipas {mimeType}.\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Unsupported rating: {ratingLabel}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Unsupported submission type: {fileTypeString}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/upcoming-posts-panel.tsx\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/header/submission-edit-card-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\n#: apps/postybirb-ui/src/remake/components/shared/reorderable-submission-list/reorderable-submission-list.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Untitled\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/submission-list.tsx\nmsgid \"Untitled Submission\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Untitled Template\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/upcoming-posts-panel.tsx\nmsgid \"Upcoming Posts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Update API Keys\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-button.tsx\n#: apps/postybirb-ui/src/remake/components/update-button/update-button.tsx\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Update available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Update Error\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Update Failed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-button.tsx\nmsgid \"Update ready\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Updated successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-submission-modal.tsx\nmsgid \"Upload\"\nmsgstr \"Įkelti\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Upload thumbnail\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/components/insert-media-modal.tsx\nmsgid \"URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-field.tsx\nmsgid \"Use custom description\"\nmsgstr \"Naudoti pasirinktinį aprašą\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Use templates to save time on repeated posts\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Use thumbnail as cover art\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Use title\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Use your Bluesky handle or email along with an app password. App passwords are more secure than your main password and can be revoked at any time.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Used for other sites (like e621) as source url base. Independent from custom pds.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/user-converter-drawer/user-converter-drawer.tsx\nmsgid \"user converter\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/user-converter-drawer/user-converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"User Converters\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/e621/e621-login-view.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\nmsgid \"Username\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Username or Email\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\nmsgid \"Validation Issues\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Video\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"View\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/archived-submission-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/archived-submission-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"View history\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"View permissions\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Violence\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Visibility\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-option-row.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/selected-accounts-forms.tsx\nmsgid \"Warning\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\nmsgid \"warnings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Warnings\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Watermark\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"We\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\nmsgid \"We encountered an unexpected error. Please try refreshing the page.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Website\"\nmsgstr \"Svetainė\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/selected-accounts-forms.tsx\nmsgid \"Website Options\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-select.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-selection-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/account-picker/account-picker.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\nmsgid \"Websites\"\nmsgstr \"Svetainės\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Weekly\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Welcome to PostyBirb!\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"What to do when new files are detected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"What's New\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Who can reply\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/components/insert-media-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/resizable-image.tsx\nmsgid \"Width\"\nmsgstr \"Plotis\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"You agree to comply with all applicable laws, terms of service, and policies of any third party platforms you connect to or interact with through this application. Do not use this software to infringe intellectual property rights, violate privacy, or circumvent platform rules.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"You have existing API keys. You can modify them above or proceed to the next step.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"You have recent {negativeOrNeutral} feedback: {feedback}, you can view it<0>here</0>\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\nmsgid \"You must first enable API access in your account settings under \\\"API (External Scripting)\\\" before you can authenticate with PostyBirb.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"You need to create a Twitter app to get API keys. <0>Visit Twitter Developer Portal</0>\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"You'll get this PIN after authorizing on Twitter\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-field.tsx\nmsgid \"Your description contains legacy shortcut syntax. These are no longer supported. Please use the new shortcut menu (type @, &lbrace; or `) to insert shortcuts.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Your handle (e.g. <0>yourname.bsky.social</0>) or custom domain (e.g. <1>username.ext</1>)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\nmsgid \"Your password will not be stored\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Zoom\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Zoom in\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Zoom out\"\nmsgstr \"\"\n"
  },
  {
    "path": "lang/nl.po",
    "content": "msgid \"\"\nmsgstr \"Project-Id-Version: \\nReport-Msgid-Bugs-To: \\nPOT-Creation-Date: 2025-06-28 08:44+0300\\nPO-Revision-Date: \\nLast-Translator: Automatically generated\\nLanguage-Team: none\\nLanguage: nl\\nMIME-Version: 1.0\\nContent-Type: text/plain; charset=utf-8\\nContent-Transfer-Encoding: 8bit\\nPlural-Forms: nplurals=2; plural=n != 1;\\nX-Generator: @lingui/cli\\n\"\n\n#: apps/postybirb-ui/src/components/translations/validation-translation.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"({fileSizeString}) is too large (max {maxFileSizeString}) and an attempt will be made to reduce size when posting\"\nmsgstr \"\"\n\n#. placeholder {0}: tag.post_count\n#: apps/postybirb-ui/src/website-components/e621/e621-tag-search-provider.tsx\nmsgid \"{0, plural, one {# post} other {# posts}}\"\nmsgstr \"\"\n\n#. placeholder {0}: Object.keys(optionsGroupedByWebsiteId).length\n#: apps/postybirb-ui/src/components/submissions/submission-view/submission-view-card/submission-view-card.tsx\nmsgid \"{0, plural, one {website} other {websites}}\"\nmsgstr \"\"\n\n#. placeholder {0}: value.length\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker.tsx\nmsgid \"{0} submissions selected\"\nmsgstr \"\"\n\n#. placeholder {0}: account?.websiteDisplayName\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\nmsgid \"{0}: Login successful\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/translations/common-translations.tsx\nmsgid \"{children} created\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/translations/common-translations.tsx\nmsgid \"{children} deleted\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/translations/common-translations.tsx\nmsgid \"{children} update failed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/translations/common-translations.tsx\nmsgid \"{children} updated\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"{count} items deleted\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"{count} selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-visibility-picker.tsx\nmsgid \"{hiddenCount} hidden\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"{selectedCount} of {totalCount} selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"{validCount} of {totalSelectedCount} selected submission(s) are ready to post. Submissions without websites or with validation errors will be skipped.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"{validCount} submission(s) will be posted in the order shown below.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\n#: apps/postybirb-ui/src/website-components/bluesky/bluesky-login-view.tsx\nmsgid \"<0>Tip:</0> If your handle doesn't work, try using the email linked to your account\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\n#: apps/postybirb-ui/src/website-components/telegram/telegram-login-view.tsx\nmsgid \"2FA enabled, password required. It won't be stored.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/app-settings.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/app-settings-section.tsx\nmsgid \"A\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"A shortcut with this name already exists\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/tag-converter-drawer/tag-converter-drawer.tsx\nmsgid \"A tag converter with this tag already exists\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/user-converter-drawer/user-converter-drawer.tsx\nmsgid \"A user converter with this username already exists\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/disclaimer/disclaimer.tsx\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"Accept and Continue\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Access Tiers\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/account-drawer/website-card.tsx\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/account-drawer/website-card.tsx\n#: apps/postybirb-ui/src/components/submissions/submission-history-view/submission-history-view.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Account\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-section.tsx\nmsgid \"Account data cleared\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Account name\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submission-templates/template-picker-modal/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Account Options\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"Account Status\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/account-drawer/account-drawer.tsx\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/legacy-import-settings.tsx\n#: apps/postybirb-ui/src/app/postybirb-layout/postybirb-layout.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/account-section-header.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Accounts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/account-drawer/website-card.tsx\nmsgid \"Actions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-calendar/submission-calendar.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\nmsgid \"Activate\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Activate schedule\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-calendar/submission-calendar.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\nmsgid \"Active\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/recent-activity-panel.tsx\nmsgid \"Activity will appear here as you use the app\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/translations/common-translations.tsx\nmsgid \"Add\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/translations/common-translations.tsx\nmsgid \"Add {children}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Add account\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"Add an account to start posting\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-submission-modal.tsx\nmsgid \"Add File Submission\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"Add shortcut\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\nmsgid \"Add tags...\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Add to Portfolio\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\nmsgid \"Add website\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-view/submission-view-card/submission-view-card-actions.tsx\nmsgid \"Add website options\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-edit-form/submission-file-manager/submission-file-card/file-metadata-manager/file-dimensions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Adjust dimensions while maintaining aspect ratio\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/translations/validation-translation.tsx\n#: apps/postybirb-ui/src/remake/components/shared/rating-input/rating-input.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Adult\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Adult themes\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\n#: apps/postybirb-ui/src/website-components/megalodon/megalodon-login-view.tsx\nmsgid \"After authorizing, copy the code and paste it below\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"AI generated\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/account-drawer/account-drawer.tsx\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/notifications-drawer.tsx\n#: apps/postybirb-ui/src/components/shared/postybirb-editor/custom/username-shortcut.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/account-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/inline-username-shortcut.tsx\nmsgid \"All\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/translations/validation-translation.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"All files are marked ignored.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"All healthy\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\nmsgid \"All submissions are ready to post\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/shared/postybirb-editor/custom/username-shortcut.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/inline-username-shortcut.tsx\nmsgid \"All Websites\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow comments\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow commercial use\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow free download\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow modifications of your work\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/remote-settings.tsx\nmsgid \"Allow other PostyBirb instances to connect to this one\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow other users to edit tags\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/description-settings.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/description-settings-section.tsx\nmsgid \"Allow PostyBirb to self-advertise at the end of descriptions\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow reblogging\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-edit-form/submission-file-manager/submission-file-card/file-metadata-manager/file-metadata-manager.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Alt Text\"\nmsgstr \"\"\n\n#. Bluesky login form\n#. Bluesky login form\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\n#: apps/postybirb-ui/src/website-components/bluesky/bluesky-login-view.tsx\nmsgid \"An <0>app password</0> (not your main password)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"An error occurred\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/error-boundary/notification-error-boundary.tsx\nmsgid \"An unexpected error occurred\"\nmsgstr \"\"\n\n#. placeholder {0}: dtext.length - text.length\n#: apps/postybirb-ui/src/website-components/e621/e621-dtext-renderer.tsx\nmsgid \"and {0} more\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/directory-watchers-view/directory-watchers-view.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"and {remainingCount} more\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/furtastic/furtastic-login-view.tsx\n#: apps/postybirb-ui/src/website-components/furtastic/furtastic-login-view.tsx\nmsgid \"API Key\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\n#: apps/postybirb-ui/src/website-components/twitter/twitter-login-view.tsx\nmsgid \"API Key (Consumer Key)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\n#: apps/postybirb-ui/src/website-components/twitter/twitter-login-view.tsx\nmsgid \"API keys saved successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\n#: apps/postybirb-ui/src/website-components/twitter/twitter-login-view.tsx\nmsgid \"API Secret (Consumer Secret)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/settings-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\nmsgid \"App\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\n#: apps/postybirb-ui/src/website-components/telegram/telegram-login-view.tsx\nmsgid \"App API Hash\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\n#: apps/postybirb-ui/src/website-components/telegram/telegram-login-view.tsx\nmsgid \"App API ID\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/app-settings.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/app-settings-section.tsx\nmsgid \"App Folder\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\n#: apps/postybirb-ui/src/website-components/bluesky/bluesky-login-view.tsx\nmsgid \"App Password\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\n#: apps/postybirb-ui/src/website-components/megalodon/megalodon-login-view.tsx\nmsgid \"App registered successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/app-settings.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/app-settings-section.tsx\nmsgid \"App Server Port\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\n#: apps/postybirb-ui/src/website-components/bluesky/bluesky-login-view.tsx\nmsgid \"App view URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\nmsgid \"Appearance\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Applied template options to {successCount} submissions\"\nmsgstr \"\"\n\n#. placeholder {0}: submissionIds.length\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/save-to-many-action.tsx\nmsgid \"Applied to {0} submission(s)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Applied to {successCount} submissions, {failedCount} failed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Apply\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/apply-template-action.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Apply template\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-view/submission-view-card/submission-view-actions/apply-submission-template-action.tsx\n#: apps/postybirb-ui/src/pages/submission/edit-submission-page.tsx\n#: apps/postybirb-ui/src/pages/submission/multi-edit-submission-page.tsx\nmsgid \"Apply Template\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-uploader/submission-uploader.tsx\nmsgid \"Apply template to uploaded files (optional)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/cron-builder.tsx\nmsgid \"April\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\nmsgid \"Archive\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/header/submission-edit-card-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-section.tsx\nmsgid \"Archived\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/dashboard/submissions-list/submissions-list.tsx\nmsgid \"Are you sure you want to cancel this submission? The app is not responsible for any partial post clean-up.\"\nmsgstr \"\"\n\n#. placeholder {0}: selectedIds.length\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-section.tsx\nmsgid \"Are you sure you want to delete {0} submission(s)? This action cannot be undone.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/translations/common-translations.tsx\nmsgid \"Are you sure you want to delete? This action cannot be undone.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-view/submission-view-card/submission-view-actions/post-selected-submissions-action.tsx\nmsgid \"Are you sure you want to post all selected submissions?\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Are you sure you want to reset all remote connection settings to their defaults? This will clear the saved host URL and password.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/directory-watchers-view/directory-watchers-view.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Are you sure you want to watch this folder?\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Artist name\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Artist URL (Off-Site only)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Aspect Ratio\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-dropzone.tsx\nmsgid \"Attach as many files as you like, each file will become a separate submission\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Attach Images as Attachments\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Audience\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/translations/validation-translation.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Audio\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/cron-builder.tsx\nmsgid \"August\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\n#: apps/postybirb-ui/src/website-components/telegram/telegram-login-view.tsx\nmsgid \"Authenticate\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\n#: apps/postybirb-ui/src/website-components/megalodon/megalodon-login-view.tsx\nmsgid \"Authorization Code\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\n#: apps/postybirb-ui/src/website-components/twitter/twitter-login-view.tsx\nmsgid \"Authorization URL generated\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\n#: apps/postybirb-ui/src/website-components/megalodon/megalodon-login-view.tsx\nmsgid \"Authorize\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Authorized viewers\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/appearance-settings-section.tsx\nmsgid \"Auto\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/directory-watchers-view/directory-watchers-view.tsx\nmsgid \"Auto Importer\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/pages/submission/file-submission-management-page.tsx\nmsgid \"Auto Importers\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Automatically import new files from specified folders\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/directory-watchers-view/directory-watchers-view.tsx\nmsgctxt \"directory-watcher.description\"\nmsgid \"Automatically import new files from specified folders into PostyBirb\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/app-settings.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/app-settings-section.tsx\nmsgid \"B\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\n#: apps/postybirb-ui/src/website-components/twitter/twitter-login-view.tsx\nmsgid \"Back\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Block guests\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Blog\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/translations/validation-translation.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Bluesky automatically converts GIFs to videos.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/app-settings.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/app-settings-section.tsx\nmsgid \"Browse\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/submission-scheduler.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Builder\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/disclaimer/disclaimer.tsx\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"By using this application, you acknowledge that you are solely responsible for how you use it and for any content you create, upload, distribute, or interact with. The authors and maintainers provide this software as is without warranties of any kind and are not liable for any damages or losses resulting from its use.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/app-settings.tsx\n#: apps/postybirb-ui/src/components/submissions/directory-watchers-view/directory-watchers-view.tsx\n#: apps/postybirb-ui/src/remake/components/confirm-action-modal/confirm-action-modal.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/app-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\n#: apps/postybirb-ui/src/translations/common-translations.tsx\nmsgid \"Cancel\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"Cancel posting\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-card.tsx\nmsgid \"Cannot delete the only file\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Category\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/app-settings.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/app-settings-section.tsx\nmsgid \"Changing UI mode requires a page reload. Any unsaved changes will be lost. Continue?\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Channel\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Characters\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Charge Patrons\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\nmsgid \"Check login status\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\n#: apps/postybirb-ui/src/website-components/bluesky/bluesky-login-view.tsx\nmsgid \"Check that handle and password are valid or try using email instead of handle.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/website-components/website-login-helpers.tsx\nmsgid \"Check that provided credentials are valid\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\n#: apps/postybirb-ui/src/website-components/telegram/telegram-login-view.tsx\nmsgid \"Check your telegram messages\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\nmsgid \"Checking...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-content.tsx\nmsgid \"Choose an account from the list on the left\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/form/user-specified-website-options-modal/user-specified-website-options-modal.tsx\nmsgid \"Choose Fields\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-picker/submission-picker-modal.tsx\nmsgid \"Choose Submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submission-templates/template-picker-modal/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgctxt \"template.picker-modal-header\"\nmsgid \"Choose Templates\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/submission-scheduler.tsx\nmsgid \"Choose when to post this submission\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/app-settings.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/app-settings-section.tsx\nmsgid \"Choose which UI experience to use. Changing this will reload the page.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/notifications-drawer.tsx\n#: apps/postybirb-ui/src/translations/common-translations.tsx\nmsgid \"Clear\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-uploader/submission-uploader.tsx\nmsgid \"Clear all\"\nmsgstr \"\"\n\n#. placeholder {0}: notifications.length\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/notifications-drawer.tsx\nmsgid \"Clear All ({0})\"\nmsgstr \"\"\n\n#. placeholder {0}: filteredNotifications.length\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/notifications-drawer.tsx\nmsgid \"Clear Filtered ({0})\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\n#: apps/postybirb-ui/src/website-components/megalodon/megalodon-login-view.tsx\nmsgid \"Click here to authorize PostyBirb\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\n#: apps/postybirb-ui/src/website-components/twitter/twitter-login-view.tsx\nmsgid \"Click the button below to get your authorization URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\n#: apps/postybirb-ui/src/website-components/twitter/twitter-login-view.tsx\nmsgid \"Click the link above, authorize the app, and copy the PIN code below\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Client\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/remote-settings.tsx\nmsgid \"Client Mode\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/translations/common-translations.tsx\nmsgid \"Close\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\n#: apps/postybirb-ui/src/website-components/telegram/telegram-login-view.tsx\nmsgid \"Cloud Password\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\n#: apps/postybirb-ui/src/website-components/telegram/telegram-login-view.tsx\nmsgid \"Code\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\n#: apps/postybirb-ui/src/website-components/telegram/telegram-login-view.tsx\nmsgid \"Code invalid, ensure it matches the code sent from telegram\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\n#: apps/postybirb-ui/src/website-components/telegram/telegram-login-view.tsx\nmsgid \"Code sent\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-edit-form/submission-file-manager/submission-file-card/submission-file-card.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-card.tsx\nmsgid \"Collapse\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Collections\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Comment permissions\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Commercial use\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/cron-builder.tsx\nmsgid \"Common Schedules\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Compact view\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\n#: apps/postybirb-ui/src/website-components/twitter/twitter-login-view.tsx\nmsgid \"Complete Authorization\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\n#: apps/postybirb-ui/src/website-components/twitter/twitter-login-view.tsx\nmsgid \"Complete Login\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Configuration Error\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\n#: apps/postybirb-ui/src/website-components/twitter/twitter-login-view.tsx\nmsgid \"Configure API Keys\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/directory-watchers-view/directory-watchers-view.tsx\n#: apps/postybirb-ui/src/remake/components/confirm-action-modal/confirm-action-modal.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Confirm\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/dashboard/submissions-list/submissions-list.tsx\nmsgid \"Confirm Cancellation\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/remote-settings.tsx\nmsgid \"Connect to another PostyBirb instance as a remote client\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Connected successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\nmsgid \"Connection error\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Connection Failed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/remote-settings.tsx\nmsgid \"Connection Information\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Contains content\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Content Blur\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Content warning\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/shared/postybirb-editor/custom/inline-system-shortcuts.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/inline-system-shortcuts.tsx\nmsgid \"Content Warning\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Continue from where it left off\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/tag-converter-drawer.tsx\nmsgid \"Conversion\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/copy-to-clipboard/copy-to-clipboard.tsx\nmsgid \"Copied\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\n#: apps/postybirb-ui/src/translations/common-translations.tsx\nmsgid \"Copied to clipboard\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/copy-to-clipboard/copy-to-clipboard.tsx\n#: apps/postybirb-ui/src/remake/components/shared/copy-to-clipboard/copy-to-clipboard.tsx\nmsgid \"Copy\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/translations/common-translations.tsx\nmsgid \"Copy to clipboard\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Create\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Create a File or Message submission to start posting\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Create a file watcher to automatically import new files from a folder\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\n#: apps/postybirb-ui/src/website-components/bluesky/bluesky-login-view.tsx\nmsgid \"Create an app password in Bluesky Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Create Message\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/pages/submission/message-submission-management-page.tsx\nmsgid \"Create Message Submission\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/tag-converter-drawer.tsx\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/tag-group-drawer.tsx\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/user-converter-drawer.tsx\nmsgid \"Create New\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Create new file watcher\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/directory-watchers-view/directory-watchers-view.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Create new submission\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Create Submission\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\n#: apps/postybirb-ui/src/website-components/telegram/telegram-login-view.tsx\nmsgctxt \"telegram.app-id-help\"\nmsgid \"Create telegram app to retrieve api_id and api_hash\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-history-view/submission-history-view.tsx\n#: apps/postybirb-ui/src/translations/common-translations.tsx\nmsgid \"Created\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Created successfully\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Creative Commons\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Creative Commons license\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/submission-scheduler.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"CRON Expression\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-uploader/edit-image-modal.tsx\n#: apps/postybirb-ui/src/components/submissions/submission-uploader/edit-image-modal.tsx\nmsgid \"Crop\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-edit-form/submission-file-manager/submission-file-card/file-card-file-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Crop from primary\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Ctrl+Enter to apply\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-edit-form/submission-file-manager/submission-file-card/file-metadata-manager/custom-account-dimensions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Custom\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-edit-form/submission-file-manager/submission-file-card/file-metadata-manager/custom-account-dimensions.tsx\nmsgid \"Custom Dimensions\"\nmsgstr \"\"\n\n#. placeholder {0}: website.displayName\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/custom-login-placeholder.tsx\nmsgid \"Custom login for {0}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\n#: apps/postybirb-ui/src/website-components/bluesky/bluesky-login-view.tsx\nmsgid \"Custom PDS or AppView\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/legacy-import-settings.tsx\n#: apps/postybirb-ui/src/app/postybirb-layout/postybirb-layout.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Custom Shortcuts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/spellchecker-settings.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/spellchecker-settings-section.tsx\nmsgid \"Custom words\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Daily\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/cron-builder.tsx\nmsgid \"Daily at 9 AM\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/cron-builder.tsx\nmsgid \"Daily at midnight\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/appearance-settings-section.tsx\nmsgid \"Dark\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/theme-picker/theme-picker.tsx\n#: apps/postybirb-ui/src/remake/components/theme-picker/theme-picker.tsx\nmsgid \"Dark Mode\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/pages/home/home-page.tsx\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Dashboard\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-view/submission-view-card/submission-view-multi-scheduler-modal.tsx\nmsgid \"Date\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/translations/validation-translation.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Date {currentDate} is after maximum allowed date {maxDate}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/translations/validation-translation.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Date {currentDate} is before minimum allowed date {minDate}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/translations/validation-translation.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Date {currentDate} is outside allowed range ({minDate} - {maxDate})\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Date and Time\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/submission-scheduler.tsx\nmsgid \"Date and Time Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/submission-scheduler.tsx\nmsgid \"Date cannot be in the past\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Date is in the past\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/cron-builder.tsx\nmsgid \"Day (1-31)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/cron-builder.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Day of Month\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/cron-builder.tsx\nmsgid \"Day of Week\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-view/submission-view-card/submission-view-multi-scheduler-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Days\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/cron-builder.tsx\nmsgid \"December\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/disclaimer/disclaimer.tsx\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"Decline and Exit\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/form/fields/radio-field.tsx\n#: apps/postybirb-ui/src/components/submission-templates/template-picker-modal/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/radio-field.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/default-shortcut-block.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\n#: apps/postybirb-ui/src/translations/common-translations.tsx\nmsgid \"Default\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/defaults-form/defaults-form.tsx\nmsgid \"Defaults\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\nmsgid \"Defaults saved successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-uploader/submission-uploader.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-preview.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-section.tsx\n#: apps/postybirb-ui/src/translations/common-translations.tsx\nmsgid \"Delete\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\nmsgid \"Delete all existing website options and use only those specified in the source.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-card.tsx\nmsgid \"Delete file\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/archived-submission-card.tsx\nmsgid \"Delete permanently\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-section.tsx\nmsgid \"Delete Submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/translations/common-translations.tsx\nmsgid \"Deleted\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Deleted successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/settings-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: apps/postybirb-ui/src/website-components/e621/e621-tag-search-provider.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Description\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/translations/validation-translation.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Description length is greater then maximum ({currentLength} / {maxLength})\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/translations/validation-translation.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Description length is lower then minimum ({currentLength} / {minLength})\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/description-settings.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/description-settings-section.tsx\nmsgid \"Description Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-selection-form.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Deselect all\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/form/website-select/website-select.tsx\n#: apps/postybirb-ui/src/components/shared/postybirb-editor/custom/username-shortcut.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/inline-username-shortcut.tsx\nmsgid \"Deselect All\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/notifications-settings.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"Desktop Notifications\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Detailed view\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-edit-form/submission-file-manager/submission-file-card/file-metadata-manager/file-dimensions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Dimensions\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Disable comments\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Discard all progress from the previous attempt and start as if this were the first time posting.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Discord\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Display resolution\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Double-click to toggle crop/move mode\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/postybirb-update-button.tsx\n#: apps/postybirb-ui/src/remake/components/update-button/update-button.tsx\nmsgid \"Download Update\"\nmsgstr \"\"\n\n#. placeholder {0}: update.updateProgress?.toFixed(0)\n#: apps/postybirb-ui/src/remake/components/update-button/update-button.tsx\nmsgid \"Downloading update... {0}%\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-button.tsx\nmsgid \"Downloading...\"\nmsgstr \"\"\n\n#. placeholder {0}: update.updateProgress?.toFixed(0)\n#: apps/postybirb-ui/src/app/postybirb-layout/postybirb-update-button.tsx\n#: apps/postybirb-ui/src/remake/components/update-button/update-button.tsx\nmsgid \"Downloading... {0}%\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-dropzone.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-uploader.tsx\nmsgid \"Drag files here or click to select\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-view/submission-view-card/submission-view-multi-scheduler-modal.tsx\nmsgid \"Drag or use arrow keys to change order.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/reorderable-submission-list/reorderable-submission-list.tsx\nmsgid \"Drag or use arrow keys to reorder\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/submission-list.tsx\nmsgid \"Drag submissions onto the calendar to schedule them\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-edit-form/submission-file-manager/submission-file-card/submission-file-card.tsx\n#: apps/postybirb-ui/src/components/submissions/submission-edit-form/submission-file-manager/submission-file-manager.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-manager.tsx\nmsgid \"Drag to reorder\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-schedule-view/submission-schedule-view.tsx\nmsgid \"Drag to schedule\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-list.tsx\nmsgid \"Drop files above or click to browse\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-uploader/submission-uploader.tsx\nmsgid \"Drop files here or click to add more\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-uploader/submission-uploader.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Drop files here or click to browse\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Drug / Alcohol\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-view/submission-view-card/submission-view-card-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\nmsgid \"Duplicate\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/e621/e621-login-view.tsx\n#: apps/postybirb-ui/src/website-components/e621/e621-login-view.tsx\nmsgid \"e621 requires API credentials for posting. You'll need to generate an API key from your account settings to authenticate PostyBirb.\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Early Access\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\n#: apps/postybirb-ui/src/translations/common-translations.tsx\nmsgid \"Edit\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-uploader/submission-uploader.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-preview.tsx\nmsgid \"Edit image\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Edit Image\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-view/submission-view-card/submission-view-actions/multi-edit-submissions-action.tsx\nmsgid \"Edit Many\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-card.tsx\nmsgid \"Edit metadata\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/pages/submission/multi-edit-submission-page.tsx\nmsgid \"Edit Multiple\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-preview.tsx\nmsgid \"Edit title\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/furtastic/furtastic-login-view.tsx\n#: apps/postybirb-ui/src/website-components/furtastic/furtastic-login-view.tsx\nmsgid \"Email\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/notifications-settings.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"Enable desktop notifications\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/spellchecker-settings.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/spellchecker-settings-section.tsx\nmsgid \"Enabled\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/languages.tsx\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"English\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Ensure the IP is correct\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/pages/submission/message-submission-management-page.tsx\nmsgid \"Enter a name for the new message submission\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\n#: apps/postybirb-ui/src/website-components/megalodon/megalodon-login-view.tsx\nmsgid \"Enter Instance URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Enter the host URL and password to connect to a remote PostyBirb instance.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\n#: apps/postybirb-ui/src/website-components/twitter/twitter-login-view.tsx\nmsgid \"Enter the PIN from Twitter\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\n#: apps/postybirb-ui/src/website-components/megalodon/megalodon-login-view.tsx\nmsgid \"Enter the URL of your Mastodon, Pleroma, or Pixelfed instance (e.g., \\\"mastodon.social\\\" or \\\"pixelfed.social\\\")\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Enter title...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\n#: apps/postybirb-ui/src/website-components/twitter/twitter-login-view.tsx\nmsgid \"Enter your Twitter app credentials\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/notifications-drawer.tsx\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/notifications-drawer.tsx\n#: apps/postybirb-ui/src/components/dashboard/recent-posts/recent-posts.tsx\n#: apps/postybirb-ui/src/components/dashboard/submissions-list/submissions-list.tsx\n#: apps/postybirb-ui/src/components/dashboard/submissions-list/submissions-list.tsx\n#: apps/postybirb-ui/src/components/dashboard/submissions-list/submissions-list.tsx\n#: apps/postybirb-ui/src/components/error-boundary/error-boundary.tsx\n#: apps/postybirb-ui/src/components/submissions/submission-history-view/submission-history-view.tsx\n#: apps/postybirb-ui/src/components/submissions/submission-uploader/submission-uploader.tsx\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-option-row.tsx\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Error\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/dashboard/recent-posts/recent-posts.tsx\nmsgid \"Error Details\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\n#: apps/postybirb-ui/src/website-components/telegram/telegram-login-view.tsx\nmsgid \"Error while authenticating Telegram\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\nmsgid \"errors\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/dashboard/recent-posts/recent-posts.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Errors\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/cron-builder.tsx\nmsgid \"Every day\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/cron-builder.tsx\nmsgid \"Every month\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/submission-scheduler.tsx\nmsgid \"Example: \\\"0 9 * * MON\\\" runs every Monday at 9:00 AM\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-edit-form/submission-file-manager/submission-file-card/submission-file-card.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"Expand\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Explicit text\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/translations/validation-translation.tsx\n#: apps/postybirb-ui/src/remake/components/shared/rating-input/rating-input.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Extreme\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"failed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/dashboard/recent-posts/recent-posts.tsx\n#: apps/postybirb-ui/src/components/submissions/submission-history-view/submission-history-view.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Failed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"Failed Posts Detected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Failed to apply template options\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-section.tsx\nmsgid \"Failed to clear account data\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\n#: apps/postybirb-ui/src/website-components/twitter/twitter-login-view.tsx\nmsgid \"Failed to complete authorization\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Failed to connect to remote host\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\nmsgid \"Failed to connect to the server\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to create\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\nmsgid \"Failed to create template\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to delete\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-card.tsx\nmsgid \"Failed to delete file\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to duplicate\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\n#: apps/postybirb-ui/src/website-components/twitter/twitter-login-view.tsx\nmsgid \"Failed to generate authorization URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/translations/validation-translation.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Failed to get user warnings. You can check your account manually\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Failed to load primary file for cropping.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to post submission\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-view/submission-view-card/submission-view-card-actions.tsx\nmsgid \"Failed to queue submission\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\n#: apps/postybirb-ui/src/website-components/megalodon/megalodon-login-view.tsx\nmsgid \"Failed to register with instance\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Failed to replace file\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to restore\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to save\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/form-fields-context.tsx\nmsgid \"Failed to save changes\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-alt-text-editor.tsx\nmsgid \"Failed to save fallback text\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Failed to save metadata\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-view/submission-view-card/submission-view-card-actions.tsx\nmsgid \"Failed to schedule submission\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Failed to schedule submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\n#: apps/postybirb-ui/src/website-components/telegram/telegram-login-view.tsx\nmsgid \"Failed to send code to begin authentication\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\n#: apps/postybirb-ui/src/website-components/twitter/twitter-login-view.tsx\nmsgid \"Failed to store API keys\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\n#: apps/postybirb-ui/src/translations/common-translations.tsx\nmsgid \"Failed to update\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\nmsgid \"Failed to update template name\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to upload files\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/translations/validation-translation.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Failed to validate submission: {message}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/translations/validation-translation.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Failed to validate tags. Please check them manually\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-edit-form/submission-file-manager/submission-file-card/file-text-alt.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-alt-text-editor.tsx\nmsgid \"Fallback Text\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Feature\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/cron-builder.tsx\nmsgid \"February\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\n#: apps/postybirb-ui/src/website-components/megalodon/megalodon-login-view.tsx\nmsgid \"Fediverse Authentication\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Female\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\n#: apps/postybirb-ui/src/translations/common-translations.tsx\nmsgid \"File\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/postybirb-spotlight/postybirb-spotlight.tsx\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-content.tsx\nmsgid \"File Submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-edit-form/submission-file-manager/submission-file-card/file-card-file-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"File types do not match. Please upload a file of the same type.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/notifications-settings.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"File Watcher Failure\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/notifications-settings.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"File Watcher Success\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"File Watchers\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/translations/validation-translation.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"File will be modified to support website requirements\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-uploader/submission-uploader.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-list.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-manager.tsx\nmsgid \"Files\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-uploader/submission-uploader.tsx\nmsgid \"Files have been uploaded successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Files uploaded successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-view/submission-view-card/submission-view-card-actions.tsx\nmsgid \"Fix validation issues\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Flip\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Flip horizontal\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Flip vertical\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Folder\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/directory-watchers-view/directory-watchers-view.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Folder Contains Files\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/directory-watchers-view/directory-watchers-view.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Folder Path\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/error-boundary/specialized-error-boundaries.tsx\n#: apps/postybirb-ui/src/remake/components/error-boundary/specialized-error-boundaries.tsx\nmsgid \"Form Error\"\nmsgstr \"\"\n\n#. Bluesky login form\n#. Bluesky login form\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\n#: apps/postybirb-ui/src/website-components/bluesky/bluesky-login-view.tsx\nmsgid \"Format should be <0>handle.bsky.social</0> or <1>domain.ext</1> for custom domains\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Format: minute hour day month weekday\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\n#: apps/postybirb-ui/src/website-components/twitter/twitter-login-view.tsx\nmsgid \"Found in your Twitter app's Keys and tokens section\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Fr\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Frequency\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/cron-builder.tsx\nmsgid \"Friday\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Friends only\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Furry\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/furtastic/furtastic-login-view.tsx\n#: apps/postybirb-ui/src/website-components/furtastic/furtastic-login-view.tsx\nmsgid \"Furtastic requires API credentials for posting. Use your email address and an API key from your account settings.\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Gender\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/translations/validation-translation.tsx\n#: apps/postybirb-ui/src/remake/components/shared/rating-input/rating-input.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"General\"\nmsgstr \"\"\n\n#. E621 login form\n#. E621 login form\n#: apps/postybirb-ui/src/remake/components/website-login-views/e621/e621-login-view.tsx\n#: apps/postybirb-ui/src/website-components/e621/e621-login-view.tsx\nmsgid \"Generate an API key from your <0>account settings</0>\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/furtastic/furtastic-login-view.tsx\n#: apps/postybirb-ui/src/website-components/furtastic/furtastic-login-view.tsx\nmsgctxt \"furtastic.api-key-help\"\nmsgid \"Generate an API key from your <0>account settings</0>\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\n#: apps/postybirb-ui/src/website-components/twitter/twitter-login-view.tsx\nmsgid \"Generate authorization link\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/languages.tsx\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"German\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\n#: apps/postybirb-ui/src/website-components/twitter/twitter-login-view.tsx\nmsgid \"Get Authorization\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\n#: apps/postybirb-ui/src/website-components/megalodon/megalodon-login-view.tsx\nmsgid \"Get authorization code\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\n#: apps/postybirb-ui/src/website-components/twitter/twitter-login-view.tsx\nmsgid \"Get Authorization URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Get started by adding your accounts and creating your first submission.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/tags-settings.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\nmsgid \"Global tag search provider\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/pages/not-found/not-found.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\n#: apps/postybirb-ui/src/website-components/megalodon/megalodon-login-view.tsx\nmsgid \"Go back\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\nmsgid \"Go forward\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Go to Accounts to add and log into your websites\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Gore\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Has sexual content\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Has validation errors\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Has validation warnings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-edit-form/submission-file-manager/submission-file-card/file-metadata-manager/custom-account-dimensions.tsx\n#: apps/postybirb-ui/src/components/submissions/submission-edit-form/submission-file-manager/submission-file-card/file-metadata-manager/file-dimensions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Height\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/translations/common-translations.tsx\nmsgid \"Hide\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/account-drawer/website-visibility-picker.tsx\nmsgid \"Hide All\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/tag-converter-drawer.tsx\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/tag-group-drawer.tsx\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/user-converter-drawer.tsx\nmsgid \"Hide Form\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\nmsgid \"Higher boost levels allow larger file uploads\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/pages/submission/edit-submission-page.tsx\n#: apps/postybirb-ui/src/pages/submission/file-submission-management-page.tsx\n#: apps/postybirb-ui/src/pages/submission/message-submission-management-page.tsx\nmsgid \"History\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"Hold to delete\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\nmsgid \"Hold to Delete\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\nmsgid \"Hold to delete {count} item(s)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"Hold to delete permanently\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"Hold to post\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/postybirb-layout.tsx\n#: apps/postybirb-ui/src/app/postybirb-layout/postybirb-spotlight/postybirb-spotlight.tsx\n#: apps/postybirb-ui/src/pages/home/home-page.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Home\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Host\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/remote-settings.tsx\nmsgid \"Host Mode\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/remote-settings.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Host URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/cron-builder.tsx\nmsgid \"Hour (0-23)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/cron-builder.tsx\nmsgid \"Hour of the day\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-view/submission-view-card/submission-view-multi-scheduler-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Hours\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\n#: apps/postybirb-ui/src/website-components/discord/discord-login-view.tsx\nmsgctxt \"discord.webhook-help\"\nmsgid \"How to create a webhook\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/disclaimer/disclaimer.tsx\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"I have read, understand, and accept the disclaimer.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\n#: apps/postybirb-ui/src/website-components/bluesky/bluesky-login-view.tsx\nmsgid \"If you are using pds other then bsky.social\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/disclaimer/disclaimer.tsx\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"If you do not agree with these terms, you must decline and exit the application.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/remote-settings.tsx\nmsgid \"If you need to connect from outside your local network you will need to set up port forwarding on your router and use the public IP of the host computer.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/form/fields/tag-field.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/tag-field.tsx\nmsgctxt \"override-default\"\nmsgid \"Ignore default tags\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/translations/validation-translation.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Image\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-uploader.tsx\nmsgid \"Images, videos, audio, and text files up to 100MB\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/legacy-import-settings.tsx\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/settings-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\nmsgid \"Import\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/directory-watchers-view/directory-watchers-view.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Import Action\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/legacy-import-settings.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\nmsgid \"Import completed successfully!\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\nmsgid \"Import encountered errors:\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/legacy-import-settings.tsx\nmsgid \"Import Errors\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/legacy-import-settings.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\nmsgid \"Import from PostyBirb Plus\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/legacy-import-settings.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\nmsgid \"Import your data from PostyBirb Plus. Select which types of data you want to import.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/directory-watchers-view/directory-watchers-view.tsx\n#: apps/postybirb-ui/src/components/submissions/directory-watchers-view/directory-watchers-view.tsx\n#: apps/postybirb-ui/src/components/submissions/directory-watchers-view/directory-watchers-view.tsx\nmsgid \"Importer\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\nmsgid \"Inactive\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/notifications-drawer.tsx\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Info\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/form/fields/description-field.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-field.tsx\nmsgid \"Insert tags at end\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/form/fields/description-field.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-field.tsx\nmsgid \"Insert title at start\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\n#: apps/postybirb-ui/src/website-components/megalodon/megalodon-login-view.tsx\nmsgid \"Instance URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Invalid CRON expression\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/translations/validation-translation.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Invalid date format: {value}. Expected ISO date string.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/directory-watchers-view/directory-watchers-view.tsx\nmsgid \"Invalid folder\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/translations/validation-translation.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Invalid post URL to reply to.\"\nmsgstr \"\"\n\n#. Bluesky login form\n#. Bluesky login form\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\n#: apps/postybirb-ui/src/website-components/bluesky/bluesky-login-view.tsx\nmsgid \"Invalid url. Format should be <0>https://bsky.app/</0>\"\nmsgstr \"\"\n\n#. Bluesky login form\n#. Bluesky login form\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\n#: apps/postybirb-ui/src/website-components/bluesky/bluesky-login-view.tsx\nmsgid \"Invalid url. Format should be <0>https://bsky.social/</0>\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/translations/validation-translation.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"It is recommended to add at least 10 general tags <0>( {generalTags} / 10 )</0>. See <1>tagging checklist</1>\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/cron-builder.tsx\nmsgid \"January\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/cron-builder.tsx\nmsgid \"July\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/cron-builder.tsx\nmsgid \"June\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\n#: apps/postybirb-ui/src/website-components/twitter/twitter-login-view.tsx\nmsgid \"Keep this secret and never share it publicly\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Ko-fi\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"LAN IP\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/language-picker.tsx\n#: apps/postybirb-ui/src/app/postybirb-layout/language-picker.tsx\n#: apps/postybirb-ui/src/remake/components/language-picker/language-picker.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Language\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/spellchecker-settings.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/spellchecker-settings-section.tsx\nmsgid \"Languages to check\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-view/submission-view-card/submission-view-card.tsx\nmsgid \"Last modified\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/form/fields/description-field.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-field.tsx\nmsgid \"Legacy Shortcuts Detected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/disclaimer/disclaimer.tsx\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"Legal Notice & Disclaimer\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\nmsgid \"Level 1\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\nmsgid \"Level 2\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\nmsgid \"Level 3\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/appearance-settings-section.tsx\nmsgid \"Light\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/theme-picker/theme-picker.tsx\n#: apps/postybirb-ui/src/remake/components/theme-picker/theme-picker.tsx\nmsgid \"Light Mode\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/languages.tsx\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"Lithuanian\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\n#: apps/postybirb-ui/src/website-components/megalodon/megalodon-login-view.tsx\nmsgid \"Log in to different instance\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"logged in\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/account-drawer/account-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/account-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\n#: apps/postybirb-ui/src/translations/common-translations.tsx\n#: apps/postybirb-ui/src/website-components/megalodon/megalodon-login-view.tsx\nmsgid \"Logged In\"\nmsgstr \"\"\n\n#. placeholder {0}: username ? ` as ${username}` : ''\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\nmsgid \"Logged in{0}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/account-drawer/website-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\n#: apps/postybirb-ui/src/website-components/megalodon/megalodon-login-view.tsx\nmsgid \"Login\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\n#: apps/postybirb-ui/src/website-components/website-login-helpers.tsx\n#: apps/postybirb-ui/src/website-components/website-login-helpers.tsx\nmsgid \"Login failed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/website-components/website-login-helpers.tsx\nmsgid \"Login success\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/translations/validation-translation.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Make sure that the default rating matches Bluesky Label Rating.\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Male\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"Manage accounts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/submission-scheduler.tsx\nmsgid \"Manual\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/cron-builder.tsx\nmsgid \"March\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/notifications-drawer.tsx\nmsgid \"Mark All as Read\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Mark as read\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/notifications-drawer.tsx\nmsgid \"Mark as Read\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Mark as unread\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/notifications-drawer.tsx\nmsgid \"Mark as Unread\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Marked as read\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-content.tsx\nmsgid \"Mass Edit\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/translations/validation-translation.tsx\n#: apps/postybirb-ui/src/remake/components/shared/rating-input/rating-input.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Mature\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Mature content\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/cron-builder.tsx\nmsgid \"May\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Media\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/pages/submission/multi-edit-submission-page.tsx\nmsgid \"Merge\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\nmsgid \"Merge (recommended)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\nmsgid \"Merge Mode\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\n#: apps/postybirb-ui/src/translations/common-translations.tsx\nmsgid \"Message\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/postybirb-spotlight/postybirb-spotlight.tsx\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-content.tsx\nmsgid \"Message Submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/cron-builder.tsx\nmsgid \"Minute (0-59)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/cron-builder.tsx\nmsgid \"Minute of the hour\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-view/submission-view-card/submission-view-multi-scheduler-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Minutes\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Mo\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Modification\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Modified\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/cron-builder.tsx\nmsgid \"Monday\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/cron-builder.tsx\nmsgid \"Month\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Monthly\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/cron-builder.tsx\nmsgid \"Monthly (1st of month)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/header/submission-edit-card-header.tsx\nmsgid \"Multi Edit\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/account-drawer/website-card.tsx\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/account-drawer/website-card.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\n#: apps/postybirb-ui/src/translations/common-translations.tsx\nmsgid \"Name\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"Name is required\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/postybirb-spotlight/postybirb-spotlight.tsx\n#: apps/postybirb-ui/src/app/postybirb-layout/postybirb-spotlight/postybirb-spotlight.tsx\n#: apps/postybirb-ui/src/app/postybirb-layout/postybirb-spotlight/postybirb-spotlight.tsx\nmsgid \"Navigation\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"need attention\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/notifications-drawer.tsx\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/tag-converter-drawer.tsx\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/tag-group-drawer.tsx\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/user-converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\n#: apps/postybirb-ui/src/translations/common-translations.tsx\nmsgid \"New\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/translations/common-translations.tsx\nmsgid \"New {children}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"New account\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"New Message\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\nmsgid \"New template name...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/submission-scheduler.tsx\nmsgid \"Next\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/submission-scheduler.tsx\nmsgid \"Next run\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/dashboard/submissions-list/submissions-list.tsx\nmsgid \"No\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"No accounts added\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/dashboard/submissions-list/submissions-list.tsx\nmsgid \"No Active Submissions\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"No AI\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"No changes\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"No custom dimensions set\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/defaults-form/defaults-form.tsx\nmsgid \"No default options available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"No file watchers configured\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-list.tsx\nmsgid \"No files added yet\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/directory-watchers-view/directory-watchers-view.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"No folder selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-history-view/submission-history-view.tsx\nmsgid \"No history available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/inline-username-shortcut.tsx\nmsgid \"No items found\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/empty-state/empty-state.tsx\nmsgid \"No notifications\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"No options selected to apply\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"No post history available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/recent-activity-panel.tsx\nmsgid \"No recent activity\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/empty-state/empty-state.tsx\nmsgid \"No records yet\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/empty-state/empty-state.tsx\n#: apps/postybirb-ui/src/translations/common-translations.tsx\nmsgid \"No results found\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"No schedule configured\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/upcoming-posts-panel.tsx\nmsgid \"No scheduled posts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/submission-scheduler.tsx\nmsgid \"No scheduling\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-view/submission-view.tsx\nmsgid \"No submissions available. Upload some files to get started!\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker.tsx\nmsgid \"No submissions found\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/dashboard/submissions-list/submissions-list.tsx\nmsgid \"No submissions in queue or scheduled\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/reorderable-submission-list/reorderable-submission-list.tsx\nmsgid \"No submissions selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker.tsx\nmsgid \"No templates found\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"No templates or submissions found\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\nmsgid \"No validation issues\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\nmsgid \"No website conversions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"No website posts found\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-view/submission-view-card/submission-view-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"No websites\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-visibility-picker.tsx\nmsgid \"No websites available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\nmsgid \"No websites found\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"No websites selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/website-components/e621/e621-tag-search-provider.tsx\nmsgid \"No wiki page available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/tags-settings.tsx\n#: apps/postybirb-ui/src/components/submission-templates/template-picker-modal/template-picker-modal.tsx\n#: apps/postybirb-ui/src/components/submissions/submission-edit-form/submission-file-manager/submission-file-card/file-card-file-actions.tsx\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/submission-scheduler.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\nmsgid \"None\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/tags-settings.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\nmsgid \"Not all tag providers support this feature.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/form/website-option-form/website-option-group-section.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-option-row.tsx\n#: apps/postybirb-ui/src/remake/components/shared/account-picker/account-picker.tsx\nmsgid \"Not logged in\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/account-drawer/account-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/account-section-header.tsx\n#: apps/postybirb-ui/src/translations/common-translations.tsx\nmsgid \"Not Logged In\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Not scheduled\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/empty-state/empty-state.tsx\nmsgid \"Nothing selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/notifications-drawer.tsx\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/settings-drawer.tsx\n#: apps/postybirb-ui/src/app/postybirb-layout/postybirb-layout.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Notifications\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Notify followers\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/cron-builder.tsx\nmsgid \"November\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Nudity\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/cron-builder.tsx\nmsgid \"October\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Offer hi-res download (gold only)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Offline\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/submission-scheduler.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Once\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Online\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-view/submission-view-card/submission-view-multi-scheduler-modal.tsx\nmsgid \"Only set scheduled date\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Only set scheduled date (don't activate)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/pages/submission/multi-edit-submission-page.tsx\nmsgid \"Only use website options specified and delete website options missing from multi-update form.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Open CRON helper\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/submission-scheduler.tsx\nmsgid \"Open CRON helper website\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Open full calendar\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\n#: apps/postybirb-ui/src/website-components/inkbunny/inkbunny-login-view.tsx\nmsgid \"Open Inkbunny Account Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/app-settings.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/app-settings-section.tsx\nmsgid \"Open PostyBirb on computer startup\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\n#: apps/postybirb-ui/src/website-components/twitter/twitter-login-view.tsx\nmsgid \"Open Twitter Authorization Page\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Optional template to apply to imported files\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submission-templates/template-picker-modal/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Options\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Original work\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Other\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-edit-form/submission-file-manager/submission-file-card/file-metadata-manager/custom-account-dimensions.tsx\nmsgid \"Override default dimensions for specific accounts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\nmsgid \"Overwrite overlapping website options only. Keeps existing website options that are not specified in the source.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/pages/submission/multi-edit-submission-page.tsx\nmsgid \"Overwrite overlapping website options only. This will keep any website options that already exist and only overwrite ones specified in the multi-update form.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/pages/not-found/not-found.tsx\nmsgid \"Page not found\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Parent ID\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\n#: apps/postybirb-ui/src/website-components/inkbunny/inkbunny-login-view.tsx\nmsgid \"Password\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\n#: apps/postybirb-ui/src/website-components/telegram/telegram-login-view.tsx\nmsgid \"Password is invalid\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/remote-settings.tsx\nmsgid \"Password provided by the host to connect to their instance\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/remote-settings.tsx\nmsgid \"Password that remote clients will use to connect to this host\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-dropzone.tsx\nmsgid \"Paste from clipboard\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-dropzone.tsx\nmsgid \"Paste image from clipboard (Ctrl+V)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-calendar/submission-calendar.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\nmsgid \"Pause\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/queue-control-card.tsx\nmsgid \"Pause Posting\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/dashboard/queue-control/queue-control.tsx\nmsgid \"Pause Submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-calendar/submission-calendar.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\nmsgid \"Paused\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\n#: apps/postybirb-ui/src/website-components/bluesky/bluesky-login-view.tsx\nmsgid \"PDS (Personal Data Server)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Pending\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Per-Account Dimensions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\n#: apps/postybirb-ui/src/website-components/telegram/telegram-login-view.tsx\nmsgid \"Phone Number\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\n#: apps/postybirb-ui/src/website-components/telegram/telegram-login-view.tsx\nmsgctxt \"telegram.phone-number-help\"\nmsgid \"Phone number must be in international format\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\n#: apps/postybirb-ui/src/website-components/twitter/twitter-login-view.tsx\nmsgid \"PIN / Verifier Code\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\nmsgid \"Please check your credentials and try again\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-edit-form/submission-file-manager/submission-file-card/file-metadata-manager/file-source-urls.tsx\nmsgid \"Please enter a valid URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/submission-scheduler.tsx\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/submission-scheduler.tsx\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/submission-scheduler.tsx\nmsgid \"Please provide a valid CRON string\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/languages.tsx\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"Portuguese (Brazil)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-history-view/submission-history-view.tsx\n#: apps/postybirb-ui/src/components/submissions/submission-view/submission-view-card/submission-view-actions/post-selected-submissions-action.tsx\n#: apps/postybirb-ui/src/components/submissions/submission-view/submission-view-card/submission-view-card-actions.tsx\n#: apps/postybirb-ui/src/components/submissions/submission-view/submission-view-card/submission-view-card-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/translations/common-translations.tsx\nmsgid \"Post\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/postybirb-spotlight/postybirb-spotlight.tsx\nmsgid \"Post a file\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/postybirb-spotlight/postybirb-spotlight.tsx\nmsgid \"Post a message\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Post Data (JSON)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/notifications-settings.tsx\nmsgid \"Post failure\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"Post Failure\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/postybirb-layout.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Post Files\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-history-view/submission-history-view.tsx\nmsgid \"Post Record\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"Post Submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/notifications-settings.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"Post Success\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/dashboard/recent-posts/recent-posts.tsx\nmsgid \"Posted To\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/dashboard/submissions-list/submissions-list.tsx\nmsgid \"Posting\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\n#: apps/postybirb-ui/src/website-components/discord/discord-login-view.tsx\nmsgid \"PostyBirb uses Discord webhooks to post content to your server. Make sure you have the necessary permissions to create webhooks in your target channel.\"\nmsgstr \"\"\n\n#. Main file data\n#: apps/postybirb-ui/src/components/submissions/submission-edit-form/submission-file-manager/submission-file-card/file-card-file-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Primary\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Private\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\n#: apps/postybirb-ui/src/website-components/twitter/twitter-login-view.tsx\nmsgid \"Proceed to Authorization\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Profanity\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Public viewers\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/dashboard/submissions-list/submissions-list.tsx\nmsgid \"Queue\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/dashboard/queue-control/queue-control.tsx\nmsgid \"Queue Control\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/dashboard/dashboard-stats/dashboard-stats.tsx\n#: apps/postybirb-ui/src/components/dashboard/submissions-list/submissions-list.tsx\n#: apps/postybirb-ui/src/components/submissions/submission-view/submission-view-card/submission-view-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Queued\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/cron-builder.tsx\nmsgid \"Quick selection of common scheduling patterns\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Quick tips to get started:\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Racism\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Rating\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Read\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Ready\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Ready to post\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/pages/home/home-page.tsx\nmsgid \"Recent\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/recent-activity-panel.tsx\nmsgid \"Recent Activity\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/dashboard/recent-posts/recent-posts.tsx\nmsgid \"Recent Posts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-calendar/submission-calendar.tsx\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/submission-scheduler.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Recurring\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/submission-scheduler.tsx\nmsgid \"Recurring Schedule\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Recurring schedule (active)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/submission-scheduler.tsx\nmsgid \"Recurring Schedule Settings\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Reference\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\nmsgid \"Refresh page\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\n#: apps/postybirb-ui/src/website-components/megalodon/megalodon-login-view.tsx\nmsgid \"Register Instance\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/app-settings.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/app-settings-section.tsx\nmsgid \"Reload Now\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/error-boundary/error-boundary.tsx\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\nmsgid \"Reload page\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/settings-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\nmsgid \"Remote\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/remote-settings.tsx\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/remote-settings.tsx\nmsgid \"Remote Access Password\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/remote-settings.tsx\nmsgid \"Remote Access Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Remote Connection\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Remote host URL is not configured\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/remote-settings.tsx\nmsgid \"Remote Mode\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Remote password is not configured\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\nmsgid \"Remove\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\nmsgid \"Rename\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-edit-form/submission-file-manager/submission-file-card/file-card-file-actions.tsx\nmsgid \"Replace\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\nmsgid \"Replace All\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submission-templates/template-picker-modal/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgctxt \"import.override-description\"\nmsgid \"Replace description\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Replace file\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submission-templates/template-picker-modal/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Replace the current description with the selected template's description\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submission-templates/template-picker-modal/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Replace the current title with the selected template's title\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submission-templates/template-picker-modal/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgctxt \"import.override-title\"\nmsgid \"Replace title\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Reply to post URL\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Request critique\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/translations/validation-translation.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Required\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\n#: apps/postybirb-ui/src/website-components/telegram/telegram-login-view.tsx\nmsgid \"Required if 2FA is enabled.\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Required tag\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/translations/validation-translation.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Requires at least {minLength} {minLength, plural, one {tag} other {tags}}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/translations/validation-translation.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Requires at least {minSelected} {minSelected, plural, one {option selected} other {options selected}} ({currentSelected} / {minSelected})\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/account-drawer/website-card.tsx\n#: apps/postybirb-ui/src/components/submissions/submission-edit-form/submission-file-manager/submission-file-card/file-metadata-manager/custom-account-dimensions.tsx\n#: apps/postybirb-ui/src/components/submissions/submission-edit-form/submission-file-manager/submission-file-card/file-metadata-manager/file-dimensions.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Reset\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Reset account data\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Reset account?\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Reset Remote Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/postybirb-update-button.tsx\n#: apps/postybirb-ui/src/remake/components/update-button/update-button.tsx\n#: apps/postybirb-ui/src/remake/components/update-button/update-button.tsx\nmsgid \"Restarting to apply update...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-button.tsx\nmsgid \"Restarting...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-history-view/submission-history-view.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/archived-submission-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/archived-submission-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"Restore\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Restored successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Resume Failed Posting\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/queue-control-card.tsx\nmsgid \"Resume Posting\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/dashboard/queue-control/queue-control.tsx\nmsgid \"Resume Submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Retry all failed or unattempted websites\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Retry posting to all websites that failed or were not attempted. All files will be re-uploaded.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Rotate\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Rotate left 90°\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Rotate right 90°\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Running\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/cron-builder.tsx\nmsgid \"Runs every day at 12:00 AM\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/cron-builder.tsx\nmsgid \"Runs every day at 9:00 AM\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/cron-builder.tsx\nmsgid \"Runs every Monday at 12:00 AM\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/cron-builder.tsx\nmsgid \"Runs Monday through Friday at 9:00 AM\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/cron-builder.tsx\nmsgid \"Runs on the 1st day of every month at 12:00 AM\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/cron-builder.tsx\nmsgid \"Runs Saturday and Sunday at 12:00 PM\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/languages.tsx\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"Russian\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Sa\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/cron-builder.tsx\nmsgid \"Saturday\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/e621/e621-login-view.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/furtastic/furtastic-login-view.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\n#: apps/postybirb-ui/src/translations/common-translations.tsx\nmsgid \"Save\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\n#: apps/postybirb-ui/src/website-components/twitter/twitter-login-view.tsx\nmsgid \"Save API Keys\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\nmsgid \"Save as default\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\nmsgid \"Save as Default\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Save changes\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/translations/common-translations.tsx\nmsgid \"Save current form values as the default values for future submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/translations/common-translations.tsx\nmsgid \"Save defaults\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\nmsgid \"Save Selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-history-view/submission-history-view.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Save to file\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/save-to-many-action.tsx\nmsgid \"Save to submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/save-to-many-action.tsx\nmsgid \"Save to Submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\nmsgid \"Saved fields will be automatically applied to this account in future submissions.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-edit-form/submission-edit-form.tsx\n#: apps/postybirb-ui/src/components/submissions/submission-view/submission-view-card/submission-view-actions/schedule-submissions-action.tsx\n#: apps/postybirb-ui/src/components/submissions/submission-view/submission-view-card/submission-view-card-actions.tsx\n#: apps/postybirb-ui/src/components/submissions/submission-view/submission-view-card/submission-view-card.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\n#: apps/postybirb-ui/src/translations/common-translations.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Schedule\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-view/submission-view-card/submission-view-multi-scheduler-modal.tsx\nmsgctxt \"schedule.modal-header\"\nmsgid \"Schedule\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/upcoming-posts-panel.tsx\nmsgid \"Schedule a submission to see it here\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Schedule configured (inactive)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/submission-scheduler.tsx\nmsgid \"Schedule Date and Time\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/submission-scheduler.tsx\nmsgid \"Schedule for a specific date and time\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Schedule is configured but inactive\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Schedule posts to automatically publish at specific times\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/submission-scheduler.tsx\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Schedule Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Schedule Submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/submission-scheduler.tsx\nmsgid \"Schedule to repeat regularly\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Schedule updated\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/dashboard/dashboard-stats/dashboard-stats.tsx\n#: apps/postybirb-ui/src/components/dashboard/submissions-list/submissions-list.tsx\n#: apps/postybirb-ui/src/components/dashboard/submissions-list/submissions-list.tsx\n#: apps/postybirb-ui/src/components/submissions/submission-view/submission-view-card/submission-view-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Scheduled\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Scheduled (active)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/submission-scheduler.tsx\nmsgid \"Scheduled for\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Scroll to zoom\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/postybirb-layout.tsx\nmsgid \"Search\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/submission-list.tsx\n#: apps/postybirb-ui/src/remake/components/shared/search-input.tsx\nmsgid \"Search...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-history-view/submission-history-view.tsx\nmsgid \"Select a post to view details\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-content.tsx\nmsgid \"Select a submission from the list to view details\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker.tsx\nmsgid \"Select a template\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-content.tsx\nmsgid \"Select a template from the list to view or edit it\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-selection-form.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Select all\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/form/website-select/website-select.tsx\n#: apps/postybirb-ui/src/components/shared/postybirb-editor/custom/username-shortcut.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/inline-username-shortcut.tsx\nmsgid \"Select All\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-content.tsx\nmsgid \"Select an account to log in\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\nmsgid \"Select fields to save as defaults for this account.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\nmsgid \"Select items to delete\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/language-picker.tsx\n#: apps/postybirb-ui/src/remake/components/language-picker/language-picker.tsx\nmsgid \"Select language\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/cron-builder.tsx\nmsgid \"Select specific day of month or every day\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/cron-builder.tsx\nmsgid \"Select specific months or all\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker.tsx\nmsgid \"Select submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submission-templates/template-picker-modal/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Select templates or submissions to import\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/cron-builder.tsx\nmsgid \"Select the days when this should run\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/shared/postybirb-editor/custom/username-shortcut.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/inline-username-shortcut.tsx\nmsgid \"Select websites to apply usernames to\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submission-templates/template-picker-modal/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Select which template to use for each account\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/submission-scheduler.tsx\nmsgid \"Selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/translations/validation-translation.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Selected option is invalid or missing\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\n#: apps/postybirb-ui/src/website-components/telegram/telegram-login-view.tsx\nmsgid \"Send Code\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/postybirb-layout.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Send Messages\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Send to scraps\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Sensitive content\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/cron-builder.tsx\nmsgid \"September\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\n#: apps/postybirb-ui/src/website-components/discord/discord-login-view.tsx\nmsgid \"Server Boost Level\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Server unreachable\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Set the interval between each submission. The first submission will be scheduled at the start date, and each subsequent submission will be offset by the interval.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/settings-drawer.tsx\n#: apps/postybirb-ui/src/app/postybirb-layout/postybirb-layout.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Settings\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Sexual Content\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Share on feed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/translations/validation-translation.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Share on Feed is required to support posting multiple files.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/remote-settings.tsx\nmsgid \"Share this URL with remote clients on your local network so they can connect to this host:\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Share your LAN IP and password with clients to allow them to connect.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"shortcut\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/translations/common-translations.tsx\nmsgid \"Shortcut\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"Shortcut name\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/account-drawer/website-visibility-picker.tsx\nmsgid \"Show All\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/tags-settings.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\nmsgid \"Show wiki page related to tag on hover\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-visibility-picker.tsx\nmsgid \"Show/Hide Websites\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Silent\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/tags-settings.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\nmsgid \"Sites like e621 provide tag autocomplete feature which you can use outside of their tag search field.\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Sketch\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-edit-form/submission-file-manager/submission-file-card/file-metadata-manager/file-metadata-manager.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Skip Accounts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"Skip websites that posted successfully and only attempt failed or unattempted ones.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Skip websites that posted successfully and only attempt failed or unattempted ones. Continues from the last successful batch of files.\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Software\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"Some accounts need to be logged in before you can post to them.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-uploader.tsx\nmsgid \"Some files were rejected. Check file type and size (max 100MB).\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"Some submissions have failed posting attempts. Choose how to handle them:\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/error-boundary/error-boundary.tsx\n#: apps/postybirb-ui/src/components/error-boundary/error-boundary.tsx\n#: apps/postybirb-ui/src/components/error-boundary/notification-error-boundary.tsx\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\nmsgid \"Something went wrong\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/translations/common-translations.tsx\nmsgid \"Sort Ascending\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/translations/common-translations.tsx\nmsgid \"Sort Descending\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Source URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-edit-form/submission-file-manager/submission-file-card/file-metadata-manager/file-source-urls.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Source URLs\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/languages.tsx\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"Spanish\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Species\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/cron-builder.tsx\nmsgid \"Specific day\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\n#: apps/postybirb-ui/src/website-components/megalodon/megalodon-login-view.tsx\nmsgid \"Specify your Fediverse instance\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/settings-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\nmsgid \"Spellchecker\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/spellchecker-settings.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/spellchecker-settings-section.tsx\nmsgid \"Spellchecker advanced configuration is controlled by system on MacOS.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/spellchecker-settings.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/spellchecker-settings-section.tsx\nmsgid \"Spellchecker Settings\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Spoiler\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-edit-form/submission-file-manager/submission-file-card/file-metadata-manager/file-metadata-manager.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Spoiler Text\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Spoilers\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Start completely fresh\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Start Date\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\n#: apps/postybirb-ui/src/website-components/twitter/twitter-login-view.tsx\n#: apps/postybirb-ui/src/website-components/twitter/twitter-login-view.tsx\nmsgid \"Start Over\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/app-settings.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/app-settings-section.tsx\nmsgid \"Startup Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/account-drawer/website-card.tsx\n#: apps/postybirb-ui/src/components/submissions/submission-history-view/submission-history-view.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Status\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Su\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/form/website-option-form/use-form-fields.tsx\n#: apps/postybirb-ui/src/components/submissions/submission-calendar/submission-calendar.tsx\n#: apps/postybirb-ui/src/components/submissions/submission-calendar/submission-calendar.tsx\n#: apps/postybirb-ui/src/components/submissions/submission-calendar/submission-calendar.tsx\n#: apps/postybirb-ui/src/components/submissions/submission-history-view/submission-history-view.tsx\n#: apps/postybirb-ui/src/components/submissions/submission-history-view/submission-history-view.tsx\n#: apps/postybirb-ui/src/components/submissions/submission-history-view/submission-history-view.tsx\n#: apps/postybirb-ui/src/components/submissions/submission-view/submission-view-card/submission-view-card-actions.tsx\n#: apps/postybirb-ui/src/components/submissions/submission-view/submission-view-card/submission-view-card-actions.tsx\n#: apps/postybirb-ui/src/translations/common-translations.tsx\nmsgid \"Submission\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-edit-form/submission-file-manager/submission-file-manager.tsx\nmsgid \"Submission Files\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"Submission has validation errors\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Submission Order\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/dashboard/queue-control/queue-control.tsx\nmsgid \"Submission processing has been paused\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/dashboard/queue-control/queue-control.tsx\nmsgid \"Submission processing has been resumed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-view/submission-view-card/submission-view-card-actions.tsx\n#: apps/postybirb-ui/src/pages/submission/edit-submission-page.tsx\nmsgid \"Submission queued\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-view/submission-view-card/submission-view-card-actions.tsx\nmsgid \"Submission scheduled\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/postybirb-spotlight/postybirb-spotlight.tsx\nmsgid \"Submission Templates\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\nmsgid \"Submission unscheduled\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Submission will be posted automatically\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/translations/validation-translation.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Submission will be split into {expectedBatchesToCreate} different submissions with {maxBatchSize} {maxBatchSize, plural, one {file each} other {files each}}.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/dashboard/dashboard-stats/dashboard-stats.tsx\n#: apps/postybirb-ui/src/components/submission-templates/template-picker-modal/template-picker-modal.tsx\n#: apps/postybirb-ui/src/components/submissions/submission-picker/submission-picker.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-section.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"success\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/notifications-drawer.tsx\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/notifications-drawer.tsx\n#: apps/postybirb-ui/src/components/dashboard/recent-posts/recent-posts.tsx\n#: apps/postybirb-ui/src/components/form/user-specified-website-options-modal/user-specified-website-options-modal.tsx\n#: apps/postybirb-ui/src/components/submissions/submission-history-view/submission-history-view.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Success\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Successful\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/remote-settings.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Successfully connected to the remote host\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\n#: apps/postybirb-ui/src/website-components/megalodon/megalodon-login-view.tsx\nmsgid \"Successfully logged in as {loggedInAs}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\n#: apps/postybirb-ui/src/website-components/twitter/twitter-login-view.tsx\nmsgid \"Successfully logged in as @{loggedInAs}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/website-components/twitter/twitter-login-view.tsx\nmsgid \"Successfully logged in to Twitter!\"\nmsgstr \"\"\n\n#. placeholder {0}: orderedSubmissions.length\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Successfully scheduled ${0} submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/cron-builder.tsx\nmsgid \"Sunday\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/translations/validation-translation.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Supports either a set of images, a single video, or a single GIF.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-uploader/submission-uploader.tsx\nmsgid \"Supports images, videos, audio, and text files up to 100MB\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/theme-picker.tsx\nmsgid \"Switch to dark mode\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/theme-picker.tsx\nmsgid \"Switch to light mode\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/tag-converter-drawer.tsx\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/tag-converter-drawer.tsx\nmsgid \"Tag\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/translations/validation-translation.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tag {tag} has {postCount} post(s). Tag may be invalid or low use\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/translations/validation-translation.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tag <0>{tag}</0> does not exist yet or is invalid.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/translations/validation-translation.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tag <0>{tag}</0> does not exist yet or is invalid. If you want to create a new tag, make a post with it, then go <1>here</1>, press edit and select tag category\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/translations/validation-translation.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tag <0>{tag}</0> is invalid.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/tag-converter-drawer/tag-converter-drawer.tsx\nmsgid \"tag converter\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/tag-converter-drawer.tsx\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/tag-converter-drawer.tsx\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/tag-converter-drawer.tsx\nmsgid \"Tag converter\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/legacy-import-settings.tsx\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/tag-converter-drawer.tsx\n#: apps/postybirb-ui/src/app/postybirb-layout/postybirb-layout.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-converter-drawer/tag-converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Tag Converters\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/tag-group-drawer.tsx\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/tag-group-drawer.tsx\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/tag-group-drawer.tsx\nmsgid \"Tag group\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/legacy-import-settings.tsx\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/tag-group-drawer.tsx\n#: apps/postybirb-ui/src/app/postybirb-layout/postybirb-layout.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Tag Groups\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/translations/validation-translation.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tag limit reached ({currentLength} / {maxLength})\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Tag permissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/simple-tag-input/simple-tag-input.tsx\nmsgid \"tags\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/settings-drawer.tsx\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/tag-group-drawer.tsx\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/tag-group-drawer.tsx\n#: apps/postybirb-ui/src/components/shared/postybirb-editor/custom/inline-system-shortcuts.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/inline-system-shortcuts.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Tags\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/translations/validation-translation.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tags longer than {maxLength} characters will be skipped\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/tags-settings.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\nmsgid \"Tags Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/languages.tsx\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"Tamil\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Teaser\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\n#: apps/postybirb-ui/src/website-components/telegram/telegram-login-view.tsx\nmsgid \"Telegram requires API credentials and phone number authentication. You'll need to create a Telegram app and verify your phone number.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/postybirb-spotlight/postybirb-spotlight.tsx\n#: apps/postybirb-ui/src/components/submission-templates/submission-template-view/submission-template-view.tsx\n#: apps/postybirb-ui/src/components/submission-templates/submission-template-view/submission-template-view.tsx\n#: apps/postybirb-ui/src/components/submission-templates/submission-template-view/submission-template-view.tsx\n#: apps/postybirb-ui/src/components/submission-templates/submission-template-view/submission-template-view.tsx\n#: apps/postybirb-ui/src/components/submissions/directory-watchers-view/directory-watchers-view.tsx\n#: apps/postybirb-ui/src/models/dtos/submission.dto.ts\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker.tsx\n#: apps/postybirb-ui/src/translations/common-translations.tsx\nmsgid \"Template\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-view/submission-view-card/submission-view-actions/apply-submission-template-action.tsx\nmsgid \"Template applied\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\nmsgid \"Template created\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-content.tsx\nmsgid \"Template Editor\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submission-templates/template-picker-modal/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-content.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Templates\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/remote-settings.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Test Connection\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/translations/validation-translation.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Text\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Th\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/directory-watchers-view/directory-watchers-view.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"The folder \\\"{folderName}\\\" contains {fileCount} files.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/error-boundary/specialized-error-boundaries.tsx\n#: apps/postybirb-ui/src/remake/components/error-boundary/specialized-error-boundaries.tsx\nmsgid \"The form encountered an error and couldn't be displayed properly.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"The last posting attempt failed. How would you like to proceed?\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/dashboard/submissions-list/submissions-list.tsx\nmsgid \"The submission has been cancelled\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/dashboard/submissions-list/submissions-list.tsx\nmsgid \"The submission has been removed from the queue\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/dashboard/submissions-list/submissions-list.tsx\nmsgid \"The submission has been unscheduled\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/remote-settings.tsx\nmsgid \"The URL of the PostyBirb host instance. Check the host's remote settings to get the correct URL.\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Theme\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/error-boundary/error-boundary.tsx\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\nmsgid \"This component encountered an error and couldn't render properly.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\n#: apps/postybirb-ui/src/website-components/discord/discord-login-view.tsx\nmsgid \"This is a forum channel\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/app-settings.tsx\nmsgid \"This is the folder where the app will store its data. You must restart the app for this to take effect.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"This post record has no events or is still processing. Check the JSON data below for more details.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/submission-scheduler.tsx\nmsgid \"This submission will be posted at the specified time.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-content.tsx\nmsgid \"This website has an unsupported login configuration\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/custom-login-placeholder.tsx\nmsgid \"This website uses a custom login form. Support coming soon.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"This will clear all account data and cookies.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/account-drawer/website-card.tsx\nmsgid \"This will clear all data associated with this account. You will need to log in again.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-edit-form/submission-file-manager/submission-file-card/file-card-file-actions.tsx\nmsgid \"Thumb\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Thumbnail\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Thumbnail must be an image file.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/cron-builder.tsx\nmsgid \"Thursday\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Time\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Time Taken\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Tips\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/shared/postybirb-editor/custom/inline-system-shortcuts.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/inline-system-shortcuts.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Title\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/translations/validation-translation.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Title is too long and will be truncated\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/translations/validation-translation.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Title is too short\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-visibility-picker.tsx\nmsgid \"Toggle website visibility\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Total\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/translations/validation-translation.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Translation {id} not found\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/error-boundary/error-boundary.tsx\n#: apps/postybirb-ui/src/components/error-boundary/error-boundary.tsx\n#: apps/postybirb-ui/src/components/error-boundary/specialized-error-boundaries.tsx\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\n#: apps/postybirb-ui/src/remake/components/error-boundary/specialized-error-boundaries.tsx\nmsgid \"Try again\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Tu\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/cron-builder.tsx\nmsgid \"Tuesday\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\n#: apps/postybirb-ui/src/website-components/twitter/twitter-login-view.tsx\nmsgid \"Twitter / X Authentication\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/shared/postybirb-editor/postybirb-editor.tsx\nmsgid \"Type @, `, or &lbrace; to insert shortcuts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/description-editor.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/description-editor.tsx\nmsgid \"Type / for commands or @, ` or '{' for shortcuts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/settings-drawer/app-settings.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/app-settings-section.tsx\nmsgid \"UI Mode\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/postybirb-spotlight/postybirb-spotlight.tsx\n#: apps/postybirb-ui/src/components/submission-templates/template-picker-modal/template-picker-modal.tsx\n#: apps/postybirb-ui/src/components/submission-templates/template-picker-modal/template-picker-modal.tsx\n#: apps/postybirb-ui/src/components/submissions/submission-picker/submission-picker.tsx\n#: apps/postybirb-ui/src/components/translations/validation-translation.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/inline-username-shortcut.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\n#: apps/postybirb-ui/src/translations/common-translations.tsx\nmsgid \"Unknown\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/translations/validation-translation.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Unknown choice\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-content.tsx\nmsgid \"Unknown login type\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Unread\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-calendar/submission-calendar.tsx\n#: apps/postybirb-ui/src/components/submissions/submission-view/submission-view-card/submission-view-card-actions.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\n#: apps/postybirb-ui/src/translations/common-translations.tsx\nmsgid \"Unschedule\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-schedule-view/submission-schedule-view.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/submission-list.tsx\nmsgid \"Unscheduled Submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/translations/validation-translation.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Unsupported file type {fileExtension}. Please provide fallback text.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/translations/validation-translation.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Unsupported file type {mimeType}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/translations/validation-translation.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Unsupported rating: {ratingLabel}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/translations/validation-translation.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Unsupported submission type: {fileTypeString}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/upcoming-posts-panel.tsx\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/header/submission-edit-card-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\n#: apps/postybirb-ui/src/remake/components/shared/reorderable-submission-list/reorderable-submission-list.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Untitled\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/submission-list.tsx\nmsgid \"Untitled Submission\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Untitled Template\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/upcoming-posts-panel.tsx\nmsgid \"Upcoming Posts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/translations/common-translations.tsx\nmsgid \"Update\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\n#: apps/postybirb-ui/src/website-components/twitter/twitter-login-view.tsx\nmsgid \"Update API Keys\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-button.tsx\nmsgid \"Update available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-button.tsx\nmsgid \"Update Available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/translations/common-translations.tsx\nmsgid \"Update failed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Update Failed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/postybirb-update-button.tsx\n#: apps/postybirb-ui/src/remake/components/update-button/update-button.tsx\nmsgid \"Update PostyBirb\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/translations/common-translations.tsx\nmsgid \"Updated\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Updated successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-uploader/submission-uploader.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-submission-modal.tsx\nmsgid \"Upload\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-edit-form/submission-file-manager/submission-file-card/file-card-file-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Upload thumbnail\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/form/fields/description-field.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-field.tsx\nmsgid \"Use custom description\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Use templates to save time on repeated posts\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Use thumbnail as cover art\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Use title\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\n#: apps/postybirb-ui/src/website-components/bluesky/bluesky-login-view.tsx\nmsgid \"Use your Bluesky handle or email along with an app password. App passwords are more secure than your main password and can be revoked at any time.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\n#: apps/postybirb-ui/src/website-components/bluesky/bluesky-login-view.tsx\nmsgid \"Used for other sites (like e621) as source url base. Independent from custom pds.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/user-converter-drawer/user-converter-drawer.tsx\nmsgid \"user converter\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/user-converter-drawer.tsx\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/user-converter-drawer.tsx\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/user-converter-drawer.tsx\nmsgid \"User converter\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/user-converter-drawer.tsx\n#: apps/postybirb-ui/src/app/postybirb-layout/postybirb-layout.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/user-converter-drawer/user-converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"User Converters\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/user-converter-drawer.tsx\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/user-converter-drawer.tsx\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/user-converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/e621/e621-login-view.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\n#: apps/postybirb-ui/src/website-components/e621/e621-login-view.tsx\n#: apps/postybirb-ui/src/website-components/inkbunny/inkbunny-login-view.tsx\nmsgid \"Username\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\n#: apps/postybirb-ui/src/website-components/bluesky/bluesky-login-view.tsx\nmsgid \"Username or Email\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\nmsgid \"Validation Issues\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/translations/validation-translation.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Video\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"View\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/archived-submission-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/archived-submission-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"View history\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"View permissions\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Violence\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Visibility\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/notifications-drawer.tsx\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-option-row.tsx\nmsgid \"Warning\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/submission-scheduler.tsx\nmsgid \"Warning: This date is in the past and may not behave as expected.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\nmsgid \"warnings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Warnings\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Watermark\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"We\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/error-boundary/error-boundary.tsx\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\nmsgid \"We encountered an unexpected error. Please try refreshing the page.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\n#: apps/postybirb-ui/src/translations/common-translations.tsx\nmsgid \"Website\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/tag-converter-drawer.tsx\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/user-converter-drawer.tsx\nmsgid \"Website Conversions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/postybirb-layout/drawers/account-drawer/website-visibility-picker.tsx\nmsgid \"Website Visibility\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/form/website-select/website-select.tsx\n#: apps/postybirb-ui/src/components/shared/postybirb-editor/custom/username-shortcut.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-selection-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/account-picker/account-picker.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/inline-username-shortcut.tsx\nmsgid \"Websites\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/cron-builder.tsx\nmsgid \"Wednesday\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/cron-builder.tsx\nmsgid \"Weekdays at 9 AM\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/cron-builder.tsx\nmsgid \"Weekend at noon\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Weekly\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-scheduler/cron-builder.tsx\nmsgid \"Weekly on Monday\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Welcome to PostyBirb!\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/directory-watchers-view/directory-watchers-view.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"What to do when new files are detected\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Who can reply\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/submissions/submission-edit-form/submission-file-manager/submission-file-card/file-metadata-manager/custom-account-dimensions.tsx\n#: apps/postybirb-ui/src/components/submissions/submission-edit-form/submission-file-manager/submission-file-card/file-metadata-manager/file-dimensions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Width\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/dashboard/submissions-list/submissions-list.tsx\nmsgid \"Yes\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/app/disclaimer/disclaimer.tsx\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"You agree to comply with all applicable laws, terms of service, and policies of any third party platforms you connect to or interact with through this application. Do not use this software to infringe intellectual property rights, violate privacy, or circumvent platform rules.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/website-components/website-login-helpers.tsx\nmsgid \"You can close this menu now\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\n#: apps/postybirb-ui/src/website-components/twitter/twitter-login-view.tsx\nmsgid \"You have existing API keys. You can modify them above or proceed to the next step.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/translations/validation-translation.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"You have recent {negativeOrNeutral} feedback: {feedback}, you can view it<0>here</0>\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\n#: apps/postybirb-ui/src/website-components/inkbunny/inkbunny-login-view.tsx\nmsgid \"You must first enable API access in your account settings under \\\"API (External Scripting)\\\" before you can authenticate with PostyBirb.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\n#: apps/postybirb-ui/src/website-components/twitter/twitter-login-view.tsx\nmsgid \"You need to create a Twitter app to get API keys. <0>Visit Twitter Developer Portal</0>\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\n#: apps/postybirb-ui/src/website-components/twitter/twitter-login-view.tsx\nmsgid \"You'll get this PIN after authorizing on Twitter\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/components/form/fields/description-field.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-field.tsx\nmsgid \"Your description contains legacy shortcut syntax. These are no longer supported. Please use the new shortcut menu (type @, &lbrace; or `) to insert shortcuts.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\n#: apps/postybirb-ui/src/website-components/bluesky/bluesky-login-view.tsx\nmsgid \"Your handle (e.g. <0>yourname.bsky.social</0>) or custom domain (e.g. <1>username.ext</1>)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\n#: apps/postybirb-ui/src/website-components/inkbunny/inkbunny-login-view.tsx\nmsgid \"Your password will not be stored\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Zoom\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Zoom in\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Zoom out\"\nmsgstr \"\"\n"
  },
  {
    "path": "lang/pt_BR.po",
    "content": "msgid \"\"\nmsgstr \"\"\n\"Project-Id-Version: \\n\"\n\"Report-Msgid-Bugs-To: \\n\"\n\"POT-Creation-Date: \\n\"\n\"PO-Revision-Date: \\n\"\n\"Last-Translator: \\n\"\n\"Language: \\n\"\n\"Language-Team: \\n\"\n\"Content-Type: \\n\"\n\"Content-Transfer-Encoding: \\n\"\n\"Plural-Forms: \\n\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"({fileSizeString}) is too large (max {maxFileSizeString}) and an attempt will be made to reduce size when posting\"\nmsgstr \"\"\n\n#. placeholder {0}: tag.post_count\n#: apps/postybirb-ui/src/remake/components/website-components/e621/e621-tag-search-provider.tsx\nmsgid \"{0, plural, one {# post} other {# posts}}\"\nmsgstr \"\"\n\n#. placeholder {0}: selectedOptions.length\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/tree-select.tsx\nmsgid \"{0} selected\"\nmsgstr \"\"\n\n#. placeholder {0}: value.length\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker.tsx\nmsgid \"{0} submissions selected\"\nmsgstr \"\"\n\n#. placeholder {0}: account?.websiteDisplayName\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\nmsgid \"{0}: Login successful\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"{count} items deleted\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"{count} selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-visibility-picker.tsx\nmsgid \"{hiddenCount} hidden\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"{selectedCount} of {totalCount} selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"{validCount} of {totalSelectedCount} selected submission(s) are ready to post. Submissions without websites or with validation errors will be skipped.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"{validCount} submission(s) will be posted in the order shown below.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"<0>Tip:</0> If your handle doesn't work, try using the email linked to your account\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"2FA enabled, password required. It won't be stored.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"A shortcut with this name already exists\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/tag-converter-drawer/tag-converter-drawer.tsx\nmsgid \"A tag converter with this tag already exists\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/user-converter-drawer/user-converter-drawer.tsx\nmsgid \"A user converter with this username already exists\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"Accept and Continue\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Access Tiers\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/selected-accounts-forms.tsx\nmsgid \"account\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Account\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-section.tsx\nmsgid \"Account data cleared\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Account name\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Account Options\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"Account Status\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/selected-accounts-forms.tsx\nmsgid \"accounts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/account-section-header.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Accounts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\nmsgid \"Activate\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Activate schedule\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\nmsgid \"Active\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/recent-activity-panel.tsx\nmsgid \"Activity will appear here as you use the app\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Add account\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"Add an account to start posting\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-submission-modal.tsx\nmsgid \"Add File Submission\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"Add shortcut\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\nmsgid \"Add tags...\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Add to Portfolio\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\nmsgid \"Add website\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-preview-panel.tsx\nmsgid \"Add website accounts to preview the description output.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Adjust dimensions while maintaining aspect ratio\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/rating-input/rating-input.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Adult\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Adult themes\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"After authorizing, copy the code and paste it below\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"AI generated\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/account-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\nmsgid \"All\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"All files are marked ignored.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"All healthy\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\nmsgid \"All submissions are ready to post\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\nmsgid \"All Websites\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow comments\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow commercial use\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow free download\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow modifications of your work\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow other users to edit tags\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/description-settings-section.tsx\nmsgid \"Allow PostyBirb to self-advertise at the end of descriptions\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow reblogging\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Alt Text\"\nmsgstr \"\"\n\n#. Bluesky login form\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"An <0>app password</0> (not your main password)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"An error occurred\"\nmsgstr \"\"\n\n#. placeholder {0}: dtext.length - text.length\n#: apps/postybirb-ui/src/remake/components/website-components/e621/e621-dtext-renderer.tsx\nmsgid \"and {0} more\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"and {remainingCount} more\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"API Key (Consumer Key)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"API keys saved successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"API Secret (Consumer Secret)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\nmsgid \"App\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"App API Hash\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"App API ID\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/app-settings-section.tsx\nmsgid \"App Folder\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"App Password\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"App registered successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/app-settings-section.tsx\nmsgid \"App Server Port\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"App view URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\nmsgid \"Appearance\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Applied template options to {successCount} submissions\"\nmsgstr \"\"\n\n#. placeholder {0}: submissionIds.length\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/save-to-many-action.tsx\nmsgid \"Applied to {0} submission(s)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Applied to {successCount} submissions, {failedCount} failed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Apply\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/apply-template-action.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Apply template\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\nmsgid \"Archive\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/header/submission-edit-card-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-section.tsx\nmsgid \"Archived\"\nmsgstr \"\"\n\n#. placeholder {0}: selectedIds.length\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-section.tsx\nmsgid \"Are you sure you want to delete {0} submission(s)? This action cannot be undone.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Are you sure you want to reset all remote connection settings to their defaults? This will clear the saved host URL and password.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Are you sure you want to watch this folder?\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Artist name\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Artist URL (Off-Site only)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Aspect Ratio\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Attach Images as Attachments\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Audience\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Audio\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Authenticate\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Authorization Code\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Authorization URL generated\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Authorize\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Authorized viewers\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/appearance-settings-section.tsx\nmsgid \"Auto\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Automatically import new files from specified folders\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Back\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Block guests\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Blog\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Bluesky automatically converts GIFs to videos.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/app-settings-section.tsx\nmsgid \"Browse\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Builder\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"By using this application, you acknowledge that you are solely responsible for how you use it and for any content you create, upload, distribute, or interact with. The authors and maintainers provide this software as is without warranties of any kind and are not liable for any damages or losses resulting from its use.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/confirm-action-modal/confirm-action-modal.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/components/insert-media-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Cancel\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"Cancel posting\"\nmsgstr \"\"\n\n#. placeholder {0}: getFileTypeLabel(newFileType)\n#. placeholder {1}: getFileTypeLabel(existingFileType)\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-uploader.tsx\nmsgid \"Cannot add {0} files to a submission containing {1} files. All files in a submission must be of the same type.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-card.tsx\nmsgid \"Cannot delete the only file\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Category\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Channel\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Characters\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Charge Patrons\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\nmsgid \"Check login status\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Check that handle and password are valid or try using email instead of handle.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Check your telegram messages\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\nmsgid \"Checking...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-content.tsx\nmsgid \"Choose an account from the list on the left\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgctxt \"template.picker-modal-header\"\nmsgid \"Choose Templates\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Click here to authorize PostyBirb\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Click the button below to get your authorization URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Click the link above, authorize the app, and copy the PIN code below\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Client\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Cloud Password\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Code\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Code invalid, ensure it matches the code sent from telegram\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Code sent\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-card.tsx\nmsgid \"Collapse\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Collections\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Comment permissions\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Commercial use\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Compact view\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Complete Authorization\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Complete Login\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Configuration Error\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Configure API Keys\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/confirm-action-modal/confirm-action-modal.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Confirm\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Connected successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\nmsgid \"Connection error\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Connection Failed\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Contains content\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Content Blur\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/content-warning-shortcut.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Content warning\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Continue from where it left off\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/copy-to-clipboard/copy-to-clipboard.tsx\nmsgid \"Copied\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Copied to clipboard\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/copy-to-clipboard/copy-to-clipboard.tsx\n#: apps/postybirb-ui/src/remake/components/shared/copy-to-clipboard/copy-to-clipboard.tsx\nmsgid \"Copy\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Create\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Create a File or Message submission to start posting\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Create a file watcher to automatically import new files from a folder\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Create an app password in Bluesky Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Create Message\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Create new file watcher\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Create new submission\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Create Submission\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgctxt \"telegram.app-id-help\"\nmsgid \"Create telegram app to retrieve api_id and api_hash\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Created successfully\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Creative Commons\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Creative Commons license\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"CRON Expression\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Crop from primary\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Ctrl+Enter to apply\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Custom\"\nmsgstr \"\"\n\n#. placeholder {0}: website.displayName\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/custom-login-placeholder.tsx\nmsgid \"Custom login for {0}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Custom PDS or AppView\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Custom Shortcuts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/spellchecker-settings-section.tsx\nmsgid \"Custom words\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Daily\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/appearance-settings-section.tsx\nmsgid \"Dark\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/theme-picker/theme-picker.tsx\n#: apps/postybirb-ui/src/remake/components/theme-picker/theme-picker.tsx\nmsgid \"Dark Mode\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Dashboard\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Date {currentDate} is after maximum allowed date {maxDate}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Date {currentDate} is before minimum allowed date {minDate}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Date {currentDate} is outside allowed range ({minDate} - {maxDate})\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Date and Time\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Date is in the past\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Day of Month\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Days\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"Decline and Exit\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/radio-field.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/default-shortcut.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Default\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/defaults-form/defaults-form.tsx\nmsgid \"Defaults\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\nmsgid \"Defaults saved successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-preview.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-section.tsx\nmsgid \"Delete\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\nmsgid \"Delete all existing website options and use only those specified in the source.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-card.tsx\nmsgid \"Delete file\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/archived-submission-card.tsx\nmsgid \"Delete permanently\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-section.tsx\nmsgid \"Delete Submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Deleted successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: apps/postybirb-ui/src/remake/components/website-components/e621/e621-tag-search-provider.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Description\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Description length is greater then maximum ({currentLength} / {maxLength})\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Description length is lower then minimum ({currentLength} / {minLength})\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/description-settings-section.tsx\nmsgid \"Description Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-select.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-selection-form.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\nmsgid \"Deselect all\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"Desktop Notifications\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Detailed view\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Dimensions\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Disable comments\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Discard all progress from the previous attempt and start as if this were the first time posting.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Discord\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Display resolution\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Double-click to toggle crop/move mode\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Download Update\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Downloading...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/reorderable-submission-list/reorderable-submission-list.tsx\nmsgid \"Drag or use arrow keys to reorder\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/submission-list.tsx\nmsgid \"Drag submissions onto the calendar to schedule them\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-manager.tsx\nmsgid \"Drag to reorder\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-dropzone.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-list.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-uploader.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Drop files here or click to browse\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Drug / Alcohol\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\nmsgid \"Duplicate\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Duplicated successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/e621/e621-login-view.tsx\nmsgid \"e621 requires API credentials for posting. You'll need to generate an API key from your account settings to authenticate PostyBirb.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-dropzone.tsx\nmsgid \"Each file becomes a separate submission\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Early Access\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\nmsgid \"Edit\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-preview.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Edit image\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-card.tsx\nmsgid \"Edit metadata\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-preview.tsx\nmsgid \"Edit title\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"Enable desktop notifications\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\nmsgid \"Enable global tag autocomplete from sites like e621.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/spellchecker-settings-section.tsx\nmsgid \"Enabled\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"English\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Ensure the IP is correct\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Enter Instance URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Enter the host URL and password to connect to a remote PostyBirb instance.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Enter the PIN from Twitter\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Enter the URL of your Mastodon, Pleroma, or Pixelfed instance (e.g., \\\"mastodon.social\\\" or \\\"pixelfed.social\\\")\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Enter title...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Enter your Twitter app credentials\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-option-row.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/selected-accounts-forms.tsx\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Error\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Error while authenticating Telegram\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\nmsgid \"errors\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Errors\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"Expand\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Explicit text\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/rating-input/rating-input.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Extreme\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"failed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Failed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"Failed Posts Detected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Failed to apply template options\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-section.tsx\nmsgid \"Failed to clear account data\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Failed to complete authorization\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Failed to connect to remote host\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\nmsgid \"Failed to connect to the server\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to create\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\nmsgid \"Failed to create template\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to delete\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-card.tsx\nmsgid \"Failed to delete file\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to duplicate\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Failed to generate authorization URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Failed to get user warnings. You can check your account manually\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Failed to load primary file for cropping.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to post submission\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Failed to register with instance\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Failed to replace file\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to restore\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to save\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/form-fields-context.tsx\nmsgid \"Failed to save changes\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-alt-text-editor.tsx\nmsgid \"Failed to save fallback text\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Failed to save metadata\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Failed to schedule submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Failed to send code to begin authentication\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Failed to store API keys\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to update\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\nmsgid \"Failed to update template name\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to upload files\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Failed to validate submission: {message}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Failed to validate tags. Please check them manually\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-alt-text-editor.tsx\nmsgid \"Fallback Text\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Feature\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Fediverse Authentication\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Female\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\nmsgid \"File\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-content.tsx\nmsgid \"File Submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"File types do not match. Please upload a file of the same type.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"File Watcher Failure\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"File Watcher Success\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"File Watchers\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"File will be modified to support website requirements\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-list.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-manager.tsx\nmsgid \"Files\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Files uploaded successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Flip\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Flip horizontal\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Flip vertical\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Folder\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Folder Contains Files\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Folder Path\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/specialized-error-boundaries.tsx\nmsgid \"Form Error\"\nmsgstr \"\"\n\n#. Bluesky login form\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Format should be <0>handle.bsky.social</0> or <1>domain.ext</1> for custom domains\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Format: minute hour day month weekday\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Found in your Twitter app's Keys and tokens section\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Fr\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Frequency\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Friends only\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Furry\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Gender\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/rating-input/rating-input.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"General\"\nmsgstr \"\"\n\n#. E621 login form\n#: apps/postybirb-ui/src/remake/components/website-login-views/e621/e621-login-view.tsx\nmsgid \"Generate an API key from your <0>account settings</0>\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Generate authorization link\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"German\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Get Authorization\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Get authorization code\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Get Authorization URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Get started by adding your accounts and creating your first submission.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\nmsgid \"Global tag search provider\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Go back\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\nmsgid \"Go forward\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Go to Accounts to add and log into your websites\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Gore\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Has sexual content\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Has validation errors\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Has validation warnings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/components/insert-media-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/resizable-image.tsx\nmsgid \"Height\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-selection-form.tsx\nmsgid \"Hide unselected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\nmsgid \"Higher boost levels allow larger file uploads\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\nmsgid \"Hold to delete\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\nmsgid \"Hold to delete {count} item(s)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"Hold to delete permanently\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"Hold to post\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Home\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Host\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Host URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Hours\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\nmsgctxt \"discord.webhook-help\"\nmsgid \"How to create a webhook\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"I have read, understand, and accept the disclaimer.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"If you are using pds other then bsky.social\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"If you do not agree with these terms, you must decline and exit the application.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/tag-field.tsx\nmsgctxt \"override-default\"\nmsgid \"Ignore default tags\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Image\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/resizable-image.tsx\nmsgid \"Image Size\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-uploader.tsx\nmsgid \"Images, videos, audio, and text files up to 100MB\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\nmsgid \"Import\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Import Action\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\nmsgid \"Import completed successfully!\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\nmsgid \"Import encountered errors:\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\nmsgid \"Import from PostyBirb Plus\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\nmsgid \"Import your data from PostyBirb Plus. Select which types of data you want to import.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\nmsgid \"Inactive\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Info\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/components/insert-media-modal.tsx\nmsgid \"Insert\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/components/insert-media-modal.tsx\nmsgid \"Insert Media\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-field.tsx\nmsgid \"Insert tags at end\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-field.tsx\nmsgid \"Insert title at start\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Instance URL\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Intended as advertisement\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Invalid CRON expression\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Invalid date format: {value}. Expected ISO date string.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Invalid post URL to reply to.\"\nmsgstr \"\"\n\n#. Bluesky login form\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Invalid url. Format should be <0>https://bsky.app/</0>\"\nmsgstr \"\"\n\n#. Bluesky login form\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Invalid url. Format should be <0>https://bsky.social/</0>\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"It is recommended to add at least 10 general tags <0>( {generalTags} / 10 )</0>. See <1>tagging checklist</1>\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Keep this secret and never share it publicly\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Ko-fi\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"LAN IP\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/language-picker/language-picker.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Language\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/spellchecker-settings-section.tsx\nmsgid \"Languages to check\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Later\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-field.tsx\nmsgid \"Legacy Shortcuts Detected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"Legal Notice & Disclaimer\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/appearance-settings-section.tsx\nmsgid \"Light\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/theme-picker/theme-picker.tsx\n#: apps/postybirb-ui/src/remake/components/theme-picker/theme-picker.tsx\nmsgid \"Light Mode\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"Lithuanian\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Log in to different instance\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"logged in\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/account-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Logged in\"\nmsgstr \"\"\n\n#. placeholder {0}: username ? ` as ${username}` : ''\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\nmsgid \"Logged in{0}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Login\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\nmsgid \"Login failed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Make sure that the default rating matches Bluesky Label Rating.\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Male\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"Manage accounts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Mark as read\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Mark as unread\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Mark as work in progress\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Marked as read\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-content.tsx\nmsgid \"Mass Edit\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/rating-input/rating-input.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Mature\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Mature content\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Media\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\nmsgid \"Merge (recommended)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\nmsgid \"Merge Mode\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\nmsgid \"Message\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-content.tsx\nmsgid \"Message Submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Minutes\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Mo\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Modification\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Modified\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Monthly\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/header/submission-edit-card-header.tsx\nmsgid \"Multi Edit\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\nmsgid \"Name\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"Name is required\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"need attention\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\nmsgid \"New\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"New account\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"New Message\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\nmsgid \"New template name...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"New version:\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"No AI\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"No changes\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"No custom dimensions set\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/defaults-form/defaults-form.tsx\nmsgid \"No default options available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-list.tsx\nmsgid \"No files added yet\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"No folder selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/empty-state/empty-state.tsx\nmsgid \"No notifications\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/tree-select.tsx\nmsgid \"No options available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"No options selected to apply\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-preview-panel.tsx\nmsgid \"No preview available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/empty-state/empty-state.tsx\nmsgid \"No records yet\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/empty-state/empty-state.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-select.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/tree-select.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker.tsx\nmsgid \"No results found\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"No schedule configured\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/reorderable-submission-list/reorderable-submission-list.tsx\nmsgid \"No submissions selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\nmsgid \"No validation issues\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"No website posts found\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"No websites\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-visibility-picker.tsx\nmsgid \"No websites available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"No websites selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-components/e621/e621-tag-search-provider.tsx\nmsgid \"No wiki page available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\nmsgid \"None\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\nmsgid \"Not all tag providers support this feature.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/account-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-option-row.tsx\n#: apps/postybirb-ui/src/remake/components/shared/account-picker/account-picker.tsx\nmsgid \"Not logged in\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Not scheduled\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/empty-state/empty-state.tsx\nmsgid \"Nothing selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Notifications\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Notify followers\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Nudity\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Offer hi-res download (gold only)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Offline\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Once\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Online\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Only set scheduled date (don't activate)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Open CRON helper\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Open full calendar\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\nmsgid \"Open Inkbunny Account Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/app-settings-section.tsx\nmsgid \"Open PostyBirb on computer startup\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Open Twitter Authorization Page\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Optional template to apply to imported files\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Options\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Original work\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Other\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\nmsgid \"Overwrite overlapping website options only. Keeps existing website options that are not specified in the source.\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Parent ID\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\nmsgid \"Password\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Password is invalid\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-dropzone.tsx\nmsgid \"Paste from clipboard\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-dropzone.tsx\nmsgid \"Paste image from clipboard (Ctrl+V)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\nmsgid \"Pause\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/queue-control-card.tsx\nmsgid \"Pause Posting\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\nmsgid \"Paused\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"PDS (Personal Data Server)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Pending\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Per-Account Dimensions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Phone Number\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgctxt \"telegram.phone-number-help\"\nmsgid \"Phone number must be in international format\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"PIN / Verifier Code\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Pixel perfect display\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\nmsgid \"Please check your credentials and try again\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"Portuguese (Brazil)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Post\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Post Data (JSON)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"Post Failure\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Post Files\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"Post Submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"Post Success\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\nmsgid \"PostyBirb uses Discord webhooks to post content to your server. Make sure you have the necessary permissions to create webhooks in your target channel.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-preview-panel.tsx\nmsgid \"Preview\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Primary\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Privacy\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Private\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Proceed to Authorization\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Profanity\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Public viewers\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Queued\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Quick tips to get started:\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Racism\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Rating\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Read\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Ready\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-button.tsx\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Ready to Install\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Ready to post\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/recent-activity-panel.tsx\nmsgid \"Recent Activity\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Recurring\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Recurring schedule (active)\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Reference\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\nmsgid \"Refresh page\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Register Instance\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\nmsgid \"Reload page\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\nmsgid \"Remote\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Remote Connection\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Remote host URL is not configured\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Remote password is not configured\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\nmsgid \"Remove\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/resizable-image.tsx\nmsgid \"Remove image\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\nmsgid \"Rename\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\nmsgid \"Replace All\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgctxt \"import.override-description\"\nmsgid \"Replace description\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Replace file\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Replace the current description with the selected template's description\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Replace the current title with the selected template's title\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgctxt \"import.override-title\"\nmsgid \"Replace title\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Reply to post URL\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Request critique\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Required\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Required if 2FA is enabled.\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Required tag\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Requires at least {minLength} {minLength, plural, one {tag} other {tags}}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Requires at least {minSelected} {minSelected, plural, one {option selected} other {options selected}} ({currentSelected} / {minSelected})\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Reset\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Reset account data\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Reset account?\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Reset Remote Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Restart Now\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/archived-submission-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/archived-submission-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"Restore\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Restored successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Resume Failed Posting\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/queue-control-card.tsx\nmsgid \"Resume Posting\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Retry all failed or unattempted websites\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Retry posting to all websites that failed or were not attempted. All files will be re-uploaded.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Rotate\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Rotate left 90°\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Rotate right 90°\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Running\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"Russian\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Sa\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/e621/e621-login-view.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\nmsgid \"Save\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Save API Keys\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\nmsgid \"Save as default\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Save changes\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\nmsgid \"Save Selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Save to file\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/save-to-many-action.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/save-to-many-action.tsx\nmsgid \"Save to submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Schedule\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/upcoming-posts-panel.tsx\nmsgid \"Schedule a submission to see it here\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Schedule configured (inactive)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Schedule posts to automatically publish at specific times\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Schedule Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Schedule Submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Schedule updated\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Scheduled\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Scheduled (active)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Scroll to zoom\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/submission-list.tsx\n#: apps/postybirb-ui/src/remake/components/shared/search-input.tsx\nmsgid \"Search...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-content.tsx\nmsgid \"Select a submission from the list to view details\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker.tsx\nmsgid \"Select a template\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-content.tsx\nmsgid \"Select a template from the list to view or edit it\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-select.tsx\nmsgid \"Select accounts...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-select.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-selection-form.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\nmsgid \"Select all\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-content.tsx\nmsgid \"Select an account to log in\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\nmsgid \"Select fields to save as defaults for this account.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\nmsgid \"Select items to delete\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/language-picker/language-picker.tsx\nmsgid \"Select language\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker.tsx\nmsgid \"Select submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Select templates or submissions to import\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\nmsgid \"Select websites to apply this shortcut to\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\nmsgid \"Select websites to apply usernames to\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Select which template to use for each account\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/tree-select.tsx\nmsgid \"Select...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Selected option is invalid or missing\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Send Code\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Send Messages\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Send to scraps\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Sensitive content\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\nmsgid \"Server Boost Level\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Server unreachable\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Set the interval between each submission.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Settings\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Sexual Content\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Share on feed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Share on Feed is required to support posting multiple files.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Share your LAN IP and password with clients to allow them to connect.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"shortcut\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"Shortcut name\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\nmsgid \"Show wiki page related to tag on hover\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-visibility-picker.tsx\nmsgid \"Show/Hide Websites\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Silent\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Sketch\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Skip Accounts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"Skip websites that posted successfully and only attempt failed or unattempted ones.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Skip websites that posted successfully and only attempt failed or unattempted ones. Continues from the last successful batch of files.\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Software\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"Some accounts need to be logged in before you can post to them.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-uploader.tsx\nmsgid \"Some files were rejected. Check file type and size (max 100MB).\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"Some submissions have failed posting attempts. Choose how to handle them:\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\nmsgid \"Something went wrong\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Source URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Source URLs\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"Spanish\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Species\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Specify your Fediverse instance\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\nmsgid \"Spellchecker\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/spellchecker-settings-section.tsx\nmsgid \"Spellchecker advanced configuration is controlled by system on MacOS.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/spellchecker-settings-section.tsx\nmsgid \"Spellchecker Settings\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Spoiler\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Spoiler Text\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Spoilers\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Start completely fresh\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Start Date\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Start Over\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/app-settings-section.tsx\nmsgid \"Startup Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Status\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Su\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"Submission has validation errors\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Submission Order\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\nmsgid \"Submission unscheduled\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Submission will be posted automatically\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Submission will be split into {expectedBatchesToCreate} different submissions with {maxBatchSize} {maxBatchSize, plural, one {file each} other {files each}}.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-section.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"success\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Success\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Successful\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Successfully connected to the remote host\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Successfully logged in as {loggedInAs}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Successfully logged in as @{loggedInAs}\"\nmsgstr \"\"\n\n#. placeholder {0}: orderedSubmissions.length\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Successfully scheduled ${0} submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Supports either a set of images, a single video, or a single GIF.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tag {tag} has {postCount} post(s). Tag may be invalid or low use\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tag <0>{tag}</0> does not exist yet or is invalid.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tag <0>{tag}</0> does not exist yet or is invalid. If you want to create a new tag, make a post with it, then go <1>here</1>, press edit and select tag category\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tag <0>{tag}</0> is invalid.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/tag-converter-drawer/tag-converter-drawer.tsx\nmsgid \"tag converter\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-converter-drawer/tag-converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Tag Converters\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Tag Groups\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tag limit reached ({currentLength} / {maxLength})\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Tag permissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/simple-tag-input/simple-tag-input.tsx\nmsgid \"tags\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/tags-shortcut.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Tags\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tags longer than {maxLength} characters will be skipped\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\nmsgid \"Tags Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tags should not start with #\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"Tamil\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Teaser\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Telegram requires API credentials and phone number authentication. You'll need to create a Telegram app and verify your phone number.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker.tsx\nmsgid \"Template\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\nmsgid \"Template created\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-content.tsx\nmsgid \"Template Editor\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-content.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Templates\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Test Connection\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Text\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Th\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"The folder \\\"{folderName}\\\" contains {fileCount} files.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/specialized-error-boundaries.tsx\nmsgid \"The form encountered an error and couldn't be displayed properly.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"The last posting attempt failed. How would you like to proceed?\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"The update has been downloaded and is ready to install. Click \\\"Restart Now\\\" to apply the update.\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Theme\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\nmsgid \"These defaults apply to future submissions.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\nmsgid \"This component encountered an error and couldn't render properly.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\nmsgid \"This is a forum channel\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"This post record has no events or is still processing. Check the JSON data below for more details.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-content.tsx\nmsgid \"This website has an unsupported login configuration\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/custom-login-placeholder.tsx\nmsgid \"This website uses a custom login form. Support coming soon.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"This will clear all account data and cookies.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Thumbnail\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Thumbnail must be an image file.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Time\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Time Taken\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Tips\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/title-shortcut.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Title\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Title is too long and will be truncated\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Title is too short\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-visibility-picker.tsx\nmsgid \"Toggle website visibility\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Total\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Translation {id} not found\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\n#: apps/postybirb-ui/src/remake/components/error-boundary/specialized-error-boundaries.tsx\nmsgid \"Try again\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Tu\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Twitter / X Authentication\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/description-editor.tsx\nmsgid \"Type / for commands or @, ` or '{' for shortcuts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Unknown\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Unknown choice\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-content.tsx\nmsgid \"Unknown login type\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Unread\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\nmsgid \"Unschedule\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/submission-list.tsx\nmsgid \"Unscheduled Submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Unsupported file type {fileExtension}. Please provide fallback text.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Unsupported file type {mimeType}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Unsupported rating: {ratingLabel}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Unsupported submission type: {fileTypeString}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/upcoming-posts-panel.tsx\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/header/submission-edit-card-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\n#: apps/postybirb-ui/src/remake/components/shared/reorderable-submission-list/reorderable-submission-list.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Untitled\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/submission-list.tsx\nmsgid \"Untitled Submission\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Untitled Template\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/upcoming-posts-panel.tsx\nmsgid \"Upcoming Posts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Update API Keys\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-button.tsx\n#: apps/postybirb-ui/src/remake/components/update-button/update-button.tsx\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Update available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Update Error\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Update Failed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-button.tsx\nmsgid \"Update ready\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Updated successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-submission-modal.tsx\nmsgid \"Upload\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Upload thumbnail\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/components/insert-media-modal.tsx\nmsgid \"URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-field.tsx\nmsgid \"Use custom description\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Use templates to save time on repeated posts\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Use thumbnail as cover art\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Use title\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Use your Bluesky handle or email along with an app password. App passwords are more secure than your main password and can be revoked at any time.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Used for other sites (like e621) as source url base. Independent from custom pds.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/user-converter-drawer/user-converter-drawer.tsx\nmsgid \"user converter\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/user-converter-drawer/user-converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"User Converters\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/e621/e621-login-view.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\nmsgid \"Username\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Username or Email\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\nmsgid \"Validation Issues\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Video\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"View\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/archived-submission-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/archived-submission-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"View history\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"View permissions\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Violence\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Visibility\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-option-row.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/selected-accounts-forms.tsx\nmsgid \"Warning\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\nmsgid \"warnings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Warnings\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Watermark\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"We\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\nmsgid \"We encountered an unexpected error. Please try refreshing the page.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Website\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/selected-accounts-forms.tsx\nmsgid \"Website Options\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-select.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-selection-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/account-picker/account-picker.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\nmsgid \"Websites\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Weekly\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Welcome to PostyBirb!\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"What to do when new files are detected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"What's New\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Who can reply\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/components/insert-media-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/resizable-image.tsx\nmsgid \"Width\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"You agree to comply with all applicable laws, terms of service, and policies of any third party platforms you connect to or interact with through this application. Do not use this software to infringe intellectual property rights, violate privacy, or circumvent platform rules.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"You have existing API keys. You can modify them above or proceed to the next step.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"You have recent {negativeOrNeutral} feedback: {feedback}, you can view it<0>here</0>\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\nmsgid \"You must first enable API access in your account settings under \\\"API (External Scripting)\\\" before you can authenticate with PostyBirb.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"You need to create a Twitter app to get API keys. <0>Visit Twitter Developer Portal</0>\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"You'll get this PIN after authorizing on Twitter\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-field.tsx\nmsgid \"Your description contains legacy shortcut syntax. These are no longer supported. Please use the new shortcut menu (type @, &lbrace; or `) to insert shortcuts.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Your handle (e.g. <0>yourname.bsky.social</0>) or custom domain (e.g. <1>username.ext</1>)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\nmsgid \"Your password will not be stored\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Zoom\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Zoom in\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Zoom out\"\nmsgstr \"\"\n"
  },
  {
    "path": "lang/ru.po",
    "content": "msgid \"\"\nmsgstr \"\"\n\"Project-Id-Version: \\n\"\n\"Report-Msgid-Bugs-To: \\n\"\n\"POT-Creation-Date: \\n\"\n\"PO-Revision-Date: \\n\"\n\"Last-Translator: \\n\"\n\"Language: \\n\"\n\"Language-Team: \\n\"\n\"Content-Type: \\n\"\n\"Content-Transfer-Encoding: \\n\"\n\"Plural-Forms: \\n\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"({fileSizeString}) is too large (max {maxFileSizeString}) and an attempt will be made to reduce size when posting\"\nmsgstr \"Файл ({fileSizeString}) слишком большой (максимум {maxFileSizeString}) и будет предпринята попытка уменьшить размер при публикации\"\n\n#. placeholder {0}: tag.post_count\n#: apps/postybirb-ui/src/remake/components/website-components/e621/e621-tag-search-provider.tsx\nmsgid \"{0, plural, one {# post} other {# posts}}\"\nmsgstr \"\"\n\n#. placeholder {0}: selectedOptions.length\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/tree-select.tsx\nmsgid \"{0} selected\"\nmsgstr \"\"\n\n#. placeholder {0}: value.length\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker.tsx\nmsgid \"{0} submissions selected\"\nmsgstr \"\"\n\n#. placeholder {0}: account?.websiteDisplayName\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\nmsgid \"{0}: Login successful\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"{count} items deleted\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"{count} selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-visibility-picker.tsx\nmsgid \"{hiddenCount} hidden\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"{selectedCount} of {totalCount} selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"{validCount} of {totalSelectedCount} selected submission(s) are ready to post. Submissions without websites or with validation errors will be skipped.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"{validCount} submission(s) will be posted in the order shown below.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"<0>Tip:</0> If your handle doesn't work, try using the email linked to your account\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"2FA enabled, password required. It won't be stored.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"A shortcut with this name already exists\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/tag-converter-drawer/tag-converter-drawer.tsx\nmsgid \"A tag converter with this tag already exists\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/user-converter-drawer/user-converter-drawer.tsx\nmsgid \"A user converter with this username already exists\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"Accept and Continue\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Access Tiers\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/selected-accounts-forms.tsx\nmsgid \"account\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Account\"\nmsgstr \"Аккаунт\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-section.tsx\nmsgid \"Account data cleared\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Account name\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Account Options\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"Account Status\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/selected-accounts-forms.tsx\nmsgid \"accounts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/account-section-header.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Accounts\"\nmsgstr \"Аккаунты\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\nmsgid \"Activate\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Activate schedule\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\nmsgid \"Active\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/recent-activity-panel.tsx\nmsgid \"Activity will appear here as you use the app\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Add account\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"Add an account to start posting\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-submission-modal.tsx\nmsgid \"Add File Submission\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"Add shortcut\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\nmsgid \"Add tags...\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Add to Portfolio\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\nmsgid \"Add website\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-preview-panel.tsx\nmsgid \"Add website accounts to preview the description output.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Adjust dimensions while maintaining aspect ratio\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/rating-input/rating-input.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Adult\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Adult themes\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"After authorizing, copy the code and paste it below\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"AI generated\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/account-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\nmsgid \"All\"\nmsgstr \"Все\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"All files are marked ignored.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"All healthy\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\nmsgid \"All submissions are ready to post\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\nmsgid \"All Websites\"\nmsgstr \"Все сайты\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow comments\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow commercial use\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow free download\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow modifications of your work\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow other users to edit tags\"\nmsgstr \"Разрешить пользователям редактировать теги\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/description-settings-section.tsx\nmsgid \"Allow PostyBirb to self-advertise at the end of descriptions\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow reblogging\"\nmsgstr \"Разрешить реблог\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Alt Text\"\nmsgstr \"Alt текст\"\n\n#. Bluesky login form\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"An <0>app password</0> (not your main password)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"An error occurred\"\nmsgstr \"\"\n\n#. placeholder {0}: dtext.length - text.length\n#: apps/postybirb-ui/src/remake/components/website-components/e621/e621-dtext-renderer.tsx\nmsgid \"and {0} more\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"and {remainingCount} more\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"API Key (Consumer Key)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"API keys saved successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"API Secret (Consumer Secret)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\nmsgid \"App\"\nmsgstr \"Приложение\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"App API Hash\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"App API ID\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/app-settings-section.tsx\nmsgid \"App Folder\"\nmsgstr \"Папка приложения\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"App Password\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"App registered successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/app-settings-section.tsx\nmsgid \"App Server Port\"\nmsgstr \"Порт сервера\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"App view URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\nmsgid \"Appearance\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Applied template options to {successCount} submissions\"\nmsgstr \"\"\n\n#. placeholder {0}: submissionIds.length\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/save-to-many-action.tsx\nmsgid \"Applied to {0} submission(s)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Applied to {successCount} submissions, {failedCount} failed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Apply\"\nmsgstr \"Применить\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/apply-template-action.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Apply template\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\nmsgid \"Archive\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/header/submission-edit-card-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-section.tsx\nmsgid \"Archived\"\nmsgstr \"\"\n\n#. placeholder {0}: selectedIds.length\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-section.tsx\nmsgid \"Are you sure you want to delete {0} submission(s)? This action cannot be undone.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Are you sure you want to reset all remote connection settings to their defaults? This will clear the saved host URL and password.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Are you sure you want to watch this folder?\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Artist name\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Artist URL (Off-Site only)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Aspect Ratio\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Attach Images as Attachments\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Audience\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Audio\"\nmsgstr \"Аудио\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Authenticate\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Authorization Code\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Authorization URL generated\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Authorize\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Authorized viewers\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/appearance-settings-section.tsx\nmsgid \"Auto\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Automatically import new files from specified folders\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Back\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Block guests\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Blog\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Bluesky automatically converts GIFs to videos.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/app-settings-section.tsx\nmsgid \"Browse\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Builder\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"By using this application, you acknowledge that you are solely responsible for how you use it and for any content you create, upload, distribute, or interact with. The authors and maintainers provide this software as is without warranties of any kind and are not liable for any damages or losses resulting from its use.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/confirm-action-modal/confirm-action-modal.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/components/insert-media-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Cancel\"\nmsgstr \"Отмена\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"Cancel posting\"\nmsgstr \"\"\n\n#. placeholder {0}: getFileTypeLabel(newFileType)\n#. placeholder {1}: getFileTypeLabel(existingFileType)\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-uploader.tsx\nmsgid \"Cannot add {0} files to a submission containing {1} files. All files in a submission must be of the same type.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-card.tsx\nmsgid \"Cannot delete the only file\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Category\"\nmsgstr \"Категория\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Channel\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Characters\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Charge Patrons\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\nmsgid \"Check login status\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Check that handle and password are valid or try using email instead of handle.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Check your telegram messages\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\nmsgid \"Checking...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-content.tsx\nmsgid \"Choose an account from the list on the left\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgctxt \"template.picker-modal-header\"\nmsgid \"Choose Templates\"\nmsgstr \"Выберите шаблоны\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Click here to authorize PostyBirb\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Click the button below to get your authorization URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Click the link above, authorize the app, and copy the PIN code below\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Client\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Cloud Password\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Code\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Code invalid, ensure it matches the code sent from telegram\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Code sent\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-card.tsx\nmsgid \"Collapse\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Collections\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Comment permissions\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Commercial use\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Compact view\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Complete Authorization\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Complete Login\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Configuration Error\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Configure API Keys\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/confirm-action-modal/confirm-action-modal.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Confirm\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Connected successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\nmsgid \"Connection error\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Connection Failed\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Contains content\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Content Blur\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/content-warning-shortcut.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Content warning\"\nmsgstr \"Предупреждение (CW)\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Continue from where it left off\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/copy-to-clipboard/copy-to-clipboard.tsx\nmsgid \"Copied\"\nmsgstr \"Скопированно\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Copied to clipboard\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/copy-to-clipboard/copy-to-clipboard.tsx\n#: apps/postybirb-ui/src/remake/components/shared/copy-to-clipboard/copy-to-clipboard.tsx\nmsgid \"Copy\"\nmsgstr \"Копировать\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Create\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Create a File or Message submission to start posting\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Create a file watcher to automatically import new files from a folder\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Create an app password in Bluesky Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Create Message\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Create new file watcher\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Create new submission\"\nmsgstr \"Создать новый пост\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Create Submission\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgctxt \"telegram.app-id-help\"\nmsgid \"Create telegram app to retrieve api_id and api_hash\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Created successfully\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Creative Commons\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Creative Commons license\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"CRON Expression\"\nmsgstr \"Выражение CRON\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Crop from primary\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Ctrl+Enter to apply\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Custom\"\nmsgstr \"\"\n\n#. placeholder {0}: website.displayName\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/custom-login-placeholder.tsx\nmsgid \"Custom login for {0}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Custom PDS or AppView\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Custom Shortcuts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/spellchecker-settings-section.tsx\nmsgid \"Custom words\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Daily\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/appearance-settings-section.tsx\nmsgid \"Dark\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/theme-picker/theme-picker.tsx\n#: apps/postybirb-ui/src/remake/components/theme-picker/theme-picker.tsx\nmsgid \"Dark Mode\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Dashboard\"\nmsgstr \"Главная\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Date {currentDate} is after maximum allowed date {maxDate}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Date {currentDate} is before minimum allowed date {minDate}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Date {currentDate} is outside allowed range ({minDate} - {maxDate})\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Date and Time\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Date is in the past\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Day of Month\"\nmsgstr \"День месяца\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Days\"\nmsgstr \"Дни\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"Decline and Exit\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/radio-field.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/default-shortcut.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Default\"\nmsgstr \"По умолчанию\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/defaults-form/defaults-form.tsx\nmsgid \"Defaults\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\nmsgid \"Defaults saved successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-preview.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-section.tsx\nmsgid \"Delete\"\nmsgstr \"Удалить\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\nmsgid \"Delete all existing website options and use only those specified in the source.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-card.tsx\nmsgid \"Delete file\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/archived-submission-card.tsx\nmsgid \"Delete permanently\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-section.tsx\nmsgid \"Delete Submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Deleted successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: apps/postybirb-ui/src/remake/components/website-components/e621/e621-tag-search-provider.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Description\"\nmsgstr \"Описание\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Description length is greater then maximum ({currentLength} / {maxLength})\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Description length is lower then minimum ({currentLength} / {minLength})\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/description-settings-section.tsx\nmsgid \"Description Settings\"\nmsgstr \"Настройки описания\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-select.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-selection-form.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\nmsgid \"Deselect all\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"Desktop Notifications\"\nmsgstr \"Уведомления на рабочем столе\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Detailed view\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Dimensions\"\nmsgstr \"Размеры\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Disable comments\"\nmsgstr \"Выключить комментарии\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Discard all progress from the previous attempt and start as if this were the first time posting.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Discord\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Display resolution\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Double-click to toggle crop/move mode\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Download Update\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Downloading...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/reorderable-submission-list/reorderable-submission-list.tsx\nmsgid \"Drag or use arrow keys to reorder\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/submission-list.tsx\nmsgid \"Drag submissions onto the calendar to schedule them\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-manager.tsx\nmsgid \"Drag to reorder\"\nmsgstr \"Перетащите чтобы переопределить порядок\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-dropzone.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-list.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-uploader.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Drop files here or click to browse\"\nmsgstr \"Перетащите файлы сюда или нажмите чтобы выбрать\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Drug / Alcohol\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\nmsgid \"Duplicate\"\nmsgstr \"Дублировать\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Duplicated successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/e621/e621-login-view.tsx\nmsgid \"e621 requires API credentials for posting. You'll need to generate an API key from your account settings to authenticate PostyBirb.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-dropzone.tsx\nmsgid \"Each file becomes a separate submission\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Early Access\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\nmsgid \"Edit\"\nmsgstr \"Редактировать\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-preview.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Edit image\"\nmsgstr \"Редактировать изображение\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-card.tsx\nmsgid \"Edit metadata\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-preview.tsx\nmsgid \"Edit title\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"Enable desktop notifications\"\nmsgstr \"Включить уведомления на рабочем столе\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\nmsgid \"Enable global tag autocomplete from sites like e621.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/spellchecker-settings-section.tsx\nmsgid \"Enabled\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"English\"\nmsgstr \"Английский\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Ensure the IP is correct\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Enter Instance URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Enter the host URL and password to connect to a remote PostyBirb instance.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Enter the PIN from Twitter\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Enter the URL of your Mastodon, Pleroma, or Pixelfed instance (e.g., \\\"mastodon.social\\\" or \\\"pixelfed.social\\\")\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Enter title...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Enter your Twitter app credentials\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-option-row.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/selected-accounts-forms.tsx\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Error\"\nmsgstr \"Ошибка\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Error while authenticating Telegram\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\nmsgid \"errors\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Errors\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"Expand\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Explicit text\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/rating-input/rating-input.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Extreme\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"failed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Failed\"\nmsgstr \"Не удалось\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"Failed Posts Detected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Failed to apply template options\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-section.tsx\nmsgid \"Failed to clear account data\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Failed to complete authorization\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Failed to connect to remote host\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\nmsgid \"Failed to connect to the server\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to create\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\nmsgid \"Failed to create template\"\nmsgstr \"Не удалось создать шаблон\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to delete\"\nmsgstr \"Не удалось удалить\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-card.tsx\nmsgid \"Failed to delete file\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to duplicate\"\nmsgstr \"Не удалось дублировать\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Failed to generate authorization URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Failed to get user warnings. You can check your account manually\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Failed to load primary file for cropping.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to post submission\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Failed to register with instance\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Failed to replace file\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to restore\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to save\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/form-fields-context.tsx\nmsgid \"Failed to save changes\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-alt-text-editor.tsx\nmsgid \"Failed to save fallback text\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Failed to save metadata\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Failed to schedule submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Failed to send code to begin authentication\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Failed to store API keys\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to update\"\nmsgstr \"Не удалось обновить\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\nmsgid \"Failed to update template name\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to upload files\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Failed to validate submission: {message}\"\nmsgstr \"Не удалось проверить пост: {message}\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Failed to validate tags. Please check them manually\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-alt-text-editor.tsx\nmsgid \"Fallback Text\"\nmsgstr \"Резервный текст\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Feature\"\nmsgstr \"Избранное (Featured)\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Fediverse Authentication\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Female\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\nmsgid \"File\"\nmsgstr \"Файл\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-content.tsx\nmsgid \"File Submissions\"\nmsgstr \"Файловые посты\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"File types do not match. Please upload a file of the same type.\"\nmsgstr \"Типы файлов не совпадают. Пожалуйста, загрузите файл того же типа.\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"File Watcher Failure\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"File Watcher Success\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"File Watchers\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"File will be modified to support website requirements\"\nmsgstr \"Файл будет изменен для поддержки требований сайта\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-list.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-manager.tsx\nmsgid \"Files\"\nmsgstr \"Файлы\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Files uploaded successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Flip\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Flip horizontal\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Flip vertical\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Folder\"\nmsgstr \"Папка\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Folder Contains Files\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Folder Path\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/specialized-error-boundaries.tsx\nmsgid \"Form Error\"\nmsgstr \"Ошибка формы\"\n\n#. Bluesky login form\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Format should be <0>handle.bsky.social</0> or <1>domain.ext</1> for custom domains\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Format: minute hour day month weekday\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Found in your Twitter app's Keys and tokens section\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Fr\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Frequency\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Friends only\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Furry\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Gender\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/rating-input/rating-input.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"General\"\nmsgstr \"\"\n\n#. E621 login form\n#: apps/postybirb-ui/src/remake/components/website-login-views/e621/e621-login-view.tsx\nmsgid \"Generate an API key from your <0>account settings</0>\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Generate authorization link\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"German\"\nmsgstr \"Немецкий\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Get Authorization\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Get authorization code\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Get Authorization URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Get started by adding your accounts and creating your first submission.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\nmsgid \"Global tag search provider\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Go back\"\nmsgstr \"Вернуться назад\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\nmsgid \"Go forward\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Go to Accounts to add and log into your websites\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Gore\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Has sexual content\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Has validation errors\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Has validation warnings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/components/insert-media-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/resizable-image.tsx\nmsgid \"Height\"\nmsgstr \"Высота\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-selection-form.tsx\nmsgid \"Hide unselected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\nmsgid \"Higher boost levels allow larger file uploads\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\nmsgid \"Hold to delete\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\nmsgid \"Hold to delete {count} item(s)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"Hold to delete permanently\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"Hold to post\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Home\"\nmsgstr \"Главная\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Host\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Host URL\"\nmsgstr \"URL хоста\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Hours\"\nmsgstr \"Часы\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\nmsgctxt \"discord.webhook-help\"\nmsgid \"How to create a webhook\"\nmsgstr \"Как создать webhook\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"I have read, understand, and accept the disclaimer.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"If you are using pds other then bsky.social\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"If you do not agree with these terms, you must decline and exit the application.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/tag-field.tsx\nmsgctxt \"override-default\"\nmsgid \"Ignore default tags\"\nmsgstr \"Игнорировать теги по умолчанию\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Image\"\nmsgstr \"Изображение\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/resizable-image.tsx\nmsgid \"Image Size\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-uploader.tsx\nmsgid \"Images, videos, audio, and text files up to 100MB\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\nmsgid \"Import\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Import Action\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\nmsgid \"Import completed successfully!\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\nmsgid \"Import encountered errors:\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\nmsgid \"Import from PostyBirb Plus\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\nmsgid \"Import your data from PostyBirb Plus. Select which types of data you want to import.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\nmsgid \"Inactive\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Info\"\nmsgstr \"Информация\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/components/insert-media-modal.tsx\nmsgid \"Insert\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/components/insert-media-modal.tsx\nmsgid \"Insert Media\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-field.tsx\nmsgid \"Insert tags at end\"\nmsgstr \"Вставить теги в конце\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-field.tsx\nmsgid \"Insert title at start\"\nmsgstr \"Вставить заголовок в начале\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Instance URL\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Intended as advertisement\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Invalid CRON expression\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Invalid date format: {value}. Expected ISO date string.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Invalid post URL to reply to.\"\nmsgstr \"Невалидный URL поста для ответа.\"\n\n#. Bluesky login form\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Invalid url. Format should be <0>https://bsky.app/</0>\"\nmsgstr \"\"\n\n#. Bluesky login form\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Invalid url. Format should be <0>https://bsky.social/</0>\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"It is recommended to add at least 10 general tags <0>( {generalTags} / 10 )</0>. See <1>tagging checklist</1>\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Keep this secret and never share it publicly\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Ko-fi\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"LAN IP\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/language-picker/language-picker.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Language\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/spellchecker-settings-section.tsx\nmsgid \"Languages to check\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Later\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-field.tsx\nmsgid \"Legacy Shortcuts Detected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"Legal Notice & Disclaimer\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/appearance-settings-section.tsx\nmsgid \"Light\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/theme-picker/theme-picker.tsx\n#: apps/postybirb-ui/src/remake/components/theme-picker/theme-picker.tsx\nmsgid \"Light Mode\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"Lithuanian\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Log in to different instance\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"logged in\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/account-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Logged in\"\nmsgstr \"\"\n\n#. placeholder {0}: username ? ` as ${username}` : ''\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\nmsgid \"Logged in{0}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Login\"\nmsgstr \"Войти\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\nmsgid \"Login failed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Make sure that the default rating matches Bluesky Label Rating.\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Male\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"Manage accounts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Mark as read\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Mark as unread\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Mark as work in progress\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Marked as read\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-content.tsx\nmsgid \"Mass Edit\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/rating-input/rating-input.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Mature\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Mature content\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Media\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\nmsgid \"Merge (recommended)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\nmsgid \"Merge Mode\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\nmsgid \"Message\"\nmsgstr \"Сообщение\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-content.tsx\nmsgid \"Message Submissions\"\nmsgstr \"Посты с текстом\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Minutes\"\nmsgstr \"Минуты\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Mo\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Modification\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Modified\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Monthly\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/header/submission-edit-card-header.tsx\nmsgid \"Multi Edit\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\nmsgid \"Name\"\nmsgstr \"Имя\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"Name is required\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"need attention\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\nmsgid \"New\"\nmsgstr \"Новое\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"New account\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"New Message\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\nmsgid \"New template name...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"New version:\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"No AI\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"No changes\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"No custom dimensions set\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/defaults-form/defaults-form.tsx\nmsgid \"No default options available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-list.tsx\nmsgid \"No files added yet\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"No folder selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/empty-state/empty-state.tsx\nmsgid \"No notifications\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/tree-select.tsx\nmsgid \"No options available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"No options selected to apply\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-preview-panel.tsx\nmsgid \"No preview available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/empty-state/empty-state.tsx\nmsgid \"No records yet\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/empty-state/empty-state.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-select.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/tree-select.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker.tsx\nmsgid \"No results found\"\nmsgstr \"Результатов не найдено\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"No schedule configured\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/reorderable-submission-list/reorderable-submission-list.tsx\nmsgid \"No submissions selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\nmsgid \"No validation issues\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"No website posts found\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"No websites\"\nmsgstr \"Нет сайтов\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-visibility-picker.tsx\nmsgid \"No websites available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"No websites selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-components/e621/e621-tag-search-provider.tsx\nmsgid \"No wiki page available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\nmsgid \"None\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\nmsgid \"Not all tag providers support this feature.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/account-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-option-row.tsx\n#: apps/postybirb-ui/src/remake/components/shared/account-picker/account-picker.tsx\nmsgid \"Not logged in\"\nmsgstr \"Нет входа\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Not scheduled\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/empty-state/empty-state.tsx\nmsgid \"Nothing selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Notifications\"\nmsgstr \"Уведомления\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Notify followers\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Nudity\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Offer hi-res download (gold only)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Offline\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Once\"\nmsgstr \"Один раз\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Online\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Only set scheduled date (don't activate)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Open CRON helper\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Open full calendar\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\nmsgid \"Open Inkbunny Account Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/app-settings-section.tsx\nmsgid \"Open PostyBirb on computer startup\"\nmsgstr \"Открывать PostyBirb при запуске компьютера\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Open Twitter Authorization Page\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Optional template to apply to imported files\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Options\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Original work\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Other\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\nmsgid \"Overwrite overlapping website options only. Keeps existing website options that are not specified in the source.\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Parent ID\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\nmsgid \"Password\"\nmsgstr \"Пароль\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Password is invalid\"\nmsgstr \"Неверный пароль\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-dropzone.tsx\nmsgid \"Paste from clipboard\"\nmsgstr \"Вставить из буфера обмена\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-dropzone.tsx\nmsgid \"Paste image from clipboard (Ctrl+V)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\nmsgid \"Pause\"\nmsgstr \"Пауза\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/queue-control-card.tsx\nmsgid \"Pause Posting\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\nmsgid \"Paused\"\nmsgstr \"На паузе\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"PDS (Personal Data Server)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Pending\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Per-Account Dimensions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Phone Number\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgctxt \"telegram.phone-number-help\"\nmsgid \"Phone number must be in international format\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"PIN / Verifier Code\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Pixel perfect display\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\nmsgid \"Please check your credentials and try again\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"Portuguese (Brazil)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Post\"\nmsgstr \"Опубликовать\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Post Data (JSON)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"Post Failure\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Post Files\"\nmsgstr \"Файлы\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"Post Submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"Post Success\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\nmsgid \"PostyBirb uses Discord webhooks to post content to your server. Make sure you have the necessary permissions to create webhooks in your target channel.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-preview-panel.tsx\nmsgid \"Preview\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Primary\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Privacy\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Private\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Proceed to Authorization\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Profanity\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Public viewers\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Queued\"\nmsgstr \"В очереди\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Quick tips to get started:\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Racism\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Rating\"\nmsgstr \"Рейтинг\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Read\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Ready\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-button.tsx\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Ready to Install\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Ready to post\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/recent-activity-panel.tsx\nmsgid \"Recent Activity\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Recurring\"\nmsgstr \"Регулярные\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Recurring schedule (active)\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Reference\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\nmsgid \"Refresh page\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Register Instance\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\nmsgid \"Reload page\"\nmsgstr \"Обновить страницу\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\nmsgid \"Remote\"\nmsgstr \"Дистанционный доступ\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Remote Connection\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Remote host URL is not configured\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Remote password is not configured\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\nmsgid \"Remove\"\nmsgstr \"Убрать\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/resizable-image.tsx\nmsgid \"Remove image\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\nmsgid \"Rename\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\nmsgid \"Replace All\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgctxt \"import.override-description\"\nmsgid \"Replace description\"\nmsgstr \"Заменить описание\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Replace file\"\nmsgstr \"Заменить файл\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Replace the current description with the selected template's description\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Replace the current title with the selected template's title\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgctxt \"import.override-title\"\nmsgid \"Replace title\"\nmsgstr \"Заменить заголовок\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Reply to post URL\"\nmsgstr \"Ответ на пост (URL)\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Request critique\"\nmsgstr \"Запросить критику\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Required\"\nmsgstr \"Обязательно\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Required if 2FA is enabled.\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Required tag\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Requires at least {minLength} {minLength, plural, one {tag} other {tags}}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Requires at least {minSelected} {minSelected, plural, one {option selected} other {options selected}} ({currentSelected} / {minSelected})\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Reset\"\nmsgstr \"Сбросить\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Reset account data\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Reset account?\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Reset Remote Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Restart Now\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/archived-submission-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/archived-submission-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"Restore\"\nmsgstr \"Восстановить\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Restored successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Resume Failed Posting\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/queue-control-card.tsx\nmsgid \"Resume Posting\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Retry all failed or unattempted websites\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Retry posting to all websites that failed or were not attempted. All files will be re-uploaded.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Rotate\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Rotate left 90°\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Rotate right 90°\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Running\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"Russian\"\nmsgstr \"Русский\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Sa\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/e621/e621-login-view.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\nmsgid \"Save\"\nmsgstr \"Сохранить\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Save API Keys\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\nmsgid \"Save as default\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Save changes\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\nmsgid \"Save Selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Save to file\"\nmsgstr \"Сохранить в файл\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/save-to-many-action.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/save-to-many-action.tsx\nmsgid \"Save to submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Schedule\"\nmsgstr \"Отложить\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/upcoming-posts-panel.tsx\nmsgid \"Schedule a submission to see it here\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Schedule configured (inactive)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Schedule posts to automatically publish at specific times\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Schedule Settings\"\nmsgstr \"Настройки отложки\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Schedule Submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Schedule updated\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Scheduled\"\nmsgstr \"Отложено\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Scheduled (active)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Scroll to zoom\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/submission-list.tsx\n#: apps/postybirb-ui/src/remake/components/shared/search-input.tsx\nmsgid \"Search...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-content.tsx\nmsgid \"Select a submission from the list to view details\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker.tsx\nmsgid \"Select a template\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-content.tsx\nmsgid \"Select a template from the list to view or edit it\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-select.tsx\nmsgid \"Select accounts...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-select.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-selection-form.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\nmsgid \"Select all\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-content.tsx\nmsgid \"Select an account to log in\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\nmsgid \"Select fields to save as defaults for this account.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\nmsgid \"Select items to delete\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/language-picker/language-picker.tsx\nmsgid \"Select language\"\nmsgstr \"Выбрать язык\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker.tsx\nmsgid \"Select submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Select templates or submissions to import\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\nmsgid \"Select websites to apply this shortcut to\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\nmsgid \"Select websites to apply usernames to\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Select which template to use for each account\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/tree-select.tsx\nmsgid \"Select...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Selected option is invalid or missing\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Send Code\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Send Messages\"\nmsgstr \"Сообщения\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Send to scraps\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Sensitive content\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\nmsgid \"Server Boost Level\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Server unreachable\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Set the interval between each submission.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Settings\"\nmsgstr \"Настройки\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Sexual Content\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Share on feed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Share on Feed is required to support posting multiple files.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Share your LAN IP and password with clients to allow them to connect.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"shortcut\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"Shortcut name\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\nmsgid \"Show wiki page related to tag on hover\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-visibility-picker.tsx\nmsgid \"Show/Hide Websites\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Silent\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Sketch\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Skip Accounts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"Skip websites that posted successfully and only attempt failed or unattempted ones.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Skip websites that posted successfully and only attempt failed or unattempted ones. Continues from the last successful batch of files.\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Software\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"Some accounts need to be logged in before you can post to them.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-uploader.tsx\nmsgid \"Some files were rejected. Check file type and size (max 100MB).\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"Some submissions have failed posting attempts. Choose how to handle them:\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\nmsgid \"Something went wrong\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Source URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Source URLs\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"Spanish\"\nmsgstr \"Испанский\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Species\"\nmsgstr \"Виды\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Specify your Fediverse instance\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\nmsgid \"Spellchecker\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/spellchecker-settings-section.tsx\nmsgid \"Spellchecker advanced configuration is controlled by system on MacOS.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/spellchecker-settings-section.tsx\nmsgid \"Spellchecker Settings\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Spoiler\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Spoiler Text\"\nmsgstr \"Спойлер\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Spoilers\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Start completely fresh\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Start Date\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Start Over\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/app-settings-section.tsx\nmsgid \"Startup Settings\"\nmsgstr \"Настройки запуска\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Status\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Su\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"Submission has validation errors\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Submission Order\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\nmsgid \"Submission unscheduled\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Submission will be posted automatically\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Submission will be split into {expectedBatchesToCreate} different submissions with {maxBatchSize} {maxBatchSize, plural, one {file each} other {files each}}.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-section.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Submissions\"\nmsgstr \"Посты\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"success\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Success\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Successful\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Successfully connected to the remote host\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Successfully logged in as {loggedInAs}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Successfully logged in as @{loggedInAs}\"\nmsgstr \"\"\n\n#. placeholder {0}: orderedSubmissions.length\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Successfully scheduled ${0} submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Supports either a set of images, a single video, or a single GIF.\"\nmsgstr \"Поддерживается только пост из нескольких изображений, одного видео или одного GIF.\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tag {tag} has {postCount} post(s). Tag may be invalid or low use\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tag <0>{tag}</0> does not exist yet or is invalid.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tag <0>{tag}</0> does not exist yet or is invalid. If you want to create a new tag, make a post with it, then go <1>here</1>, press edit and select tag category\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tag <0>{tag}</0> is invalid.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/tag-converter-drawer/tag-converter-drawer.tsx\nmsgid \"tag converter\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-converter-drawer/tag-converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Tag Converters\"\nmsgstr \"Конвертер тегов\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Tag Groups\"\nmsgstr \"Группы тегов\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tag limit reached ({currentLength} / {maxLength})\"\nmsgstr \"Достигнут лимит длины тега ({currentLength} / {maxLength})\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Tag permissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/simple-tag-input/simple-tag-input.tsx\nmsgid \"tags\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/tags-shortcut.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Tags\"\nmsgstr \"Теги\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tags longer than {maxLength} characters will be skipped\"\nmsgstr \"Тэги длиннее чем {maxLength} будут пропущены\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\nmsgid \"Tags Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tags should not start with #\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"Tamil\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Teaser\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Telegram requires API credentials and phone number authentication. You'll need to create a Telegram app and verify your phone number.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker.tsx\nmsgid \"Template\"\nmsgstr \"Шаблон\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\nmsgid \"Template created\"\nmsgstr \"Шаблон создан\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-content.tsx\nmsgid \"Template Editor\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-content.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Templates\"\nmsgstr \"Шаблоны\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Test Connection\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Text\"\nmsgstr \"Текст\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Th\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"The folder \\\"{folderName}\\\" contains {fileCount} files.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/specialized-error-boundaries.tsx\nmsgid \"The form encountered an error and couldn't be displayed properly.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"The last posting attempt failed. How would you like to proceed?\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"The update has been downloaded and is ready to install. Click \\\"Restart Now\\\" to apply the update.\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Theme\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\nmsgid \"These defaults apply to future submissions.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\nmsgid \"This component encountered an error and couldn't render properly.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\nmsgid \"This is a forum channel\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"This post record has no events or is still processing. Check the JSON data below for more details.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-content.tsx\nmsgid \"This website has an unsupported login configuration\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/custom-login-placeholder.tsx\nmsgid \"This website uses a custom login form. Support coming soon.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"This will clear all account data and cookies.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Thumbnail\"\nmsgstr \"Набросок (Маленькая картинка)\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Thumbnail must be an image file.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Time\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Time Taken\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Tips\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/title-shortcut.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Title\"\nmsgstr \"Название\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Title is too long and will be truncated\"\nmsgstr \"Название слишком длинное и будет сокращено\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Title is too short\"\nmsgstr \"Название слишком короткое\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-visibility-picker.tsx\nmsgid \"Toggle website visibility\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Total\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Translation {id} not found\"\nmsgstr \"Перевод {id} не найден\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\n#: apps/postybirb-ui/src/remake/components/error-boundary/specialized-error-boundaries.tsx\nmsgid \"Try again\"\nmsgstr \"Попробовать снова\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Tu\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Twitter / X Authentication\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/description-editor.tsx\nmsgid \"Type / for commands or @, ` or '{' for shortcuts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Unknown\"\nmsgstr \"Неизвестно\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Unknown choice\"\nmsgstr \"Неизвестный выбор\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-content.tsx\nmsgid \"Unknown login type\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Unread\"\nmsgstr \"Непрочитанные\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\nmsgid \"Unschedule\"\nmsgstr \"Отменить отложку\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/submission-list.tsx\nmsgid \"Unscheduled Submissions\"\nmsgstr \"Неотложенные посты\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Unsupported file type {fileExtension}. Please provide fallback text.\"\nmsgstr \"Неподдерживаемый формат файла: {fileExtension}. Пожалуйста, укажите резервный текст.\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Unsupported file type {mimeType}\"\nmsgstr \"Неподдерживаемый тип файла {mimeType}\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Unsupported rating: {ratingLabel}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Unsupported submission type: {fileTypeString}\"\nmsgstr \"Неподдерживаемый тип поста: {fileTypeString}\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/upcoming-posts-panel.tsx\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/header/submission-edit-card-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\n#: apps/postybirb-ui/src/remake/components/shared/reorderable-submission-list/reorderable-submission-list.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Untitled\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/submission-list.tsx\nmsgid \"Untitled Submission\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Untitled Template\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/upcoming-posts-panel.tsx\nmsgid \"Upcoming Posts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Update API Keys\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-button.tsx\n#: apps/postybirb-ui/src/remake/components/update-button/update-button.tsx\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Update available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Update Error\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Update Failed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-button.tsx\nmsgid \"Update ready\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Updated successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-submission-modal.tsx\nmsgid \"Upload\"\nmsgstr \"Загрузить\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Upload thumbnail\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/components/insert-media-modal.tsx\nmsgid \"URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-field.tsx\nmsgid \"Use custom description\"\nmsgstr \"Кастомное описание\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Use templates to save time on repeated posts\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Use thumbnail as cover art\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Use title\"\nmsgstr \"Использовать название\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Use your Bluesky handle or email along with an app password. App passwords are more secure than your main password and can be revoked at any time.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Used for other sites (like e621) as source url base. Independent from custom pds.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/user-converter-drawer/user-converter-drawer.tsx\nmsgid \"user converter\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/user-converter-drawer/user-converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"User Converters\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/e621/e621-login-view.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\nmsgid \"Username\"\nmsgstr \"Псевдоним\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Username or Email\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\nmsgid \"Validation Issues\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Video\"\nmsgstr \"Видео\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"View\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/archived-submission-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/archived-submission-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"View history\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"View permissions\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Violence\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Visibility\"\nmsgstr \"Видимость\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-option-row.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/selected-accounts-forms.tsx\nmsgid \"Warning\"\nmsgstr \"Предупреждение\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\nmsgid \"warnings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Warnings\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Watermark\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"We\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\nmsgid \"We encountered an unexpected error. Please try refreshing the page.\"\nmsgstr \"Мы столкнулись с неожиданной ошибкой. Попробуйте перезагрузить страницу.\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Website\"\nmsgstr \"Сайт\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/selected-accounts-forms.tsx\nmsgid \"Website Options\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-select.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-selection-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/account-picker/account-picker.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\nmsgid \"Websites\"\nmsgstr \"Сайты\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Weekly\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Welcome to PostyBirb!\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"What to do when new files are detected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"What's New\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Who can reply\"\nmsgstr \"Кто может отвечать\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/components/insert-media-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/resizable-image.tsx\nmsgid \"Width\"\nmsgstr \"Ширина\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"You agree to comply with all applicable laws, terms of service, and policies of any third party platforms you connect to or interact with through this application. Do not use this software to infringe intellectual property rights, violate privacy, or circumvent platform rules.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"You have existing API keys. You can modify them above or proceed to the next step.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"You have recent {negativeOrNeutral} feedback: {feedback}, you can view it<0>here</0>\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\nmsgid \"You must first enable API access in your account settings under \\\"API (External Scripting)\\\" before you can authenticate with PostyBirb.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"You need to create a Twitter app to get API keys. <0>Visit Twitter Developer Portal</0>\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"You'll get this PIN after authorizing on Twitter\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-field.tsx\nmsgid \"Your description contains legacy shortcut syntax. These are no longer supported. Please use the new shortcut menu (type @, &lbrace; or `) to insert shortcuts.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Your handle (e.g. <0>yourname.bsky.social</0>) or custom domain (e.g. <1>username.ext</1>)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\nmsgid \"Your password will not be stored\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Zoom\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Zoom in\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Zoom out\"\nmsgstr \"\"\n"
  },
  {
    "path": "lang/ta.po",
    "content": "msgid \"\"\nmsgstr \"\"\n\"Project-Id-Version: \\n\"\n\"Report-Msgid-Bugs-To: \\n\"\n\"POT-Creation-Date: \\n\"\n\"PO-Revision-Date: \\n\"\n\"Last-Translator: \\n\"\n\"Language: \\n\"\n\"Language-Team: \\n\"\n\"Content-Type: \\n\"\n\"Content-Transfer-Encoding: \\n\"\n\"Plural-Forms: \\n\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"({fileSizeString}) is too large (max {maxFileSizeString}) and an attempt will be made to reduce size when posting\"\nmsgstr \"({fileSizeString}) மிகப் பெரியது (அதிகபட்சம் {maxFileSizeString}) மற்றும் இடுகையிடும்போது அளவைக் குறைக்க முயற்சி செய்யப்படும்\"\n\n#. placeholder {0}: tag.post_count\n#: apps/postybirb-ui/src/remake/components/website-components/e621/e621-tag-search-provider.tsx\nmsgid \"{0, plural, one {# post} other {# posts}}\"\nmsgstr \"\"\n\n#. placeholder {0}: selectedOptions.length\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/tree-select.tsx\nmsgid \"{0} selected\"\nmsgstr \"\"\n\n#. placeholder {0}: value.length\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker.tsx\nmsgid \"{0} submissions selected\"\nmsgstr \"\"\n\n#. placeholder {0}: account?.websiteDisplayName\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\nmsgid \"{0}: Login successful\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"{count} items deleted\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"{count} selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-visibility-picker.tsx\nmsgid \"{hiddenCount} hidden\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"{selectedCount} of {totalCount} selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"{validCount} of {totalSelectedCount} selected submission(s) are ready to post. Submissions without websites or with validation errors will be skipped.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"{validCount} submission(s) will be posted in the order shown below.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"<0>Tip:</0> If your handle doesn't work, try using the email linked to your account\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"2FA enabled, password required. It won't be stored.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"A shortcut with this name already exists\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/tag-converter-drawer/tag-converter-drawer.tsx\nmsgid \"A tag converter with this tag already exists\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/user-converter-drawer/user-converter-drawer.tsx\nmsgid \"A user converter with this username already exists\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"Accept and Continue\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Access Tiers\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/selected-accounts-forms.tsx\nmsgid \"account\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Account\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-section.tsx\nmsgid \"Account data cleared\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Account name\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Account Options\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"Account Status\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/selected-accounts-forms.tsx\nmsgid \"accounts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/account-section-header.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Accounts\"\nmsgstr \"கணக்குகள்\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\nmsgid \"Activate\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Activate schedule\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\nmsgid \"Active\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/recent-activity-panel.tsx\nmsgid \"Activity will appear here as you use the app\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Add account\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"Add an account to start posting\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-submission-modal.tsx\nmsgid \"Add File Submission\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"Add shortcut\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\nmsgid \"Add tags...\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Add to Portfolio\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\nmsgid \"Add website\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-preview-panel.tsx\nmsgid \"Add website accounts to preview the description output.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Adjust dimensions while maintaining aspect ratio\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/rating-input/rating-input.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Adult\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Adult themes\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"After authorizing, copy the code and paste it below\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"AI generated\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/account-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\nmsgid \"All\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"All files are marked ignored.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"All healthy\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\nmsgid \"All submissions are ready to post\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\nmsgid \"All Websites\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow comments\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow commercial use\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow free download\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow modifications of your work\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow other users to edit tags\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/description-settings-section.tsx\nmsgid \"Allow PostyBirb to self-advertise at the end of descriptions\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Allow reblogging\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Alt Text\"\nmsgstr \"மாற்று உரை\"\n\n#. Bluesky login form\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"An <0>app password</0> (not your main password)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"An error occurred\"\nmsgstr \"\"\n\n#. placeholder {0}: dtext.length - text.length\n#: apps/postybirb-ui/src/remake/components/website-components/e621/e621-dtext-renderer.tsx\nmsgid \"and {0} more\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"and {remainingCount} more\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"API Key (Consumer Key)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"API keys saved successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"API Secret (Consumer Secret)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\nmsgid \"App\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"App API Hash\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"App API ID\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/app-settings-section.tsx\nmsgid \"App Folder\"\nmsgstr \"பயன்பாட்டு கோப்புறை\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"App Password\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"App registered successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/app-settings-section.tsx\nmsgid \"App Server Port\"\nmsgstr \"பயன்பாட்டு சேவையக துறைமுகம்\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"App view URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\nmsgid \"Appearance\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Applied template options to {successCount} submissions\"\nmsgstr \"\"\n\n#. placeholder {0}: submissionIds.length\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/save-to-many-action.tsx\nmsgid \"Applied to {0} submission(s)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Applied to {successCount} submissions, {failedCount} failed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Apply\"\nmsgstr \"இடு\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/apply-template-action.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Apply template\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\nmsgid \"Archive\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/header/submission-edit-card-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-section.tsx\nmsgid \"Archived\"\nmsgstr \"\"\n\n#. placeholder {0}: selectedIds.length\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-section.tsx\nmsgid \"Are you sure you want to delete {0} submission(s)? This action cannot be undone.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Are you sure you want to reset all remote connection settings to their defaults? This will clear the saved host URL and password.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Are you sure you want to watch this folder?\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Artist name\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Artist URL (Off-Site only)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Aspect Ratio\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Attach Images as Attachments\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Audience\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Audio\"\nmsgstr \"ஆடியோ\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Authenticate\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Authorization Code\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Authorization URL generated\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Authorize\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Authorized viewers\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/appearance-settings-section.tsx\nmsgid \"Auto\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Automatically import new files from specified folders\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Back\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Block guests\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Blog\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Bluesky automatically converts GIFs to videos.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/app-settings-section.tsx\nmsgid \"Browse\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Builder\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"By using this application, you acknowledge that you are solely responsible for how you use it and for any content you create, upload, distribute, or interact with. The authors and maintainers provide this software as is without warranties of any kind and are not liable for any damages or losses resulting from its use.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/confirm-action-modal/confirm-action-modal.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/components/insert-media-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Cancel\"\nmsgstr \"ரத்துசெய்\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"Cancel posting\"\nmsgstr \"\"\n\n#. placeholder {0}: getFileTypeLabel(newFileType)\n#. placeholder {1}: getFileTypeLabel(existingFileType)\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-uploader.tsx\nmsgid \"Cannot add {0} files to a submission containing {1} files. All files in a submission must be of the same type.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-card.tsx\nmsgid \"Cannot delete the only file\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Category\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Channel\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Characters\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Charge Patrons\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\nmsgid \"Check login status\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Check that handle and password are valid or try using email instead of handle.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Check your telegram messages\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\nmsgid \"Checking...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-content.tsx\nmsgid \"Choose an account from the list on the left\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgctxt \"template.picker-modal-header\"\nmsgid \"Choose Templates\"\nmsgstr \"வார்ப்புருக்களைத் தேர்வுசெய்க\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Click here to authorize PostyBirb\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Click the button below to get your authorization URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Click the link above, authorize the app, and copy the PIN code below\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Client\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Cloud Password\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Code\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Code invalid, ensure it matches the code sent from telegram\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Code sent\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-card.tsx\nmsgid \"Collapse\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Collections\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Comment permissions\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Commercial use\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Compact view\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Complete Authorization\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Complete Login\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Configuration Error\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Configure API Keys\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/confirm-action-modal/confirm-action-modal.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Confirm\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Connected successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\nmsgid \"Connection error\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Connection Failed\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Contains content\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Content Blur\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/content-warning-shortcut.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Content warning\"\nmsgstr \"உள்ளடக்க எச்சரிக்கை\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Continue from where it left off\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/copy-to-clipboard/copy-to-clipboard.tsx\nmsgid \"Copied\"\nmsgstr \"நகலெடுக்கப்பட்டது\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Copied to clipboard\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/copy-to-clipboard/copy-to-clipboard.tsx\n#: apps/postybirb-ui/src/remake/components/shared/copy-to-clipboard/copy-to-clipboard.tsx\nmsgid \"Copy\"\nmsgstr \"நகலெடு\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Create\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Create a File or Message submission to start posting\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Create a file watcher to automatically import new files from a folder\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Create an app password in Bluesky Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Create Message\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Create new file watcher\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Create new submission\"\nmsgstr \"புதிய சமர்ப்பிப்பை உருவாக்கவும்\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Create Submission\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgctxt \"telegram.app-id-help\"\nmsgid \"Create telegram app to retrieve api_id and api_hash\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Created successfully\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Creative Commons\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Creative Commons license\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"CRON Expression\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Crop from primary\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Ctrl+Enter to apply\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Custom\"\nmsgstr \"\"\n\n#. placeholder {0}: website.displayName\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/custom-login-placeholder.tsx\nmsgid \"Custom login for {0}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Custom PDS or AppView\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Custom Shortcuts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/spellchecker-settings-section.tsx\nmsgid \"Custom words\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Daily\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/appearance-settings-section.tsx\nmsgid \"Dark\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/theme-picker/theme-picker.tsx\n#: apps/postybirb-ui/src/remake/components/theme-picker/theme-picker.tsx\nmsgid \"Dark Mode\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Dashboard\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Date {currentDate} is after maximum allowed date {maxDate}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Date {currentDate} is before minimum allowed date {minDate}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Date {currentDate} is outside allowed range ({minDate} - {maxDate})\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Date and Time\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Date is in the past\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Day of Month\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Days\"\nmsgstr \"நாட்கள்\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"Decline and Exit\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/radio-field.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/default-shortcut.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Default\"\nmsgstr \"இயல்புநிலை\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/defaults-form/defaults-form.tsx\nmsgid \"Defaults\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\nmsgid \"Defaults saved successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-preview.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-section.tsx\nmsgid \"Delete\"\nmsgstr \"நீக்கு\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\nmsgid \"Delete all existing website options and use only those specified in the source.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-card.tsx\nmsgid \"Delete file\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/archived-submission-card.tsx\nmsgid \"Delete permanently\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-section.tsx\nmsgid \"Delete Submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Deleted successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: apps/postybirb-ui/src/remake/components/website-components/e621/e621-tag-search-provider.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Description\"\nmsgstr \"விவரம்\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Description length is greater then maximum ({currentLength} / {maxLength})\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Description length is lower then minimum ({currentLength} / {minLength})\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/description-settings-section.tsx\nmsgid \"Description Settings\"\nmsgstr \"விளக்கம் அமைப்புகள்\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-select.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-selection-form.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\nmsgid \"Deselect all\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"Desktop Notifications\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Detailed view\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Dimensions\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Disable comments\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Discard all progress from the previous attempt and start as if this were the first time posting.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Discord\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Display resolution\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Double-click to toggle crop/move mode\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Download Update\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Downloading...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/reorderable-submission-list/reorderable-submission-list.tsx\nmsgid \"Drag or use arrow keys to reorder\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/submission-list.tsx\nmsgid \"Drag submissions onto the calendar to schedule them\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-manager.tsx\nmsgid \"Drag to reorder\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-dropzone.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-list.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-uploader.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Drop files here or click to browse\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Drug / Alcohol\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\nmsgid \"Duplicate\"\nmsgstr \"நகல்\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Duplicated successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/e621/e621-login-view.tsx\nmsgid \"e621 requires API credentials for posting. You'll need to generate an API key from your account settings to authenticate PostyBirb.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-dropzone.tsx\nmsgid \"Each file becomes a separate submission\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Early Access\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\nmsgid \"Edit\"\nmsgstr \"தொகு\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-preview.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Edit image\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-card.tsx\nmsgid \"Edit metadata\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-preview.tsx\nmsgid \"Edit title\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"Enable desktop notifications\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\nmsgid \"Enable global tag autocomplete from sites like e621.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/spellchecker-settings-section.tsx\nmsgid \"Enabled\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"English\"\nmsgstr \"ஆங்கிலம்\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Ensure the IP is correct\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Enter Instance URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Enter the host URL and password to connect to a remote PostyBirb instance.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Enter the PIN from Twitter\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Enter the URL of your Mastodon, Pleroma, or Pixelfed instance (e.g., \\\"mastodon.social\\\" or \\\"pixelfed.social\\\")\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Enter title...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Enter your Twitter app credentials\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-option-row.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/selected-accounts-forms.tsx\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Error\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Error while authenticating Telegram\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\nmsgid \"errors\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Errors\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"Expand\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Explicit text\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/rating-input/rating-input.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Extreme\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"failed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Failed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"Failed Posts Detected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Failed to apply template options\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-section.tsx\nmsgid \"Failed to clear account data\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Failed to complete authorization\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Failed to connect to remote host\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\nmsgid \"Failed to connect to the server\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to create\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\nmsgid \"Failed to create template\"\nmsgstr \"வார்ப்புருவை உருவாக்குவதில் தோல்வி\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to delete\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-card.tsx\nmsgid \"Failed to delete file\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to duplicate\"\nmsgstr \"நகல் எடுக்கத் தவறிவிட்டது\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Failed to generate authorization URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Failed to get user warnings. You can check your account manually\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Failed to load primary file for cropping.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to post submission\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Failed to register with instance\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Failed to replace file\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to restore\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to save\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/form-fields-context.tsx\nmsgid \"Failed to save changes\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-alt-text-editor.tsx\nmsgid \"Failed to save fallback text\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Failed to save metadata\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Failed to schedule submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Failed to send code to begin authentication\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Failed to store API keys\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to update\"\nmsgstr \"புதுப்பிக்கத் தவறிவிட்டது\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\nmsgid \"Failed to update template name\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Failed to upload files\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Failed to validate submission: {message}\"\nmsgstr \"சமர்ப்பிப்பை சரிபார்க்கத் தவறிவிட்டது: {message}\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Failed to validate tags. Please check them manually\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-alt-text-editor.tsx\nmsgid \"Fallback Text\"\nmsgstr \"குறைவடையும் உரை\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Feature\"\nmsgstr \"நற்பொருத்தம்\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Fediverse Authentication\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Female\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\nmsgid \"File\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-content.tsx\nmsgid \"File Submissions\"\nmsgstr \"கோப்பு சமர்ப்பிப்புகள்\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"File types do not match. Please upload a file of the same type.\"\nmsgstr \"கோப்பு வகைகள் பொருந்தவில்லை. அதே வகை கோப்பை பதிவேற்றவும்.\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"File Watcher Failure\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"File Watcher Success\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"File Watchers\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"File will be modified to support website requirements\"\nmsgstr \"வலைத்தள தேவைகளை ஆதரிக்க கோப்பு மாற்றப்படும்\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-list.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/submission-file-manager.tsx\nmsgid \"Files\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Files uploaded successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Flip\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Flip horizontal\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Flip vertical\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Folder\"\nmsgstr \"கோப்புறை\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Folder Contains Files\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Folder Path\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/specialized-error-boundaries.tsx\nmsgid \"Form Error\"\nmsgstr \"\"\n\n#. Bluesky login form\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Format should be <0>handle.bsky.social</0> or <1>domain.ext</1> for custom domains\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Format: minute hour day month weekday\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Found in your Twitter app's Keys and tokens section\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Fr\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Frequency\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Friends only\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Furry\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Gender\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/rating-input/rating-input.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"General\"\nmsgstr \"\"\n\n#. E621 login form\n#: apps/postybirb-ui/src/remake/components/website-login-views/e621/e621-login-view.tsx\nmsgid \"Generate an API key from your <0>account settings</0>\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Generate authorization link\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"German\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Get Authorization\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Get authorization code\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Get Authorization URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Get started by adding your accounts and creating your first submission.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\nmsgid \"Global tag search provider\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Go back\"\nmsgstr \"திரும்பிச் செல்லுங்கள்\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\nmsgid \"Go forward\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Go to Accounts to add and log into your websites\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Gore\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Has sexual content\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Has validation errors\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Has validation warnings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/components/insert-media-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/resizable-image.tsx\nmsgid \"Height\"\nmsgstr \"உயரம்\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-selection-form.tsx\nmsgid \"Hide unselected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\nmsgid \"Higher boost levels allow larger file uploads\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\nmsgid \"Hold to delete\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\nmsgid \"Hold to delete {count} item(s)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"Hold to delete permanently\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"Hold to post\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Home\"\nmsgstr \"வீடு\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Host\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Host URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Hours\"\nmsgstr \"மணி\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\nmsgctxt \"discord.webhook-help\"\nmsgid \"How to create a webhook\"\nmsgstr \"வெப்ஊக்கை எவ்வாறு உருவாக்குவது\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"I have read, understand, and accept the disclaimer.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"If you are using pds other then bsky.social\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"If you do not agree with these terms, you must decline and exit the application.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/tag-field.tsx\nmsgctxt \"override-default\"\nmsgid \"Ignore default tags\"\nmsgstr \"இயல்புநிலை குறிச்சொற்களை புறக்கணிக்கவும்\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Image\"\nmsgstr \"படம்\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/resizable-image.tsx\nmsgid \"Image Size\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-uploader.tsx\nmsgid \"Images, videos, audio, and text files up to 100MB\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\nmsgid \"Import\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Import Action\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\nmsgid \"Import completed successfully!\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\nmsgid \"Import encountered errors:\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\nmsgid \"Import from PostyBirb Plus\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\nmsgid \"Import your data from PostyBirb Plus. Select which types of data you want to import.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\nmsgid \"Inactive\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Info\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/components/insert-media-modal.tsx\nmsgid \"Insert\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/components/insert-media-modal.tsx\nmsgid \"Insert Media\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-field.tsx\nmsgid \"Insert tags at end\"\nmsgstr \"குறிச்சொற்களை இறுதியில் செருகவும்\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-field.tsx\nmsgid \"Insert title at start\"\nmsgstr \"தொடக்கத்தில் தலைப்பு செருகவும்\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Instance URL\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Intended as advertisement\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Invalid CRON expression\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Invalid date format: {value}. Expected ISO date string.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Invalid post URL to reply to.\"\nmsgstr \"\"\n\n#. Bluesky login form\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Invalid url. Format should be <0>https://bsky.app/</0>\"\nmsgstr \"\"\n\n#. Bluesky login form\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Invalid url. Format should be <0>https://bsky.social/</0>\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"It is recommended to add at least 10 general tags <0>( {generalTags} / 10 )</0>. See <1>tagging checklist</1>\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Keep this secret and never share it publicly\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Ko-fi\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"LAN IP\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/language-picker/language-picker.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Language\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/spellchecker-settings-section.tsx\nmsgid \"Languages to check\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Later\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-field.tsx\nmsgid \"Legacy Shortcuts Detected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"Legal Notice & Disclaimer\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/appearance-settings-section.tsx\nmsgid \"Light\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/theme-picker/theme-picker.tsx\n#: apps/postybirb-ui/src/remake/components/theme-picker/theme-picker.tsx\nmsgid \"Light Mode\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"Lithuanian\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Log in to different instance\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"logged in\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/account-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Logged in\"\nmsgstr \"\"\n\n#. placeholder {0}: username ? ` as ${username}` : ''\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\nmsgid \"Logged in{0}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Login\"\nmsgstr \"புகுபதிவு\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\nmsgid \"Login failed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Make sure that the default rating matches Bluesky Label Rating.\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Male\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"Manage accounts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Mark as read\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Mark as unread\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Mark as work in progress\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Marked as read\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-content.tsx\nmsgid \"Mass Edit\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/rating-input/rating-input.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Mature\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Mature content\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Media\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\nmsgid \"Merge (recommended)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\nmsgid \"Merge Mode\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\nmsgid \"Message\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-content.tsx\nmsgid \"Message Submissions\"\nmsgstr \"செய்தி சமர்ப்பிப்புகள்\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Minutes\"\nmsgstr \"நிமிடங்கள்\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Mo\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Modification\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Modified\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Monthly\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/header/submission-edit-card-header.tsx\nmsgid \"Multi Edit\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\nmsgid \"Name\"\nmsgstr \"பெயர்\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"Name is required\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"need attention\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\nmsgid \"New\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"New account\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"New Message\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\nmsgid \"New template name...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"New version:\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"No AI\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"No changes\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"No custom dimensions set\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/defaults-form/defaults-form.tsx\nmsgid \"No default options available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-list.tsx\nmsgid \"No files added yet\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"No folder selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/empty-state/empty-state.tsx\nmsgid \"No notifications\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/tree-select.tsx\nmsgid \"No options available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"No options selected to apply\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-preview-panel.tsx\nmsgid \"No preview available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/empty-state/empty-state.tsx\nmsgid \"No records yet\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/empty-state/empty-state.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-select.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/tree-select.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker.tsx\nmsgid \"No results found\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"No schedule configured\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/reorderable-submission-list/reorderable-submission-list.tsx\nmsgid \"No submissions selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\nmsgid \"No validation issues\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"No website posts found\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"No websites\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-visibility-picker.tsx\nmsgid \"No websites available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"No websites selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-components/e621/e621-tag-search-provider.tsx\nmsgid \"No wiki page available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\nmsgid \"None\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\nmsgid \"Not all tag providers support this feature.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/account-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-option-row.tsx\n#: apps/postybirb-ui/src/remake/components/shared/account-picker/account-picker.tsx\nmsgid \"Not logged in\"\nmsgstr \"உள்நுழையவில்லை\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Not scheduled\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/empty-state/empty-state.tsx\nmsgid \"Nothing selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Notifications\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Notify followers\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Nudity\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Offer hi-res download (gold only)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Offline\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Once\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Online\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Only set scheduled date (don't activate)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Open CRON helper\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Open full calendar\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\nmsgid \"Open Inkbunny Account Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/app-settings-section.tsx\nmsgid \"Open PostyBirb on computer startup\"\nmsgstr \"கணினி தொடக்கத்தில் போச்டிபிர்பைத் திறக்கவும்\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Open Twitter Authorization Page\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Optional template to apply to imported files\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Options\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Original work\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Other\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\nmsgid \"Overwrite overlapping website options only. Keeps existing website options that are not specified in the source.\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Parent ID\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\nmsgid \"Password\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Password is invalid\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-dropzone.tsx\nmsgid \"Paste from clipboard\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-dropzone.tsx\nmsgid \"Paste image from clipboard (Ctrl+V)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\nmsgid \"Pause\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/queue-control-card.tsx\nmsgid \"Pause Posting\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\nmsgid \"Paused\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"PDS (Personal Data Server)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Pending\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Per-Account Dimensions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Phone Number\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgctxt \"telegram.phone-number-help\"\nmsgid \"Phone number must be in international format\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"PIN / Verifier Code\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Pixel perfect display\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/helpers.tsx\nmsgid \"Please check your credentials and try again\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"Portuguese (Brazil)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Post\"\nmsgstr \"இடுகை\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Post Data (JSON)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"Post Failure\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Post Files\"\nmsgstr \"கோப்புகளை இடுங்கள்\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"Post Submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/notifications-settings-section.tsx\nmsgid \"Post Success\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\nmsgid \"PostyBirb uses Discord webhooks to post content to your server. Make sure you have the necessary permissions to create webhooks in your target channel.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-preview-panel.tsx\nmsgid \"Preview\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Primary\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Privacy\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Private\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Proceed to Authorization\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Profanity\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Public viewers\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Queued\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Quick tips to get started:\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Racism\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Rating\"\nmsgstr \"செயல்வரம்பு\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Read\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Ready\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-button.tsx\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Ready to Install\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Ready to post\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/recent-activity-panel.tsx\nmsgid \"Recent Activity\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Recurring\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Recurring schedule (active)\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Reference\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/login-webview.tsx\nmsgid \"Refresh page\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Register Instance\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\nmsgid \"Reload page\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\nmsgid \"Remote\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Remote Connection\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Remote host URL is not configured\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Remote password is not configured\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\nmsgid \"Remove\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/resizable-image.tsx\nmsgid \"Remove image\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\nmsgid \"Rename\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker-modal.tsx\nmsgid \"Replace All\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgctxt \"import.override-description\"\nmsgid \"Replace description\"\nmsgstr \"விளக்கத்தை மாற்றவும்\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Replace file\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Replace the current description with the selected template's description\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Replace the current title with the selected template's title\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgctxt \"import.override-title\"\nmsgid \"Replace title\"\nmsgstr \"தலைப்பை மாற்றவும்\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Reply to post URL\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Request critique\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Required\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Required if 2FA is enabled.\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Required tag\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Requires at least {minLength} {minLength, plural, one {tag} other {tags}}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Requires at least {minSelected} {minSelected, plural, one {option selected} other {options selected}} ({currentSelected} / {minSelected})\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Reset\"\nmsgstr \"மீட்டமை\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Reset account data\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"Reset account?\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Reset Remote Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Restart Now\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/archived-submission-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/archived-submission-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"Restore\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Restored successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Resume Failed Posting\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/queue-control-card.tsx\nmsgid \"Resume Posting\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Retry all failed or unattempted websites\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Retry posting to all websites that failed or were not attempted. All files will be re-uploaded.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Rotate\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Rotate left 90°\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Rotate right 90°\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Running\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"Russian\"\nmsgstr \"ரச்ய\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Sa\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/e621/e621-login-view.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\nmsgid \"Save\"\nmsgstr \"சேமி\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Save API Keys\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\nmsgid \"Save as default\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"Save changes\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\nmsgid \"Save Selected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Save to file\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/save-to-many-action.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/save-to-many-action.tsx\nmsgid \"Save to submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Schedule\"\nmsgstr \"அட்டவணை\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/upcoming-posts-panel.tsx\nmsgid \"Schedule a submission to see it here\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Schedule configured (inactive)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Schedule posts to automatically publish at specific times\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Schedule Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Schedule Submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Schedule updated\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\nmsgid \"Scheduled\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Scheduled (active)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Scroll to zoom\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/submission-list.tsx\n#: apps/postybirb-ui/src/remake/components/shared/search-input.tsx\nmsgid \"Search...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-content.tsx\nmsgid \"Select a submission from the list to view details\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker.tsx\nmsgid \"Select a template\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-content.tsx\nmsgid \"Select a template from the list to view or edit it\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-select.tsx\nmsgid \"Select accounts...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-select.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-selection-form.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-section-header.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\nmsgid \"Select all\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-content.tsx\nmsgid \"Select an account to log in\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\nmsgid \"Select fields to save as defaults for this account.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/converter-drawer/converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\nmsgid \"Select items to delete\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/language-picker/language-picker.tsx\nmsgid \"Select language\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/submission-picker/submission-picker.tsx\nmsgid \"Select submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Select templates or submissions to import\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\nmsgid \"Select websites to apply this shortcut to\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\nmsgid \"Select websites to apply usernames to\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Select which template to use for each account\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/tree-select.tsx\nmsgid \"Select...\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Selected option is invalid or missing\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Send Code\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Send Messages\"\nmsgstr \"செய்திகளை அனுப்பவும்\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Send to scraps\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Sensitive content\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\nmsgid \"Server Boost Level\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Server unreachable\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Set the interval between each submission.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Settings\"\nmsgstr \"அமைப்புகள்\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Sexual Content\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Share on feed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Share on Feed is required to support posting multiple files.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Share your LAN IP and password with clients to allow them to connect.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"shortcut\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/custom-shortcuts-drawer/custom-shortcuts-drawer.tsx\nmsgid \"Shortcut name\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\nmsgid \"Show wiki page related to tag on hover\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-visibility-picker.tsx\nmsgid \"Show/Hide Websites\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Silent\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Sketch\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Skip Accounts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"Skip websites that posted successfully and only attempt failed or unattempted ones.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Skip websites that posted successfully and only attempt failed or unattempted ones. Continues from the last successful batch of files.\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Software\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/account-health-panel.tsx\nmsgid \"Some accounts need to be logged in before you can post to them.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-uploader.tsx\nmsgid \"Some files were rejected. Check file type and size (max 100MB).\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\nmsgid \"Some submissions have failed posting attempts. Choose how to handle them:\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\nmsgid \"Something went wrong\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Source URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Source URLs\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"Spanish\"\nmsgstr \"ச்பானிச்\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Species\"\nmsgstr \"இனங்கள்\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Specify your Fediverse instance\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\nmsgid \"Spellchecker\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/spellchecker-settings-section.tsx\nmsgid \"Spellchecker advanced configuration is controlled by system on MacOS.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/spellchecker-settings-section.tsx\nmsgid \"Spellchecker Settings\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Spoiler\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\nmsgid \"Spoiler Text\"\nmsgstr \"ச்பாய்லர் உரை\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Spoilers\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/post-confirm-modal/post-confirm-modal.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"Start completely fresh\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Start Date\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Start Over\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/app-settings-section.tsx\nmsgid \"Startup Settings\"\nmsgstr \"தொடக்க அமைப்புகள்\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Status\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Su\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"Submission has validation errors\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Submission Order\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\nmsgid \"Submission unscheduled\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/schedule-form/schedule-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/schedule-popover.tsx\nmsgid \"Submission will be posted automatically\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Submission will be split into {expectedBatchesToCreate} different submissions with {maxBatchSize} {maxBatchSize, plural, one {file each} other {files each}}.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submissions-section.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Submissions\"\nmsgstr \"சமர்ப்பிப்புகள்\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"success\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Success\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Successful\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Successfully connected to the remote host\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/megalodon/megalodon-login-view.tsx\nmsgid \"Successfully logged in as {loggedInAs}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Successfully logged in as @{loggedInAs}\"\nmsgstr \"\"\n\n#. placeholder {0}: orderedSubmissions.length\n#: apps/postybirb-ui/src/remake/components/shared/multi-scheduler-modal/multi-scheduler-modal.tsx\nmsgid \"Successfully scheduled ${0} submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Supports either a set of images, a single video, or a single GIF.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tag {tag} has {postCount} post(s). Tag may be invalid or low use\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tag <0>{tag}</0> does not exist yet or is invalid.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tag <0>{tag}</0> does not exist yet or is invalid. If you want to create a new tag, make a post with it, then go <1>here</1>, press edit and select tag category\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tag <0>{tag}</0> is invalid.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/tag-converter-drawer/tag-converter-drawer.tsx\nmsgid \"tag converter\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-converter-drawer/tag-converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Tag Converters\"\nmsgstr \"குறிச்சொல் மாற்றிகள்\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/import-settings-section.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Tag Groups\"\nmsgstr \"குறிச்சொல் குழுக்கள்\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tag limit reached ({currentLength} / {maxLength})\"\nmsgstr \"குறிச்சொல் வரம்பு அடைந்தது ({currentLength} / {maxLength})\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Tag permissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/simple-tag-input/simple-tag-input.tsx\nmsgid \"tags\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/settings-dialog.tsx\n#: apps/postybirb-ui/src/remake/components/drawers/tag-group-drawer/tag-group-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/tags-shortcut.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Tags\"\nmsgstr \"குறிச்சொற்கள்\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tags longer than {maxLength} characters will be skipped\"\nmsgstr \"{maxLength} எழுத்துக்களை விட நீளமான குறிச்சொற்கள் தவிர்க்கப்படும்\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/tags-settings-section.tsx\nmsgid \"Tags Settings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Tags should not start with #\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/languages.tsx\nmsgid \"Tamil\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Teaser\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/telegram/telegram-login-view.tsx\nmsgid \"Telegram requires API credentials and phone number authentication. You'll need to create a Telegram app and verify your phone number.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/submission-options.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker.tsx\nmsgid \"Template\"\nmsgstr \"வார்ப்புரு\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\nmsgid \"Template created\"\nmsgstr \"வார்ப்புரு உருவாக்கப்பட்டது\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-content.tsx\nmsgid \"Template Editor\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-content.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/templates-section.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"Templates\"\nmsgstr \"வார்ப்புருக்கள்\"\n\n#: apps/postybirb-ui/src/remake/components/dialogs/settings-dialog/sections/remote-settings-section.tsx\nmsgid \"Test Connection\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Text\"\nmsgstr \"உரை\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Th\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"The folder \\\"{folderName}\\\" contains {fileCount} files.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/specialized-error-boundaries.tsx\nmsgid \"The form encountered an error and couldn't be displayed properly.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/resume-mode-modal/resume-mode-modal.tsx\nmsgid \"The last posting attempt failed. How would you like to proceed?\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"The update has been downloaded and is ready to install. Click \\\"Restart Now\\\" to apply the update.\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Theme\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/save-defaults-popover.tsx\nmsgid \"These defaults apply to future submissions.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\nmsgid \"This component encountered an error and couldn't render properly.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/discord/discord-login-view.tsx\nmsgid \"This is a forum channel\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"This post record has no events or is still processing. Check the JSON data below for more details.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-content.tsx\nmsgid \"This website has an unsupported login configuration\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/custom-login-placeholder.tsx\nmsgid \"This website uses a custom login form. Support coming soon.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-account-card.tsx\nmsgid \"This will clear all account data and cookies.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Thumbnail\"\nmsgstr \"சிறுபடம்\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Thumbnail must be an image file.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Time\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Time Taken\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Tips\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/title-shortcut.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Title\"\nmsgstr \"தலைப்பு\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Title is too long and will be truncated\"\nmsgstr \"தலைப்பு மிக நீளமானது மற்றும் துண்டிக்கப்படும்\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Title is too short\"\nmsgstr \"தலைப்பு மிகக் குறைவு\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/website-visibility-picker.tsx\nmsgid \"Toggle website visibility\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Total\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Translation {id} not found\"\nmsgstr \"மொழிபெயர்ப்பு {id} காணப்படவில்லை\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\n#: apps/postybirb-ui/src/remake/components/error-boundary/specialized-error-boundaries.tsx\nmsgid \"Try again\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"Tu\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Twitter / X Authentication\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/description-editor.tsx\nmsgid \"Type / for commands or @, ` or '{' for shortcuts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Unknown\"\nmsgstr \"தெரியவில்லை\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Unknown choice\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/accounts-section/accounts-content.tsx\nmsgid \"Unknown login type\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\nmsgid \"Unread\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/schedule-calendar.tsx\nmsgid \"Unschedule\"\nmsgstr \"திட்டமிடப்படாத\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/submission-list.tsx\nmsgid \"Unscheduled Submissions\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Unsupported file type {fileExtension}. Please provide fallback text.\"\nmsgstr \"ஆதரிக்கப்படாத கோப்பு வகை {fileExtension}. குறைவடையும் உரையை வழங்கவும்.\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Unsupported file type {mimeType}\"\nmsgstr \"ஆதரிக்கப்படாத கோப்பு வகை {mimeType}\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Unsupported rating: {ratingLabel}\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Unsupported submission type: {fileTypeString}\"\nmsgstr \"ஆதரிக்கப்படாத சமர்ப்பிப்பு வகை: {fileTypeString}\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/upcoming-posts-panel.tsx\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/header/submission-edit-card-header.tsx\n#: apps/postybirb-ui/src/remake/components/sections/templates-section/template-card.tsx\n#: apps/postybirb-ui/src/remake/components/shared/reorderable-submission-list/reorderable-submission-list.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Untitled\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/schedule-drawer/submission-list.tsx\nmsgid \"Untitled Submission\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/template-picker/template-picker-modal.tsx\nmsgid \"Untitled Template\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/upcoming-posts-panel.tsx\nmsgid \"Upcoming Posts\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"Update API Keys\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-button.tsx\n#: apps/postybirb-ui/src/remake/components/update-button/update-button.tsx\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Update available\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"Update Error\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\nmsgid \"Update Failed\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-button.tsx\nmsgid \"Update ready\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/utils/notifications.tsx\nmsgid \"Updated successfully\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/file-submission-modal.tsx\nmsgid \"Upload\"\nmsgstr \"பதிவேற்றும்\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-actions.tsx\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Upload thumbnail\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/components/insert-media-modal.tsx\nmsgid \"URL\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-field.tsx\nmsgid \"Use custom description\"\nmsgstr \"தனிப்பயன் விளக்கத்தைப் பயன்படுத்தவும்\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Use templates to save time on repeated posts\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Use thumbnail as cover art\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Use title\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Use your Bluesky handle or email along with an app password. App passwords are more secure than your main password and can be revoked at any time.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Used for other sites (like e621) as source url base. Independent from custom pds.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/user-converter-drawer/user-converter-drawer.tsx\nmsgid \"user converter\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/user-converter-drawer/user-converter-drawer.tsx\n#: apps/postybirb-ui/src/remake/config/nav-items.tsx\nmsgid \"User Converters\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/e621/e621-login-view.tsx\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\nmsgid \"Username\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Username or Email\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\nmsgid \"Validation Issues\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"Video\"\nmsgstr \"ஒளிதோற்றம்\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"View\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/archived-submission-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/archived-submission-card.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/actions/submission-edit-card-actions.tsx\nmsgid \"View history\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"View permissions\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Violence\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Visibility\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-option-row.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/selected-accounts-forms.tsx\nmsgid \"Warning\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/validation-issues-panel.tsx\nmsgid \"warnings\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/notifications-drawer/notifications-drawer.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-card/submission-badges.tsx\nmsgid \"Warnings\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Watermark\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/schedule-calendar-panel.tsx\nmsgid \"We\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/error-boundary/error-boundary.tsx\nmsgid \"We encountered an unexpected error. Please try refreshing the page.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-history-drawer.tsx\nmsgid \"Website\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/selected-accounts-forms.tsx\nmsgid \"Website Options\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-select.tsx\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/account-selection-form.tsx\n#: apps/postybirb-ui/src/remake/components/shared/account-picker/account-picker.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/custom-blocks/website-only-selector.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/username-shortcut.tsx\nmsgid \"Websites\"\nmsgstr \"வலைத்தளங்கள்\"\n\n#: apps/postybirb-ui/src/remake/components/shared/schedule-popover/cron-picker.tsx\nmsgid \"Weekly\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/home-section/home-content.tsx\nmsgid \"Welcome to PostyBirb!\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/drawers/file-watcher-drawer/file-watcher-drawer.tsx\nmsgid \"What to do when new files are detected\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/update-button/update-modal.tsx\nmsgid \"What's New\"\nmsgstr \"\"\n\n#: libs/translations/src/lib/field-translations.ts\nmsgid \"Who can reply\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/file-management/file-metadata.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/components/insert-media-modal.tsx\n#: apps/postybirb-ui/src/remake/components/shared/description-editor/extensions/resizable-image.tsx\nmsgid \"Width\"\nmsgstr \"அகலம்\"\n\n#: apps/postybirb-ui/src/remake/components/disclaimer/disclaimer.tsx\nmsgid \"You agree to comply with all applicable laws, terms of service, and policies of any third party platforms you connect to or interact with through this application. Do not use this software to infringe intellectual property rights, violate privacy, or circumvent platform rules.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"You have existing API keys. You can modify them above or proceed to the next step.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/i18n/validation-translation.tsx\nmsgid \"You have recent {negativeOrNeutral} feedback: {feedback}, you can view it<0>here</0>\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\nmsgid \"You must first enable API access in your account settings under \\\"API (External Scripting)\\\" before you can authenticate with PostyBirb.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"You need to create a Twitter app to get API keys. <0>Visit Twitter Developer Portal</0>\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/twitter/twitter-login-view.tsx\nmsgid \"You'll get this PIN after authorizing on Twitter\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/submission-edit-card/account-selection/form/fields/description-field.tsx\nmsgid \"Your description contains legacy shortcut syntax. These are no longer supported. Please use the new shortcut menu (type @, &lbrace; or `) to insert shortcuts.\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/bluesky/bluesky-login-view.tsx\nmsgid \"Your handle (e.g. <0>yourname.bsky.social</0>) or custom domain (e.g. <1>username.ext</1>)\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/website-login-views/inkbunny/inkbunny-login-view.tsx\nmsgid \"Your password will not be stored\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Zoom\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Zoom in\"\nmsgstr \"\"\n\n#: apps/postybirb-ui/src/remake/components/sections/submissions-section/file-submission-modal/image-editor.tsx\nmsgid \"Zoom out\"\nmsgstr \"\"\n"
  },
  {
    "path": "libs/database/.eslintrc.json",
    "content": "{\n  \"extends\": [\"../../.eslintrc.js\"],\n  \"ignorePatterns\": [\"!**/*\"],\n  \"overrides\": [\n    {\n      \"files\": [\"*.ts\", \"*.tsx\", \"*.js\", \"*.jsx\"],\n      \"rules\": {}\n    },\n    {\n      \"files\": [\"*.ts\", \"*.tsx\"],\n      \"rules\": {}\n    },\n    {\n      \"files\": [\"*.js\", \"*.jsx\"],\n      \"rules\": {}\n    }\n  ]\n}\n"
  },
  {
    "path": "libs/database/README.md",
    "content": "# database\n\nThis library was generated with [Nx](https://nx.dev).\n\n## Running unit tests\n\nRun `nx test database` to execute the unit tests via [Jest](https://jestjs.io).\n"
  },
  {
    "path": "libs/database/jest.config.ts",
    "content": "/* eslint-disable */\nexport default {\n  displayName: 'database',\n  preset: '../../jest.preset.js',\n  testEnvironment: 'node',\n  moduleFileExtensions: ['ts', 'js', 'html'],\n  coverageDirectory: '../../coverage/libs/database',\n};\n"
  },
  {
    "path": "libs/database/project.json",
    "content": "{\n  \"name\": \"database\",\n  \"$schema\": \"../../node_modules/nx/schemas/project-schema.json\",\n  \"sourceRoot\": \"libs/database/src\",\n  \"projectType\": \"library\",\n  \"targets\": {\n    \"lint\": {\n      \"executor\": \"@nx/eslint:lint\"\n    },\n    \"test\": {\n      \"executor\": \"@nx/jest:jest\",\n      \"outputs\": [\"{workspaceRoot}/coverage/{projectRoot}\"],\n      \"options\": {\n        \"jestConfig\": \"libs/database/jest.config.ts\"\n      }\n    }\n  },\n  \"tags\": []\n}\n"
  },
  {
    "path": "libs/database/src/index.ts",
    "content": "import * as Relations from './lib/relations/relations';\nimport * as schemas from './lib/schemas';\n\nconst Schemas = {\n  ...schemas,\n  ...Relations,\n};\n\nexport * from './lib/database';\nexport * from './lib/helper-types';\nexport * from './lib/schemas';\nexport { Schemas };\n\n"
  },
  {
    "path": "libs/database/src/lib/database.ts",
    "content": "import { PostyBirbDirectories } from '@postybirb/fs';\nimport { IsTestEnvironment } from '@postybirb/utils/electron';\nimport { BetterSQLite3Database, drizzle } from 'drizzle-orm/better-sqlite3';\nimport { migrate } from 'drizzle-orm/better-sqlite3/migrator';\nimport { join } from 'path';\nimport * as schema from './schemas';\n\nexport type PostyBirbDatabaseType = BetterSQLite3Database<typeof schema>;\n\nconst migrationsFolder = IsTestEnvironment()\n  ? join(__dirname.split('libs')[0], 'apps', 'postybirb', 'src', 'migrations')\n  : join(__dirname, 'migrations');\nlet db: PostyBirbDatabaseType;\n\n/**\n * Get the database instance\n * @param {boolean} newInstance - Whether to get a new instance of the database or force a\n * new instance (mostly for testing)\n */\nexport function getDatabase() {\n  if (!db) {\n    const path = IsTestEnvironment()\n      ? ':memory:'\n      : join(\n          PostyBirbDirectories.DATA_DIRECTORY,\n          `database-${process.env.POSTYBIRB_ENV}.sqlite`,\n        );\n    db = drizzle(path, { schema });\n    migrate(db, { migrationsFolder });\n  }\n\n  return db;\n}\n\n/**\n * Clear the database instance.\n * Used for testing.\n */\nexport function clearDatabase() {\n  db = undefined;\n}\n"
  },
  {
    "path": "libs/database/src/lib/helper-types.ts",
    "content": "import { PostyBirbDatabaseType } from './database';\nimport * as Schemas from './schemas';\n\nexport type SchemaKey = keyof PostyBirbDatabaseType['_']['schema'];\n\nexport type PostyBirbTransaction = Parameters<\n  Parameters<PostyBirbDatabaseType['transaction']>['0']\n>['0'];\n\nexport type Insert<TSchemaKey extends SchemaKey> =\n  (typeof Schemas)[TSchemaKey]['$inferInsert'];\n\nexport type Select<TSchemaKey extends SchemaKey> =\n  (typeof Schemas)[TSchemaKey]['$inferSelect'];\n"
  },
  {
    "path": "libs/database/src/lib/relations/index.ts",
    "content": ""
  },
  {
    "path": "libs/database/src/lib/relations/relations.ts",
    "content": "import { relations } from 'drizzle-orm';\nimport {\n    AccountSchema,\n    DirectoryWatcherSchema,\n    FileBufferSchema,\n    PostQueueRecordSchema,\n    PostRecordSchema,\n    SubmissionFileSchema,\n    SubmissionSchema,\n    UserSpecifiedWebsiteOptionsSchema,\n    WebsiteDataSchema,\n    WebsiteOptionsSchema,\n} from '../schemas';\n\nexport const AccountRelations = relations(AccountSchema, ({ one, many }) => ({\n  websiteOptions: many(WebsiteOptionsSchema),\n  websiteData: one(WebsiteDataSchema, {\n    fields: [AccountSchema.id],\n    references: [WebsiteDataSchema.id],\n  }),\n}));\n\nexport const DirectoryWatcherRelations = relations(\n  DirectoryWatcherSchema,\n  ({ one }) => ({\n    template: one(SubmissionSchema, {\n      fields: [DirectoryWatcherSchema.templateId],\n      references: [SubmissionSchema.id],\n    }),\n  }),\n);\n\nexport const PostQueueRecordRelations = relations(\n  PostQueueRecordSchema,\n  ({ one }) => ({\n    submission: one(SubmissionSchema, {\n      fields: [PostQueueRecordSchema.submissionId],\n      references: [SubmissionSchema.id],\n    }),\n    postRecord: one(PostRecordSchema, {\n      fields: [PostQueueRecordSchema.postRecordId],\n      references: [PostRecordSchema.id],\n    }),\n  }),\n);\n\nexport const PostRecordRelations = relations(\n  PostRecordSchema,\n  ({ one, many }) => ({\n    submission: one(SubmissionSchema, {\n      fields: [PostRecordSchema.submissionId],\n      references: [SubmissionSchema.id],\n    }),\n  }),\n);\n\nexport const SubmissionFileRelations = relations(\n  SubmissionFileSchema,\n  ({ one }) => ({\n    submission: one(SubmissionSchema, {\n      fields: [SubmissionFileSchema.submissionId],\n      references: [SubmissionSchema.id],\n    }),\n    file: one(FileBufferSchema, {\n      fields: [SubmissionFileSchema.primaryFileId],\n      references: [FileBufferSchema.id],\n    }),\n    thumbnail: one(FileBufferSchema, {\n      fields: [SubmissionFileSchema.thumbnailId],\n      references: [FileBufferSchema.id],\n    }),\n    altFile: one(FileBufferSchema, {\n      fields: [SubmissionFileSchema.altFileId],\n      references: [FileBufferSchema.id],\n    }),\n  }),\n);\n\nexport const SubmissionRelations = relations(\n  SubmissionSchema,\n  ({ one, many }) => ({\n    options: many(WebsiteOptionsSchema),\n    posts: many(PostRecordSchema),\n    files: many(SubmissionFileSchema),\n    postQueueRecord: one(PostQueueRecordSchema),\n  }),\n);\n\nexport const UserSpecifiedWebsiteOptionsRelations = relations(\n  UserSpecifiedWebsiteOptionsSchema,\n  ({ one }) => ({\n    account: one(AccountSchema, {\n      fields: [UserSpecifiedWebsiteOptionsSchema.accountId],\n      references: [AccountSchema.id],\n    }),\n  }),\n);\n\nexport const WebsiteDataRelations = relations(WebsiteDataSchema, ({ one }) => ({\n  account: one(AccountSchema, {\n    fields: [WebsiteDataSchema.id],\n    references: [AccountSchema.id],\n  }),\n}));\n\nexport const WebsiteOptionsRelations = relations(\n  WebsiteOptionsSchema,\n  ({ one }) => ({\n    account: one(AccountSchema, {\n      fields: [WebsiteOptionsSchema.accountId],\n      references: [AccountSchema.id],\n    }),\n    submission: one(SubmissionSchema, {\n      fields: [WebsiteOptionsSchema.submissionId],\n      references: [SubmissionSchema.id],\n    }),\n  }),\n);\n"
  },
  {
    "path": "libs/database/src/lib/schemas/account.schema.ts",
    "content": "import { relations } from 'drizzle-orm';\nimport { sqliteTable, text } from 'drizzle-orm/sqlite-core';\nimport { CommonSchema } from './common.schema';\nimport { WebsiteDataSchema } from './website-data.schema';\nimport { WebsiteOptionsSchema } from './website-options.schema';\n\nexport const AccountSchema = sqliteTable('account', {\n  ...CommonSchema(),\n  groups: text({ mode: 'json' }).notNull().$type<string[]>(),\n  name: text().notNull(),\n  website: text().notNull(),\n});\n\nexport const AccountRelations = relations(AccountSchema, ({ one, many }) => ({\n  websiteOptions: many(WebsiteOptionsSchema),\n  websiteData: one(WebsiteDataSchema, {\n    fields: [AccountSchema.id],\n    references: [WebsiteDataSchema.id],\n  }),\n}));\n"
  },
  {
    "path": "libs/database/src/lib/schemas/common.schema.ts",
    "content": "import { text } from 'drizzle-orm/sqlite-core';\nimport { v4 } from 'uuid';\n// eslint-disable-next-line @nrwl/nx/enforce-module-boundaries\nimport { SubmissionType } from '../../../../types/src/index';\n\nexport const id = text;\n\nexport function CommonSchema() {\n  return {\n    id: id()\n      .primaryKey()\n      .unique()\n      .notNull()\n      .$default(() => v4()),\n    createdAt: text()\n      .notNull()\n      .$default(() => new Date().toISOString()),\n    updatedAt: text()\n      .notNull()\n      .$default(() => new Date().toISOString())\n      .$onUpdate(() => new Date().toISOString()),\n  };\n}\n\nexport function submissionType() {\n  return {\n    type: text({\n      enum: [SubmissionType.FILE, SubmissionType.MESSAGE],\n    }).notNull(),\n  };\n}\n"
  },
  {
    "path": "libs/database/src/lib/schemas/custom-shortcut.schema.ts",
    "content": "import { sqliteTable, text } from 'drizzle-orm/sqlite-core';\nimport { CommonSchema } from './common.schema';\n// eslint-disable-next-line @nrwl/nx/enforce-module-boundaries\nimport {\n    Description,\n} from '../../../../types/src/index';\n\nexport const CustomShortcutSchema = sqliteTable('custom-shortcut', {\n  ...CommonSchema(),\n  name: text().notNull().unique(),\n  shortcut: text({ mode: 'json' })\n    .notNull()\n    .$type<Description>()\n    .default({ type: 'doc', content: [] }),\n});\n"
  },
  {
    "path": "libs/database/src/lib/schemas/directory-watcher.schema.ts",
    "content": "import { relations } from 'drizzle-orm';\nimport { sqliteTable, text } from 'drizzle-orm/sqlite-core';\n// eslint-disable-next-line @nrwl/nx/enforce-module-boundaries\nimport { DirectoryWatcherImportAction } from '../../../../types/src/index';\nimport { CommonSchema, id } from './common.schema';\nimport { SubmissionSchema } from './submission.schema';\n\nexport const DirectoryWatcherSchema = sqliteTable('directory-watcher', {\n  ...CommonSchema(),\n  templateId: id().references(() => SubmissionSchema.id, {\n    onDelete: 'set null',\n  }),\n  importAction: text({\n    enum: [DirectoryWatcherImportAction.NEW_SUBMISSION],\n  })\n    .notNull()\n    .default(DirectoryWatcherImportAction.NEW_SUBMISSION),\n  path: text(),\n});\n\nexport const DirectoryWatcherRelations = relations(\n  DirectoryWatcherSchema,\n  ({ one }) => ({\n    template: one(SubmissionSchema, {\n      fields: [DirectoryWatcherSchema.templateId],\n      references: [SubmissionSchema.id],\n    }),\n  }),\n);\n"
  },
  {
    "path": "libs/database/src/lib/schemas/file-buffer.schema.ts",
    "content": "import { blob, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';\nimport { CommonSchema, id } from './common.schema';\nimport { SubmissionFileSchema } from './submission-file.schema';\n\nexport const FileBufferSchema = sqliteTable('file-buffer', {\n  ...CommonSchema(),\n  submissionFileId: id()\n    .notNull()\n    .references(() => SubmissionFileSchema.id, {\n      onDelete: 'cascade',\n    }),\n  buffer: blob({ mode: 'buffer' }).notNull(),\n  fileName: text().notNull(),\n  height: integer().notNull(),\n  mimeType: text().notNull(),\n  size: integer().notNull(),\n  width: integer().notNull(),\n});\n"
  },
  {
    "path": "libs/database/src/lib/schemas/index.ts",
    "content": "export * from './account.schema';\nexport * from './custom-shortcut.schema';\nexport * from './directory-watcher.schema';\nexport * from './file-buffer.schema';\nexport * from './notification.schema';\nexport * from './post-event.schema';\nexport * from './post-queue-record.schema';\nexport * from './post-record.schema';\nexport * from './settings.schema';\nexport * from './submission-file.schema';\nexport * from './submission.schema';\nexport * from './tag-converter.schema';\nexport * from './tag-group.schema';\nexport * from './user-converter.schema';\nexport * from './user-specified-website-options.schema';\nexport * from './website-data.schema';\nexport * from './website-options.schema';\n\n"
  },
  {
    "path": "libs/database/src/lib/schemas/notification.schema.ts",
    "content": "import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';\nimport { CommonSchema } from './common.schema';\n\nexport const NotificationSchema = sqliteTable('notification', {\n  ...CommonSchema(),\n  title: text().notNull(),\n  message: text().notNull(),\n  tags: text({ mode: 'json' }).notNull().$type<string[]>(),\n  data: text({ mode: 'json' }).notNull().$type<Record<string, unknown>>(),\n  isRead: integer({ mode: 'boolean' }).notNull().default(false),\n  hasEmitted: integer({ mode: 'boolean' }).notNull().default(false),\n  type: text().notNull(),\n});\n"
  },
  {
    "path": "libs/database/src/lib/schemas/post-event.schema.ts",
    "content": "import { relations } from 'drizzle-orm';\nimport { index, sqliteTable, text } from 'drizzle-orm/sqlite-core';\nimport { v4 } from 'uuid';\n// eslint-disable-next-line @nrwl/nx/enforce-module-boundaries\nimport {\n  IPostEventError,\n  IPostEventMetadata,\n  PostEventType,\n} from '../../../../types/src/index';\nimport { AccountSchema } from './account.schema';\nimport { id } from './common.schema';\nimport { PostRecordSchema } from './post-record.schema';\n\n/**\n * Post Event schema - immutable ledger of posting actions.\n * Each posting action creates one or more events that are never mutated.\n */\nexport const PostEventSchema = sqliteTable(\n  'post-event',\n  {\n    id: id()\n      .primaryKey()\n      .unique()\n      .notNull()\n      .$default(() => v4()),\n    createdAt: text()\n      .notNull()\n      .$default(() => new Date().toISOString()),\n\n    // Parent reference\n    postRecordId: id()\n      .notNull()\n      .references(() => PostRecordSchema.id, { onDelete: 'cascade' }),\n\n    // Account this event relates to\n    accountId: id().references(() => AccountSchema.id, {\n      onDelete: 'set null',\n    }),\n\n    // Event classification\n    eventType: text({\n      enum: [\n        PostEventType.POST_ATTEMPT_STARTED,\n        PostEventType.POST_ATTEMPT_COMPLETED,\n        PostEventType.POST_ATTEMPT_FAILED,\n        PostEventType.FILE_POSTED,\n        PostEventType.FILE_FAILED,\n        PostEventType.MESSAGE_POSTED,\n        PostEventType.MESSAGE_FAILED,\n      ],\n    }).notNull(),\n\n    // File reference (null for message submissions and lifecycle events)\n    fileId: id(),\n\n    // Success outcome\n    sourceUrl: text(),\n\n    // Failure outcome\n    error: text({ mode: 'json' }).$type<IPostEventError>(),\n\n    // Flexible metadata with snapshots\n    metadata: text({ mode: 'json' }).$type<IPostEventMetadata>(),\n  },\n  (table) => [\n    // Composite index for queries by postRecordId + eventType (most common pattern)\n    index('idx_post_event_type').on(table.postRecordId, table.eventType),\n    // Composite index for queries that also filter by accountId\n    index('idx_post_event_account').on(\n      table.postRecordId,\n      table.accountId,\n      table.eventType,\n    ),\n  ],\n);\n\nexport const PostEventRelations = relations(PostEventSchema, ({ one }) => ({\n  postRecord: one(PostRecordSchema, {\n    fields: [PostEventSchema.postRecordId],\n    references: [PostRecordSchema.id],\n  }),\n  account: one(AccountSchema, {\n    fields: [PostEventSchema.accountId],\n    references: [AccountSchema.id],\n  }),\n}));\n"
  },
  {
    "path": "libs/database/src/lib/schemas/post-queue-record.schema.ts",
    "content": "import { relations } from 'drizzle-orm';\nimport { sqliteTable } from 'drizzle-orm/sqlite-core';\nimport { CommonSchema, id } from './common.schema';\nimport { PostRecordSchema } from './post-record.schema';\nimport { SubmissionSchema } from './submission.schema';\n\nexport const PostQueueRecordSchema = sqliteTable('post-queue', {\n  ...CommonSchema(),\n  postRecordId: id().references(() => PostRecordSchema.id, {\n    onDelete: 'set null',\n  }),\n  submissionId: id()\n    .notNull()\n    .references(() => SubmissionSchema.id, {\n      onDelete: 'cascade',\n    }),\n});\n\nexport const PostQueueRecordRelations = relations(\n  PostQueueRecordSchema,\n  ({ one }) => ({\n    submission: one(SubmissionSchema, {\n      fields: [PostQueueRecordSchema.submissionId],\n      references: [SubmissionSchema.id],\n    }),\n    postRecord: one(PostRecordSchema, {\n      fields: [PostQueueRecordSchema.postRecordId],\n      references: [PostRecordSchema.id],\n    }),\n  }),\n);\n"
  },
  {
    "path": "libs/database/src/lib/schemas/post-record.schema.ts",
    "content": "import { relations } from 'drizzle-orm';\nimport { sqliteTable, text } from 'drizzle-orm/sqlite-core';\n// eslint-disable-next-line @nrwl/nx/enforce-module-boundaries\nimport {\n  PostRecordResumeMode,\n  PostRecordState,\n} from '../../../../types/src/index';\nimport { CommonSchema, id } from './common.schema';\nimport { PostEventSchema } from './post-event.schema';\nimport { SubmissionSchema } from './submission.schema';\n\nexport const PostRecordSchema = sqliteTable('post-record', {\n  ...CommonSchema(),\n  submissionId: id().references(() => SubmissionSchema.id, {\n    onDelete: 'cascade',\n  }),\n\n  /**\n   * Reference to the originating NEW PostRecord for this chain.\n   * - null for NEW records (they ARE the origin)\n   * - Set to the origin's ID for CONTINUE/RETRY records\n   * Used to group related posting attempts together.\n   */\n  originPostRecordId: id().references(() => PostRecordSchema.id, {\n    onDelete: 'set null',\n  }),\n\n  completedAt: text(),\n  resumeMode: text({\n    enum: [\n      PostRecordResumeMode.CONTINUE,\n      PostRecordResumeMode.NEW,\n      PostRecordResumeMode.CONTINUE_RETRY,\n    ],\n  })\n    .notNull()\n    .default(PostRecordResumeMode.CONTINUE),\n  state: text({\n    enum: [\n      PostRecordState.DONE,\n      PostRecordState.FAILED,\n      PostRecordState.PENDING,\n      PostRecordState.RUNNING,\n    ],\n  })\n    .notNull()\n    .default(PostRecordState.PENDING),\n});\n\nexport const PostRecordRelations = relations(\n  PostRecordSchema,\n  ({ one, many }) => ({\n    submission: one(SubmissionSchema, {\n      fields: [PostRecordSchema.submissionId],\n      references: [SubmissionSchema.id],\n    }),\n    events: many(PostEventSchema),\n    /** The originating NEW PostRecord for this chain (null if this IS the origin) */\n    origin: one(PostRecordSchema, {\n      fields: [PostRecordSchema.originPostRecordId],\n      references: [PostRecordSchema.id],\n      relationName: 'originChain',\n    }),\n    /** All CONTINUE/RETRY PostRecords that chain to this origin */\n    chainedRecords: many(PostRecordSchema, {\n      relationName: 'originChain',\n    }),\n  }),\n);\n"
  },
  {
    "path": "libs/database/src/lib/schemas/settings.schema.ts",
    "content": "import { sqliteTable, text } from 'drizzle-orm/sqlite-core';\n// eslint-disable-next-line @nrwl/nx/enforce-module-boundaries\nimport {\n  ISettingsOptions,\n  SettingsConstants,\n} from '../../../../types/src/index';\nimport { CommonSchema } from './common.schema';\n\nexport const SettingsSchema = sqliteTable('settings', {\n  ...CommonSchema(),\n  profile: text()\n    .notNull()\n    .default(SettingsConstants.DEFAULT_PROFILE_NAME)\n    .unique(),\n  settings: text({ mode: 'json' })\n    .notNull()\n    .$type<ISettingsOptions>()\n    .default(SettingsConstants.DEFAULT_SETTINGS),\n});\n"
  },
  {
    "path": "libs/database/src/lib/schemas/submission-file.schema.ts",
    "content": "import { relations } from 'drizzle-orm';\nimport { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';\nimport { CommonSchema, id } from './common.schema';\nimport { FileBufferSchema } from './file-buffer.schema';\nimport { SubmissionSchema } from './submission.schema';\n// eslint-disable-next-line @nrwl/nx/enforce-module-boundaries\nimport { SubmissionFileMetadata } from '../../../../types/src/index';\n\nexport const SubmissionFileSchema = sqliteTable('submission-file', {\n  ...CommonSchema(),\n  submissionId: id()\n    .notNull()\n    .references(() => SubmissionSchema.id, {\n      onDelete: 'cascade',\n    }),\n  primaryFileId: id().references(() => FileBufferSchema.id),\n  thumbnailId: id().references(() => FileBufferSchema.id),\n  altFileId: id().references(() => FileBufferSchema.id),\n  fileName: text().notNull(),\n  hasAltFile: integer({ mode: 'boolean' }).notNull().default(false),\n  hasCustomThumbnail: integer({ mode: 'boolean' }).notNull().default(false),\n  hasThumbnail: integer({ mode: 'boolean' }).notNull(),\n  hash: text().notNull(),\n  height: integer().notNull(),\n  mimeType: text().notNull(),\n  size: integer().notNull(),\n  width: integer().notNull(),\n  metadata: text({ mode: 'json' })\n    .notNull()\n    .$type<SubmissionFileMetadata>()\n    .default({} as SubmissionFileMetadata),\n  order: integer().default(Number.MAX_SAFE_INTEGER).notNull(),\n});\n\nexport const SubmissionFileRelations = relations(\n  SubmissionFileSchema,\n  ({ one }) => ({\n    submission: one(SubmissionSchema, {\n      fields: [SubmissionFileSchema.submissionId],\n      references: [SubmissionSchema.id],\n    }),\n    file: one(FileBufferSchema, {\n      fields: [SubmissionFileSchema.primaryFileId],\n      references: [FileBufferSchema.id],\n    }),\n    thumbnail: one(FileBufferSchema, {\n      fields: [SubmissionFileSchema.thumbnailId],\n      references: [FileBufferSchema.id],\n    }),\n    altFile: one(FileBufferSchema, {\n      fields: [SubmissionFileSchema.altFileId],\n      references: [FileBufferSchema.id],\n    }),\n  }),\n);\n"
  },
  {
    "path": "libs/database/src/lib/schemas/submission.schema.ts",
    "content": "import { relations } from 'drizzle-orm';\nimport { integer, real, sqliteTable, text } from 'drizzle-orm/sqlite-core';\n// eslint-disable-next-line @nrwl/nx/enforce-module-boundaries\nimport {\n    ISubmissionMetadata,\n    ISubmissionScheduleInfo,\n} from '../../../../types/src/index';\nimport { CommonSchema, submissionType } from './common.schema';\nimport { PostQueueRecordSchema } from './post-queue-record.schema';\nimport { PostRecordSchema } from './post-record.schema';\nimport { SubmissionFileSchema } from './submission-file.schema';\nimport { WebsiteOptionsSchema } from './website-options.schema';\n\nexport const SubmissionSchema = sqliteTable('submission', {\n  ...CommonSchema(),\n  ...submissionType(),\n  isArchived: integer({ mode: 'boolean' }).default(false),\n  isInitialized: integer({ mode: 'boolean' }).default(false),\n  isMultiSubmission: integer({ mode: 'boolean' }).notNull(),\n  isScheduled: integer({ mode: 'boolean' }).notNull(),\n  isTemplate: integer({ mode: 'boolean' }).notNull(),\n  metadata: text({ mode: 'json' }).notNull().$type<ISubmissionMetadata>(),\n  order: real().notNull(),\n  schedule: text({ mode: 'json' }).notNull().$type<ISubmissionScheduleInfo>(),\n});\n\nexport const SubmissionRelations = relations(\n  SubmissionSchema,\n  ({ one, many }) => ({\n    options: many(WebsiteOptionsSchema),\n    posts: many(PostRecordSchema),\n    files: many(SubmissionFileSchema),\n    postQueueRecord: one(PostQueueRecordSchema),\n  }),\n);\n"
  },
  {
    "path": "libs/database/src/lib/schemas/tag-converter.schema.ts",
    "content": "import { sqliteTable, text } from 'drizzle-orm/sqlite-core';\nimport { CommonSchema } from './common.schema';\n\nexport const TagConverterSchema = sqliteTable('tag-converter', {\n  ...CommonSchema(),\n  convertTo: text({ mode: 'json' }).notNull().$type<Record<string, string>>(),\n  tag: text().notNull().unique(),\n});\n"
  },
  {
    "path": "libs/database/src/lib/schemas/tag-group.schema.ts",
    "content": "import { sqliteTable, text } from 'drizzle-orm/sqlite-core';\nimport { CommonSchema } from './common.schema';\n\nexport const TagGroupSchema = sqliteTable('tag-group', {\n  ...CommonSchema(),\n  name: text().notNull().unique(),\n  tags: text({ mode: 'json' }).notNull().$type<string[]>(),\n});\n"
  },
  {
    "path": "libs/database/src/lib/schemas/user-converter.schema.ts",
    "content": "import { sqliteTable, text } from 'drizzle-orm/sqlite-core';\nimport { CommonSchema } from './common.schema';\n\nexport const UserConverterSchema = sqliteTable('user-converter', {\n  ...CommonSchema(),\n  convertTo: text({ mode: 'json' }).notNull().$type<Record<string, string>>(),\n  username: text().notNull().unique(),\n});\n"
  },
  {
    "path": "libs/database/src/lib/schemas/user-specified-website-options.schema.ts",
    "content": "import { relations } from 'drizzle-orm';\nimport { sqliteTable, text } from 'drizzle-orm/sqlite-core';\nimport { AccountSchema } from './account.schema';\nimport { CommonSchema, id, submissionType } from './common.schema';\n\nexport const UserSpecifiedWebsiteOptionsSchema = sqliteTable(\n  'user-specified-website-options',\n  {\n    ...CommonSchema(),\n    accountId: id()\n      .notNull()\n      .references(() => AccountSchema.id, {\n        onDelete: 'cascade',\n      }),\n    options: text({ mode: 'json' }).notNull(),\n    ...submissionType(),\n  },\n);\n\nexport const UserSpecifiedWebsiteOptionsRelations = relations(\n  UserSpecifiedWebsiteOptionsSchema,\n  ({ one }) => ({\n    account: one(AccountSchema, {\n      fields: [UserSpecifiedWebsiteOptionsSchema.accountId],\n      references: [AccountSchema.id],\n    }),\n  }),\n);\n"
  },
  {
    "path": "libs/database/src/lib/schemas/website-data.schema.ts",
    "content": "import { relations } from 'drizzle-orm';\nimport { sqliteTable, text } from 'drizzle-orm/sqlite-core';\nimport { AccountSchema } from './account.schema';\nimport { CommonSchema, id } from './common.schema';\n\nconst commonSchema = CommonSchema();\n\nexport const WebsiteDataSchema = sqliteTable('website-data', {\n  id: id()\n    .primaryKey()\n    .unique()\n    .notNull()\n    .references(() => AccountSchema.id, {\n      onDelete: 'cascade',\n    }),\n  createdAt: commonSchema.createdAt,\n  data: text({ mode: 'json' }).notNull().default({}),\n  updatedAt: commonSchema.updatedAt,\n});\n\nexport const WebsiteDataRelations = relations(WebsiteDataSchema, ({ one }) => ({\n  account: one(AccountSchema, {\n    fields: [WebsiteDataSchema.id],\n    references: [AccountSchema.id],\n  }),\n}));\n"
  },
  {
    "path": "libs/database/src/lib/schemas/website-options.schema.ts",
    "content": "import { relations } from 'drizzle-orm';\nimport { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';\nimport { AccountSchema } from './account.schema';\nimport { CommonSchema, id } from './common.schema';\nimport { SubmissionSchema } from './submission.schema';\n// eslint-disable-next-line @nrwl/nx/enforce-module-boundaries\nimport { IWebsiteFormFields } from '../../../../types/src/index';\n\nexport const WebsiteOptionsSchema = sqliteTable('website-options', {\n  ...CommonSchema(),\n  accountId: id()\n    .notNull()\n    .references(() => AccountSchema.id, {\n      onDelete: 'cascade',\n    }),\n  submissionId: id()\n    .notNull()\n    .references(() => SubmissionSchema.id, {\n      onDelete: 'cascade',\n    }),\n  data: text({ mode: 'json' }).notNull().$type<IWebsiteFormFields>(),\n  isDefault: integer({ mode: 'boolean' }).notNull(),\n});\n\nexport const WebsiteOptionsRelations = relations(\n  WebsiteOptionsSchema,\n  ({ one }) => ({\n    account: one(AccountSchema, {\n      fields: [WebsiteOptionsSchema.accountId],\n      references: [AccountSchema.id],\n    }),\n    submission: one(SubmissionSchema, {\n      fields: [WebsiteOptionsSchema.submissionId],\n      references: [SubmissionSchema.id],\n    }),\n  }),\n);\n"
  },
  {
    "path": "libs/database/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.base.json\",\n  \"compilerOptions\": {\n    \"module\": \"commonjs\"\n  },\n  \"files\": [],\n  \"include\": [],\n  \"references\": [\n    {\n      \"path\": \"./tsconfig.lib.json\"\n    },\n    {\n      \"path\": \"./tsconfig.spec.json\"\n    }\n  ]\n}\n"
  },
  {
    "path": "libs/database/tsconfig.lib.json",
    "content": "{\n  \"extends\": \"./tsconfig.json\",\n  \"compilerOptions\": {\n    \"module\": \"commonjs\",\n    \"outDir\": \"../../dist/out-tsc\",\n    \"declaration\": true,\n    \"types\": [\"node\"]\n  },\n  \"exclude\": [\"jest.config.ts\", \"src/**/*.spec.ts\", \"src/**/*.test.ts\"],\n  \"include\": [\"src/**/*.ts\"]\n}\n"
  },
  {
    "path": "libs/database/tsconfig.spec.json",
    "content": "{\n  \"extends\": \"./tsconfig.json\",\n  \"compilerOptions\": {\n    \"outDir\": \"../../dist/out-tsc\",\n    \"module\": \"commonjs\",\n    \"types\": [\"jest\", \"node\"]\n  },\n  \"include\": [\n    \"jest.config.ts\",\n    \"src/**/*.test.ts\",\n    \"src/**/*.spec.ts\",\n    \"src/**/*.d.ts\"\n  ]\n}\n"
  },
  {
    "path": "libs/form-builder/.eslintrc.json",
    "content": "{\n  \"extends\": [\"../../.eslintrc.js\"],\n  \"ignorePatterns\": [\"!**/*\"],\n  \"overrides\": [\n    {\n      \"files\": [\"*.ts\", \"*.tsx\", \"*.js\", \"*.jsx\"],\n      \"rules\": {}\n    },\n    {\n      \"files\": [\"*.ts\", \"*.tsx\"],\n      \"rules\": {}\n    },\n    {\n      \"files\": [\"*.js\", \"*.jsx\"],\n      \"rules\": {}\n    }\n  ]\n}\n"
  },
  {
    "path": "libs/form-builder/README.md",
    "content": "# form-builder\n\nThis library was generated with [Nx](https://nx.dev).\n\n## Running unit tests\n\nRun `nx test form-builder` to execute the unit tests via [Jest](https://jestjs.io).\n"
  },
  {
    "path": "libs/form-builder/jest.config.ts",
    "content": "/* eslint-disable */\nexport default {\n  displayName: 'form-builder',\n  preset: '../../jest.preset.js',\n  globals: {},\n  testEnvironment: 'node',\n  moduleFileExtensions: ['js', 'ts', 'html'],\n  coverageDirectory: '../../coverage/libs/form-builder',\n};\n"
  },
  {
    "path": "libs/form-builder/project.json",
    "content": "{\n  \"name\": \"form-builder\",\n  \"$schema\": \"../../node_modules/nx/schemas/project-schema.json\",\n  \"sourceRoot\": \"libs/form-builder/src\",\n  \"projectType\": \"library\",\n  \"targets\": {\n    \"lint\": {\n      \"executor\": \"@nx/eslint:lint\",\n      \"outputs\": [\"{options.outputFile}\"]\n    },\n    \"test\": {\n      \"executor\": \"@nx/jest:jest\",\n      \"outputs\": [\"{workspaceRoot}/coverage/libs/form-builder\"],\n      \"options\": {\n        \"jestConfig\": \"libs/form-builder/jest.config.ts\"\n      }\n    }\n  },\n  \"tags\": []\n}\n"
  },
  {
    "path": "libs/form-builder/src/constants.ts",
    "content": "export const METADATA_KEY = 'POSTYBIRB_FORM_BUILDER_METADATA';\n"
  },
  {
    "path": "libs/form-builder/src/index.ts",
    "content": "export * from './lib/form-builder';\n"
  },
  {
    "path": "libs/form-builder/src/lib/decorators/boolean-field.decorator.ts",
    "content": "import 'reflect-metadata';\nimport { createFieldDecorator } from '../utils/assign-metadata';\n\nexport const BooleanField = createFieldDecorator<boolean>('boolean')({\n  defaults: {\n    formField: 'checkbox',\n  },\n});\n"
  },
  {
    "path": "libs/form-builder/src/lib/decorators/date-time-field.decorator.ts",
    "content": "import 'reflect-metadata';\nimport { createFieldDecorator } from '../utils/assign-metadata';\n\n// ISO String\nexport type DateString = string;\n\ntype ExtraOptions = {\n  /**\n   * Minimum date/time allowed (ISO string or Date)\n   */\n  min?: DateString;\n  /**\n   * Maximum date/time allowed (ISO string or Date)\n   */\n  max?: DateString;\n  /**\n   * Whether to show time picker (false = date only)\n   * @default true\n   */\n  showTime?: boolean;\n  /**\n   * Date format string for display\n   */\n  format?: string;\n};\n\nexport const DateTimeField = createFieldDecorator<string, ExtraOptions>(\n  'datetime',\n)({\n  defaults: {\n    defaultValue: '',\n    formField: 'datetime',\n    showTime: true,\n  },\n});\n"
  },
  {
    "path": "libs/form-builder/src/lib/decorators/description-field.decorator.ts",
    "content": "import {\n  DefaultDescriptionValue,\n  DescriptionType,\n  DescriptionValue,\n} from '@postybirb/types';\nimport 'reflect-metadata';\nimport { createFieldDecorator } from '../utils/assign-metadata';\n\ntype DescriptionExtraFields = {\n  minDescriptionLength?: number;\n  maxDescriptionLength?: number;\n  descriptionType?: DescriptionType;\n\n  /**\n   * Whenether are expected to be included into the description as text or not.\n   * If enabled it will enable \"Insert title at start\" by default and produce a warning if title is not included.\n   * If disabled it will produce a warning if title was included.\n   */\n  expectsInlineTitle?: boolean;\n\n  /**\n   * Whenether tags are expected to be included into the description as text or not.\n   * If enabled it will enable \"Insert tags at end\" by default and produce a warning if tags are not included.\n   * If disabled it will produce a warning if tags were included.\n   */\n  expectsInlineTags?: boolean;\n};\n\nexport const DescriptionField = createFieldDecorator<\n  DescriptionValue,\n  DescriptionExtraFields\n>('description')({\n  defaults: {\n    label: 'description',\n    formField: 'description',\n    defaultValue: DefaultDescriptionValue(),\n    descriptionType: DescriptionType.HTML as DescriptionType, // otherwise aggregatefield type will treat it as html only,\n  },\n  onCreate(options) {\n    const { defaultValue } = options;\n    if (defaultValue) {\n      defaultValue.insertTitle = options.expectsInlineTitle;\n      defaultValue.insertTags = options.expectsInlineTags;\n    }\n  },\n});\n"
  },
  {
    "path": "libs/form-builder/src/lib/decorators/index.ts",
    "content": "export * from './boolean-field.decorator';\nexport * from './date-time-field.decorator';\nexport * from './description-field.decorator';\nexport * from './radio-field.decorator';\nexport * from './rating.decorator';\nexport * from './select-field.decorator';\nexport * from './tag-field.decorator';\nexport * from './text-field.decorator';\nexport * from './title-field.decorator';\n\n"
  },
  {
    "path": "libs/form-builder/src/lib/decorators/radio-field.decorator.ts",
    "content": "import 'reflect-metadata';\nimport { Primitive } from 'type-fest';\nimport { createFieldDecorator } from '../utils/assign-metadata';\n\nexport type RadioOption = {\n  label: string;\n  value: Primitive;\n};\n\ntype ExtraOptions = {\n  options: RadioOption[];\n  layout?: 'vertical' | 'horizontal';\n};\n\nexport const RadioField = createFieldDecorator<string, ExtraOptions>('radio')({\n  defaults: {\n    formField: 'radio',\n    layout: 'vertical',\n  },\n});\n"
  },
  {
    "path": "libs/form-builder/src/lib/decorators/rating.decorator.ts",
    "content": "/* eslint-disable no-param-reassign */\nimport { SubmissionRating } from '@postybirb/types';\nimport 'reflect-metadata';\nimport { createFieldDecorator } from '../utils/assign-metadata';\n\nexport type RatingOption = {\n  label: string;\n  value: SubmissionRating | string | undefined;\n};\n\ntype ExtraOptions = {\n  options: RatingOption[];\n  layout?: 'vertical' | 'horizontal';\n};\n\nexport const RatingField = createFieldDecorator<SubmissionRating, ExtraOptions>(\n  'rating',\n)({\n  defaults: {\n    label: 'rating',\n    layout: 'horizontal',\n    formField: 'rating',\n    defaultValue: undefined,\n    options: [\n      {\n        label: 'General',\n        value: SubmissionRating.GENERAL,\n      },\n      {\n        label: 'Mature',\n        value: SubmissionRating.MATURE,\n      },\n      {\n        label: 'Adult',\n        value: SubmissionRating.ADULT,\n      },\n      {\n        label: 'Extreme',\n        value: SubmissionRating.EXTREME,\n      },\n    ],\n  },\n});\n"
  },
  {
    "path": "libs/form-builder/src/lib/decorators/select-field.decorator.ts",
    "content": "import 'reflect-metadata';\nimport { createFieldDecorator } from '../utils/assign-metadata';\n\nexport type SelectOptionWithDiscriminator = {\n  options: Record<string, SelectOption[]>;\n  discriminator: 'overallFileType';\n};\n\nexport type SelectOptionSingle = {\n  label: string;\n  value: string;\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  data?: any;\n  mutuallyExclusive?: boolean; // Deselect all others when true and chosen\n};\n\nexport type SelectOptionGroup = {\n  label: string;\n  value?: string;\n  items: SelectOption[];\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  data?: any;\n  mutuallyExclusive?: boolean; // Deselect all others when true and chosen\n};\n\nexport type SelectOption = SelectOptionGroup | SelectOptionSingle;\n\ntype ExtraOptions = {\n  options: SelectOption[] | SelectOptionWithDiscriminator;\n  allowMultiple: boolean;\n  minSelected?: number;\n};\n\nexport const SelectField = createFieldDecorator<unknown, ExtraOptions>(\n  'select',\n)({\n  defaults: {\n    formField: 'select',\n    allowMultiple: false,\n  },\n});\n"
  },
  {
    "path": "libs/form-builder/src/lib/decorators/tag-field.decorator.ts",
    "content": "import { DefaultTagValue, TagValue } from '@postybirb/types';\nimport 'reflect-metadata';\nimport { createFieldDecorator } from '../utils/assign-metadata';\n\ntype TagExtraFields = {\n  searchProviderId?: string;\n  minTags?: number;\n  maxTags?: number;\n  maxTagLength?: number;\n  minTagLength?: number;\n  spaceReplacer?: string;\n};\n\nexport const TagField = createFieldDecorator<TagValue, TagExtraFields>('tag')({\n  defaults: {\n    formField: 'tag',\n    label: 'tags',\n    defaultValue: DefaultTagValue(),\n    minTagLength: 1,\n    spaceReplacer: '_',\n  },\n});\n"
  },
  {
    "path": "libs/form-builder/src/lib/decorators/text-field.decorator.ts",
    "content": "import 'reflect-metadata';\nimport { createFieldDecorator } from '../utils/assign-metadata';\n\ntype ExtraOptions = {\n  maxLength?: number;\n  formField: 'input' | 'textarea';\n};\n\nexport const TextField = createFieldDecorator<string, ExtraOptions>('text')({\n  defaults: {\n    defaultValue: '',\n    formField: 'input' as 'input' | 'textarea', // otherwise aggregate field will treat formField as input only\n  },\n});\n"
  },
  {
    "path": "libs/form-builder/src/lib/decorators/title-field.decorator.ts",
    "content": "import 'reflect-metadata';\nimport { createFieldDecorator } from '../utils/assign-metadata';\n\ntype ExtraOptions = {\n  maxLength?: number;\n  minLength?: number;\n  formField: 'input';\n};\n\nexport const TitleField = createFieldDecorator<string, ExtraOptions>('title')({\n  defaults: {\n    defaultValue: '',\n    formField: 'input',\n    label: 'title',\n    expectedInDescription: false,\n  },\n});\n"
  },
  {
    "path": "libs/form-builder/src/lib/form-builder.spec.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\n/* eslint-disable max-classes-per-file */\nimport {\n  BooleanField,\n  DescriptionField,\n  TagField,\n  TextField,\n} from './decorators';\nimport { formBuilder } from './form-builder';\n\ndescribe('formBuilder', () => {\n  it('should build boolean types', () => {\n    class BooleanType {\n      @BooleanField({ label: 'description' })\n      public field = false;\n    }\n\n    expect(formBuilder(new BooleanType(), {})).toEqual({\n      field: {\n        label: 'description',\n        defaultValue: false,\n        type: 'boolean',\n        formField: 'checkbox',\n        section: 'website',\n        responsive: {\n          xs: 12,\n        },\n        order: 999,\n        span: 12,\n      },\n    });\n  });\n\n  it('should extend classes', () => {\n    class BooleanType {\n      @BooleanField({ label: 'description' })\n      public field = false;\n    }\n\n    class ExtendedType extends BooleanType {\n      @TextField({ label: 'description', section: 'website', span: 6 })\n      public field2 = 'hello';\n\n      @DescriptionField({ label: 'feature', descriptionType: 'html' })\n      public field3: unknown;\n    }\n\n    class ExtendedAndOverrideType extends ExtendedType {\n      @TextField({ label: 'title' })\n      public field2 = 'Goodbye';\n\n      @DescriptionField({ label: 'feature', descriptionType: 'markdown' })\n      public field3: unknown;\n    }\n\n    expect(formBuilder(new BooleanType(), {})).toEqual({\n      field: {\n        label: 'description',\n        defaultValue: false,\n        type: 'boolean',\n        formField: 'checkbox',\n        section: 'website',\n        responsive: {\n          xs: 12,\n        },\n        order: 999,\n        span: 12,\n      },\n    });\n\n    expect(formBuilder(new ExtendedType(), {})).toEqual({\n      field: {\n        label: 'description',\n        defaultValue: false,\n        type: 'boolean',\n        formField: 'checkbox',\n        section: 'website',\n        responsive: {\n          xs: 12,\n        },\n        order: 999,\n        span: 12,\n      },\n      field2: {\n        label: 'description',\n        defaultValue: 'hello',\n        type: 'text',\n        formField: 'input',\n        section: 'website',\n        order: 999,\n        span: 6,\n        responsive: {\n          xs: 12,\n        },\n      },\n      field3: {\n        section: 'website',\n        responsive: {\n          xs: 12,\n        },\n        order: 999,\n        span: 12,\n        defaultValue: {\n          description: { type: 'doc', content: [] },\n          overrideDefault: false,\n        },\n        descriptionType: 'html',\n        formField: 'description',\n        label: 'feature',\n        type: 'description',\n      },\n    });\n\n    expect(formBuilder(new ExtendedAndOverrideType(), {})).toEqual({\n      field: {\n        label: 'description',\n        defaultValue: false,\n        type: 'boolean',\n        formField: 'checkbox',\n        section: 'website',\n        responsive: {\n          xs: 12,\n        },\n        order: 999,\n        span: 12,\n      },\n      field2: {\n        label: 'title',\n        defaultValue: 'Goodbye',\n        type: 'text',\n        formField: 'input',\n        section: 'website',\n        order: 999,\n        span: 6,\n        responsive: {\n          xs: 12,\n        },\n      },\n      field3: {\n        section: 'website',\n        responsive: {\n          xs: 12,\n        },\n        order: 999,\n        span: 12,\n        defaultValue: {\n          description: { type: 'doc', content: [] },\n          overrideDefault: false,\n        },\n        descriptionType: 'markdown',\n        formField: 'description',\n        label: 'feature',\n        type: 'description',\n      },\n    });\n  });\n\n  it('should build text types', () => {\n    class TextType {\n      @TextField({ label: 'description' })\n      public field = 'hello';\n    }\n\n    expect(formBuilder(new TextType(), {})).toEqual({\n      field: {\n        label: 'description',\n        defaultValue: 'hello',\n        type: 'text',\n        formField: 'input',\n        section: 'website',\n        responsive: {\n          xs: 12,\n        },\n        order: 999,\n        span: 12,\n      },\n    });\n  });\n\n  it('should build tag fields', () => {\n    class TestType {\n      @TagField({})\n      field: string[];\n    }\n\n    expect(formBuilder(new TestType(), {})).toMatchInlineSnapshot(`\n      {\n        \"field\": {\n          \"defaultValue\": {\n            \"overrideDefault\": false,\n            \"tags\": [],\n          },\n          \"formField\": \"tag\",\n          \"label\": \"tags\",\n          \"minTagLength\": 1,\n          \"order\": 999,\n          \"responsive\": {\n            \"xs\": 12,\n          },\n          \"section\": \"website\",\n          \"spaceReplacer\": \"_\",\n          \"span\": 12,\n          \"type\": \"tag\",\n        },\n      }\n    `);\n  });\n\n  it('should support section and layout properties', () => {\n    class TestType {\n      @TextField({\n        label: 'description',\n        section: 'website',\n        order: 1,\n        span: 6,\n        offset: 2,\n        responsive: { xs: 12, sm: 8 },\n      })\n      public field = 'hello';\n    }\n\n    expect(formBuilder(new TestType(), {})).toEqual({\n      field: {\n        label: 'description',\n        defaultValue: 'hello',\n        type: 'text',\n        formField: 'input',\n        section: 'website',\n        order: 1,\n        span: 6,\n        offset: 2,\n        responsive: { xs: 12, sm: 8 },\n      },\n    });\n  });\n\n  it('should support defaultFrom', () => {\n    type TestType = { testBoolean: true };\n    const test: TestType = { testBoolean: true };\n    class BooleanType {\n      @BooleanField<TestType>({\n        label: 'description',\n        defaultValue: false,\n        defaultFrom: 'testBoolean',\n      })\n      public field: boolean;\n    }\n\n    expect(formBuilder(new BooleanType(), test)).toEqual({\n      field: {\n        label: 'description',\n        defaultFrom: 'testBoolean',\n        defaultValue: test.testBoolean,\n        type: 'boolean',\n        formField: 'checkbox',\n        section: 'website',\n        responsive: {\n          xs: 12,\n        },\n        order: 999,\n        span: 12,\n      },\n    });\n  });\n});\n"
  },
  {
    "path": "libs/form-builder/src/lib/form-builder.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport 'reflect-metadata';\nimport { FormBuilderMetadata } from './types/form-builder-metadata';\nimport { PrimitiveRecord } from './types/primitive-record';\nimport { getMetadataKey, getParentMetadataKeys } from './utils/assign-metadata';\n\nexport function formBuilder(\n  target: object,\n  data: PrimitiveRecord,\n): FormBuilderMetadata {\n  const key = getMetadataKey(target.constructor.name);\n  let sym: symbol = (target as any)[key];\n  if (!sym) {\n    // Handle case where a class extends another class with metadata, but provides no metadata itself\n    const parentKeys = getParentMetadataKeys(target);\n    for (const parentKey of parentKeys) {\n      sym = (target as any)[parentKey];\n      if (sym) break;\n    }\n  }\n  if (!sym) throw new Error('No metadata symbol found');\n  const metadata = JSON.parse(\n    JSON.stringify(Reflect.getMetadata(sym, target.constructor)),\n  ) as FormBuilderMetadata;\n\n  for (const value of Object.values(metadata)) {\n    if (value.defaultFrom) value.defaultValue = data[value.defaultFrom];\n    value.derive?.forEach((d) => {\n      (value as PrimitiveRecord)[d.populate] = data[d.key];\n    });\n  }\n\n  return metadata;\n}\n\nexport * from './decorators';\nexport * from './types';\n"
  },
  {
    "path": "libs/form-builder/src/lib/types/field-aggregate.ts",
    "content": "import {\n  BooleanField,\n  DateTimeField,\n  DescriptionField,\n  RadioField,\n  RatingField,\n  SelectField,\n  TagField,\n  TextField,\n  TitleField,\n} from '../decorators';\nimport { ExtractFieldTypeFromDecorator } from '../utils/assign-metadata';\n\nexport type RatingFieldType = ExtractFieldTypeFromDecorator<typeof RatingField>;\nexport type TagFieldType = ExtractFieldTypeFromDecorator<typeof TagField>;\nexport type DescriptionFieldType = ExtractFieldTypeFromDecorator<\n  typeof DescriptionField\n>;\nexport type TextFieldType = ExtractFieldTypeFromDecorator<typeof TextField>;\nexport type TitleFieldType = ExtractFieldTypeFromDecorator<typeof TitleField>;\nexport type SelectFieldType = ExtractFieldTypeFromDecorator<typeof SelectField>;\nexport type BooleanFieldType = ExtractFieldTypeFromDecorator<\n  typeof BooleanField\n>;\nexport type RadioFieldType = ExtractFieldTypeFromDecorator<typeof RadioField>;\nexport type DateTimeFieldType = ExtractFieldTypeFromDecorator<\n  typeof DateTimeField\n>;\n\nexport type FieldAggregateType =\n  | BooleanFieldType\n  | DateTimeFieldType\n  | TextFieldType\n  | RadioFieldType\n  | RatingFieldType\n  | TagFieldType\n  | DescriptionFieldType\n  | SelectFieldType\n  | TitleFieldType;\n"
  },
  {
    "path": "libs/form-builder/src/lib/types/field.ts",
    "content": "import type {\n  FieldLabelTranslations,\n  FieldLabelTranslationsId,\n} from '@postybirb/translations';\nimport { PrimitiveRecord } from './primitive-record';\n\nexport type FieldType<\n  V,\n  F extends string,\n  T extends PrimitiveRecord | unknown = unknown,\n> = {\n  /**\n   * Pulls a default value from a key property\n   */\n  defaultFrom?: keyof T;\n\n  /**\n   * The default value when populated.\n   * This is also populated when the defaultFrom property is defined.\n   * Can also be read from the value of a field set in the object i.e. `field = 'value'`.\n   */\n  defaultValue?: V;\n\n  /**\n   * The field will be enabled when all listed props are defined.\n   */\n  enableWhenDefined?: Array<keyof T>;\n\n  /**\n   * The field will be enabled when all the listed props are undefined.\n   */\n  enableWhenUndefined?: Array<keyof T>;\n\n  /**\n   * Metadata for determining what type of field to generate.\n   */\n  formField?: F;\n  /**\n   * The translation id of the label to display. All possible values can be found here: {@link FieldLabelTranslations}. Use `{ untranslated: string }` **ONLY** if it is difficult to translate the label and the website itself does not provide a translation for it, or if it is NSFW/offensive and should not be shown in public translations.\n   */\n  label:\n    | FieldLabelTranslationsId\n    | {\n        /**\n         * Use **ONLY** if it is difficult to translate the label and the website itself does not provide a translation for it, or if it is NSFW/offensive and should not be shown in public translations.\n         */\n        untranslated: string;\n      };\n\n  /**\n   * Whether the field is considered required.\n   */\n  required?: boolean;\n\n  /**\n   * Metadata type.\n   */\n  type?: string;\n\n  /**\n   * Whether the component should grow to fill the available space.\n   * @deprecated Use span instead\n   */\n  grow?: boolean;\n\n  /**\n   * Whether the field should be hidden.\n   */\n  hidden?: boolean;\n\n  // New section-based layout properties\n  /**\n   * The section this field belongs to for layout purposes\n   * Well-known sections: 'common', 'website', others will be grouped below\n   */\n  section?: 'common' | 'website' | string;\n\n  /**\n   * Priority/order within the section (lower numbers appear first)\n   */\n  order?: number;\n\n  /**\n   * Column span in 12-column grid (1-12)\n   * @default 12 (full width)\n   */\n  span?: number;\n\n  /**\n   * Offset from left in columns (0-11)\n   */\n  offset?: number;\n\n  /**\n   * Responsive column spans for different breakpoints\n   */\n  responsive?: {\n    xs?: number; // mobile\n    sm?: number; // tablet\n    md?: number; // desktop\n    lg?: number; // large desktop\n  };\n\n  /**\n   * Whether the field should break to a new row\n   */\n  breakRow?: boolean;\n\n  /**\n   * Allow derivation of a field from derived external data\n   * Selects the key from the provided object and sets the populate field to that value.\n   */\n  derive?: {\n    key: keyof T;\n    populate: string;\n  }[];\n\n  /**\n   * Shows in the UI when all properties are satisfied.\n   * Evaluates in field.tsx\n   * @type {Array<[keyof T, any[]]>}\n   */\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  showWhen?: Array<[keyof T, any[]]>;\n};\n"
  },
  {
    "path": "libs/form-builder/src/lib/types/form-builder-metadata.ts",
    "content": "import { FieldAggregateType } from './field-aggregate';\n\nexport type FormBuilderMetadata = Record<string, FieldAggregateType>;\n"
  },
  {
    "path": "libs/form-builder/src/lib/types/index.ts",
    "content": "export * from './field-aggregate';\nexport * from './field';\nexport * from './form-builder-metadata';\n"
  },
  {
    "path": "libs/form-builder/src/lib/types/primitive-record.ts",
    "content": "import { Primitive } from 'type-fest';\n\ntype ValidValue = Primitive | Primitive[];\n\nexport type PrimitiveRecord<\n  T extends Record<string, ValidValue> = Record<string, ValidValue>,\n> = T;\n"
  },
  {
    "path": "libs/form-builder/src/lib/utils/assign-metadata.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\n/* eslint-disable no-param-reassign */\nimport 'reflect-metadata';\nimport { Class } from 'type-fest';\nimport { METADATA_KEY } from '../../constants';\nimport { FieldAggregateType, FieldType } from '../types';\nimport { FormBuilderMetadata } from '../types/form-builder-metadata';\nimport { PrimitiveRecord } from '../types/primitive-record';\n\nexport function getMetadataKey(name: string) {\n  return `__${METADATA_KEY}__${name}__`;\n}\n\nexport function getParentMetadataKeys(proto: object) {\n  const chain = [];\n  let currentProto = proto.constructor;\n  while (currentProto && currentProto.name) {\n    chain.push(currentProto.name);\n    currentProto = Object.getPrototypeOf(currentProto);\n  }\n  return chain.map((c) => getMetadataKey(c));\n}\n\n/**\n * Make keys V in type T partial\n */\nexport type PartialOnly<T, V extends string | number | symbol> = Omit<T, V> &\n  Partial<{\n    [P in V]: V extends keyof T ? T[V] : never;\n  }>;\n\n/**\n * Field decorator creator superfunction\n *\n * @param type - Type of the decorator to create. Will be set to the {@link FieldType['type']} field.\n * @returns Creator function\n */\nexport function createFieldDecorator<\n  FieldValue,\n  ExtraFields extends object = object,\n  TypeKey extends string = string,\n>(type: TypeKey) {\n  // Note that we cant just create one function that returns a decorator\n  // because if you specify generics by hand, e.g. createFieldDecorator<SomeType>('type', {})\n  // it will stop narrowing all other generic types such as Defaults\n  /**\n   * Function used to finalize field decorator options\n   */\n  return function create<\n    const Defaults extends\n      | Partial<FieldType<FieldValue, TypeKey> & ExtraFields>\n      | unknown,\n  >(field: {\n    /**\n     * Default values of the field. If label or defaultValue are provided, they will be not required when using a decorator\n     *\n     * Example:\n     * ```ts\n     * const WithDefaults = createFieldDecorator('with')({\n     *   defaults: {\n     *     label: 'title',\n     *     defaultValue: ''\n     *   }\n     * })\n     * const WithoutDefaults = createFieldDecorator('without')({})\n     *\n     * class TestType {\n     *   WithDefaults({})\n     *   field1: string\n     *\n     *   WithoutDefaults({}) // Error! Missing properties label and defaultValue\n     *   field2: string\n     * }\n     *\n     * ```\n     */\n    defaults?: Defaults;\n    /**\n     * Function that being called on decorator applying. Can be used to change options or to change applied value\n     *\n     * @param options - Options to be changed\n     */\n    onCreate?: (options: FieldType<FieldValue, TypeKey> & ExtraFields) => void;\n  }) {\n    function decorator<Data extends unknown | PrimitiveRecord = unknown>(\n      options: PartialOnly<\n        FieldType<FieldValue, TypeKey, Data> & ExtraFields,\n        keyof Defaults\n      >,\n    ): PropertyDecorator {\n      if (field.defaults) {\n        for (const [key, value] of Object.entries(field.defaults)) {\n          (options as any)[key] ??= value;\n        }\n      }\n\n      const fieldOptions = options as FieldType<FieldValue, TypeKey, Data> &\n        ExtraFields;\n\n      fieldOptions.type ??= type;\n\n      return (target: any, propertyKey) => {\n        if (typeof propertyKey === 'symbol') return;\n\n        const proto = target.constructor;\n        // eslint-disable-next-line new-cap\n        const obj = new (proto as Class<object>)();\n        const propKeyValue = (obj as any)[propertyKey];\n        if (propKeyValue !== undefined) {\n          /*\n           * This is to allow for setting of defaults through field setting\n           * @Field()\n           * field = 'value' // makes defaultValue = 'value'\n           */\n          fieldOptions.defaultValue = propKeyValue as FieldValue;\n        }\n\n        const chain = [];\n        let currentProto = proto;\n        while (currentProto && currentProto.name) {\n          chain.push(currentProto.name);\n          currentProto = Object.getPrototypeOf(currentProto);\n        }\n        const key = getMetadataKey(proto.name);\n        if (!target[key]) {\n          target[key] = Symbol(key);\n        }\n        const sym = target[key];\n        const fields: FormBuilderMetadata =\n          Reflect.getMetadata(sym, proto) || {};\n\n        const chainedFields = chain\n          .filter((c) => c !== target.constructor.name)\n          .map((c) => Reflect.getMetadata(target[getMetadataKey(c)], proto));\n\n        // Iterate over all chained parent classes and merge their fields\n        // Uniqueness is maintained by use of the Symbol(key)\n        for (const c of chainedFields) {\n          if (c) {\n            Object.entries(c).forEach(([fieldKey, value]) => {\n              if (value !== undefined) {\n                fields[fieldKey] = Object.assign(\n                  JSON.parse(JSON.stringify(value)),\n                  fields[fieldKey] ?? {},\n                ) as unknown as FieldAggregateType;\n              }\n            });\n          }\n        }\n\n        field.onCreate?.(\n          fieldOptions as unknown as FieldType<FieldValue, TypeKey> &\n            ExtraFields,\n        );\n\n        fields[propertyKey] = Object.assign(\n          fields[propertyKey] ?? {},\n          fieldOptions,\n        ) as unknown as FieldAggregateType;\n\n        // Set new defaults for section-based layout\n        fields[propertyKey].section ??= 'website';\n        fields[propertyKey].order ??= 999;\n        fields[propertyKey].span ??= 12;\n        fields[propertyKey].responsive ??= { xs: 12 };\n\n        Reflect.defineMetadata(sym, fields, proto);\n      };\n    }\n\n    return decorator as typeof decorator & {\n      field: FieldType<FieldValue, TypeKey> & ExtraFields & Defaults;\n    };\n  };\n}\n\nexport type ExtractFieldTypeFromDecorator<D> = D extends { field: infer T }\n  ? T\n  : D;\n"
  },
  {
    "path": "libs/form-builder/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.base.json\",\n  \"files\": [],\n  \"include\": [],\n  \"compilerOptions\": {\n    \"experimentalDecorators\": true,\n    \"strict\": true,\n    \"strictPropertyInitialization\": false\n  },\n  \"references\": [\n    {\n      \"path\": \"./tsconfig.lib.json\"\n    },\n    {\n      \"path\": \"./tsconfig.spec.json\"\n    }\n  ]\n}\n"
  },
  {
    "path": "libs/form-builder/tsconfig.lib.json",
    "content": "{\n  \"extends\": \"./tsconfig.json\",\n  \"compilerOptions\": {\n    \"module\": \"commonjs\",\n    \"outDir\": \"../../dist/out-tsc\",\n    \"declaration\": true,\n    \"types\": [\"node\"]\n  },\n  \"exclude\": [\"**/*.spec.ts\", \"**/*.test.ts\", \"jest.config.ts\"],\n  \"include\": [\"**/*.ts\"]\n}\n"
  },
  {
    "path": "libs/form-builder/tsconfig.spec.json",
    "content": "{\n  \"extends\": \"./tsconfig.json\",\n  \"compilerOptions\": {\n    \"outDir\": \"../../dist/out-tsc\",\n    \"module\": \"commonjs\",\n    \"types\": [\"jest\", \"node\"]\n  },\n  \"include\": [\n    \"**/*.spec.ts\",\n    \"**/*.test.ts\",\n    \"**/*.spec.tsx\",\n    \"**/*.test.tsx\",\n    \"**/*.spec.js\",\n    \"**/*.test.js\",\n    \"**/*.spec.jsx\",\n    \"**/*.test.jsx\",\n    \"**/*.d.ts\",\n    \"jest.config.ts\"\n  ]\n}\n"
  },
  {
    "path": "libs/fs/.eslintrc.json",
    "content": "{\n  \"extends\": [\"../../.eslintrc.js\"],\n  \"ignorePatterns\": [\"!**/*\"],\n  \"overrides\": [\n    {\n      \"files\": [\"*.ts\", \"*.tsx\", \"*.js\", \"*.jsx\"],\n      \"rules\": {}\n    },\n    {\n      \"files\": [\"*.ts\", \"*.tsx\"],\n      \"rules\": {}\n    },\n    {\n      \"files\": [\"*.js\", \"*.jsx\"],\n      \"rules\": {}\n    }\n  ]\n}\n"
  },
  {
    "path": "libs/fs/README.md",
    "content": "# fs\n\nContains wrappers around filesystem functions and directory references to be used by PostyBirb.\nProtects actions to only affect approved directories.\n\n## Running unit tests\n\nRun `nx test fs` to execute the unit tests via [Jest](https://jestjs.io).\n"
  },
  {
    "path": "libs/fs/jest.config.ts",
    "content": "/* eslint-disable */\nexport default {\n  displayName: 'fs',\n  preset: '../../jest.preset.js',\n  globals: {},\n  testEnvironment: 'node',\n  moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],\n  coverageDirectory: '../../coverage/libs/fs',\n  runner: '@kayahr/jest-electron-runner/main',\n};\n"
  },
  {
    "path": "libs/fs/project.json",
    "content": "{\n  \"name\": \"fs\",\n  \"$schema\": \"../../node_modules/nx/schemas/project-schema.json\",\n  \"sourceRoot\": \"libs/fs/src\",\n  \"projectType\": \"library\",\n  \"targets\": {\n    \"lint\": {\n      \"executor\": \"@nx/eslint:lint\",\n      \"outputs\": [\"{options.outputFile}\"]\n    },\n    \"test\": {\n      \"executor\": \"@nx/jest:jest\",\n      \"outputs\": [\"{workspaceRoot}/coverage/libs/fs\"],\n      \"options\": {\n        \"jestConfig\": \"libs/fs/jest.config.ts\"\n      }\n    }\n  },\n  \"tags\": []\n}\n"
  },
  {
    "path": "libs/fs/src/index.ts",
    "content": "import * as PostyBirbDirectories from './lib/directories';\n\nexport { PostyBirbDirectories };\nexport * from './lib/fs';\n"
  },
  {
    "path": "libs/fs/src/lib/directories.ts",
    "content": "import {\n  getStartupOptions,\n  IsTestEnvironment,\n} from '@postybirb/utils/electron';\nimport { join } from 'path';\nimport { deleteDirSync, ensureDirSync } from './fs';\n\nfunction getPostyBirbDirectory() {\n  if (IsTestEnvironment()) {\n    return join(__dirname.split('libs')[0], 'test');\n  }\n\n  return getStartupOptions().appDataPath;\n}\n\n/**\n * Base PostyBirb document directory.\n */\nconst POSTYBIRB_DIRECTORY = getPostyBirbDirectory();\n\n/** Directory that stores PostyBirb data. */\nconst DATA_DIRECTORY = join(POSTYBIRB_DIRECTORY, 'data');\n\n/** Directory that stores application logs. */\nconst LOGS_DIRECTORY = join(POSTYBIRB_DIRECTORY, 'logs');\n\n/** Directory used for storing uploaded files. */\nconst TEMP_DIRECTORY = join(POSTYBIRB_DIRECTORY, 'temp');\n\nfunction clearTempDirectory() {\n  deleteDirSync(TEMP_DIRECTORY);\n  ensureDirSync(TEMP_DIRECTORY);\n}\n\nensureDirSync(DATA_DIRECTORY);\nensureDirSync(LOGS_DIRECTORY);\nclearTempDirectory();\n\nexport {\n  clearTempDirectory,\n  DATA_DIRECTORY,\n  LOGS_DIRECTORY,\n  POSTYBIRB_DIRECTORY,\n  TEMP_DIRECTORY,\n};\n"
  },
  {
    "path": "libs/fs/src/lib/fs.spec.ts",
    "content": "import { rmSync } from 'fs';\nimport { join } from 'path';\nimport { POSTYBIRB_DIRECTORY } from './directories';\nimport {\n  readJsonSync,\n  readSync,\n  removeFileSync,\n  writeJsonSync,\n  writeSync,\n} from './fs';\n\nlet filepath: string;\n\nbeforeEach(() => {\n  filepath = join(POSTYBIRB_DIRECTORY, `${Date.now()}.test.txt`);\n});\n\nafterAll(() => {\n  rmSync(POSTYBIRB_DIRECTORY, { recursive: true, force: true });\n});\n\ndescribe('PostyBirbFS', () => {\n  describe('writeSync/readSync', () => {\n    it('should write file', () => {\n      const data = 'test data';\n      writeSync(filepath, data);\n\n      const readAsBuffer = readSync(filepath);\n      expect(readAsBuffer).toEqual(Buffer.from(data));\n\n      const readData = readSync(filepath);\n      expect(readData.toString()).toBe(data);\n    });\n\n    it('should write json file', () => {\n      const data = { test: false };\n      writeJsonSync(filepath, data);\n\n      const readData = readJsonSync(filepath);\n      expect(readData).toEqual(data);\n    });\n\n    it('should not write outside of allowed directory', () => {\n      const data = 'test data';\n      let err: Error;\n      try {\n        writeSync(`./test/file.txt`, data);\n      } catch (e) {\n        err = e;\n      }\n\n      expect(err).toBeTruthy();\n      expect(err.message).toBe(\n        'Cannot read/write outside of PostyBirb directory',\n      );\n    });\n  });\n\n  describe('removeFile', () => {\n    it('should not remove file outside of', () => {\n      let err: Error;\n      try {\n        removeFileSync(`./test/file.txt`);\n      } catch (e) {\n        err = e;\n      }\n\n      expect(err).toBeTruthy();\n      expect(err.message).toBe(\n        'Cannot read/write outside of PostyBirb directory',\n      );\n    });\n  });\n});\n"
  },
  {
    "path": "libs/fs/src/lib/fs.ts",
    "content": "import {\n  accessSync,\n  mkdirSync,\n  readFileSync,\n  rmSync,\n  unlinkSync,\n  writeFileSync,\n} from 'fs';\nimport { readFile, unlink, writeFile } from 'fs/promises';\nimport { POSTYBIRB_DIRECTORY } from './directories';\n\nfunction validatePath(path: string) {\n  if (!path.startsWith(POSTYBIRB_DIRECTORY)) {\n    throw new Error('Cannot read/write outside of PostyBirb directory');\n  }\n}\n\nexport function ensureDirSync(path: string) {\n  try {\n    accessSync(path);\n  } catch {\n    mkdirSync(path, { recursive: true });\n  }\n}\n\nexport function deleteDirSync(path: string) {\n  validatePath(path);\n  try {\n    rmSync(path, { recursive: true });\n  } catch {\n    // Nothing\n  }\n}\n\nexport function writeSync(path: string, data: string | Buffer) {\n  validatePath(path);\n  writeFileSync(path, Buffer.from(data));\n}\n\nexport function write(path: string, data: string | Buffer) {\n  validatePath(path);\n  return writeFile(path, data);\n}\n\nexport function writeJson(path: string, data: Record<string, unknown>) {\n  return write(path, Buffer.from(JSON.stringify(data, null, 1)));\n}\n\nexport function writeJsonSync(path: string, data: Record<string, unknown>) {\n  writeSync(path, Buffer.from(JSON.stringify(data, null, 1)));\n}\n\nexport function read(path: string) {\n  validatePath(path);\n  return readFile(path);\n}\n\nexport function readSync(path: string): Buffer {\n  validatePath(path);\n  return readFileSync(path);\n}\n\nexport function readJson<T extends Record<string, unknown>>(path: string) {\n  return read(path).then((buffer) => JSON.parse(buffer.toString()) as T);\n}\n\nexport function readJsonSync<T extends Record<string, unknown>>(\n  path: string,\n): T {\n  return JSON.parse(readSync(path).toString());\n}\n\nexport function removeFile(path: string) {\n  validatePath(path);\n  return unlink(path);\n}\n\nexport function removeFileSync(path: string) {\n  validatePath(path);\n  unlinkSync(path);\n}\n"
  },
  {
    "path": "libs/fs/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.base.json\",\n  \"files\": [],\n  \"include\": [],\n  \"references\": [\n    {\n      \"path\": \"./tsconfig.lib.json\"\n    },\n    {\n      \"path\": \"./tsconfig.spec.json\"\n    }\n  ]\n}\n"
  },
  {
    "path": "libs/fs/tsconfig.lib.json",
    "content": "{\n  \"extends\": \"./tsconfig.json\",\n  \"compilerOptions\": {\n    \"module\": \"commonjs\",\n    \"outDir\": \"../../dist/out-tsc\",\n    \"declaration\": true,\n    \"types\": [\"node\"]\n  },\n  \"exclude\": [\"**/*.spec.ts\", \"**/*.test.ts\", \"jest.config.ts\"],\n  \"include\": [\"**/*.ts\"]\n}\n"
  },
  {
    "path": "libs/fs/tsconfig.spec.json",
    "content": "{\n  \"extends\": \"./tsconfig.json\",\n  \"compilerOptions\": {\n    \"outDir\": \"../../dist/out-tsc\",\n    \"module\": \"commonjs\",\n    \"types\": [\"jest\", \"node\"]\n  },\n  \"include\": [\n    \"**/*.spec.ts\",\n    \"**/*.test.ts\",\n    \"**/*.spec.tsx\",\n    \"**/*.test.tsx\",\n    \"**/*.spec.js\",\n    \"**/*.test.js\",\n    \"**/*.spec.jsx\",\n    \"**/*.test.jsx\",\n    \"**/*.d.ts\",\n    \"jest.config.ts\"\n  ]\n}\n"
  },
  {
    "path": "libs/http/.eslintrc.json",
    "content": "{\n  \"extends\": [\"../../.eslintrc.js\"],\n  \"ignorePatterns\": [\"!**/*\"],\n  \"overrides\": [\n    {\n      \"files\": [\"*.ts\", \"*.tsx\", \"*.js\", \"*.jsx\"],\n      \"rules\": {}\n    },\n    {\n      \"files\": [\"*.ts\", \"*.tsx\"],\n      \"rules\": {}\n    },\n    {\n      \"files\": [\"*.js\", \"*.jsx\"],\n      \"rules\": {}\n    }\n  ]\n}\n"
  },
  {
    "path": "libs/http/README.md",
    "content": "# http\n\nExtends implementation of ElectronJS ClientRequest.\n\n## Running unit tests\n\nRun `nx test http` to execute the unit tests via [Jest](https://jestjs.io).\n"
  },
  {
    "path": "libs/http/jest.config.ts",
    "content": "/* eslint-disable */\nexport default {\n  displayName: 'http',\n  preset: '../../jest.preset.js',\n  globals: {},\n  moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],\n  coverageDirectory: '../../coverage/libs/http',\n  runner: '@kayahr/jest-electron-runner/main',\n  testEnvironment: 'node',\n};\n"
  },
  {
    "path": "libs/http/project.json",
    "content": "{\n  \"name\": \"http\",\n  \"$schema\": \"../../node_modules/nx/schemas/project-schema.json\",\n  \"sourceRoot\": \"libs/http/src\",\n  \"projectType\": \"library\",\n  \"targets\": {\n    \"lint\": {\n      \"executor\": \"@nx/eslint:lint\",\n      \"outputs\": [\"{options.outputFile}\"]\n    },\n    \"test\": {\n      \"executor\": \"@nx/jest:jest\",\n      \"outputs\": [\"{workspaceRoot}/coverage/libs/http\"],\n      \"options\": {\n        \"jestConfig\": \"libs/http/jest.config.ts\"\n      }\n    }\n  },\n  \"tags\": []\n}\n"
  },
  {
    "path": "libs/http/src/index.ts",
    "content": "// Ensure proxy is imported first to patch fetch before any request is made\nexport * from './lib/proxy';\n\nexport * from './lib/form-file';\nexport * from './lib/http';\n"
  },
  {
    "path": "libs/http/src/lib/form-file.ts",
    "content": "type FormFileOptions = {\n  contentType: string;\n  filename: string;\n};\n\nexport class FormFile {\n  constructor(\n    private readonly value: Buffer,\n    private readonly options: FormFileOptions,\n  ) {}\n\n  get buffer(): Buffer {\n    return this.value;\n  }\n\n  get fileOptions(): FormFileOptions {\n    return this.options;\n  }\n\n  get fileName(): string {\n    return this.options.filename;\n  }\n\n  get contentType(): string {\n    return this.options.contentType;\n  }\n\n  setContentType(contentType: string): void {\n    this.options.contentType = contentType;\n  }\n\n  setFileName(fileName: string): void {\n    this.options.filename = fileName;\n  }\n\n  toString(): string {\n    return `<File name=\"${this.fileName}\" mimeType=\"(${this.contentType})\" size=\"${this.buffer.length}\" />`;\n  }\n}\n"
  },
  {
    "path": "libs/http/src/lib/http.spec.ts",
    "content": "import * as http from 'http';\nimport { Http } from './http';\n\nclass TestServer {\n  private server: http.Server;\n\n  constructor() {\n    this.server = http.createServer((req, res) => {\n      if (req.url === '/test') {\n        res.write('hello');\n        res.end();\n        return;\n      }\n\n      if (req.url === '/redirect') {\n        res.writeHead(302, {\n          Location: 'http://localhost:3000/test',\n        });\n        res.end();\n        return;\n      }\n\n      if (req.url === '/json') {\n        res.setHeader('Content-Type', 'application/json');\n        res.end(JSON.stringify({ test: 'hello' }));\n      }\n    });\n  }\n\n  public start(): Promise<void> {\n    return new Promise((resolve) => {\n      this.server.listen(3000, resolve);\n    });\n  }\n\n  public stop(): void {\n    this.server.close();\n  }\n}\n\nconst server = new TestServer();\n\nbeforeAll(() => server.start());\nafterAll(() => server.stop());\n\ndescribe('http', () => {\n  it('should retrieve server response', async () => {\n    const res = await Http.get<string>('http://localhost:3000/test', {\n      partition: 'test',\n    });\n\n    expect(res).toBeTruthy();\n    expect(res.body).toBe('hello');\n  });\n\n  it('should follow redirect', async () => {\n    const res = await Http.get<string>('http://localhost:3000/redirect', {\n      partition: 'test',\n    });\n\n    expect(res).toBeTruthy();\n    expect(res.body).toBe('hello');\n    expect(res.responseUrl).toBe('http://localhost:3000/test');\n  });\n\n  it('should parse json', async () => {\n    const res = await Http.get<{ test: string }>('http://localhost:3000/json', {\n      partition: 'test',\n    });\n\n    expect(res).toBeTruthy();\n    expect(res.body).toEqual({ test: 'hello' });\n  });\n});\n"
  },
  {
    "path": "libs/http/src/lib/http.ts",
    "content": "/* eslint-disable no-console */\nimport { Logger } from '@nestjs/common';\nimport { trackDependency, trackException } from '@postybirb/logger';\nimport {\n  BrowserWindow,\n  ClientRequest,\n  ClientRequestConstructorOptions,\n  net,\n  session,\n} from 'electron';\nimport FormData from 'form-data';\nimport urlEncoded from 'form-urlencoded';\nimport { encode as encodeQueryString } from 'querystring';\nimport { FormFile } from './form-file';\n\n// https://www.electronjs.org/docs/api/client-request#instance-methods\nconst RESTRICTED_HEADERS: string[] = [\n  'Content-Length',\n  'Host',\n  'Trailer',\n  'TE',\n  'Upgrade',\n  'Cookie2',\n  'Keep-Alive',\n  'Transfer-Encoding',\n];\n\n// Note: Unsure if gzip actually does anything through this method.\n// Electron might deflate automatically.\nconst DEFAULT_HEADERS: Record<string, string> = {\n  'Accept-Encoding': 'gzip, deflate, br',\n};\n\nexport interface HttpRequestOptions {\n  // Skips adding index to url encoded data for arrays\n  skipUrlEncodedIndexing?: boolean;\n}\n\ninterface HttpOptions {\n  headers?: Record<string, string>;\n  queryParameters?: Record<\n    string,\n    | string\n    | number\n    | boolean\n    | readonly string[]\n    | readonly number[]\n    | readonly boolean[]\n  >;\n  partition?: string | undefined;\n  options?: HttpRequestOptions;\n}\n\nexport interface PostOptions extends HttpOptions {\n  type: 'multipart' | 'json' | 'urlencoded';\n  data: Record<string, unknown>;\n  /**\n   * When true, sends the request via Electron's BrowserWindow.loadURL\n   * with raw data bytes instead of using net.request ClientRequest.\n   * Useful for websites that require browser-like form submissions.\n   */\n  uploadAsRawData?: boolean;\n}\n\ninterface BinaryPostOptions extends HttpOptions {\n  type: 'binary';\n  data: Buffer;\n  /**\n   * When true, sends the request via Electron's BrowserWindow.loadURL\n   * with raw data bytes instead of using net.request ClientRequest.\n   */\n  uploadAsRawData?: boolean;\n}\n\nexport interface HttpResponse<T> {\n  body: T;\n  statusCode: number;\n  statusMessage: string;\n  responseUrl: string;\n}\n\ninterface CreateBodyData {\n  contentType: string;\n  buffer: Buffer;\n}\n\nfunction getPartitionKey(partition: string): string {\n  return `persist:${partition}`;\n}\n\n/**\n * Http module that wraps around Electron's {ClientRequest} and {net}.\n * Tracks all HTTP requests to Application Insights for monitoring.\n *\n * @class Http\n */\nexport class Http {\n  private static logger: Logger = new Logger(Http.name);\n\n  /**\n   * Tracks an HTTP request as a dependency in Application Insights\n   * This populates the Application Map and dependency views\n   * Creates a hierarchical structure: postybirb-app -> hostname -> method + pathname\n   */\n  private static trackHttpDependency(\n    method: string,\n    url: string,\n    statusCode: number,\n    duration: number,\n    success: boolean,\n    error?: Error,\n  ): void {\n    try {\n      const urlObj = new URL(url);\n      const target = urlObj.origin;\n      const name = `${method} ${urlObj.pathname}`;\n\n      // Track as HTTP dependency\n      // Using origin as the target creates sub-dependency trees\n      // origin -> method + pathname hierarchy in Application Map\n      trackDependency(\n        name,\n        target,\n        'HTTP',\n        url.substring(0, 500), // Full URL as data\n        duration,\n        success,\n        statusCode,\n        {\n          method,\n          domain: target,\n          hasError: error ? 'true' : 'false',\n        },\n      );\n\n      // Track exception separately if request failed\n      if (error || statusCode >= 400) {\n        trackException(error ?? new Error(`Status ${statusCode}`), {\n          source: 'http-dependency',\n          method,\n          url: url.substring(0, 200),\n          domain: target,\n          statusCode: String(statusCode),\n        });\n      }\n    } catch (trackingError) {\n      // Don't let tracking errors break the actual HTTP request\n      console.error('Error tracking HTTP dependency:', trackingError);\n    }\n  }\n\n  private static createClientRequest(\n    options: HttpOptions,\n    crOptions: ClientRequestConstructorOptions & { url: string },\n  ): ClientRequest {\n    const clientRequestOptions: ClientRequestConstructorOptions & {\n      url: string;\n    } = {\n      ...crOptions,\n    };\n\n    // Enforced Options\n    // clientRequestOptions.protocol = 'https';\n\n    if (options.partition && options.partition.trim().length) {\n      clientRequestOptions.useSessionCookies = true;\n      clientRequestOptions.partition = getPartitionKey(options.partition);\n    }\n\n    if (options.queryParameters) {\n      const url = new URL(clientRequestOptions.url);\n      url.search = new URLSearchParams(\n        encodeQueryString(options.queryParameters),\n      ).toString();\n      clientRequestOptions.url = url.toString();\n    }\n\n    const req = net.request(clientRequestOptions);\n    if (\n      clientRequestOptions.method === 'POST' ||\n      clientRequestOptions.method === 'PATCH' ||\n      clientRequestOptions.method === 'PUT'\n    ) {\n      if ((options as PostOptions).type === 'multipart') {\n        req.chunkedEncoding = true;\n      }\n\n      Object.entries(DEFAULT_HEADERS).forEach(([key, value]) => {\n        req.setHeader(key, value);\n      });\n    }\n\n    if (options.headers) {\n      Object.entries(options.headers).forEach(([headerKey, headerValue]) => {\n        if (RESTRICTED_HEADERS.includes(headerKey)) {\n          Http.logger.error(\n            `Not allowed to set header: ${headerKey} [https://www.electronjs.org/docs/api/client-request#instance-methods]`,\n          );\n          throw new Error(`Not allowed to set header: ${headerKey}`);\n        }\n\n        req.setHeader(headerKey, headerValue);\n      });\n    }\n\n    return req;\n  }\n\n  private static createPostBody(\n    options: PostOptions | BinaryPostOptions,\n  ): CreateBodyData {\n    const { data, type, options: httpOptions } = options;\n    switch (type) {\n      case 'json': {\n        return {\n          contentType: 'application/json',\n          buffer: Buffer.from(JSON.stringify(data)),\n        };\n      }\n\n      case 'urlencoded': {\n        const skipIndex = httpOptions?.skipUrlEncodedIndexing ?? false;\n        return {\n          contentType: 'application/x-www-form-urlencoded',\n          buffer: Buffer.from(urlEncoded(data, { skipIndex })),\n        };\n      }\n\n      case 'binary':\n        return {\n          contentType: 'application/octet-stream',\n          buffer: data as Buffer,\n        };\n\n      case 'multipart': {\n        const form = new FormData();\n        // eslint-disable-next-line @typescript-eslint/no-explicit-any\n        Object.entries(data).forEach(([key, value]: [string, any]) => {\n          if (value === undefined || value === null) {\n            // form.append(key, '');\n            return;\n          }\n\n          if (value instanceof FormFile) {\n            form.append(key, value.buffer, value.fileOptions);\n          } else if (Array.isArray(value)) {\n            value.forEach((v) => {\n              // handle file objects\n              if (v instanceof FormFile) {\n                form.append(key, v.buffer, v.fileOptions);\n              } else {\n                form.append(key, v);\n              }\n            });\n          } else {\n            form.append(key, value.toString());\n          }\n        });\n\n        return {\n          contentType: form.getHeaders()['content-type'],\n          buffer: form.getBuffer(),\n        };\n      }\n\n      default: {\n        throw new Error(`Unknown post type: ${type}`);\n      }\n    }\n  }\n\n  private static handleError(\n    req: ClientRequest,\n    reject: (reason?: Error) => void,\n  ): void {\n    req.on('error', (err: Error) => {\n      Http.logger.error(err);\n      reject(err);\n    });\n  }\n\n  private static handleResponse<T>(\n    url: string,\n    req: ClientRequest,\n    resolve: (value: HttpResponse<T> | PromiseLike<HttpResponse<T>>) => void,\n    reject: (reason?: Error) => void,\n  ): void {\n    let responseUrl: undefined | string;\n\n    req.on('redirect', (statusCode, method, redirectUrl, responseHeaders) => {\n      responseUrl = redirectUrl;\n    });\n\n    req.on('response', (response) => {\n      const { headers, statusCode, statusMessage } = response;\n\n      const chunks: Buffer[] = [];\n\n      response.on('error', (err: Error) => {\n        Http.logger.error(err);\n        reject(err);\n      });\n\n      response.on('aborted', () => {\n        Http.logger.warn(`Request to ${url} aborted`);\n        reject(new Error(`Request to ${url} aborted`));\n      });\n\n      response.on('end', () => {\n        const message = Buffer.concat(chunks);\n\n        let body: T | string = message.toString();\n        if (\n          headers['content-type'] &&\n          (headers['content-type'].includes('application/json') ||\n            headers['content-type'].includes('application/vnd.api+json'))\n        ) {\n          try {\n            body = JSON.parse(body) as T;\n          } catch {\n            Http.logger.warn(\n              `Unable to parse application/json to object.\\nUrl:${url}\\nBody: ${body}`,\n            );\n          }\n        }\n\n        return resolve({\n          statusCode,\n          statusMessage,\n          body: body as T,\n          responseUrl: responseUrl as unknown as string,\n        });\n      });\n\n      response.on('data', (chunk) => {\n        chunks.push(chunk);\n      });\n    });\n  }\n\n  static getUserAgent(appVersion: string): string {\n    return `PostyBirb/${appVersion}`;\n  }\n\n  /**\n   * Gets the cookies for a given URL.\n   *\n   * @static\n   * @param {string} partitionId\n   * @param {string} url\n   * @return {*}  {Promise<Electron.Cookie[]>}\n   */\n  static async getWebsiteCookies(\n    partitionId: string,\n    url: string,\n  ): Promise<Electron.Cookie[]> {\n    return session.fromPartition(`persist:${partitionId}`).cookies.get({\n      url: new URL(url).origin,\n    });\n  }\n\n  /**\n   * Creates a GET method request.\n   *\n   * @param url\n   * @param options\n   * @param crOptions\n   */\n  static async get<T>(\n    url: string,\n    options: HttpOptions,\n    crOptions?: ClientRequestConstructorOptions,\n  ): Promise<HttpResponse<T>> {\n    if (!net.isOnline()) {\n      return Promise.reject(new Error('No internet connection.'));\n    }\n\n    const startTime = Date.now();\n    let statusCode = 0;\n    let success = false;\n    let error: Error | undefined;\n\n    try {\n      const response = await new Promise<HttpResponse<T>>((resolve, reject) => {\n        const req = Http.createClientRequest(options, {\n          ...(crOptions ?? {}),\n          url,\n        });\n        Http.handleError(req, reject);\n        Http.handleResponse(url, req, resolve, reject);\n        req.end();\n      });\n\n      const { body } = response;\n      statusCode = response.statusCode ?? 0;\n      success = statusCode >= 200 && statusCode < 400;\n\n      if (typeof body === 'string' && Http.isOnCloudFlareChallengePage(body)) {\n        console.log('Cloudflare detected. Attempting to bypass...');\n        return await Http.performBrowserWindowGetRequest<T>(\n          url,\n          options,\n          crOptions,\n        );\n      }\n      return response;\n    } catch (err) {\n      error = err instanceof Error ? err : new Error(String(err));\n      throw err;\n    } finally {\n      const duration = Date.now() - startTime;\n      Http.trackHttpDependency(\n        'GET',\n        url,\n        statusCode,\n        duration,\n        success,\n        error,\n      );\n    }\n  }\n\n  /**\n   * Creates a POST method request.\n   *\n   * @param url\n   * @param options\n   * @param crOptions\n   */\n  static async post<T>(\n    url: string,\n    options: PostOptions | BinaryPostOptions,\n    crOptions?: ClientRequestConstructorOptions,\n  ): Promise<HttpResponse<T>> {\n    return Http.postLike('post', url, options, crOptions ?? {});\n  }\n\n  /**\n   * Creates a PATCH method request.\n   *\n   * @param url\n   * @param options\n   * @param crOptions\n   */\n  static patch<T>(\n    url: string,\n    options: PostOptions,\n    crOptions?: ClientRequestConstructorOptions,\n  ): Promise<HttpResponse<T>> {\n    return Http.postLike('patch', url, options, crOptions ?? {});\n  }\n\n  /**\n   * Creates a PUT method request.\n   *\n   * @param url\n   * @param options\n   * @param crOptions\n   */\n  static put<T>(\n    url: string,\n    options: PostOptions | BinaryPostOptions,\n    crOptions?: ClientRequestConstructorOptions,\n  ): Promise<HttpResponse<T>> {\n    return Http.postLike('put', url, options, crOptions ?? {});\n  }\n\n  private static async postLike<T>(\n    method: 'post' | 'patch' | 'put',\n    url: string,\n    options: PostOptions | BinaryPostOptions,\n    crOptions: ClientRequestConstructorOptions,\n  ): Promise<HttpResponse<T>> {\n    if (!net.isOnline()) {\n      return Promise.reject(new Error('No internet connection.'));\n    }\n\n    const startTime = Date.now();\n    let statusCode = 0;\n    let success = false;\n    let error: Error | undefined;\n\n    try {\n      // When uploadAsRawData is set, bypass net.request and send via\n      // BrowserWindow.loadURL with the body as raw bytes.\n      if (options.uploadAsRawData) {\n        const response = await Http.performBrowserWindowPostRequest<T>(\n          url,\n          options,\n          crOptions ?? {},\n        );\n        statusCode = response.statusCode ?? 0;\n        success = statusCode >= 200 && statusCode < 400;\n        return response;\n      }\n\n      const response = await new Promise<HttpResponse<T>>((resolve, reject) => {\n        const req = Http.createClientRequest(options, {\n          ...(crOptions ?? {}),\n          url,\n          method,\n        });\n        Http.handleError(req, reject);\n        Http.handleResponse(url, req, resolve, reject);\n\n        const { contentType, buffer } = Http.createPostBody(options);\n        req.setHeader('Content-Type', contentType);\n        req.write(buffer);\n        req.end();\n      });\n\n      const { body } = response;\n      statusCode = response.statusCode ?? 0;\n      success = statusCode >= 200 && statusCode < 400;\n\n      if (typeof body === 'string' && Http.isOnCloudFlareChallengePage(body)) {\n        console.log('Cloudflare detected. Attempting to bypass...');\n        return await Http.performBrowserWindowPostRequest<T>(\n          url,\n          options,\n          crOptions,\n        );\n      }\n      return response;\n    } catch (err) {\n      error = err instanceof Error ? err : new Error(String(err));\n      throw err;\n    } finally {\n      const duration = Date.now() - startTime;\n      Http.trackHttpDependency(\n        method.toUpperCase(),\n        url,\n        statusCode,\n        duration,\n        success,\n        error,\n      );\n    }\n  }\n\n  private static async performBrowserWindowGetRequest<T>(\n    url: string,\n    options: HttpOptions,\n    crOptions?: ClientRequestConstructorOptions,\n  ): Promise<HttpResponse<T>> {\n    const window = new BrowserWindow({\n      show: false,\n      webPreferences: {\n        partition: options.partition\n          ? getPartitionKey(options.partition)\n          : undefined,\n      },\n    });\n\n    try {\n      await window.loadURL(url);\n      return await Http.handleCloudFlareChallengePage<T>(window);\n    } catch (err) {\n      console.error(err);\n      return await Promise.reject(err);\n    } finally {\n      window.destroy();\n    }\n  }\n\n  private static async performBrowserWindowPostRequest<T>(\n    url: string,\n    options: PostOptions | BinaryPostOptions,\n    crOptions: ClientRequestConstructorOptions,\n  ): Promise<HttpResponse<T>> {\n    const { contentType, buffer } = Http.createPostBody(options);\n    const headers = Object.entries({\n      ...(options.headers ?? {}),\n      'Content-Type': contentType,\n    })\n      .map(([key, value]) => `${key}: ${value}`)\n      .join('\\n');\n\n    const window = new BrowserWindow({\n      show: false,\n      webPreferences: {\n        partition: options.partition\n          ? getPartitionKey(options.partition)\n          : undefined,\n      },\n    });\n\n    try {\n      await window.loadURL(url, {\n        extraHeaders: headers,\n        postData: [\n          {\n            type: 'rawData',\n            bytes: buffer,\n          },\n        ],\n      });\n      return await Http.handleCloudFlareChallengePage<T>(window);\n    } catch (err) {\n      console.error(err);\n      return await Promise.reject(err);\n    } finally {\n      window.destroy();\n    }\n  }\n\n  private static isOnCloudFlareChallengePage(html: string): boolean {\n    if (\n      html.includes('challenge-error-title') ||\n      html.includes('<title>Just a moment...</title>')\n    ) {\n      return true;\n    }\n    return false;\n  }\n\n  private static async awaitCloudFlareChallengePage(\n    window: BrowserWindow,\n  ): Promise<void> {\n    const checkInterval = 1000; // 1 second\n\n    let isShown = false;\n    for (let i = 0; i < 60; i++) {\n      await Http.awaitCheckInterval(checkInterval);\n      const html = await window.webContents.executeJavaScript(\n        'document.body.parentElement.innerHTML',\n      );\n      if (i >= 3 && !isShown) {\n        // Try to let it solve itself for 3 seconds before showing the window.\n        window.show();\n        window.focus();\n        isShown = true;\n      }\n      if (!Http.isOnCloudFlareChallengePage(html)) {\n        return;\n      }\n    }\n\n    throw new Error('Unable to bypass Cloudflare challenge.');\n  }\n\n  private static async awaitCheckInterval(interval: number): Promise<void> {\n    return new Promise<void>((resolve) => {\n      setTimeout(() => {\n        resolve();\n      }, interval);\n    });\n  }\n\n  private static async handleCloudFlareChallengePage<T>(\n    window: BrowserWindow,\n  ): Promise<HttpResponse<T>> {\n    let html = await window.webContents.executeJavaScript(\n      'document.body.parentElement.innerHTML',\n    );\n\n    if (Http.isOnCloudFlareChallengePage(html)) {\n      await Http.awaitCloudFlareChallengePage(window);\n      html = await window.webContents.executeJavaScript(\n        'document.body.parentElement.innerHTML',\n      );\n    }\n\n    const text = await window.webContents.executeJavaScript(\n      'document.body.innerText',\n    );\n    const pageUrl = await window.webContents.executeJavaScript(\n      'window.location.href',\n    );\n\n    let rValue = html;\n    if (text.startsWith('{') && text.endsWith('}')) {\n      try {\n        rValue = JSON.parse(text);\n      } catch (err) {\n        console.error(pageUrl, text, err);\n      }\n    }\n\n    return Promise.resolve({\n      body: rValue as unknown as T,\n      statusCode: 200,\n      statusMessage: 'OK',\n      responseUrl: pageUrl,\n    });\n  }\n}\n"
  },
  {
    "path": "libs/http/src/lib/proxy.ts",
    "content": "// Electron has its own network stack with excellent proxy support\n// Nodejs has its own stack without it, and thus we need to patch fetch\n// and axios to use network stack instead\n// @postybirb/http library uses electron network stack so no additional\n// configuration there is required\n\n// To configure proxy use electron proxy settings (env or command line)\n\nimport { app, net, session } from 'electron';\n\nimport http from 'node:http';\nimport nodeNet from 'node:net';\nimport nodeTLS from 'node:tls';\n\nimport { Agent, AgentConnectOpts } from 'agent-base';\nimport { HttpProxyAgent } from 'http-proxy-agent';\nimport { HttpsProxyAgent } from 'https-proxy-agent';\nimport { SocksProxyAgent } from 'socks-proxy-agent';\n\n// eslint-disable-next-line import/no-extraneous-dependencies\nimport axios from 'axios';\nimport { format } from 'node:url';\n\n// Monkey-patch global fetch to use electron-fetch\nglobal.fetch = async (\n  input: RequestInfo | URL,\n  init?: RequestInit,\n): Promise<Response> => {\n  // Handle Request objects properly - extract the URL string and merge options.\n  // Libraries like @atproto/xrpc pass a Request object to fetch(), and calling\n  // .toString() on a Request returns \"[object Request]\" instead of the URL.\n  if (input instanceof Request) {\n    // If no separate init was provided, pass the Request itself as init\n    // so headers, method, body, etc. are preserved\n    if (!init) {\n      return net.fetch(input.url, input);\n    }\n    // If init was provided, it takes precedence (per fetch spec)\n    return net.fetch(input.url, init);\n  }\n\n  // For string or URL inputs, convert to string\n  return net.fetch(input.toString(), init);\n};\n\nexport async function getParsedProxiesFor(url: string) {\n  const proxySources = await session.defaultSession.resolveProxy(url);\n  if (proxySources === 'DIRECT') return [];\n\n  // proxyUrl Example:\n  // PROXY 127.0.0.1:2080; DIRECT\n  // SOCKS 127.0.0.1:1080; PROXY 127.0.0.1:1010; DIRECT\n  const types = proxySources\n    .split(';')\n    .map((section) => {\n      try {\n        const [type, proxyUrl] = section.split(' ', 2);\n        const parsed = new URL(\n          proxyUrl.includes('://') ? proxyUrl : `scheme://${proxyUrl}`,\n        );\n        return { type, hostname: parsed.hostname, port: parsed.port };\n      } catch (error) {\n        // eslint-disable-next-line no-console\n        console.error(\n          'Parsing proxy failed',\n          error,\n          'proxySources',\n          proxySources,\n          'section',\n          section,\n        );\n        return false;\n      }\n    })\n    .filter((e) => e !== false);\n\n  return types as ((typeof types)[number] | undefined)[];\n}\n\nclass ElectronProxyAgent extends Agent {\n  private agentsCache = new Map<string, Agent>();\n\n  async connect(req: http.ClientRequest, options: AgentConnectOpts) {\n    const secure = options.secureEndpoint;\n\n    // It includes only the first part of the url, excluding path and search params\n    const url = format(req);\n\n    let agent = this.agentsCache.get(url);\n    if (!agent) {\n      agent = await this.getAgent(url, secure);\n      this.agentsCache.set(url, agent);\n    }\n\n    return agent.connect(req, options);\n  }\n\n  private async getAgent(url: string, secure: boolean): Promise<Agent> {\n    const proxy = (await getParsedProxiesFor(url))[0];\n    const type = proxy?.type ?? 'DIRECT';\n    const proxyHostname = proxy?.hostname;\n\n    switch (type) {\n      case 'DIRECT':\n        if (secure) return nodeTLS as unknown as Agent;\n        return nodeNet as unknown as Agent;\n\n      case 'SOCKS':\n      case 'SOCKS5':\n        return new SocksProxyAgent(`socks://${proxyHostname}`);\n\n      case 'PROXY':\n      case 'HTTPS': {\n        const proxyURL = `${type === 'HTTPS' ? 'https' : 'http'}://${proxyHostname}`;\n        if (secure) return new HttpsProxyAgent(proxyURL);\n        return new HttpProxyAgent(proxyURL);\n      }\n      default:\n        throw new Error(`Unknown proxy type: ${type}`);\n    }\n  }\n}\n\napp.on('ready', () => {\n  // Configure axios default instance\n  const httpsAgent = new ElectronProxyAgent();\n\n  axios.defaults.httpAgent = httpsAgent;\n  axios.defaults.httpsAgent = httpsAgent;\n});\n"
  },
  {
    "path": "libs/http/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.base.json\",\n  \"compilerOptions\": {\n    \"strict\": true\n  },\n  \"files\": [],\n  \"include\": [],\n  \"references\": [\n    {\n      \"path\": \"./tsconfig.lib.json\"\n    },\n    {\n      \"path\": \"./tsconfig.spec.json\"\n    }\n  ]\n}\n"
  },
  {
    "path": "libs/http/tsconfig.lib.json",
    "content": "{\n  \"extends\": \"./tsconfig.json\",\n  \"compilerOptions\": {\n    \"module\": \"commonjs\",\n    \"outDir\": \"../../dist/out-tsc\",\n    \"declaration\": true,\n    \"types\": [\"node\"]\n  },\n  \"exclude\": [\"**/*.spec.ts\", \"**/*.test.ts\", \"jest.config.ts\"],\n  \"include\": [\"**/*.ts\"]\n}\n"
  },
  {
    "path": "libs/http/tsconfig.spec.json",
    "content": "{\n  \"extends\": \"./tsconfig.json\",\n  \"compilerOptions\": {\n    \"outDir\": \"../../dist/out-tsc\",\n    \"module\": \"commonjs\",\n    \"types\": [\"jest\", \"node\"]\n  },\n  \"include\": [\n    \"**/*.spec.ts\",\n    \"**/*.test.ts\",\n    \"**/*.spec.tsx\",\n    \"**/*.test.tsx\",\n    \"**/*.spec.js\",\n    \"**/*.test.js\",\n    \"**/*.spec.jsx\",\n    \"**/*.test.jsx\",\n    \"**/*.d.ts\",\n    \"jest.config.ts\"\n  ]\n}\n"
  },
  {
    "path": "libs/logger/.eslintrc.json",
    "content": "{\n  \"extends\": [\"../../.eslintrc.js\"],\n  \"ignorePatterns\": [\"!**/*\"],\n  \"overrides\": [\n    {\n      \"files\": [\"*.ts\", \"*.tsx\", \"*.js\", \"*.jsx\"],\n      \"rules\": {}\n    },\n    {\n      \"files\": [\"*.ts\", \"*.tsx\"],\n      \"rules\": {}\n    },\n    {\n      \"files\": [\"*.js\", \"*.jsx\"],\n      \"rules\": {}\n    }\n  ]\n}\n"
  },
  {
    "path": "libs/logger/README.md",
    "content": "# logger\n\nCommon non-browser logging wrapper around Winstorm\n"
  },
  {
    "path": "libs/logger/jest.config.ts",
    "content": "/* eslint-disable */\nexport default {\n  displayName: 'logger',\n  preset: '../../jest.preset.js',\n  globals: {},\n  testEnvironment: 'node',\n  moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],\n  coverageDirectory: '../../coverage/libs/logger',\n  runner: '@kayahr/jest-electron-runner/main',\n};\n"
  },
  {
    "path": "libs/logger/project.json",
    "content": "{\n  \"name\": \"logger\",\n  \"$schema\": \"../../node_modules/nx/schemas/project-schema.json\",\n  \"sourceRoot\": \"libs/logger/src\",\n  \"projectType\": \"library\",\n  \"targets\": {\n    \"lint\": {\n      \"executor\": \"@nx/eslint:lint\",\n      \"outputs\": [\"{options.outputFile}\"]\n    },\n    \"test\": {\n      \"executor\": \"@nx/jest:jest\",\n      \"outputs\": [\"{workspaceRoot}/coverage/libs/logger\"],\n      \"options\": {\n        \"jestConfig\": \"libs/logger/jest.config.ts\"\n      }\n    }\n  },\n  \"tags\": []\n}\n"
  },
  {
    "path": "libs/logger/src/index.ts",
    "content": "export * from './lib/app-insights';\nexport * from './lib/logger';\nexport * from './lib/winston-appinsights-transport';\n"
  },
  {
    "path": "libs/logger/src/lib/app-insights.ts",
    "content": "import * as appInsights from 'applicationinsights';\n// eslint-disable-next-line import/no-extraneous-dependencies\nimport { diag, DiagConsoleLogger, DiagLogLevel } from '@opentelemetry/api';\n\nlet client: appInsights.TelemetryClient | null = null;\nlet isInitialized = false;\nlet diagInitialized = false;\n\nexport interface AppInsightsConfig {\n  connectionString?: string;\n  instrumentationKey?: string;\n  enabled: boolean;\n  cloudRole?: string;\n  appVersion?: string;\n}\n\nconst appInsightsConnectionString: string | null = null;\n\n/**\n * Initialize Application Insights\n * Call this once during application startup\n * Subsequent calls will update the cloud role only\n */\nexport function initializeAppInsights(config: AppInsightsConfig): void {\n  // eslint-disable-next-line no-param-reassign\n  config.connectionString = appInsightsConnectionString ?? undefined;\n  // eslint-disable-next-line no-param-reassign\n  config.cloudRole = 'postybirb';\n  if (\n    !config.enabled ||\n    (!config.connectionString && !config.instrumentationKey)\n  ) {\n    // eslint-disable-next-line no-console\n    console.log('Application Insights is disabled or not configured');\n    return;\n  }\n\n  if (isInitialized && client) {\n    return;\n  }\n\n  try {\n    // Enable OpenTelemetry diagnostic logging to capture SDK initialization errors\n    // This helps debug silent failures in the Application Insights SDK v3\n    if (!diagInitialized) {\n      diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.WARN);\n      diagInitialized = true;\n    }\n\n    // Disable metrics to avoid AzureMonitorMetricExporter initialization issues\n    // This is a workaround for a bug in applicationinsights v3.x where the\n    // metrics exporter fails to initialize due to version incompatibilities\n    // process.env.APPLICATION_INSIGHTS_NO_STANDARD_METRICS = 'true';\n\n    // For Application Insights SDK v3 (OpenTelemetry-based), we need to set\n    // OTEL_RESOURCE_ATTRIBUTES to properly configure cloud role name and instance\n    // This must be set BEFORE calling setup()\n    const cloudRoleName = config.cloudRole || 'postybirb';\n    const cloudRoleInstance = 'postybirb-app';\n\n    // Build the OTEL resource attributes\n    // service.name maps to cloud_RoleName\n    // service.instance.id maps to cloud_RoleInstance\n    const resourceAttributes = [\n      `service.name=${cloudRoleName}`,\n      `service.instance.id=${cloudRoleInstance}`,\n    ];\n\n    if (config.appVersion) {\n      resourceAttributes.push(`service.version=${config.appVersion}`);\n    }\n\n    // Set the environment variable that OpenTelemetry uses\n    process.env.OTEL_RESOURCE_ATTRIBUTES = resourceAttributes.join(',');\n\n    appInsights\n      .setup(config.connectionString || config.instrumentationKey)\n      .setAutoDependencyCorrelation(false)\n      .setAutoCollectRequests(true)\n      .setAutoCollectPerformance(false, false) // Disable extended metrics\n      .setAutoCollectExceptions(true)\n      .setAutoCollectDependencies(false)\n      .setAutoCollectConsole(false) // We'll use Winston transport instead\n      .setUseDiskRetryCaching(true)\n      .setSendLiveMetrics(false)\n      .setDistributedTracingMode(\n        appInsights.DistributedTracingModes.AI_AND_W3C,\n      );\n\n    // Start the Application Insights client\n    appInsights.start();\n    client = appInsights.defaultClient;\n\n    // Verify the client is properly initialized\n    // The SDK v3 has a bug where _logApi can be undefined if internal init fails\n    if (client) {\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any, no-underscore-dangle\n      const clientAny = client as any;\n      // eslint-disable-next-line no-underscore-dangle\n      if (!clientAny._logApi) {\n        // eslint-disable-next-line no-console\n        console.error(\n          'Application Insights client exists but _logApi is undefined - SDK initialization failed silently',\n        );\n        /* eslint-disable no-console, no-underscore-dangle */\n        console.error('Client internal state:', {\n          _isInitialized: clientAny._isInitialized,\n          _options: clientAny._options ? 'present' : 'missing',\n          hasConfig: !!clientAny.config,\n          configConnectionString: clientAny.config?.connectionString\n            ? 'present'\n            : 'missing',\n        });\n        /* eslint-enable no-console, no-underscore-dangle */\n        // Don't set isInitialized - the client is broken\n        client = null;\n        return;\n      }\n    } else {\n      // eslint-disable-next-line no-console\n      console.error('Application Insights defaultClient is null after start()');\n      return;\n    }\n\n    isInitialized = true;\n\n    // eslint-disable-next-line no-console\n    console.log(\n      `Application Insights initialized for ${cloudRoleName} (instance: ${cloudRoleInstance})`,\n    );\n  } catch (error) {\n    // eslint-disable-next-line no-console\n    console.error('Failed to initialize Application Insights:', error);\n  }\n}\n\n/**\n * Get the Application Insights client\n */\nexport function getAppInsightsClient(): appInsights.TelemetryClient | null {\n  return client;\n}\n\n/**\n * Check if Application Insights is initialized\n */\nexport function isAppInsightsInitialized(): boolean {\n  return isInitialized && client !== null;\n}\n\n/**\n * Track a custom event\n */\nexport function trackEvent(\n  name: string,\n  properties?: { [key: string]: string },\n  measurements?: { [key: string]: number },\n): void {\n  if (client) {\n    try {\n      client.trackEvent({\n        name,\n        properties,\n        measurements,\n      });\n    } catch (error) {\n      // Telemetry should never break the application\n      // eslint-disable-next-line no-console\n      console.warn('Failed to track event:', error);\n    }\n  }\n}\n\n/**\n * Track an exception\n */\nexport function trackException(\n  error: Error,\n  properties?: { [key: string]: string },\n): void {\n  if (client) {\n    try {\n      client.trackException({\n        exception: error,\n        properties,\n      });\n    } catch (err) {\n      // Telemetry should never break the application\n      // eslint-disable-next-line no-console\n      console.warn('Failed to track exception:', err);\n    }\n  }\n}\n\n/**\n * Track a metric\n */\nexport function trackMetric(\n  name: string,\n  value: number,\n  properties?: { [key: string]: string },\n): void {\n  if (client) {\n    try {\n      client.trackMetric({\n        name,\n        value,\n        properties,\n      });\n    } catch (error) {\n      // Telemetry should never break the application\n      // eslint-disable-next-line no-console\n      console.warn('Failed to track metric:', error);\n    }\n  }\n}\n\n/**\n * Track a trace/log message\n */\nexport function trackTrace(\n  message: string,\n  properties?: { [key: string]: string },\n): void {\n  if (client) {\n    try {\n      client.trackTrace({\n        message,\n        properties,\n      });\n    } catch (error) {\n      // Telemetry should never break the application\n      // eslint-disable-next-line no-console\n      console.warn('Failed to track trace:', error);\n    }\n  }\n}\n\n/**\n * Track a dependency (HTTP call, database query, etc.)\n * This populates the Application Map and Dependency views in App Insights\n */\nexport function trackDependency(\n  name: string,\n  target: string,\n  dependencyTypeName: string,\n  data: string,\n  duration: number,\n  success: boolean,\n  resultCode?: number,\n  properties?: { [key: string]: string },\n): void {\n  if (client) {\n    try {\n      client.trackDependency({\n        name,\n        dependencyTypeName,\n        target,\n        data,\n        duration,\n        success,\n        resultCode,\n        properties,\n      });\n    } catch (error) {\n      // Telemetry should never break the application\n      // eslint-disable-next-line no-console\n      console.warn('Failed to track dependency:', error);\n    }\n  }\n}\n\n/**\n * Flush any pending telemetry\n */\nexport function flushAppInsights(): Promise<void> {\n  return new Promise((resolve) => {\n    if (client) {\n      try {\n        client.flush();\n      } catch (error) {\n        // Telemetry should never break the application\n        // eslint-disable-next-line no-console\n        console.warn('Failed to flush telemetry:', error);\n      }\n      // Give it a moment to flush\n      setTimeout(() => resolve(), 100);\n    } else {\n      resolve();\n    }\n  });\n}\n"
  },
  {
    "path": "libs/logger/src/lib/logger.ts",
    "content": "import { PostyBirbDirectories } from '@postybirb/fs';\nimport { LogLayer, LoggerType } from 'loglayer';\nimport * as winston from 'winston';\nimport { Logger as WinstonLogger } from 'winston';\nimport DailyRotateFile from 'winston-daily-rotate-file';\nimport { SerializeLog } from './serialize-log';\nimport { AppInsightsTransport } from './winston-appinsights-transport';\n\nexport type PostyBirbLogger = LogLayer<WinstonLogger>;\n\nlet log: PostyBirbLogger;\n\nexport function Logger(prefix?: string): PostyBirbLogger {\n  initializeLogger();\n\n  if (prefix) {\n    return log.withPrefix(`[${prefix}]`);\n  }\n\n  return log.child();\n}\n\nexport function initializeLogger(): void {\n  if (log) return;\n\n  let instance: WinstonLogger;\n\n  if (process.env.NODE_ENV === 'test') {\n    instance = winston.createLogger({\n      format: new SerializeLog(),\n      transports: [\n        new winston.transports.Console({ level: 'error', forceConsole: true }),\n      ],\n    });\n  } else {\n    const fileTransport = new DailyRotateFile({\n      filename: 'postybirb-%DATE%.log',\n      datePattern: 'YYYY-MM-DD',\n      zippedArchive: false,\n      maxSize: '20m',\n      maxFiles: '14d',\n      format: winston.format.combine(\n        new SerializeLog(),\n        winston.format.uncolorize(),\n      ),\n      dirname: PostyBirbDirectories.LOGS_DIRECTORY,\n    });\n\n    const consoleTransport = new winston.transports.Console({\n      format: new SerializeLog(),\n    });\n\n    instance = winston.createLogger({\n      level: 'debug',\n      transports: [consoleTransport, fileTransport],\n    });\n    instance.add(new AppInsightsTransport({ level: 'error' }));\n  }\n\n  log = new LogLayer({\n    logger: { instance, type: LoggerType.WINSTON },\n  });\n}\n"
  },
  {
    "path": "libs/logger/src/lib/serialize-log.ts",
    "content": "/* eslint-disable no-param-reassign */\nimport util from 'util';\nimport winston from 'winston';\n// eslint-disable-next-line import/no-extraneous-dependencies\nimport { LEVEL, MESSAGE, SPLAT } from 'triple-beam';\n\n// For some reason combining already existing formatters\n// Was not enough to make logging look like NestJS\n\nexport class SerializeLog {\n  timestamp = winston.format.timestamp();\n\n  colorizer = winston.format.colorize({ colors: { error: ['red', 'bold'] } });\n\n  transform(info: winston.Logform.TransformableInfo) {\n    this.timestamp.transform(info, { format: 'YYYY.MM.DD HH:mm:ss.SSS' });\n\n    // Remove internal info & get values\n    const {\n      [LEVEL]: level,\n      [SPLAT]: splat,\n      timestamp,\n      padding,\n      message,\n    } = info;\n\n    // Mix label\n    let label = '';\n    if (timestamp) label += `${timestamp} `;\n    if (level) label += `[${level.toUpperCase()}]`;\n    if (padding && level && typeof padding === 'object' && level in padding) {\n      label += (padding as Record<string, string>)[level];\n    }\n\n    // Errors from loglayer come as { err: <Error> }\n    const normalizedSplat =\n      (splat as undefined | unknown[])?.map((e) =>\n        typeof e === 'object' && e && 'err' in e && Object.keys(e).length === 1\n          ? e.err\n          : e,\n      ) ?? [];\n\n    // Colorize label\n    label = this.colorizer.colorize(level || 'info', label);\n\n    // Mix all\n    info[MESSAGE] =\n      `${label} ${util.formatWithOptions({ colors: true }, message, ...normalizedSplat)}`;\n\n    return info;\n  }\n}\n"
  },
  {
    "path": "libs/logger/src/lib/winston-appinsights-transport.ts",
    "content": "import TransportStream from 'winston-transport';\nimport { getAppInsightsClient } from './app-insights';\n\n/**\n * Custom Winston transport for Application Insights\n * Only logs error level messages to reduce noise\n */\nexport class AppInsightsTransport extends TransportStream {\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  log(info: any, callback: () => void) {\n    setImmediate(() => {\n      this.emit('logged', info);\n    });\n\n    const client = getAppInsightsClient();\n    if (!client) {\n      callback();\n      return;\n    }\n\n    try {\n      const { level, message, ...meta } = info;\n      const properties: { [key: string]: string } = {};\n\n      // Convert metadata to string properties\n      Object.keys(meta).forEach((key) => {\n        if (key !== 'timestamp') {\n          try {\n            properties[key] =\n              typeof meta[key] === 'object'\n                ? JSON.stringify(meta[key])\n                : String(meta[key]);\n          } catch {\n            properties[key] = '[Unable to serialize]';\n          }\n        }\n      });\n\n      // Map Winston levels to App Insights severity\n      // 0 = Verbose, 1 = Information, 2 = Warning, 3 = Error, 4 = Critical\n      const severityMap: { [key: string]: number } = {\n        error: 3, // Error\n        warn: 2, // Warning\n        info: 1, // Information\n        debug: 0, // Verbose\n      };\n\n      const severity = severityMap[level] ?? 1;\n\n      // Track as trace (log message) in App Insights\n      // Note: Severity is omitted due to type compatibility issues\n      client.trackTrace({\n        message: `[${level.toUpperCase()}] ${message}`,\n        properties,\n      });\n\n      // If it's an error with an exception, also track it as an exception\n      let err = meta.error;\n      if (!(meta.error instanceof Error) && info.message instanceof Error) {\n        err = info.message;\n      }\n      if (level === 'error' && err instanceof Error) {\n        client.trackException({\n          exception: err,\n          properties,\n        });\n      }\n    } catch (error) {\n      // eslint-disable-next-line no-console\n      console.error('Error in AppInsightsTransport:', error);\n    }\n\n    callback();\n  }\n}\n"
  },
  {
    "path": "libs/logger/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.base.json\",\n  \"files\": [],\n  \"include\": [],\n  \"references\": [\n    {\n      \"path\": \"./tsconfig.lib.json\"\n    },\n    {\n      \"path\": \"./tsconfig.spec.json\"\n    }\n  ]\n}\n"
  },
  {
    "path": "libs/logger/tsconfig.lib.json",
    "content": "{\n  \"extends\": \"./tsconfig.json\",\n  \"compilerOptions\": {\n    \"module\": \"commonjs\",\n    \"outDir\": \"../../dist/out-tsc\",\n    \"declaration\": true,\n    \"types\": [\"node\"]\n  },\n  \"exclude\": [\"**/*.spec.ts\"],\n  \"include\": [\"**/*.ts\", \"src/lib/logger.spec.ts\"]\n}\n"
  },
  {
    "path": "libs/logger/tsconfig.spec.json",
    "content": "{\n  \"extends\": \"./tsconfig.json\",\n  \"compilerOptions\": {\n    \"outDir\": \"../../dist/out-tsc\",\n    \"module\": \"commonjs\",\n    \"types\": [\"jest\", \"node\"]\n  },\n  \"include\": [\n    \"**/*.spec.ts\",\n    \"**/*.test.ts\",\n    \"**/*.spec.tsx\",\n    \"**/*.test.tsx\",\n    \"**/*.spec.js\",\n    \"**/*.test.js\",\n    \"**/*.spec.jsx\",\n    \"**/*.test.jsx\",\n    \"**/*.d.ts\",\n    \"jest.config.ts\",\n    \"src/lib/logger.spec.ts\"\n  ]\n}\n"
  },
  {
    "path": "libs/socket-events/.eslintrc.json",
    "content": "{\n  \"extends\": [\"../../.eslintrc.js\"],\n  \"ignorePatterns\": [\"!**/*\"],\n  \"overrides\": [\n    {\n      \"files\": [\"*.ts\", \"*.tsx\", \"*.js\", \"*.jsx\"],\n      \"rules\": {}\n    },\n    {\n      \"files\": [\"*.ts\", \"*.tsx\"],\n      \"rules\": {}\n    },\n    {\n      \"files\": [\"*.js\", \"*.jsx\"],\n      \"rules\": {}\n    }\n  ]\n}\n"
  },
  {
    "path": "libs/socket-events/README.md",
    "content": "# socket-events\n\nContains reference to all socket event name constants.\n"
  },
  {
    "path": "libs/socket-events/project.json",
    "content": "{\n  \"name\": \"socket-events\",\n  \"$schema\": \"../../node_modules/nx/schemas/project-schema.json\",\n  \"sourceRoot\": \"libs/socket-events/src\",\n  \"projectType\": \"library\",\n  \"targets\": {\n    \"lint\": {\n      \"executor\": \"@nx/eslint:lint\",\n      \"outputs\": [\"{options.outputFile}\"]\n    }\n  },\n  \"tags\": []\n}\n"
  },
  {
    "path": "libs/socket-events/src/index.ts",
    "content": "export * from './lib/socket-events';\n"
  },
  {
    "path": "libs/socket-events/src/lib/socket-events.ts",
    "content": "export const ACCOUNT_UPDATES = 'ACCOUNT_UPDATES';\nexport const DIRECTORY_WATCHER_UPDATES = 'DIRECTORY_WATCHER_UPDATES';\nexport const NOTIFICATION_UPDATES = 'NOTIFICATION_UPDATES';\nexport const SETTINGS_UPDATES = 'SETTINGS_UPDATES';\nexport const SUBMISSION_TEMPLATE_UPDATES = 'SUBMISSION_TEMPLATE_UPDATES';\nexport const SUBMISSION_UPDATES = 'SUBMISSION_UPDATES';\nexport const TAG_CONVERTER_UPDATES = 'TAG_CONVERTER_UPDATES';\nexport const TAG_GROUP_UPDATES = 'TAG_GROUP_UPDATES';\nexport const UPDATE_UPDATES = 'UPDATE_UPDATES';\nexport const USER_CONVERTER_UPDATES = 'USER_CONVERTER_UPDATES';\nexport const WEBSITE_UPDATES = 'WEBSITE_UPDATES';\nexport const CUSTOM_SHORTCUT_UPDATES = 'CUSTOM_SHORTCUT_UPDATES';\n"
  },
  {
    "path": "libs/socket-events/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.base.json\",\n  \"files\": [],\n  \"include\": [],\n  \"references\": [\n    {\n      \"path\": \"./tsconfig.lib.json\"\n    }\n  ]\n}\n"
  },
  {
    "path": "libs/socket-events/tsconfig.lib.json",
    "content": "{\n  \"extends\": \"./tsconfig.json\",\n  \"compilerOptions\": {\n    \"module\": \"commonjs\",\n    \"outDir\": \"../../dist/out-tsc\",\n    \"declaration\": true,\n    \"types\": [\"node\"]\n  },\n  \"exclude\": [\"**/*.spec.ts\"],\n  \"include\": [\"**/*.ts\"]\n}\n"
  },
  {
    "path": "libs/translations/.eslintrc.json",
    "content": "{\n  \"extends\": [\"../../.eslintrc.js\"],\n  \"ignorePatterns\": [\"!**/*\"],\n  \"overrides\": [\n    {\n      \"files\": [\"*.ts\", \"*.tsx\", \"*.js\", \"*.jsx\"],\n      \"rules\": {}\n    },\n    {\n      \"files\": [\"*.ts\", \"*.tsx\"],\n      \"rules\": {}\n    },\n    {\n      \"files\": [\"*.js\", \"*.jsx\"],\n      \"rules\": {}\n    }\n  ]\n}\n"
  },
  {
    "path": "libs/translations/README.md",
    "content": "# translations\n\nThis library was generated with [Nx](https://nx.dev).\n"
  },
  {
    "path": "libs/translations/project.json",
    "content": "{\n  \"name\": \"translations\",\n  \"$schema\": \"../../node_modules/nx/schemas/project-schema.json\",\n  \"sourceRoot\": \"libs/translations/src\",\n  \"projectType\": \"library\",\n  \"targets\": {\n    \"lint\": {\n      \"executor\": \"@nx/eslint:lint\",\n      \"outputs\": [\"{options.outputFile}\"]\n    }\n  },\n  \"tags\": []\n}\n"
  },
  {
    "path": "libs/translations/src/index.ts",
    "content": "export * from './lib/field-translations';\n"
  },
  {
    "path": "libs/translations/src/lib/field-translations.ts",
    "content": "import { MessageDescriptor } from '@lingui/core';\nimport { msg } from '@lingui/core/macro';\n\nexport const FieldLabelTranslations = {\n  aIGenerated: msg`AI generated`,\n  accessTiers: msg`Access Tiers`,\n  addToPortfolio: msg`Add to Portfolio`,\n  adultThemes: msg`Adult themes`,\n  allAsAttachment: msg`Attach Images as Attachments`,\n  allowComments: msg`Allow comments`,\n  allowCommercialUse: msg`Allow commercial use`,\n  allowCommunityTags: msg`Allow other users to edit tags`,\n  allowFreeDownload: msg`Allow free download`,\n  allowModifications: msg`Allow modifications of your work`,\n  allowReblogging: msg`Allow reblogging`,\n  artistName: msg`Artist name`,\n  audience: msg`Audience`,\n  authorizedViewers: msg`Authorized viewers`,\n  blockGuests: msg`Block guests`,\n  blog: msg`Blog`,\n  category: msg`Category`,\n  channel: msg`Channel`,\n  characters: msg`Characters`,\n  chargePatrons: msg`Charge Patrons`,\n  collections: msg`Collections`,\n  commentPermissions: msg`Comment permissions`,\n  commercial: msg`Commercial use`,\n  containsContent: msg`Contains content`,\n  contentBlur: msg`Content Blur`,\n  contentWarning: msg`Content warning`,\n  creativeCommons: msg`Creative Commons`,\n  critique: msg`Request critique`,\n  description: msg`Description`,\n  disableComments: msg`Disable comments`,\n  displayResolution: msg`Display resolution`,\n  drugUse: msg`Drug / Alcohol`,\n  earlyAccess: msg`Early Access`,\n  explicitText: msg`Explicit text`,\n  feature: msg`Feature`,\n  female: msg`Female`,\n  folder: msg`Folder`,\n  friendsOnly: msg`Friends only`,\n  furry: msg`Furry`,\n  gender: msg`Gender`,\n  gore: msg`Gore`,\n  hasSexualContent: msg`Has sexual content`,\n  hiRes: msg`Offer hi-res download (gold only)`,\n  intendedAsAdvertisement: msg`Intended as advertisement`,\n  isCreativeCommons: msg`Creative Commons license`,\n  language: msg`Language`,\n  male: msg`Male`,\n  markAsWorkInProgress: msg`Mark as work in progress`,\n  matureContent: msg`Mature content`,\n  media: msg`Media`,\n  modification: msg`Modification`,\n  noAI: msg`No AI`,\n  notify: msg`Notify followers`,\n  nudity: msg`Nudity`,\n  offSiteArtistUrl: msg`Artist URL (Off-Site only)`,\n  originalWork: msg`Original work`,\n  other: msg`Other`,\n  parentId: msg`Parent ID`,\n  pixelPerfectDisplay: msg`Pixel perfect display`,\n  private: msg`Private`,\n  privacy: msg`Privacy`,\n  profanity: msg`Profanity`,\n  publicViewers: msg`Public viewers`,\n  racism: msg`Racism`,\n  rating: msg`Rating`,\n  reference: msg`Reference`,\n  replyToUrl: msg`Reply to post URL`,\n  requiredTag: msg`Required tag`,\n  schedule: msg`Schedule`,\n  scraps: msg`Send to scraps`,\n  sensitiveContent: msg`Sensitive content`,\n  sexualContent: msg`Sexual Content`,\n  shareOnFeed: msg`Share on feed`,\n  silent: msg`Silent`,\n  sketch: msg`Sketch`,\n  software: msg`Software`,\n  species: msg`Species`,\n  spoiler: msg`Spoiler`,\n  spoilers: msg`Spoilers`,\n  tagPermissions: msg`Tag permissions`,\n  tags: msg`Tags`,\n  teaser: msg`Teaser`,\n  theme: msg`Theme`,\n  thumbnailAsCoverArt: msg`Use thumbnail as cover art`,\n  timeTaken: msg`Time Taken`,\n  title: msg`Title`,\n  uploadThumbnail: msg`Upload thumbnail`,\n  useTitle: msg`Use title`,\n  viewPermissions: msg`View permissions`,\n  violence: msg`Violence`,\n  visibility: msg`Visibility`,\n  watermark: msg`Watermark`,\n  whoCanReply: msg`Who can reply`,\n} satisfies Record<string, MessageDescriptor>;\n\nexport type FieldLabelTranslationsId = keyof typeof FieldLabelTranslations;\n"
  },
  {
    "path": "libs/translations/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.base.json\",\n  \"files\": [],\n  \"include\": [],\n  \"references\": [\n    {\n      \"path\": \"./tsconfig.lib.json\"\n    }\n  ]\n}\n"
  },
  {
    "path": "libs/translations/tsconfig.lib.json",
    "content": "{\n  \"extends\": \"./tsconfig.json\",\n  \"compilerOptions\": {\n    \"module\": \"commonjs\",\n    \"jsx\": \"react-jsx\",\n    \"outDir\": \"../../dist/out-tsc\",\n    \"declaration\": true,\n    \"types\": [\"node\"]\n  },\n  \"exclude\": [\"**/*.spec.ts\"],\n  \"include\": [\"**/*.ts\"]\n}\n"
  },
  {
    "path": "libs/types/.eslintrc.json",
    "content": "{\n  \"extends\": [\"../../.eslintrc.js\"],\n  \"ignorePatterns\": [\"!**/*\"],\n  \"overrides\": [\n    {\n      \"files\": [\"*.ts\", \"*.tsx\", \"*.js\", \"*.jsx\"],\n      \"rules\": {}\n    },\n    {\n      \"files\": [\"*.ts\", \"*.tsx\"],\n      \"rules\": {}\n    },\n    {\n      \"files\": [\"*.js\", \"*.jsx\"],\n      \"rules\": {}\n    }\n  ]\n}\n"
  },
  {
    "path": "libs/types/README.md",
    "content": "# types\n\nThis library was generated with [Nx](https://nx.dev).\n\n## Running lint\n\nRun `nx lint types` to execute the lint via [ESLint](https://eslint.org/).\n"
  },
  {
    "path": "libs/types/project.json",
    "content": "{\n  \"name\": \"types\",\n  \"$schema\": \"../../node_modules/nx/schemas/project-schema.json\",\n  \"sourceRoot\": \"libs/types/src\",\n  \"projectType\": \"library\",\n  \"targets\": {\n    \"lint\": {\n      \"executor\": \"@nx/eslint:lint\",\n      \"outputs\": [\"{options.outputFile}\"]\n    }\n  },\n  \"tags\": []\n}\n"
  },
  {
    "path": "libs/types/src/dtos/account/account.dto.ts",
    "content": "import { IAccount, ILoginState } from '../../models';\nimport { IWebsiteInfo } from '../../models/website/website-info.interface';\nimport { IEntityDto } from '../database/entity.dto';\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport type IAccountDto<T = any> = IEntityDto<IAccount> & {\n  /**\n   * Current login state for the account.\n   * @type {ILoginState}\n   */\n  state: ILoginState;\n\n  /**\n   * Additional information from website data entity.\n   * @type {T}\n   */\n  data: T;\n\n  /**\n   * Website info for display purposes from API consumers.\n   * @type {IWebsiteInfo}\n   */\n  websiteInfo: IWebsiteInfo;\n};\n"
  },
  {
    "path": "libs/types/src/dtos/account/create-account.dto.ts",
    "content": "import { IAccount } from '../../models';\n\nexport type ICreateAccountDto = Pick<IAccount, 'name' | 'website' | 'groups'>;\n"
  },
  {
    "path": "libs/types/src/dtos/account/update-account.dto.ts",
    "content": "import { IAccount } from '../../models';\n\nexport type IUpdateAccountDto = Pick<IAccount, 'name' | 'groups'>;\n"
  },
  {
    "path": "libs/types/src/dtos/custom-shortcut/create-custom-shortcut.dto.ts",
    "content": "import { ICustomShortcut } from '../../models';\n\nexport type ICreateCustomShortcutDto = Pick<ICustomShortcut, 'name'>;\n"
  },
  {
    "path": "libs/types/src/dtos/custom-shortcut/custom-shortcut.dto.ts",
    "content": "import { ICustomShortcut } from '../../models';\nimport { IEntityDto } from '../database/entity.dto';\n\nexport type ICustomShortcutDto = IEntityDto<ICustomShortcut>;\n"
  },
  {
    "path": "libs/types/src/dtos/custom-shortcut/update-custom-shortcut.dto.ts",
    "content": "import { ICustomShortcut } from '../../models';\n\nexport type IUpdateCustomShortcutDto = Pick<\n  ICustomShortcut,\n  'name' | 'shortcut'\n>;\n"
  },
  {
    "path": "libs/types/src/dtos/database/entity.dto.ts",
    "content": "import { IEntity } from '../../models';\n\nexport type IEntityDto<T extends IEntity = IEntity> = T;\n"
  },
  {
    "path": "libs/types/src/dtos/directory-watcher/create-directory-watcher.dto.ts",
    "content": "import { IDirectoryWatcher } from '../../models';\n\nexport type ICreateDirectoryWatcherDto = Pick<\n  IDirectoryWatcher,\n  'importAction' | 'path'\n>;\n"
  },
  {
    "path": "libs/types/src/dtos/directory-watcher/directory-watcher.dto.ts",
    "content": "import { IDirectoryWatcher, SubmissionId } from '../../models';\nimport { IEntityDto } from '../database/entity.dto';\n\nexport type DirectoryWatcherDto = Omit<\n  IEntityDto<IDirectoryWatcher>,\n  'template'\n> & {\n  template?: SubmissionId;\n};\n"
  },
  {
    "path": "libs/types/src/dtos/directory-watcher/update-directory-watcher.dto.ts",
    "content": "import { IDirectoryWatcher, SubmissionId } from '../../models';\n\nexport type IUpdateDirectoryWatcherDto = Partial<\n  Pick<IDirectoryWatcher, 'importAction' | 'path'>\n> & {\n  templateId?: SubmissionId;\n};\n"
  },
  {
    "path": "libs/types/src/dtos/index.ts",
    "content": "export * from './account/account.dto';\nexport * from './account/create-account.dto';\nexport * from './account/update-account.dto';\nexport * from './custom-shortcut/create-custom-shortcut.dto';\nexport * from './custom-shortcut/custom-shortcut.dto';\nexport * from './custom-shortcut/update-custom-shortcut.dto';\nexport * from './database/entity.dto';\nexport * from './directory-watcher/create-directory-watcher.dto';\nexport * from './directory-watcher/directory-watcher.dto';\nexport * from './directory-watcher/update-directory-watcher.dto';\nexport * from './notification/create-notification.dto';\nexport * from './notification/update-notification.dto';\nexport * from './post/post-event.dto';\nexport * from './post/post-queue-action.dto';\nexport * from './post/post-queue-record.dto';\nexport * from './post/post-record.dto';\nexport * from './post/queue-post-record-request.dto';\nexport * from './settings/settings.dto';\nexport * from './settings/update-settings.dto';\nexport * from './submission/apply-multi-submission.dto';\nexport * from './submission/create-submission.dto';\nexport * from './submission/file-buffer.dto';\nexport * from './submission/reorder-submission-files.dto';\nexport * from './submission/submission-file.dto';\nexport * from './submission/submission.dto';\nexport * from './submission/update-alt-file.dto';\nexport * from './submission/update-submission-template-name.dto';\nexport * from './submission/update-submission.dto';\nexport * from './tag/create-tag-converter.dto';\nexport * from './tag/create-tag-group.dto';\nexport * from './tag/tag-converter.dto';\nexport * from './tag/tag-group.dto';\nexport * from './tag/update-tag-converter.dto';\nexport * from './tag/update-tag-group.dto';\nexport * from './user/create-user-converter.dto';\nexport * from './user/update-user-converter.dto';\nexport * from './user/user-converter.dto';\nexport * from './website-options/create-user-specified-website-options.dto';\nexport * from './website-options/create-website-options.dto';\nexport * from './website-options/preview-description.dto';\nexport * from './website-options/update-submission-website-options.dto';\nexport * from './website-options/update-user-specified-website-options.dto';\nexport * from './website-options/update-website-options.dto';\nexport * from './website-options/user-specified-website-options.dto';\nexport * from './website-options/validate-website-options.dto';\nexport * from './website-options/website-options.dto';\nexport * from './website/custom-website-route.dto';\nexport * from './website/form-generation-request.dto';\nexport * from './website/oauth-website-request.dto';\nexport * from './website/set-website-data-request.dto';\nexport * from './website/website-data.dto';\nexport * from './website/website-info.dto';\n\n"
  },
  {
    "path": "libs/types/src/dtos/notification/create-notification.dto.ts",
    "content": "import { INotification } from '../../models';\n\nexport type ICreateNotificationDto = Pick<\n  INotification,\n  'title' | 'message' | 'tags' | 'data' | 'type'\n>;\n"
  },
  {
    "path": "libs/types/src/dtos/notification/update-notification.dto.ts",
    "content": "import { INotification } from '../../models';\n\nexport type IUpdateNotificationDto = Pick<\n  INotification,\n  'isRead' | 'hasEmitted'\n>;\n"
  },
  {
    "path": "libs/types/src/dtos/post/post-event.dto.ts",
    "content": "import { IPostEvent } from '../../models';\nimport { IAccountDto } from '../account/account.dto';\nimport { IEntityDto } from '../database/entity.dto';\n\nexport type PostEventDto = Omit<IEntityDto<IPostEvent>, 'account'> & {\n  account?: IAccountDto;\n};\n"
  },
  {
    "path": "libs/types/src/dtos/post/post-queue-action.dto.ts",
    "content": "import { PostRecordResumeMode } from '../../enums';\nimport { SubmissionId } from '../../models';\n\nexport type IPostQueueActionDto = {\n  submissionIds: SubmissionId[];\n  resumeMode?: PostRecordResumeMode;\n};\n"
  },
  {
    "path": "libs/types/src/dtos/post/post-queue-record.dto.ts",
    "content": "import { IPostQueueRecord } from '../../models';\n\nexport type PostQueueRecordDto = IPostQueueRecord;\n"
  },
  {
    "path": "libs/types/src/dtos/post/post-record.dto.ts",
    "content": "import { IPostRecord } from '../../models';\nimport { IEntityDto } from '../database/entity.dto';\nimport { PostEventDto } from './post-event.dto';\n\nexport type PostRecordDto = Omit<IEntityDto<IPostRecord>, 'events'> & {\n  events?: PostEventDto[];\n};\n"
  },
  {
    "path": "libs/types/src/dtos/post/queue-post-record-request.dto.ts",
    "content": "import { SubmissionId } from '../../models';\n\n/**\n * DTO model for performing a queue/dequeue operation on a post record\n * @interface IQueuePostRecordRequestDto\n */\nexport interface IQueuePostRecordRequestDto {\n  ids: SubmissionId[];\n}\n"
  },
  {
    "path": "libs/types/src/dtos/settings/settings.dto.ts",
    "content": "import { ISettings } from '../../models';\nimport { IEntityDto } from '../database/entity.dto';\n\nexport type SettingsDto = IEntityDto<ISettings>;\n"
  },
  {
    "path": "libs/types/src/dtos/settings/update-settings.dto.ts",
    "content": "import { ISettings } from '../../models';\n\nexport type IUpdateSettingsDto = Pick<ISettings, 'settings'>;\n"
  },
  {
    "path": "libs/types/src/dtos/submission/apply-multi-submission.dto.ts",
    "content": "import { SubmissionId } from '../../models';\n\n/**\n * The DTO for applying a submission's data to multiple submissions.\n *\n * @interface IApplyMultiSubmissionDto\n */\nexport type IApplyMultiSubmissionDto = {\n  /**\n   * The origin submission id.\n   * @type {SubmissionId}\n   */\n  submissionToApply: SubmissionId;\n\n  /**\n   * The submission ids to apply the origin submission to.\n   *\n   * @type {SubmissionId[]}\n   */\n  submissionIds: SubmissionId[];\n\n  /**\n   * Whether to merge the origin submission data with the target submissions.\n   *\n   * A value of `true` will result in the overwrite of overlapping website options, but the preservation of unique options.\n   * A value of `false` will result in the overwrite of all website options, and delete the non-included options.\n   * @type {boolean}\n   */\n  merge: boolean;\n};\n"
  },
  {
    "path": "libs/types/src/dtos/submission/create-submission.dto.ts",
    "content": "import { SubmissionRating, SubmissionType } from '../../enums';\nimport { DescriptionValue } from '../../models/submission/description-value.type';\nimport { Tag } from '../../models/tag/tag.type';\n\n/**\n * Default options to apply to all created submissions.\n */\nexport interface ICreateSubmissionDefaultOptions {\n  tags?: Tag[];\n  description?: DescriptionValue;\n  rating?: SubmissionRating;\n}\n\n/**\n * Metadata for individual files during batch upload.\n */\nexport interface IFileMetadata {\n  /** Original filename to match against */\n  filename: string;\n  /** Custom title for this file's submission */\n  title: string;\n}\n\nexport interface ICreateSubmissionDto {\n  name: string;\n  type: SubmissionType;\n  isTemplate?: boolean;\n  /** Default options (tags, description, rating) to apply to all created submissions */\n  defaultOptions?: ICreateSubmissionDefaultOptions;\n  /** Per-file metadata for batch uploads (title overrides per file) */\n  fileMetadata?: IFileMetadata[];\n}\n"
  },
  {
    "path": "libs/types/src/dtos/submission/file-buffer.dto.ts",
    "content": "import { IFileBuffer } from '../../models';\nimport { IEntityDto } from '../database/entity.dto';\n\nexport type FileBufferDto = Omit<IEntityDto<IFileBuffer>, 'buffer'>;\n"
  },
  {
    "path": "libs/types/src/dtos/submission/reorder-submission-files.dto.ts",
    "content": "import { EntityId } from '../../models';\n\nexport interface IReorderSubmissionFilesDto {\n  order: Record<EntityId, number>;\n}\n"
  },
  {
    "path": "libs/types/src/dtos/submission/submission-file.dto.ts",
    "content": "import { ISubmissionFile } from '../../models';\nimport { IEntityDto } from '../database/entity.dto';\n\nexport type ISubmissionFileDto = IEntityDto<ISubmissionFile>;\n"
  },
  {
    "path": "libs/types/src/dtos/submission/submission.dto.ts",
    "content": "import {\n  ISubmission,\n  ISubmissionMetadata,\n  ValidationResult,\n} from '../../models';\nimport { IEntityDto } from '../database/entity.dto';\nimport { PostQueueRecordDto } from '../post/post-queue-record.dto';\nimport { PostRecordDto } from '../post/post-record.dto';\nimport { WebsiteOptionsDto } from '../website-options/website-options.dto';\nimport { ISubmissionFileDto } from './submission-file.dto';\n\nexport type ISubmissionDto<\n  T extends ISubmissionMetadata = ISubmissionMetadata,\n> = IEntityDto<\n  Omit<ISubmission<T>, 'files' | 'options' | 'posts' | 'postQueueRecord'>\n> & {\n  files: ISubmissionFileDto[];\n  options: WebsiteOptionsDto[];\n  posts: PostRecordDto[];\n  validations: ValidationResult[];\n  postQueueRecord?: PostQueueRecordDto;\n};\n"
  },
  {
    "path": "libs/types/src/dtos/submission/update-alt-file.dto.ts",
    "content": "export interface IUpdateAltFileDto {\n  text: string;\n}\n"
  },
  {
    "path": "libs/types/src/dtos/submission/update-submission-template-name.dto.ts",
    "content": "export type IUpdateSubmissionTemplateNameDto = {\n  name: string;\n};\n"
  },
  {
    "path": "libs/types/src/dtos/submission/update-submission.dto.ts",
    "content": "import {\n  ISubmission,\n  ISubmissionScheduleInfo,\n  IWebsiteFormFields,\n} from '../../models';\nimport { WebsiteOptionsDto } from '../website-options/website-options.dto';\n\nexport type IUpdateSubmissionDto = Partial<\n  Pick<ISubmission, 'isScheduled' | 'metadata'>\n> &\n  Partial<ISubmissionScheduleInfo> & {\n    deletedWebsiteOptions?: string[];\n    newOrUpdatedOptions?: WebsiteOptionsDto<IWebsiteFormFields>[];\n  };\n"
  },
  {
    "path": "libs/types/src/dtos/tag/create-tag-converter.dto.ts",
    "content": "import { ITagConverter } from '../../models';\n\nexport type ICreateTagConverterDto = Pick<ITagConverter, 'tag' | 'convertTo'>;\n"
  },
  {
    "path": "libs/types/src/dtos/tag/create-tag-group.dto.ts",
    "content": "import { ITagGroup } from '../../models';\n\nexport type ICreateTagGroupDto = Pick<ITagGroup, 'name' | 'tags'>;\n"
  },
  {
    "path": "libs/types/src/dtos/tag/tag-converter.dto.ts",
    "content": "import { ITagConverter } from '../../models';\nimport { IEntityDto } from '../database/entity.dto';\n\nexport type TagConverterDto = IEntityDto<ITagConverter>;\n"
  },
  {
    "path": "libs/types/src/dtos/tag/tag-group.dto.ts",
    "content": "import { ITagGroup } from '../../models';\nimport { IEntityDto } from '../database/entity.dto';\n\nexport type TagGroupDto = IEntityDto<ITagGroup>;\n"
  },
  {
    "path": "libs/types/src/dtos/tag/update-tag-converter.dto.ts",
    "content": "import { ITagConverter } from '../../models';\n\nexport type IUpdateTagConverterDto = Pick<ITagConverter, 'convertTo' | 'tag'>;\n"
  },
  {
    "path": "libs/types/src/dtos/tag/update-tag-group.dto.ts",
    "content": "import { ITagGroup } from '../../models';\n\nexport type IUpdateTagGroupDto = Pick<ITagGroup, 'name' | 'tags'>;\n"
  },
  {
    "path": "libs/types/src/dtos/user/create-user-converter.dto.ts",
    "content": "import { IUserConverter } from '../../models';\n\nexport type ICreateUserConverterDto = Pick<IUserConverter, 'username' | 'convertTo'>;\n"
  },
  {
    "path": "libs/types/src/dtos/user/update-user-converter.dto.ts",
    "content": "import { IUserConverter } from '../../models';\n\nexport type IUpdateUserConverterDto = Partial<Pick<IUserConverter, 'username' | 'convertTo'>>;\n"
  },
  {
    "path": "libs/types/src/dtos/user/user-converter.dto.ts",
    "content": "import { IUserConverter } from '../../models';\n\nexport type UserConverterDto = IUserConverter;\n"
  },
  {
    "path": "libs/types/src/dtos/website/custom-website-route.dto.ts",
    "content": "import { WebsiteId } from '../../models';\n\nexport interface ICustomWebsiteRouteDto {\n  id: WebsiteId;\n  route: string;\n  data: unknown;\n}\n"
  },
  {
    "path": "libs/types/src/dtos/website/form-generation-request.dto.ts",
    "content": "import { SubmissionType } from '../../enums';\nimport { AccountId } from '../../models';\n\nexport interface IFormGenerationRequestDto {\n  accountId: AccountId;\n  type: SubmissionType;\n  isMultiSubmission?: boolean;\n}\n"
  },
  {
    "path": "libs/types/src/dtos/website/oauth-website-request.dto.ts",
    "content": "import { DynamicObject } from '../../models';\n\nexport interface IOAuthWebsiteRequestDto<T extends DynamicObject> {\n  id: string;\n  data: T;\n  route: string;\n}\n"
  },
  {
    "path": "libs/types/src/dtos/website/set-website-data-request.dto.ts",
    "content": "import { WebsiteId } from '../../models';\n\nexport interface ISetWebsiteDataRequestDto<T> {\n  id: WebsiteId;\n  data: T;\n}\n"
  },
  {
    "path": "libs/types/src/dtos/website/website-data.dto.ts",
    "content": "import { DynamicObject, IWebsiteData } from '../../models';\nimport { IEntityDto } from '../database/entity.dto';\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport type IWebsiteDataDto<T extends DynamicObject = any> = IEntityDto<\n  IWebsiteData<T>\n>;\n"
  },
  {
    "path": "libs/types/src/dtos/website/website-info.dto.ts",
    "content": "import { WebsiteId } from '../../models';\nimport { WebsiteLoginType } from '../../models/website/website-login-type';\nimport { IWebsiteMetadata } from '../../website-modifiers';\nimport { UsernameShortcut } from '../../website-modifiers/username-shortcut';\nimport { WebsiteFileOptions } from '../../website-modifiers/website-file-options';\nimport { IAccountDto } from '../account/account.dto';\n\nexport interface IWebsiteInfoDto {\n  id: WebsiteId;\n  displayName: string;\n  loginType: WebsiteLoginType;\n  usernameShortcut?: UsernameShortcut;\n  metadata: IWebsiteMetadata;\n  accounts: IAccountDto[];\n  fileOptions?: WebsiteFileOptions;\n  supportsFile: boolean;\n  supportsMessage: boolean;\n}\n"
  },
  {
    "path": "libs/types/src/dtos/website-options/create-user-specified-website-options.dto.ts",
    "content": "import { AccountId, IUserSpecifiedWebsiteOptions } from '../../models';\n\nexport type ICreateUserSpecifiedWebsiteOptionsDto = Pick<\n  IUserSpecifiedWebsiteOptions,\n  'options' | 'type'\n> & {\n  accountId: AccountId;\n};\n"
  },
  {
    "path": "libs/types/src/dtos/website-options/create-website-options.dto.ts",
    "content": "import { AccountId, IWebsiteFormFields, SubmissionId } from '../../models';\n\nexport type ICreateWebsiteOptionsDto = {\n  submissionId: SubmissionId;\n  accountId: AccountId;\n  data: IWebsiteFormFields;\n};\n"
  },
  {
    "path": "libs/types/src/dtos/website-options/preview-description.dto.ts",
    "content": "import { DescriptionType } from '../../enums/description-types.enum';\nimport { EntityId, SubmissionId } from '../../models';\n\nexport interface IPreviewDescriptionDto {\n  submissionId: SubmissionId;\n  websiteOptionId: EntityId;\n}\n\nexport interface IDescriptionPreviewResult {\n  descriptionType: DescriptionType;\n  description: string;\n}\n"
  },
  {
    "path": "libs/types/src/dtos/website-options/update-submission-website-options.dto.ts",
    "content": "import { EntityId } from '../../models';\nimport { ICreateWebsiteOptionsDto } from './create-website-options.dto';\n\nexport type IUpdateSubmissionWebsiteOptionsDto = {\n  remove?: EntityId[];\n  add?: ICreateWebsiteOptionsDto[];\n};\n"
  },
  {
    "path": "libs/types/src/dtos/website-options/update-user-specified-website-options.dto.ts",
    "content": "import { IUserSpecifiedWebsiteOptions } from '../../models';\n\nexport type IUpdateUserSpecifiedWebsiteOptionsDto = Pick<\n  IUserSpecifiedWebsiteOptions,\n  'type' | 'options'\n>;\n"
  },
  {
    "path": "libs/types/src/dtos/website-options/update-website-options.dto.ts",
    "content": "import { IWebsiteOptions } from '../../models';\n\nexport type IUpdateWebsiteOptionsDto = Pick<IWebsiteOptions, 'data'>;\n"
  },
  {
    "path": "libs/types/src/dtos/website-options/user-specified-website-options.dto.ts",
    "content": "import { IUserSpecifiedWebsiteOptions } from '../../models';\nimport { IEntityDto } from '../database/entity.dto';\n\nexport type UserSpecifiedWebsiteOptionsDto =\n  IEntityDto<IUserSpecifiedWebsiteOptions>;\n"
  },
  {
    "path": "libs/types/src/dtos/website-options/validate-website-options.dto.ts",
    "content": "import { EntityId, SubmissionId } from '../../models';\n\nexport interface IValidateWebsiteOptionsDto {\n  submissionId: SubmissionId;\n  websiteOptionId: EntityId;\n}\n"
  },
  {
    "path": "libs/types/src/dtos/website-options/website-options.dto.ts",
    "content": "import { IWebsiteFormFields, IWebsiteOptions } from '../../models';\nimport { IEntityDto } from '../database/entity.dto';\nimport { ISubmissionDto } from '../submission/submission.dto';\n\nexport type WebsiteOptionsDto<\n  T extends IWebsiteFormFields = IWebsiteFormFields,\n> = Omit<IEntityDto<IWebsiteOptions<T>>, 'submission'> & {\n  submission?: ISubmissionDto;\n};\n"
  },
  {
    "path": "libs/types/src/enums/description-types.enum.ts",
    "content": "export enum DescriptionType {\n  BBCODE = 'bbcode',\n  CUSTOM = 'custom',\n  RUNTIME = 'runtime', // Used for determining at-runtime dynamic DescriptionType\n  HTML = 'html',\n  MARKDOWN = 'markdown',\n  NONE = 'none',\n  PLAINTEXT = 'plaintext',\n}\n"
  },
  {
    "path": "libs/types/src/enums/directory-watcher-import-action.enum.ts",
    "content": "/**\n * An enumeration representing the actions for directory watcher imports.\n * @enum {string}\n */\nexport enum DirectoryWatcherImportAction {\n  /**\n   * Indicates a new submission.\n   */\n  NEW_SUBMISSION = 'NEW_SUBMISSION',\n}\n"
  },
  {
    "path": "libs/types/src/enums/file-type.enum.ts",
    "content": "/**\n * An enumeration representing the types of files.\n * @enum {string}\n */\nexport enum FileType {\n  /**\n   * Indicates an audio file.\n   */\n  AUDIO = 'AUDIO',\n  /**\n   * Indicates an image file.\n   */\n  IMAGE = 'IMAGE',\n  /**\n   * Indicates a text file.\n   */\n  TEXT = 'TEXT',\n  /**\n   * Indicates an unknown file type.\n   */\n  UNKNOWN = 'UNKNOWN',\n  /**\n   * Indicates a video file.\n   */\n  VIDEO = 'VIDEO',\n}\n"
  },
  {
    "path": "libs/types/src/enums/index.ts",
    "content": "export * from './description-types.enum';\nexport * from './directory-watcher-import-action.enum';\nexport * from './file-type.enum';\nexport * from './post-event-type.enum';\nexport * from './post-record-resume-mode.enum';\nexport * from './post-record-state.enum';\nexport * from './schedule-type.enum';\nexport * from './submission-rating.enum';\nexport * from './submission-type.enum';\n\n"
  },
  {
    "path": "libs/types/src/enums/post-event-type.enum.ts",
    "content": "/**\n * Event types for the post event ledger.\n * Each posting action is recorded as an immutable event.\n * @enum {string}\n */\nexport enum PostEventType {\n  /**\n   * Emitted when posting to a website begins.\n   * Metadata includes postData and accountSnapshot.\n   */\n  POST_ATTEMPT_STARTED = 'POST_ATTEMPT_STARTED',\n\n  /**\n   * Emitted when posting to a website completes successfully.\n   */\n  POST_ATTEMPT_COMPLETED = 'POST_ATTEMPT_COMPLETED',\n\n  /**\n   * Emitted when posting to a website fails terminally.\n   */\n  POST_ATTEMPT_FAILED = 'POST_ATTEMPT_FAILED',\n\n  /**\n   * Emitted when a single file is successfully posted.\n   * Includes sourceUrl and fileSnapshot in metadata.\n   */\n  FILE_POSTED = 'FILE_POSTED',\n\n  /**\n   * Emitted when a single file fails to post.\n   * Includes error details and fileSnapshot in metadata.\n   */\n  FILE_FAILED = 'FILE_FAILED',\n\n  /**\n   * Emitted when a message submission is successfully posted.\n   * Includes sourceUrl.\n   */\n  MESSAGE_POSTED = 'MESSAGE_POSTED',\n\n  /**\n   * Emitted when a message submission fails to post.\n   * Includes error details.\n   */\n  MESSAGE_FAILED = 'MESSAGE_FAILED',\n}\n"
  },
  {
    "path": "libs/types/src/enums/post-record-resume-mode.enum.ts",
    "content": "/**\n * The resume mode of a requeued post record.\n * @enum {number}\n */\nexport enum PostRecordResumeMode {\n  /**\n   * Will continue only unattempted/failed children.\n   * Continues from last successful batched file.\n   */\n  CONTINUE = 'CONTINUE',\n\n  /**\n   * Same as CONTINUE, but also restarts all files in a post record.\n   */\n  CONTINUE_RETRY = 'RETRY',\n\n  /**\n   * New record will restart the entire post record if necessary.\n   */\n  NEW = 'NEW',\n}\n"
  },
  {
    "path": "libs/types/src/enums/post-record-state.enum.ts",
    "content": "/**\n * The state of the post record.\n * @enum {number}\n */\nexport enum PostRecordState {\n  PENDING = 'PENDING',\n  RUNNING = 'RUNNING',\n  DONE = 'DONE',\n  FAILED = 'FAILED',\n}\n"
  },
  {
    "path": "libs/types/src/enums/schedule-type.enum.ts",
    "content": "/**\n * An enumeration representing the types of schedules.\n * @enum {string}\n */\nexport enum ScheduleType {\n  /**\n   * Indicates no schedule.\n   */\n  NONE = 'NONE',\n  /**\n   * Indicates a single schedule occurrence.\n   */\n  SINGLE = 'SINGLE',\n  /**\n   * Indicates a recurring schedule occurrence.\n   */\n  RECURRING = 'RECURRING',\n}\n"
  },
  {
    "path": "libs/types/src/enums/submission-rating.enum.ts",
    "content": "/**\n * An enumeration representing the rating of a submission.\n * @enum {string}\n */\nexport enum SubmissionRating {\n  /**\n   * Indicates a general rating.\n   */\n  GENERAL = 'GENERAL',\n  /**\n   * Indicates a mature rating.\n   */\n  MATURE = 'MATURE',\n  /**\n   * Indicates an adult rating.\n   */\n  ADULT = 'ADULT',\n  /**\n   * Indicates an extreme rating.\n   */\n  EXTREME = 'EXTREME',\n}\n"
  },
  {
    "path": "libs/types/src/enums/submission-type.enum.ts",
    "content": "/**\n * An enumeration representing the types of submissions.\n * @enum {string}\n */\nexport enum SubmissionType {\n  /**\n   * Indicates a file submission.\n   */\n  FILE = 'FILE',\n  /**\n   * Indicates a message submission.\n   */\n  MESSAGE = 'MESSAGE',\n}\n"
  },
  {
    "path": "libs/types/src/index.ts",
    "content": "export * from './dtos';\nexport * from './enums';\nexport * from './models';\nexport * from './website-modifiers';\nexport * from './website-modifiers/website-file-options';\nexport * from './website-public';\n"
  },
  {
    "path": "libs/types/src/models/account/account.interface.ts",
    "content": "import { EntityId, IEntity } from '../database/entity.interface';\nimport { WebsiteId } from '../website/website.type';\n\nexport type AccountId = EntityId;\n\n/**\n * Represents an account with its associated properties.\n */\nexport interface IAccount extends IEntity {\n  /**\n   * The unique identifier of the account and the session partition key.\n   * @type {AccountId}\n   */\n  id: AccountId;\n\n  /**\n   * The display name of the account.\n   * @type {string}\n   */\n  name: string;\n\n  /**\n   * The website associated with the account.\n   * @type {WebsiteId}\n   */\n  website: WebsiteId;\n\n  /**\n   * The list of tags that the account is associated with.\n   * @type {string[]}\n   */\n  groups: string[];\n}\n\nexport const NULL_ACCOUNT_ID = 'NULL_ACCOUNT';\n\nexport class NullAccount implements IAccount {\n  id: AccountId = NULL_ACCOUNT_ID;\n\n  name: string = NULL_ACCOUNT_ID;\n\n  website: WebsiteId = 'default';\n\n  groups: string[] = [];\n\n  createdAt!: string;\n\n  updatedAt!: string;\n}\n"
  },
  {
    "path": "libs/types/src/models/common/dynamic-object.ts",
    "content": "// A generic object type. Really just to get around using Object.\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport type DynamicObject = Record<string, any>;\n"
  },
  {
    "path": "libs/types/src/models/custom-shortcut/custom-shortcut.interface.ts",
    "content": "import { IEntity } from '../database/entity.interface';\nimport {\n  Description\n} from '../submission/description-value.type';\n\nexport interface ICustomShortcut extends IEntity {\n  name: string;\n  shortcut: Description;\n}\n"
  },
  {
    "path": "libs/types/src/models/database/entity-primitive.type.ts",
    "content": "import { IEntityDto } from '../../dtos';\nimport { IEntity } from './entity.interface';\n\nexport type EntityPrimitive<T extends IEntity | IEntityDto = IEntity> = Omit<\n  T,\n  'createdAt' | 'updatedAt'\n> & {\n  createdAt: string;\n  updatedAt: string;\n};\n"
  },
  {
    "path": "libs/types/src/models/database/entity.interface.ts",
    "content": "export type EntityId = string;\n\n/**\n * An interface representing a base entity with common properties.\n */\nexport interface IEntity {\n  /**\n   * The unique identifier of the entity.\n   * @type {EntityId}\n   */\n  id: EntityId;\n\n  /**\n   * The date string when the entity was created.\n   * @type {string}\n   */\n  createdAt: string;\n\n  /**\n   * The date string when the entity was last updated.\n   * @type {string}\n   */\n  updatedAt: string;\n}\n"
  },
  {
    "path": "libs/types/src/models/directory-watcher/directory-watcher.interface.ts",
    "content": "import { DirectoryWatcherImportAction } from '../../enums/directory-watcher-import-action.enum';\nimport { IEntity } from '../database/entity.interface';\nimport { ISubmission, SubmissionId } from '../submission/submission.interface';\n\n/**\n * Defines an entity that reads in files to the app from a folder.\n * @interface IDirectoryWatcher\n * @extends {IEntity}\n */\nexport interface IDirectoryWatcher extends IEntity {\n  /**\n   * The path that is read for file ingestion.\n   * @type {string|undefined}\n   */\n  path?: string;\n\n  /**\n   * The action that is applied when ingesting files.\n   * @type {DirectoryWatcherImportAction}\n   */\n  importAction: DirectoryWatcherImportAction;\n\n  /**\n   * The template that is applied when `importAction` is `NEW_SUBMISSION_WITH_TEMPLATE`.\n   * @type {object|undefined}\n   */\n  template?: ISubmission;\n\n  /**\n   * Template FK.\n   * @type {SubmissionId}\n   */\n  templateId?: SubmissionId;\n}\n"
  },
  {
    "path": "libs/types/src/models/file/file-buffer.interface.ts",
    "content": "import { IEntity } from '../database/entity.interface';\nimport { SubmissionId } from '../submission/submission.interface';\nimport { IFileDimensions } from './file-dimensions.interface';\n\n/**\n * Defines a buffer for a file and its associated attributes.\n * @interface IFileBuffer\n * @extends {FileDimensions, IEntity}\n */\nexport interface IFileBuffer extends IFileDimensions, IEntity {\n  /**\n   * Submission file FK.\n   * @type {SubmissionId}\n   */\n  submissionFileId: SubmissionId;\n\n  /**\n   * Buffer for the file.\n   */\n  buffer: Buffer;\n\n  /**\n   * Name of the file.\n   */\n  fileName: string;\n\n  /**\n   * MIME type of the file.\n   */\n  mimeType: string;\n}\n"
  },
  {
    "path": "libs/types/src/models/file/file-dimensions.interface.ts",
    "content": "/**\n * Defines dimensions for a file.\n * @interface IFileDimensions\n */\nexport interface IFileDimensions {\n  /**\n   * The size of the file in bytes.\n   * @type {number}\n   */\n  size: number;\n\n  /**\n   * The width of the file in pixels.\n   * @type {number}\n   */\n  width: number;\n\n  /**\n   * The height of the file in pixels.\n   * @type {number}\n   */\n  height: number;\n}\n"
  },
  {
    "path": "libs/types/src/models/index.ts",
    "content": "export * from './account/account.interface';\nexport * from './common/dynamic-object';\nexport * from './custom-shortcut/custom-shortcut.interface';\nexport * from './database/entity-primitive.type';\nexport * from './database/entity.interface';\nexport * from './directory-watcher/directory-watcher.interface';\nexport * from './file/file-buffer.interface';\nexport * from './file/file-dimensions.interface';\nexport * from './notification/notification.interface';\nexport * from './post/post-event.interface';\nexport * from './post/post-queue-record.interface';\nexport * from './post/post-record.interface';\nexport * from './post/post-response.type';\nexport * from './remote/update-cookies-remote.type';\nexport * from './settings/settings-options.interface';\nexport * from './settings/settings.constants';\nexport * from './settings/settings.interface';\nexport * from './submission/default-submission-file-props';\nexport * from './submission/description-value.type';\nexport * from './submission/file-submission/file-submission';\nexport * from './submission/file-submission/file-submission-metadata.type';\nexport * from './submission/file-submission/modified-file-dimension.type';\nexport * from './submission/message-submission/message-submission.type';\nexport * from './submission/npf-description.type';\nexport * from './submission/submission-file-props.interface';\nexport * from './submission/submission-file.interface';\nexport * from './submission/submission-metadata.interface';\nexport * from './submission/submission-metadata.type';\nexport * from './submission/submission-schedule-info.interface';\nexport * from './submission/submission.interface';\nexport * from './submission/validation-result.type';\nexport * from './submission/website-form-fields.interface';\nexport * from './tag/default-tag-value';\nexport * from './tag/tag-converter.interface';\nexport * from './tag/tag-group.interface';\nexport * from './tag/tag-value.type';\nexport * from './tag/tag.type';\nexport * from './update/update.type';\nexport * from './user/user-converter.interface';\nexport * from './website-options/user-specified-website-options.interface';\nexport * from './website-options/website-options.interface';\nexport * from './website/file-website-form-fields.interface';\nexport * from './website/folder.type';\nexport * from './website/image-resize-props';\nexport * from './website/login-request-data.type';\nexport * from './website/login-response.interface';\nexport * from './website/login-state.class';\nexport * from './website/login-state.interface';\nexport * from './website/post-data.type';\nexport * from './website/website-data.interface';\nexport * from './website/website-info.interface';\nexport * from './website/website-login-type';\nexport * from './website/website.type';\n"
  },
  {
    "path": "libs/types/src/models/notification/notification.interface.ts",
    "content": "import { IEntity } from '../database/entity.interface';\n\nexport interface INotification extends IEntity {\n  title: string;\n  message: string;\n  tags: string[];\n  data: Record<string, unknown>;\n  isRead: boolean;\n  hasEmitted: boolean;\n  type: 'warning' | 'error' | 'info' | 'success';\n}\n"
  },
  {
    "path": "libs/types/src/models/post/post-event.interface.ts",
    "content": "import { PostEventType } from '../../enums';\nimport { AccountId } from '../account/account.interface';\nimport { EntityId, IEntity } from '../database/entity.interface';\nimport { IWebsiteOptions } from '../website-options/website-options.interface';\nimport { PostFields } from '../website/post-data.type';\n\n/**\n * The post data captured at posting time.\n * @interface WebsitePostRecordData\n */\nexport type WebsitePostRecordData = {\n  /**\n   * The merged parsed website options form data.\n   * @type {PostFields}\n   */\n  parsedOptions?: PostFields;\n\n  /**\n   * The website options used.\n   * @type {IWebsiteOptions}\n   */\n  websiteOptions: IWebsiteOptions[];\n};\n\n/**\n * Error information stored on failed post events.\n * @interface IPostEventError\n */\nexport interface IPostEventError {\n  /**\n   * The error message.\n   * @type {string}\n   */\n  message: string;\n\n  /**\n   * The error stack trace.\n   * @type {string}\n   */\n  stack?: string;\n\n  /**\n   * The stage at which the error occurred (e.g., 'validation', 'upload', 'post').\n   * @type {string}\n   */\n  stage?: string;\n\n  /**\n   * Any additional error context that may be useful for debugging.\n   * @type {unknown}\n   */\n  additionalInfo?: unknown;\n}\n\n/**\n * Snapshot of account information at the time of the event.\n * Survives account deletion for historical display.\n * @interface IAccountSnapshot\n */\nexport interface IAccountSnapshot {\n  /**\n   * The account display name.\n   * @type {string}\n   */\n  name: string;\n\n  /**\n   * The website identifier (e.g., 'deviantart', 'furaffinity').\n   * @type {string}\n   */\n  website: string;\n}\n\n/**\n * Snapshot of file information at the time of posting.\n * Survives file deletion for historical display.\n * @interface IFileSnapshot\n */\nexport interface IFileSnapshot {\n  /**\n   * The original file name.\n   * @type {string}\n   */\n  fileName: string;\n\n  /**\n   * The MIME type of the file.\n   * @type {string}\n   */\n  mimeType: string;\n\n  /**\n   * The file size in bytes.\n   * @type {number}\n   */\n  size: number;\n\n  /**\n   * Optional hash for deduplication verification.\n   * @type {string}\n   */\n  hash?: string;\n}\n\n/**\n * Flexible metadata stored on post events.\n * Contains snapshots and debug information.\n * @interface IPostEventMetadata\n */\nexport interface IPostEventMetadata {\n  /**\n   * The batch number this file was posted in (debug/audit only).\n   * @type {number}\n   */\n  batchNumber?: number;\n\n  /**\n   * The website instance ID.\n   * @type {string}\n   */\n  instanceId?: string;\n\n  /**\n   * Any response message from the website.\n   * @type {string}\n   */\n  responseMessage?: string;\n\n  /**\n   * Account snapshot at time of event (survives account deletion).\n   * Present on POST_ATTEMPT_STARTED events.\n   * @type {IAccountSnapshot}\n   */\n  accountSnapshot?: IAccountSnapshot;\n\n  /**\n   * File snapshot at time of event (survives file deletion).\n   * Present on FILE_POSTED and FILE_FAILED events.\n   * @type {IFileSnapshot}\n   */\n  fileSnapshot?: IFileSnapshot;\n\n  /**\n   * The post data used for this attempt.\n   * Present on POST_ATTEMPT_STARTED events.\n   * @type {WebsitePostRecordData}\n   */\n  postData?: WebsitePostRecordData;\n\n  /**\n   * Any additional information for debugging.\n   * @type {unknown}\n   */\n  additionalInfo?: unknown;\n}\n\n/**\n * Represents an immutable event in the post event ledger.\n * Each posting action creates one or more events that are never mutated.\n * @interface IPostEvent\n * @extends {IEntity}\n */\nexport interface IPostEvent extends IEntity {\n  /**\n   * The post record this event belongs to.\n   * @type {EntityId}\n   */\n  postRecordId: EntityId;\n\n  /**\n   * The account this event relates to.\n   * May be null if account was deleted.\n   * @type {AccountId}\n   */\n  accountId?: AccountId;\n\n  /**\n   * The type of event.\n   * @type {PostEventType}\n   */\n  eventType: PostEventType;\n\n  /**\n   * The file this event relates to (null for message submissions and lifecycle events).\n   * @type {EntityId}\n   */\n  fileId?: EntityId;\n\n  /**\n   * The source URL returned by the website on successful post.\n   * @type {string}\n   */\n  sourceUrl?: string;\n\n  /**\n   * Error information for failed events.\n   * @type {IPostEventError}\n   */\n  error?: IPostEventError;\n\n  /**\n   * Flexible metadata including snapshots.\n   * @type {IPostEventMetadata}\n   */\n  metadata?: IPostEventMetadata;\n}\n"
  },
  {
    "path": "libs/types/src/models/post/post-queue-record.interface.ts",
    "content": "import { EntityId, IEntity } from '../database/entity.interface';\nimport { ISubmission, SubmissionId } from '../submission/submission.interface';\nimport { IPostRecord } from './post-record.interface';\n\nexport interface IPostQueueRecord extends IEntity {\n  /**\n   * Post record FK.\n   * @type {EntityId}\n   */\n  postRecordId: EntityId;\n\n  /**\n   * Submission FK.\n   * @type {SubmissionId}\n   */\n  submissionId: SubmissionId;\n\n  postRecord?: IPostRecord;\n\n  submission: ISubmission;\n}\n"
  },
  {
    "path": "libs/types/src/models/post/post-record.interface.ts",
    "content": "import { PostRecordResumeMode, PostRecordState } from '../../enums';\nimport { EntityId, IEntity } from '../database/entity.interface';\nimport { ISubmission, SubmissionId } from '../submission/submission.interface';\nimport { IPostEvent } from './post-event.interface';\nimport { IPostQueueRecord } from './post-queue-record.interface';\n\n/**\n * Represents a record in queue to post (or already posted).\n * @interface IPostRecord\n * @extends {IEntity}\n */\nexport interface IPostRecord extends IEntity {\n  submissionId: SubmissionId;\n\n  /**\n   * Parent submission Id.\n   * @type {SubmissionId}\n   */\n  submission: ISubmission;\n\n  /**\n   * Reference to the originating NEW PostRecord for this chain.\n   * - null/undefined for NEW records (they ARE the origin)\n   * - Set to the origin's ID for CONTINUE/RETRY records\n   * @type {EntityId}\n   */\n  originPostRecordId?: EntityId;\n\n  /**\n   * The originating NEW PostRecord for this chain (resolved relation).\n   * @type {IPostRecord}\n   */\n  origin?: IPostRecord;\n\n  /**\n   * All CONTINUE/RETRY PostRecords that chain to this origin (resolved relation).\n   * Only populated when this record is the origin (resumeMode = NEW).\n   * @type {IPostRecord[]}\n   */\n  chainedRecords?: IPostRecord[];\n\n  /**\n   * The date the post was completed.\n   * @type {Date}\n   */\n  completedAt?: string;\n\n  /**\n   * The state of the post record.\n   * @type {PostRecordState}\n   */\n  state: PostRecordState;\n\n  /**\n   * The resume mode of the post record.\n   * Relevant when a post record is requeued or resumed from an app termination.\n   * @type {PostRecordResumeMode}\n   */\n  resumeMode: PostRecordResumeMode;\n\n  /**\n   * The event ledger for this post record.\n   * Each event represents an immutable posting action or state change.\n   * @type {IPostEvent[]}\n   */\n  events?: IPostEvent[];\n\n  postQueueRecordId: EntityId;\n\n  /**\n   * The post queue record associated with the post record.\n   * @type {IPostQueueRecord}\n   */\n  postQueueRecord?: IPostQueueRecord;\n}\n"
  },
  {
    "path": "libs/types/src/models/post/post-response.type.ts",
    "content": "import { HttpResponse } from '@postybirb/http';\n\nexport type IPostResponse = {\n  /**\n   * The exception associated with the post.\n   * @type {Error}\n   */\n  exception?: Error;\n\n  /**\n   * The source Url of the post.\n   * @type {string}\n   */\n  sourceUrl?: string;\n\n  /**\n   * The stage that the post failed at.\n   * Not always present if unexpected throw occurs.\n   * @type {string}\n   */\n  stage?: string;\n\n  /**\n   * The response message to return to a user.\n   * Flexible for different types of responses.\n   * @type {string}\n   */\n  message?: string;\n\n  /**\n   * Any additional logging info that may be useful.\n   * @type {unknown}\n   */\n  additionalInfo?: unknown;\n\n  /**\n   * The instance id of the post.\n   * @type {string}\n   */\n  instanceId: string;\n};\n\nexport class PostResponse implements IPostResponse {\n  exception?: Error;\n\n  sourceUrl?: string;\n\n  stage?: string;\n\n  message?: string;\n\n  additionalInfo?: unknown;\n\n  readonly at: string = new Date().toISOString();\n\n  protected constructor(readonly instanceId: string) {}\n\n  static fromWebsite(website: { id: string }) {\n    return new PostResponse(website.id);\n  }\n\n  static validateBody(\n    website: { id: string },\n    res: HttpResponse<unknown>,\n    stage?: string,\n    url?: string,\n  ): void {\n    if (res.statusCode > 303) {\n      // eslint-disable-next-line @typescript-eslint/no-throw-literal\n      throw PostResponse.fromWebsite(website)\n        .withException(\n          new Error(\n            `Unexpected status code from ${res.responseUrl || url}: ${res.statusCode}`,\n          ),\n        )\n        .atStage(stage || 'Unknown')\n        .withAdditionalInfo(res.body);\n    }\n  }\n\n  withException(exception: Error) {\n    this.exception = exception;\n    if (!this.message) {\n      this.message = exception.message;\n    }\n    return this;\n  }\n\n  withMessage(message: string) {\n    this.message = message;\n    return this;\n  }\n\n  withSourceUrl(url: string) {\n    this.sourceUrl = url;\n    return this;\n  }\n\n  withAdditionalInfo(info: unknown) {\n    this.additionalInfo = info;\n    return this;\n  }\n\n  atStage(stage: string) {\n    this.stage = stage;\n    return this;\n  }\n}\n"
  },
  {
    "path": "libs/types/src/models/remote/update-cookies-remote.type.ts",
    "content": "export type UpdateCookiesRemote = {\n  /**\n   * The account ID for which cookies are being updated.\n   */\n  accountId: string;\n\n  /**\n   * The cookies to be set for the account as base64.\n   */\n  cookies: string;\n};\n"
  },
  {
    "path": "libs/types/src/models/settings/settings-options.interface.ts",
    "content": "import { WebsiteId } from '../website/website.type';\n\n/**\n * Setting properties.\n * @interface\n */\nexport interface ISettingsOptions {\n  /**\n   * Websites that should not be display in the UI.\n   * @type {string[]}\n   */\n  hiddenWebsites: WebsiteId[];\n  /**\n   * Language that is used by i18next\n   * @type {string}\n   */\n  language: string;\n\n  /**\n   * Whether to allow ad for postybirb to be added to the description.\n   * @type {boolean}\n   */\n  allowAd: boolean;\n\n  /**\n   * Whether the queue is paused by the user.\n   * @type {boolean}\n   */\n  queuePaused: boolean;\n\n  /**\n   * Desktop notification settings.\n   * @type {DesktopNotificationSettings}\n   */\n  desktopNotifications: DesktopNotificationSettings;\n\n  /**\n   * Global tag search provider id\n   */\n  tagSearchProvider: TagSearchProviderSettings;\n}\n\nexport type TagSearchProviderSettings = {\n  id: string | undefined;\n  showWikiInHelpOnHover: boolean;\n};\n\nexport type DesktopNotificationSettings = {\n  enabled: boolean;\n  showOnPostSuccess: boolean;\n  showOnPostError: boolean;\n  showOnDirectoryWatcherError: boolean;\n  showOnDirectoryWatcherSuccess: boolean;\n};\n"
  },
  {
    "path": "libs/types/src/models/settings/settings.constants.ts",
    "content": "import { ISettingsOptions } from './settings-options.interface';\n\nexport class SettingsConstants {\n  static readonly DEFAULT_PROFILE_NAME = 'default';\n\n  static readonly DEFAULT_SETTINGS: ISettingsOptions = {\n    hiddenWebsites: [],\n    language: 'en',\n    allowAd: true,\n    queuePaused: false,\n    desktopNotifications: {\n      enabled: true,\n      showOnPostSuccess: true,\n      showOnPostError: true,\n      showOnDirectoryWatcherError: true,\n      showOnDirectoryWatcherSuccess: true,\n    },\n    tagSearchProvider: {\n      id: undefined,\n      showWikiInHelpOnHover: false,\n    },\n  };\n}\n"
  },
  {
    "path": "libs/types/src/models/settings/settings.interface.ts",
    "content": "import { IEntity } from '../database/entity.interface';\nimport { ISettingsOptions } from './settings-options.interface';\n\n/**\n * User settings entity.\n * @interface ISettings\n */\nexport interface ISettings extends IEntity {\n  /**\n   * Account profile (on the off change the app supports multiple settings profile in the future)\n   * @type {string}\n   */\n  profile: string;\n\n  /**\n   * Settings.\n   * @type {ISettingsOptions}\n   */\n  settings: ISettingsOptions;\n}\n"
  },
  {
    "path": "libs/types/src/models/submission/default-submission-file-props.ts",
    "content": "import { ISubmissionFileProps } from './submission-file-props.interface';\n\nexport const DefaultSubmissionFileProps: ISubmissionFileProps = {\n  hasCustomThumbnail: false,\n};\n"
  },
  {
    "path": "libs/types/src/models/submission/description-value.type.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\n\n/**\n * A mark applied to a text node in TipTap JSON (bold, italic, link, etc.).\n */\nexport interface TipTapMark {\n  type: string;\n  attrs?: Record<string, any>;\n}\n\n/**\n * A node in TipTap's JSON document structure.\n * Can be a block (paragraph, heading), inline (custom shortcut), or text node.\n */\nexport interface TipTapNode {\n  type: string;\n  attrs?: Record<string, any>;\n  content?: TipTapNode[];\n  marks?: TipTapMark[];\n  text?: string;\n}\n\n/**\n * A TipTap JSON document — the root wrapper for editor content.\n */\nexport interface TipTapDoc {\n  type: 'doc';\n  content: TipTapNode[];\n}\n\n/**\n * The description content stored for a submission.\n * Uses TipTap's native JSON document format.\n */\nexport type Description = TipTapDoc;\n\nexport const DefaultDescription = (): Description => ({\n  type: 'doc',\n  content: [],\n});\n\n/**\n * An object representing a description value.\n * @typedef {Object} DescriptionValue\n * @property {boolean} overrideDefault - Indicates whether the default description is overridden.\n * @property {string} description - The description value.\n */\nexport type DescriptionValue = {\n  /**\n   * Indicates whether the default description is overridden.\n   * @type {boolean}\n   */\n  overrideDefault: boolean;\n\n  /**\n   * Indicates whether the tags should be inserted at the end of the description.\n   * @type {boolean}\n   */\n  insertTags?: boolean;\n\n  /**\n   * Indicates whether the title should be inserted at the beginning of the description.\n   * @type {boolean}\n   */\n  insertTitle?: boolean;\n\n  /**\n   * The description value.\n   * @type {Description}\n   */\n  description: Description;\n};\n\n/** Default description value @type {DescriptionValue} */\nexport const DefaultDescriptionValue = (): DescriptionValue => ({\n  overrideDefault: false,\n  description: DefaultDescription(),\n  insertTags: undefined,\n  insertTitle: undefined,\n});\n"
  },
  {
    "path": "libs/types/src/models/submission/file-submission/file-submission-metadata.type.ts",
    "content": "import { ISubmissionMetadata } from '../submission-metadata.interface';\n\n/**\n * Metadata for a file submission.\n * @extends ISubmissionMetadata\n */\nexport type FileSubmissionMetadata = ISubmissionMetadata;\n"
  },
  {
    "path": "libs/types/src/models/submission/file-submission/file-submission.ts",
    "content": "import { SubmissionType } from '../../../enums';\nimport { ISubmissionMetadata } from '../submission-metadata.interface';\nimport { ISubmission } from '../submission.interface';\nimport { FileSubmissionMetadata } from './file-submission-metadata.type';\n\n/**\n * Represents a file submission.\n * @type {FileSubmission}\n */\nexport type FileSubmission = ISubmission<FileSubmissionMetadata>;\n\n/**\n * Checks if the given submission is a file submission.\n * @param {ISubmission<ISubmissionMetadata>} submission - The submission to check.\n * @returns {boolean} - True if the submission is a file submission, false otherwise.\n */\nexport function isFileSubmission(\n  submission: ISubmission<ISubmissionMetadata>,\n): submission is FileSubmission {\n  return submission && submission.type === SubmissionType.FILE;\n}\n"
  },
  {
    "path": "libs/types/src/models/submission/file-submission/modified-file-dimension.type.ts",
    "content": "/**\n * Represents the dimensions of a file used in a website.\n * @typedef {Object} WebsiteFileDimension\n * @property {string} fileId - The ID of the file.\n * @property {number} height - The height of the file in pixels.\n * @property {number} width - The width of the file in pixels.\n */\nexport type ModifiedFileDimension = {\n  /**\n   * The height of the file in pixels.\n   * @type {number}\n   */\n  height: number;\n\n  /**\n   * The width of the file in pixels.\n   * @type {number}\n   */\n  width: number;\n};\n"
  },
  {
    "path": "libs/types/src/models/submission/message-submission/message-submission.type.ts",
    "content": "import { ISubmissionMetadata } from '../submission-metadata.interface';\nimport { ISubmission } from '../submission.interface';\n\n/**\n * Metadata associated with a message submission.\n * @typedef {ISubmissionMetadata} MessageSubmissionMetadata\n */\nexport type MessageSubmissionMetadata = ISubmissionMetadata;\n\n/**\n * Represents a message submission with associated metadata.\n * @typedef {ISubmission<MessageSubmissionMetadata>} MessageSubmission\n */\nexport type MessageSubmission = ISubmission<MessageSubmissionMetadata>;\n"
  },
  {
    "path": "libs/types/src/models/submission/npf-description.type.ts",
    "content": "/**\n * Tumblr NPF (Nuevo Post Format) content block types.\n * @see https://www.tumblr.com/docs/npf\n */\nexport type NPFContentBlock =\n  | NPFTextBlock\n  | NPFImageBlock\n  | NPFLinkBlock\n  | NPFAudioBlock\n  | NPFVideoBlock;\n\nexport interface NPFTextBlock {\n  type: 'text';\n  text: string;\n  subtype?:\n    | 'heading1'\n    | 'heading2'\n    | 'quote'\n    | 'indented'\n    | 'chat'\n    | 'ordered-list-item'\n    | 'unordered-list-item'\n    | 'quirky';\n  indent_level?: number;\n  formatting?: NPFInlineFormatting[];\n}\n\nexport interface NPFInlineFormatting {\n  start: number;\n  end: number;\n  type:\n    | 'bold'\n    | 'italic'\n    | 'strikethrough'\n    | 'small'\n    | 'link'\n    | 'mention'\n    | 'color';\n  url?: string;\n  blog?: { uuid: string };\n  hex?: string;\n}\n\nexport interface NPFImageBlock {\n  type: 'image';\n  media: NPFMediaObject[];\n  alt_text?: string;\n  caption?: string;\n  colors?: Record<string, string>;\n}\n\nexport interface NPFMediaObject {\n  url: string;\n  type?: string;\n  width?: number;\n  height?: number;\n  original_dimensions_missing?: boolean;\n}\n\nexport interface NPFLinkBlock {\n  type: 'link';\n  url: string;\n  title?: string;\n  description?: string;\n  author?: string;\n  site_name?: string;\n  display_url?: string;\n  poster?: NPFMediaObject[];\n}\n\nexport interface NPFAudioBlock {\n  type: 'audio';\n  provider?: string;\n  url?: string;\n  media?: NPFMediaObject;\n  title?: string;\n  artist?: string;\n  album?: string;\n  poster?: NPFMediaObject[];\n}\n\nexport interface NPFVideoBlock {\n  type: 'video';\n  provider?: string;\n  url?: string;\n  media?: NPFMediaObject;\n  poster?: NPFMediaObject[];\n  embed_html?: string;\n  embed_url?: string;\n}\n"
  },
  {
    "path": "libs/types/src/models/submission/submission-file-props.interface.ts",
    "content": "// TODO - consider deleting model\nexport interface ISubmissionFileProps {\n  /**\n   * Flag for determining if a thumbnail needs to be re-generated on file replace\n   * @type {boolean}\n   */\n  hasCustomThumbnail: boolean;\n  /**\n   * Height of the file (when image, otherwise 0)\n   * @type {number}\n   * @deprecated\n   */\n  height?: number;\n  /**\n   * Width of the file (when image, otherwise 0)\n   * @type {number}\n   * @deprecated - not sure if this ever got any use\n   */\n  width?: number;\n}\n"
  },
  {
    "path": "libs/types/src/models/submission/submission-file.interface.ts",
    "content": "import { AccountId, ModifiedFileDimension } from '@postybirb/types';\nimport { EntityId, IEntity } from '../database/entity.interface';\nimport { IFileBuffer } from '../file/file-buffer.interface';\nimport { IFileDimensions } from '../file/file-dimensions.interface';\nimport { FileSubmissionMetadata } from './file-submission/file-submission-metadata.type';\nimport { ISubmission } from './submission.interface';\n\nexport type SubmissionFileId = EntityId;\n\n/**\n * Represents a file attached to a submission for posting.\n *\n * @interface ISubmissionFile\n * @extends {IFileDimensions}\n * @extends {IEntity}\n */\nexport interface ISubmissionFile extends IFileDimensions, IEntity {\n  /**\n   * Parent submission.\n   *\n   * @type {ISubmission<FileSubmissionMetadata>}\n   */\n  submission: ISubmission<FileSubmissionMetadata>;\n\n  submissionId: EntityId;\n\n  /**\n   * Name of the file.\n   *\n   * @type {string}\n   */\n  fileName: string;\n\n  /**\n   * Hash of the file.\n   *\n   * @type {string}\n   */\n  hash: string;\n\n  /**\n   * Mime Type of the file.\n   *\n   * @type {string}\n   */\n  mimeType: string;\n\n  /**\n   * Reference to the buffer entity.\n   *\n   * @type {IFileBuffer}\n   */\n  file: IFileBuffer;\n\n  /**\n   * Optional thumbnail for the file.\n   * Should be autogenerated if possible.\n   *\n   * @type {(IFileBuffer | undefined)}\n   */\n  thumbnail?: IFileBuffer;\n\n  /**\n   * Alternate file to post instead of the main.\n   * Primarily used for Text based submission.\n   *\n   * @type {(IFileBuffer | undefined)}\n   */\n  altFile?: IFileBuffer;\n\n  /**\n   * Status flag for internal processing.\n   *\n   * @type {boolean}\n   */\n  hasThumbnail: boolean;\n\n  /**\n   * Status flag for internal processing.\n   *\n   * @type {boolean}\n   */\n  hasAltFile: boolean;\n\n  /**\n   * Whether or not the file has a custom thumbnail that was not\n   * auto-generated and instead added by the user.\n   * @type {boolean}\n   */\n  hasCustomThumbnail: boolean;\n\n  primaryFileId: EntityId;\n\n  thumbnailId?: EntityId;\n\n  altFileId?: EntityId;\n\n  metadata: SubmissionFileMetadata;\n\n  order: number;\n}\n\nexport interface SubmissionFileMetadata {\n  /**\n   * The alternative text for the file.\n   * @type {string}\n   */\n  altText?: string;\n\n  /**\n   * The spoiler text for the file.\n   * @type {string}\n   */\n  spoilerText?: string;\n\n  /**\n   * The dimensions of the file for different websites.\n   * @type {Record<AccountId, ModifiedFileDimension>}\n   */\n  dimensions: Record<AccountId, ModifiedFileDimension>;\n\n  /**\n   * The list of websites where the file is ignored.\n   * @type {AccountId[]}\n   */\n  ignoredWebsites: AccountId[];\n\n  /**\n   * The source URLs for the file.\n   * @type {string[]}\n   */\n  sourceUrls: string[];\n}\n\nexport const DefaultSubmissionFileMetadata: () => SubmissionFileMetadata =\n  () => ({\n    altText: '',\n    spoilerText: '',\n    dimensions: {},\n    ignoredWebsites: [],\n    sourceUrls: [],\n  });\n"
  },
  {
    "path": "libs/types/src/models/submission/submission-metadata.interface.ts",
    "content": "export interface ISubmissionMetadata {\n  template?: SubmissionTemplateMetadata;\n}\n\nexport type SubmissionTemplateMetadata = {\n  name: string;\n};\n"
  },
  {
    "path": "libs/types/src/models/submission/submission-metadata.type.ts",
    "content": "import { MessageSubmissionMetadata } from './message-submission/message-submission.type';\nimport { FileSubmissionMetadata } from './file-submission/file-submission-metadata.type';\n\n/**\n * Defines the type of submission metadata.\n *\n * @typedef {FileSubmissionMetadata | MessageSubmissionMetadata} SubmissionMetadataType\n */\nexport type SubmissionMetadataType =\n  | FileSubmissionMetadata // Submission metadata for a file\n  | MessageSubmissionMetadata; // Submission metadata for a message\n"
  },
  {
    "path": "libs/types/src/models/submission/submission-schedule-info.interface.ts",
    "content": "import { ScheduleType } from '../../enums';\n\n/**\n * Represents information about the schedule for a submission.\n * @interface ISubmissionScheduleInfo\n */\nexport interface ISubmissionScheduleInfo {\n  /**\n   * The time at which the submission is scheduled, specified as a CRON string or a Date string.\n   *\n   * @type {string}\n   */\n  scheduledFor?: string;\n  /**\n   * The type of schedule for the submission.\n   * @type {ScheduleType}\n   */\n  scheduleType: ScheduleType;\n\n  /**\n   * The CRON string for the schedule.\n   * @type {string}\n   */\n  cron?: string;\n}\n"
  },
  {
    "path": "libs/types/src/models/submission/submission.interface.ts",
    "content": "import { SubmissionType } from '../../enums';\nimport { EntityId, IEntity } from '../database/entity.interface';\nimport { IPostQueueRecord } from '../post/post-queue-record.interface';\nimport { IPostRecord } from '../post/post-record.interface';\nimport { IWebsiteOptions } from '../website-options/website-options.interface';\nimport { ISubmissionFile } from './submission-file.interface';\nimport { ISubmissionMetadata } from './submission-metadata.interface';\nimport { ISubmissionScheduleInfo } from './submission-schedule-info.interface';\nimport { IWebsiteFormFields } from './website-form-fields.interface';\n\nexport type SubmissionId = EntityId;\n\n/**\n * Represents a submission entity.\n * @interface ISubmission\n * @template T - The type of metadata associated with the submission.\n * @extends {IEntity}\n */\nexport interface ISubmission<\n  T extends ISubmissionMetadata = ISubmissionMetadata,\n> extends IEntity {\n  /**\n   * The type of the submission.\n   * @type {SubmissionType}\n   */\n  type: SubmissionType;\n\n  /**\n   * The options associated with the submission.\n   * @type {Collection<IWebsiteOptions<IWebsiteFormFields>>}\n   */\n  options: IWebsiteOptions<IWebsiteFormFields>[];\n\n  /**\n   * The post queue record associated with the submission.\n   * @type {IPostQueueRecord}\n   */\n  postQueueRecord?: IPostQueueRecord;\n\n  /**\n   * Indicates whether the submission is scheduled.\n   * @type {boolean}\n   */\n  isScheduled: boolean;\n\n  /**\n   * Indicates whether the submission is a template.\n   * @type {boolean}\n   */\n  isTemplate: boolean;\n\n  /**\n   * Indicates whether or not the submission is a auto-created\n   * multi-submission.\n   * @type {boolean}\n   */\n  isMultiSubmission: boolean;\n\n  /**\n   * Indicates whether the submission is archived (manually or through post completion).\n   * @type {boolean}\n   */\n  isArchived: boolean;\n\n  /**\n   * Indicates whether the submission has completed initialization\n   * (all files, options, and metadata have been set up).\n   * @type {boolean}\n   */\n  isInitialized: boolean;\n\n  /**\n   * Information about the schedule for the submission.\n   * @type {ISubmissionScheduleInfo}\n   */\n  schedule: ISubmissionScheduleInfo;\n\n  /**\n   * The files associated with the submission.\n   * @type {Collection<ISubmissionFile>}\n   */\n  files: ISubmissionFile[];\n\n  /**\n   * The metadata associated with the submission.\n   * @type {T}\n   */\n  metadata: T;\n\n  /**\n   * The post records associated with the submission.\n   * @type {Collection<IPostRecord>}\n   */\n  posts: IPostRecord[];\n\n  /**\n   * The index of the submission for display purposes.\n   * @type {number}\n   */\n  order: number;\n}\n"
  },
  {
    "path": "libs/types/src/models/submission/validation-result.type.ts",
    "content": "import { IEntityDto } from '../../dtos';\nimport { FileType } from '../../enums';\nimport { IAccount } from '../account/account.interface';\nimport { EntityId } from '../database/entity.interface';\nimport { ImageResizeProps } from '../website/image-resize-props';\nimport { IWebsiteFormFields } from './website-form-fields.interface';\n\nexport type SimpleValidationResult<T extends IWebsiteFormFields = never> = Omit<\n  ValidationResult<T>,\n  'id' | 'account'\n>;\n\nexport type ValidationResult<T extends IWebsiteFormFields = never> = {\n  /**\n   * Id that associates with the website options the validation was performed on.\n   */\n  id: EntityId;\n\n  /**\n   * The account associated with the website options.\n   * More for readability and to avoid having to look up the account from the website options.\n   */\n  account: IEntityDto<IAccount>;\n\n  /**\n   * Non-blocking issues with the validated submission.\n   */\n  warnings?: ValidationMessage<T>[];\n\n  /**\n   * Blocking issues with the validated submission.\n   */\n  errors?: ValidationMessage<T>[];\n};\n\nexport type ValidationMessage<\n  T extends object = never,\n  Id extends keyof ValidationMessages = keyof ValidationMessages,\n> = {\n  /**\n   * Localization message id.\n   */\n  id: Id;\n\n  /**\n   * Associates the message to a input field.\n   */\n  field?: keyof T;\n\n  /**\n   * Values to fill in the message.\n   */\n  values: ValidationMessages[Id];\n};\n\n/**\n * Map containing validation id as key and values as value\n */\nexport interface ValidationMessages {\n  // An error message for when the validation fails\n  'validation.failed': {\n    /**\n     * The error message\n     */\n    message: string;\n  };\n\n  'validation.file.invalid-mime-type': {\n    mimeType: string;\n    acceptedMimeTypes: string[];\n    fileId: string;\n  };\n\n  'validation.file.all-ignored': object;\n\n  'validation.file.unsupported-file-type': {\n    fileName: string;\n    fileType: FileType;\n    fileId: string;\n  };\n\n  'validation.file.file-batch-size': {\n    maxBatchSize: number;\n    expectedBatchesToCreate: number;\n  };\n\n  'validation.file.text-file-no-fallback': {\n    fileName: string;\n    fileExtension: string;\n    fileId: string;\n  };\n\n  'validation.file.file-size': {\n    maxFileSize: number;\n    fileSize: number;\n    fileName: string;\n    fileId: string;\n  };\n\n  'validation.file.image-resize': {\n    fileName: string;\n    resizeProps: ImageResizeProps;\n    fileId: string;\n  };\n\n  'validation.description.max-length': {\n    currentLength: number;\n    maxLength: number;\n  };\n\n  'validation.description.min-length': {\n    currentLength: number;\n    minLength: number;\n  };\n\n  'validation.description.missing-tags': object;\n\n  'validation.description.unexpected-tags': object;\n\n  'validation.description.missing-title': object;\n\n  'validation.description.unexpected-title': object;\n\n  'validation.tags.max-tags': {\n    currentLength: number;\n    maxLength: number;\n  };\n\n  'validation.tags.min-tags': {\n    currentLength: number;\n    minLength: number;\n  };\n\n  'validation.tags.max-tag-length': {\n    tags: string[];\n    maxLength: number;\n  };\n\n  'validation.tags.double-hashtag': {\n    tags: string[];\n  };\n\n  'validation.title.max-length': {\n    currentLength: number;\n    maxLength: number;\n  };\n\n  'validation.title.min-length': {\n    currentLength: number;\n    minLength: number;\n  };\n\n  'validation.select-field.min-selected': {\n    minSelected: number;\n    currentSelected: number;\n  };\n\n  'validation.select-field.invalid-option': {\n    invalidOptions: string[];\n  };\n\n  'validation.field.required': object;\n\n  'validation.datetime.invalid-format': {\n    value: string;\n  };\n\n  'validation.datetime.min': {\n    currentDate: string;\n    minDate: string;\n  };\n\n  'validation.datetime.max': {\n    currentDate: string;\n    maxDate: string;\n  };\n\n  'validation.datetime.range': {\n    currentDate: string;\n    minDate: string;\n    maxDate: string;\n  };\n\n  'validation.rating.unsupported-rating': {\n    rating: string;\n  };\n\n  // ----------- Website specific validation messages --------------\n  'validation.file.itaku.must-share-feed': object;\n\n  'validation.file.bluesky.unsupported-combination-of-files': object;\n\n  'validation.file.bluesky.gif-conversion': object;\n\n  'validation.file.bluesky.invalid-reply-url': object;\n\n  'validation.file.bluesky.rating-matches-default': object;\n\n  'validation.file.e621.tags.network-error': object;\n\n  'validation.file.e621.tags.recommended': {\n    generalTags: number;\n  };\n\n  'validation.file.e621.tags.missing': {\n    tag: string;\n  };\n\n  'validation.file.e621.tags.missing-create': {\n    tag: string;\n  };\n\n  'validation.file.e621.tags.invalid': {\n    tag: string;\n  };\n\n  'validation.file.e621.tags.low-use': {\n    tag: string;\n    postCount: number;\n  };\n\n  'validation.file.e621.user-feedback.network-error': object;\n\n  'validation.file.e621.user-feedback.recent': {\n    negativeOrNeutral: string;\n    feedback: string;\n    username: string;\n  };\n\n  'validation.folder.missing-or-invalid': object;\n\n  'validation.file.instagram.invalid-aspect-ratio': {\n    fileName: string;\n  };\n}\n"
  },
  {
    "path": "libs/types/src/models/submission/website-form-fields.interface.ts",
    "content": "import { SubmissionRating } from '../../enums';\nimport { TagValue } from '../tag/tag-value.type';\nimport { DescriptionValue } from './description-value.type';\n\n/**\n * An interface representing the base fields for a submission.\n * @interface\n */\nexport interface IWebsiteFormFields {\n  /**\n   * The title of the submission.\n   * @type {string|undefined}\n   */\n  title?: string;\n\n  /**\n   * The tags associated with the submission.\n   * @type {TagValue|undefined}\n   */\n  tags?: TagValue;\n\n  /**\n   * The description of the submission.\n   * @type {DescriptionValue|undefined}\n   */\n  description?: DescriptionValue;\n\n  /**\n   * The rating of the submission.\n   * @type {SubmissionRating}\n   */\n  rating?: SubmissionRating;\n\n  /**\n   * Optional spoiler/content warning text\n   * @type {string}\n   */\n  contentWarning?: string;\n}\n"
  },
  {
    "path": "libs/types/src/models/tag/default-tag-value.ts",
    "content": "import { TagValue } from './tag-value.type';\n\n/** Default tag value {@link TagValue} */\nexport const DefaultTagValue = (): TagValue => ({\n  overrideDefault: false,\n  tags: [],\n});\n"
  },
  {
    "path": "libs/types/src/models/tag/tag-converter.interface.ts",
    "content": "import { IEntity } from '../database/entity.interface';\nimport { WebsiteId } from '../website/website.type';\nimport { Tag } from './tag.type';\n\n/**\n * Represents a Tag Converter.\n * @interface ITagConverter\n * @extends {IEntity}\n */\nexport interface ITagConverter extends IEntity {\n  /**\n   * The tag to convert.\n   * @type {Tag}\n   */\n  tag: Tag;\n\n  /**\n   * The website to tag conversion.\n   * @type {Record<WebsiteId, Tag>}\n   */\n  convertTo: Record<WebsiteId, Tag>;\n}\n"
  },
  {
    "path": "libs/types/src/models/tag/tag-group.interface.ts",
    "content": "import { IEntity } from '../database/entity.interface';\nimport { Tag } from './tag.type';\n\n/**\n * Represents a Tag Group.\n * @interface ITagGroup\n * @extends {IEntity}\n */\nexport interface ITagGroup extends IEntity {\n  /**\n   * User provided name of a tag group.\n   * @type {string}\n   */\n  name: string;\n\n  /**\n   * Tags for the tag group.\n   * @type {Tag[]}\n   */\n  tags: Tag[];\n}\n"
  },
  {
    "path": "libs/types/src/models/tag/tag-value.type.ts",
    "content": "import { Tag } from './tag.type';\n\n/**\n * Represents a set of tag values that may override the default values.\n * @typedef {Object} TagValue\n * @property {boolean} overrideDefault - Whether the default values should be overridden.\n * @property {Tag[]} tags - The tag values.\n */\nexport type TagValue = {\n  /**\n   * Whether the default values should be overridden.\n   * @type {boolean}\n   */\n  overrideDefault: boolean;\n  /**\n   * The tag values.\n   * @type {Tag[]}\n   */\n  tags: Tag[];\n};\n"
  },
  {
    "path": "libs/types/src/models/tag/tag.type.ts",
    "content": "export type Tag = string;\n"
  },
  {
    "path": "libs/types/src/models/update/update.type.ts",
    "content": "/**\n * Information about a specific release note.\n */\nexport interface ReleaseNoteInfo {\n  /**\n   * The version number for this release.\n   */\n  readonly version: string;\n  /**\n   * The release note content (HTML formatted).\n   */\n  readonly note: string | null;\n}\n\n/**\n * The current state of the application update process.\n */\nexport interface UpdateState {\n  /**\n   * Whether an update is available for download.\n   */\n  updateAvailable?: boolean;\n  /**\n   * Whether the update has been downloaded and is ready to install.\n   */\n  updateDownloaded?: boolean;\n  /**\n   * Whether an update is currently being downloaded.\n   */\n  updateDownloading?: boolean;\n  /**\n   * Error message if the update process failed.\n   */\n  updateError?: string;\n  /**\n   * Download progress percentage (0-100).\n   */\n  updateProgress?: number;\n  /**\n   * Release notes for available updates.\n   */\n  updateNotes?: ReleaseNoteInfo[];\n}\n"
  },
  {
    "path": "libs/types/src/models/user/user-converter.interface.ts",
    "content": "import { IEntity } from '../database/entity.interface';\nimport { WebsiteId } from '../website/website.type';\n\n/**\n * Represents a User Converter.\n * @interface IUserConverter\n * @extends {IEntity}\n */\nexport interface IUserConverter extends IEntity {\n  /**\n   * The username to convert.\n   * @type {string}\n   */\n  username: string;\n\n  /**\n   * The website to username conversion.\n   * @type {Record<WebsiteId, string>}\n   */\n  convertTo: Record<WebsiteId, string>;\n}\n"
  },
  {
    "path": "libs/types/src/models/website/file-website-form-fields.interface.ts",
    "content": "import { IWebsiteFormFields } from '../submission/website-form-fields.interface';\n\n/**\n * File submission specific fields.\n * Empty at the moment\n * @interface FileWebsiteFormFields\n * @extends {IWebsiteFormFields}\n */\n// eslint-disable-next-line @typescript-eslint/no-empty-interface\nexport interface FileWebsiteFormFields extends IWebsiteFormFields {}\n"
  },
  {
    "path": "libs/types/src/models/website/folder.type.ts",
    "content": "export type Folder = {\n  value?: string; // Fall back to label if not provided\n  label: string;\n  children?: Folder[];\n  nsfw?: boolean;\n};\n"
  },
  {
    "path": "libs/types/src/models/website/image-resize-props.ts",
    "content": "export type ImageResizeProps = {\n  width?: number;\n  height?: number;\n  maxBytes?: number;\n  allowQualityLoss?: boolean;\n  /**\n   * If set, convert the image to this MIME type (e.g., 'image/jpeg', 'image/png').\n   * Uses sharp for conversion during the resize pass.\n   */\n  outputMimeType?: string;\n};\n"
  },
  {
    "path": "libs/types/src/models/website/login-request-data.type.ts",
    "content": "import { AccountId } from '../account/account.interface';\n\n/**\n * Data structure for login request.\n * @interface LoginRequestData\n */\nexport type LoginRequestData = {\n  /**\n   * Account ID.\n   * @type {AccountId}\n   */\n  accountId: AccountId;\n};\n"
  },
  {
    "path": "libs/types/src/models/website/login-response.interface.ts",
    "content": "/**\n * Interface representing the response of a login request.\n * @interface\n */\nexport interface ILoginResponse {\n  /**\n   * The username of the account that logged in. Optional.\n   * @type {string|undefined}\n   */\n  username?: string;\n  /**\n   * A flag indicating whether the login was successful.\n   * @type {boolean}\n   */\n  loggedIn: boolean;\n}\n"
  },
  {
    "path": "libs/types/src/models/website/login-state.class.ts",
    "content": "import { ILoginState } from './login-state.interface';\n\n/**\n * A class used for tracking the login state of a website.\n * @class\n * @implements ILoginState\n */\nexport class LoginState implements ILoginState {\n  /**\n   * Whether a login request is pending.\n   * @type {boolean}\n   */\n  pending = false;\n\n  /**\n   * Whether the user is currently logged in.\n   * @type {boolean}\n   */\n  isLoggedIn = false;\n\n  /**\n   * The username of the logged-in user, or null if not logged in.\n   * @type {string | null}\n   */\n  username: string | null = null;\n\n  /**\n   * ISO 8601 timestamp of the last time the login state was updated.\n   * @type {string | null}\n   */\n  lastUpdated: string | null = null;\n\n  /**\n   * Updates the lastUpdated timestamp to now.\n   */\n  private touch(): void {\n    this.lastUpdated = new Date().toISOString();\n  }\n\n  /**\n   * Logs the user out by resetting the login state.\n   * @returns {LoginState} The current LoginState object.\n   */\n  public logout(): LoginState {\n    this.isLoggedIn = false;\n    this.username = null;\n    this.pending = false;\n    this.touch();\n    return this;\n  }\n\n  /**\n   * Sets the login state to the given values.\n   * @param {boolean} isLoggedIn - Whether the user is currently logged in.\n   * @param {string | null} username - The username of the logged-in user, or null if not logged in.\n   * @returns {ILoginState} The current login state.\n   */\n  public setLogin(isLoggedIn: boolean, username: string | null): ILoginState {\n    this.isLoggedIn = isLoggedIn;\n    this.username = username;\n    this.touch();\n    return this.getState();\n  }\n\n  /**\n   * Sets the pending flag.\n   * @param {boolean} value - Whether a login request is pending.\n   */\n  public setPending(value: boolean): void {\n    this.pending = value;\n    this.touch();\n  }\n\n  /**\n   * Returns a copy of the current login state.\n   * @returns {ILoginState} A copy of the current login state.\n   */\n  getState(): ILoginState {\n    return {\n      isLoggedIn: this.isLoggedIn,\n      username: this.username,\n      pending: this.pending,\n      lastUpdated: this.lastUpdated,\n    };\n  }\n}\n"
  },
  {
    "path": "libs/types/src/models/website/login-state.interface.ts",
    "content": "/**\n * Represents the login state of a website.\n * @interface\n */\nexport interface ILoginState {\n  /**\n   * Whether the user is currently logged in.\n   * @type {boolean}\n   */\n  isLoggedIn: boolean;\n  /**\n   * The username of the logged-in user, or null if not logged in.\n   * @type {(string | null)}\n   */\n  username: string | null;\n  /**\n   * Whether a login request is pending.\n   * @type {boolean}\n   */\n  pending: boolean;\n  /**\n   * ISO 8601 timestamp of the last time the login state was updated.\n   * Used to detect stale cached values.\n   * @type {(string | null)}\n   */\n  lastUpdated: string | null;\n}\n"
  },
  {
    "path": "libs/types/src/models/website/post-data.type.ts",
    "content": "import { ISubmission } from '../submission/submission.interface';\nimport { IWebsiteFormFields } from '../submission/website-form-fields.interface';\n\nexport type PostFields<T extends IWebsiteFormFields = IWebsiteFormFields> =\n  Omit<T, 'description' | 'tags'> & {\n    description?: string;\n    tags?: string[];\n  };\n\n/**\n * The data associated with a post request.\n * @template S - The type of submission.\n * @template T - The type of submission options.\n * @typedef {Object} PostData\n * @property {PostFields<T>} options - The submission options.\n * @property {S} submission - The submission data.\n */\nexport type PostData<T extends IWebsiteFormFields = IWebsiteFormFields> = {\n  /**\n   * The submission options.\n   * @type {T}\n   */\n  options: PostFields<T>;\n  /**\n   * The submission data.\n   * @type {S}\n   */\n  submission: ISubmission;\n};\n"
  },
  {
    "path": "libs/types/src/models/website/website-data.interface.ts",
    "content": "import { DynamicObject } from '../common/dynamic-object';\nimport { IEntity } from '../database/entity.interface';\n\n/**\n * Represents data associated with a website.\n * @interface IWebsiteData\n * @template T - The type of data associated with the website.\n * @extends {IEntity}\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport interface IWebsiteData<T extends DynamicObject = any> extends IEntity {\n  /**\n   * The data associated with the website.\n   * @type {T}\n   */\n  data: T;\n}\n"
  },
  {
    "path": "libs/types/src/models/website/website-info.interface.ts",
    "content": "import { SubmissionType } from '../../enums';\n\n/**\n * Website specific info to be passed down to consumers of the API.\n *\n * @interface IWebsiteInfo\n */\nexport interface IWebsiteInfo {\n  websiteDisplayName: string;\n  supports: SubmissionType[];\n}\n"
  },
  {
    "path": "libs/types/src/models/website/website-login-type.ts",
    "content": "export type WebsiteLoginType = UserLoginType | CustomLoginType;\n\ninterface BaseLoginType {\n  type: string;\n}\n\nexport interface UserLoginType extends BaseLoginType {\n  type: 'user';\n  url: string;\n}\n\nexport interface CustomLoginType extends BaseLoginType {\n  type: 'custom';\n  loginComponentName: string;\n}\n"
  },
  {
    "path": "libs/types/src/models/website/website.type.ts",
    "content": "/**\n * Defines typings for a website Id.\n * These are not defined by a database but by the name of the\n * implemented website class.\n */\nexport type WebsiteId = string | 'default';\n"
  },
  {
    "path": "libs/types/src/models/website-options/user-specified-website-options.interface.ts",
    "content": "import { SubmissionType } from '../../enums';\nimport { AccountId, IAccount } from '../account/account.interface';\nimport { DynamicObject } from '../common/dynamic-object';\nimport { IEntity } from '../database/entity.interface';\n\n/**\n * Represents default website options set for an account by a user.\n * @interface IUserSpecifiedWebsiteOptions\n * @extends {IEntity}\n */\nexport interface IUserSpecifiedWebsiteOptions extends IEntity {\n  accountId: AccountId;\n\n  /**\n   * The account the options are tied to.\n   * @type {IAccount}\n   */\n  account: IAccount;\n\n  /**\n   * The {SubmissionType} that the defaults are applied to.\n   * @type {SubmissionType}\n   */\n  type: SubmissionType;\n\n  /**\n   * The option defaults to be applied.\n   * @type {DynamicObject}\n   */\n  options: DynamicObject;\n}\n"
  },
  {
    "path": "libs/types/src/models/website-options/website-options.interface.ts",
    "content": "import { AccountId, IAccount } from '../account/account.interface';\nimport { IEntity } from '../database/entity.interface';\nimport { ISubmissionMetadata } from '../submission/submission-metadata.interface';\nimport { ISubmission } from '../submission/submission.interface';\nimport { IWebsiteFormFields } from '../submission/website-form-fields.interface';\n\n/**\n * Represents options associated with a submission per account.\n * @interface IWebsiteOptions\n * @template T - The type of fields associated with the submission.\n * @extends {IEntity}\n */\nexport interface IWebsiteOptions<\n  T extends IWebsiteFormFields = IWebsiteFormFields,\n> extends IEntity {\n  /**\n   * The submission associated with the options.\n   * @type {ISubmission<ISubmissionMetadata>}\n   */\n  submission: ISubmission<ISubmissionMetadata>;\n\n  /**\n   * The fields associated with the options.\n   * @type {T}\n   */\n  data: T;\n\n  accountId: AccountId;\n\n  /**\n   * The account associated with the options.\n   * @type {IAccount}\n   */\n  account: IAccount;\n\n  /**\n   * Indicates whether the the entity as targeting default options.\n   * @type {boolean}\n   */\n  isDefault: boolean;\n}\n"
  },
  {
    "path": "libs/types/src/website-modifiers/index.ts",
    "content": "export * from './oauth-routes';\nexport * from './username-shortcut';\nexport * from './website-metadata';\n"
  },
  {
    "path": "libs/types/src/website-modifiers/oauth-routes.ts",
    "content": "import { DynamicObject } from '../models';\n\nexport type OAuthRoutes = Record<\n  string,\n  { request: DynamicObject; response: unknown }\n>;\n\ntype MaybePromise<T> = T | Promise<T>;\n\nexport type OAuthRouteHandlers<T extends OAuthRoutes> = {\n  [K in keyof T]: (request: T[K]['request']) => MaybePromise<T[K]['response']>;\n};\n"
  },
  {
    "path": "libs/types/src/website-modifiers/username-shortcut.ts",
    "content": "export type UsernameShortcut = {\n  /**\n   * Id used by users when referencing a username shortcut.\n   * @type {string}\n   */\n  id: string;\n\n  /**\n   * Url that is used for inserting the username.\n   * Need to add a '$1' where the username should be inserted.\n   * @example https://twitter.com/$1\n   * @type {string}\n   */\n  url: string;\n\n  /**\n   * Optional function that can be used to modify the username string before insertion.\n   * @param {string} websiteName\n   * @param {string} shortcut\n   * @returns {string | undefined}\n   */\n  convert?: (websiteName: string, shortcut: string) => string | undefined;\n};\n"
  },
  {
    "path": "libs/types/src/website-modifiers/website-file-options.ts",
    "content": "import { FileType } from '../enums';\n\nexport type WebsiteFileOptions = {\n  /**\n   * A list of accepted Mime types.\n   * Only needed when using File posting websites.\n   */\n  acceptedMimeTypes: string[];\n\n  /**\n   * The acceptable file size limits in bytes.\n   * Only needed when using File posting websites.\n   * Supports FileType(FileType.IMAGE), MimeType (image/png), WildCard (image/*),\n   * and File Extension (e.g. '.txt')\n   *\n   * Example:\n   *  {\n   *    'image/*': 5 * 1024 * 1024,\n   *    'text/*': 2 * 1024 * 1024,\n   *    '*': 10 * 1024 * 1024,\n   *  }\n   */\n  acceptedFileSizes?: Record<string, number>;\n\n  /**\n   * Indicates a website that takes a source URL as input.\n   * This is used for determining the order of posting websites.\n   * Websites that support external sources will be posted last unless\n   * otherwise overridden.\n   */\n  acceptsExternalSourceUrls?: boolean;\n\n  /**\n   * The batch size of files to send to the website.\n   * Defaults to 1.\n   */\n  fileBatchSize?: number;\n\n  /**\n   * The supported file types for the website.\n   */\n  supportedFileTypes: FileType[];\n};\n"
  },
  {
    "path": "libs/types/src/website-modifiers/website-metadata.ts",
    "content": "export interface IWebsiteMetadata {\n  /**\n   * Internal name of the website to be used.\n   * You will set this once and never change it once released.\n   */\n  name: string;\n\n  /**\n   * Display name of the website to be shown.\n   * If not provided, will default to capitalized name property.\n   */\n  displayName?: string;\n\n  /**\n   * How often in milliseconds login should be re-checked.\n   */\n  refreshInterval?: number;\n\n  /**\n   * Minimum wait time before a post can be made after a post has been made.\n   * This is used to prevent the website from being spammed with posts or to avoid\n   * spam detection measures.\n   */\n  minimumPostWaitInterval?: number;\n}\n"
  },
  {
    "path": "libs/types/src/website-public/README.md",
    "content": "# Website-Public\n\nThis is a location for website typings that are shared between the UI and the Client-Server.\nMost likely use case is when a website has a custom login form.\n\nThis may lead to a breakage in the standard co-location pattern of AccountData types.\n"
  },
  {
    "path": "libs/types/src/website-public/bluesky-account-data.ts",
    "content": "export type BlueskyAccountData = {\n  username: string;\n  password: string;\n  serviceUrl?: string; // Defaults to bsky.social\n  appViewUrl?: string; // Defaults to bsky.app\n};\n\nexport type BlueskyOAuthRoutes = {\n  login: {\n    request: BlueskyAccountData;\n    response: { result: boolean };\n  };\n};\n"
  },
  {
    "path": "libs/types/src/website-public/custom-account-data.ts",
    "content": "export interface CustomAccountData {\n  descriptionField?: string;\n  descriptionType?: 'html' | 'text' | 'md' | 'bbcode';\n  fileField?: string;\n  fileUrl?: string;\n  headers: { name: string; value: string }[];\n  notificationUrl?: string;\n  ratingField?: string;\n  tagField?: string;\n  thumbnailField?: string;\n  titleField?: string;\n  altTextField?: string;\n  fileBatchLimit?: number;\n}\n"
  },
  {
    "path": "libs/types/src/website-public/discord-account-data.ts",
    "content": "export type DiscordAccountData = {\n  webhook: string;\n  serverLevel: number;\n  isForum: boolean;\n};\n"
  },
  {
    "path": "libs/types/src/website-public/e621-account-data.ts",
    "content": "export type E621AccountData = {\n  username: string;\n  key: string;\n};\n\nexport type E621OAuthRoutes = {\n  login: {\n    request: E621AccountData;\n    response: { result: boolean };\n  };\n};\n\n// Source: https://e621.net/tags\nexport enum E621TagCategory {\n  General = 0,\n  Artist = 1,\n  Contributor = 2,\n  Copyright = 3,\n  Character = 4,\n  Species = 5,\n  Invalid = 6,\n  Meta = 7,\n  Lore = 8,\n}\n"
  },
  {
    "path": "libs/types/src/website-public/index.ts",
    "content": "export * from './bluesky-account-data';\nexport * from './custom-account-data';\nexport * from './discord-account-data';\nexport * from './e621-account-data';\nexport * from './inkbunny-account-data';\nexport * from './instagram-account-data';\nexport * from './megalodon-account-data';\nexport * from './misskey-account-data';\nexport * from './telegram-account-data';\nexport * from './twitter-account-data';\n\n"
  },
  {
    "path": "libs/types/src/website-public/inkbunny-account-data.ts",
    "content": "export type InkbunnyAccountData = {\n  username?: string;\n  sid?: string;\n  folders?: string[];\n};\n\n/**\n * OAuth routes for Inkbunny login.\n * Note: password is only used in the request and never stored.\n */\nexport type InkbunnyOAuthRoutes = {\n  login: {\n    request: { username: string; password: string };\n    response: void;\n  };\n};"
  },
  {
    "path": "libs/types/src/website-public/instagram-account-data.ts",
    "content": "export type InstagramAccountData = {\n  /** Meta/Facebook App ID */\n  appId?: string;\n  /** Meta/Facebook App Secret */\n  appSecret?: string;\n  /** Long-lived access token (60-day expiry) */\n  accessToken?: string;\n  /** ISO string of when the access token expires */\n  tokenExpiry?: string;\n  /** Instagram-scoped user ID */\n  igUserId?: string;\n  /** Instagram username */\n  igUsername?: string;\n};\n\nexport type InstagramOAuthRoutes = {\n  /**\n   * Stores Meta App credentials (App ID / App Secret).\n   */\n  setAppCredentials: {\n    request: { appId: string; appSecret: string };\n    response: { success: boolean };\n  };\n  /**\n   * Generates and returns the Facebook OAuth dialog URL.\n   */\n  getAuthUrl: {\n    request: Record<string, never>;\n    response: {\n      success: boolean;\n      url?: string;\n      state?: string;\n      message?: string;\n    };\n  };\n  /**\n   * Retrieves the authorization code captured by the OAuth callback.\n   * The UI polls this after opening the auth URL.\n   */\n  retrieveCode: {\n    request: { state: string };\n    response: {\n      success: boolean;\n      code?: string;\n      message?: string;\n    };\n  };\n  /**\n   * Exchanges the authorization code for tokens, discovers the IG Business account.\n   */\n  exchangeCode: {\n    request: { code: string };\n    response: {\n      success: boolean;\n      igUsername?: string;\n      igUserId?: string;\n      tokenExpiry?: string;\n      message?: string;\n    };\n  };\n  /**\n   * Refreshes the long-lived token before it expires.\n   */\n  refreshToken: {\n    request: Record<string, never>;\n    response: {\n      success: boolean;\n      tokenExpiry?: string;\n      message?: string;\n    };\n  };\n};\n"
  },
  {
    "path": "libs/types/src/website-public/megalodon-account-data.ts",
    "content": "export type MegalodonAccountData = {\n  // Base URL of the instance (e.g., \"mastodon.social\", \"pixelfed.social\")\n  instanceUrl: string;\n\n  // OAuth client credentials (registered per-instance)\n  clientId?: string;\n  clientSecret?: string;\n\n  // OAuth authorization code (temporary during login)\n  authCode?: string;\n\n  // Final access token after completing OAuth\n  accessToken?: string;\n\n  // Logged in username\n  username?: string;\n\n  // User's display name\n  displayName?: string;\n\n  // Instance type for polymorphic behavior if needed\n  instanceType?: string;\n};\n\nexport type MegalodonOAuthRoutes = {\n  /**\n   * Step 1: Register OAuth app with the instance\n   */\n  registerApp: {\n    request: { instanceUrl: string };\n    response: {\n      success: boolean;\n      authorizationUrl?: string;\n      clientId?: string;\n      clientSecret?: string;\n      message?: string;\n    };\n  };\n\n  /**\n   * Step 2: Complete OAuth by exchanging code for token\n   */\n  completeOAuth: {\n    request: { authCode: string };\n    response: {\n      success: boolean;\n      username?: string;\n      displayName?: string;\n      message?: string;\n    };\n  };\n};\n\n// Specific website types can extend if needed\nexport type MastodonAccountData = MegalodonAccountData & {\n  // Mastodon-specific fields if any\n};\n\nexport type PleromaAccountData = MegalodonAccountData & {\n  // Pleroma-specific fields if any\n};\n\nexport type PixelfedAccountData = MegalodonAccountData & {\n  // Pixelfed-specific fields if any\n};\n"
  },
  {
    "path": "libs/types/src/website-public/misskey-account-data.ts",
    "content": "export type MisskeyAccountData = {\n  /** Base URL of the instance (e.g., \"misskey.io\", \"sharkey.example.com\") */\n  instanceUrl: string;\n\n  /** MiAuth session ID (temporary, used during auth flow) */\n  miAuthSessionId?: string;\n\n  /** Access token obtained via MiAuth */\n  accessToken?: string;\n\n  /** Logged-in username */\n  username?: string;\n};\n\nexport type MisskeyOAuthRoutes = {\n  /**\n   * Step 1: Generate MiAuth URL for the user to authorize\n   */\n  generateAuthUrl: {\n    request: { instanceUrl: string };\n    response: {\n      success: boolean;\n      authUrl?: string;\n      sessionId?: string;\n      message?: string;\n    };\n  };\n\n  /**\n   * Step 2: Check MiAuth session and exchange for token\n   */\n  completeAuth: {\n    request: Record<string, never>;\n    response: {\n      success: boolean;\n      username?: string;\n      message?: string;\n    };\n  };\n};\n"
  },
  {
    "path": "libs/types/src/website-public/telegram-account-data.ts",
    "content": "import { SelectOption } from '@postybirb/form-builder';\n\nexport interface TelegramAccountLoginData {\n  appId: number;\n  appHash: string;\n  phoneNumber: string;\n}\n\nexport interface TelegramAccountData extends TelegramAccountLoginData {\n  session?: string;\n  channels: SelectOption[];\n}\n\nexport type TelegramOAuthRoutes = {\n  startAuthentication: {\n    request: TelegramAccountLoginData;\n    response: undefined;\n  };\n  authenticate: {\n    request: TelegramAccountLoginData & {\n      password: string;\n      code: string;\n    };\n    response: {\n      success: boolean;\n      message?: string;\n      passwordRequired?: boolean;\n      passwordInvalid?: boolean;\n      codeInvalid?: boolean;\n    };\n  };\n};\n"
  },
  {
    "path": "libs/types/src/website-public/twitter-account-data.ts",
    "content": "export type TwitterAccountData = {\n  apiKey?: string; // Consumer key\n  apiSecret?: string; // Consumer secret\n  requestToken?: string; // Temporary oauth_token during authorization\n  requestTokenSecret?: string; // Temporary oauth_token_secret during authorization\n  accessToken?: string; // Final access token\n  accessTokenSecret?: string; // Final access token secret\n  screenName?: string; // User screen name\n  userId?: string; // User id\n};\n\nexport type TwitterOAuthRoutes = {\n  /**\n   * Sets API credentials (consumer key/secret) into website data store.\n   */\n  setApiKeys: {\n    request: { apiKey: string; apiSecret: string };\n    response: { success: boolean };\n  };\n  /**\n   * Requests a request token from Twitter and returns the authorization URL.\n   */\n  requestToken: {\n    request: Record<string, never>;\n    response: {\n      success: boolean;\n      url?: string;\n      oauthToken?: string;\n      message?: string;\n    };\n  };\n  /**\n   * Completes OAuth with the provided oauth_verifier (PIN) and stores access tokens.\n   */\n  completeOAuth: {\n    request: { verifier: string };\n    response: {\n      success: boolean;\n      screenName?: string;\n      userId?: string;\n      message?: string;\n    };\n  };\n};\n"
  },
  {
    "path": "libs/types/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.base.json\",\n  \"compilerOptions\": {\n    \"strict\": true\n  },\n  \"files\": [],\n  \"include\": [],\n  \"references\": [\n    {\n      \"path\": \"./tsconfig.lib.json\"\n    }\n  ]\n}\n"
  },
  {
    "path": "libs/types/tsconfig.lib.json",
    "content": "{\n  \"extends\": \"./tsconfig.json\",\n  \"compilerOptions\": {\n    \"module\": \"commonjs\",\n    \"outDir\": \"../../dist/out-tsc\",\n    \"declaration\": true,\n    \"types\": [\"node\"]\n  },\n  \"exclude\": [\"**/*.spec.ts\", \"**/*.test.ts\"],\n  \"include\": [\"**/*.ts\"]\n}\n"
  },
  {
    "path": "libs/utils/electron/.eslintrc.json",
    "content": "{\n  \"extends\": [\"../../../.eslintrc.js\"],\n  \"ignorePatterns\": [\"!**/*\"],\n  \"overrides\": [\n    {\n      \"files\": [\"*.ts\", \"*.tsx\", \"*.js\", \"*.jsx\"],\n      \"rules\": {}\n    },\n    {\n      \"files\": [\"*.ts\", \"*.tsx\"],\n      \"rules\": {}\n    },\n    {\n      \"files\": [\"*.js\", \"*.jsx\"],\n      \"rules\": {}\n    }\n  ]\n}\n"
  },
  {
    "path": "libs/utils/electron/README.md",
    "content": "# utils-electron\n\nPostyBirb utilities.\n\n## Running unit tests\n\nRun `nx test utils-electron` to execute the unit tests via [Jest](https://jestjs.io).\n"
  },
  {
    "path": "libs/utils/electron/jest.config.ts",
    "content": "/* eslint-disable */\nexport default {\n  displayName: 'utils-electron',\n  preset: '../../../jest.preset.js',\n  globals: {},\n  testEnvironment: 'node',\n  moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],\n  coverageDirectory: '../../../coverage/libs/utils/electron',\n};\n"
  },
  {
    "path": "libs/utils/electron/project.json",
    "content": "{\n  \"name\": \"utils-electron\",\n  \"$schema\": \"../../../node_modules/nx/schemas/project-schema.json\",\n  \"sourceRoot\": \"libs/utils/electron/src\",\n  \"projectType\": \"library\",\n  \"targets\": {\n    \"lint\": {\n      \"executor\": \"@nx/eslint:lint\",\n      \"outputs\": [\"{options.outputFile}\"]\n    },\n    \"test\": {\n      \"executor\": \"@nx/jest:jest\",\n      \"outputs\": [\"{workspaceRoot}/coverage/libs/utils/electron\"],\n      \"options\": {\n        \"jestConfig\": \"libs/utils/electron/jest.config.ts\"\n      }\n    }\n  },\n  \"tags\": []\n}\n"
  },
  {
    "path": "libs/utils/electron/src/index.ts",
    "content": "export * from './lib/browser-window-utils';\nexport * from './lib/postybirb-env-config';\nexport * from './lib/remote-utils';\nexport * from './lib/startup-options-electron';\nexport * from './lib/utils-electron';\nexport * from './lib/utils-test';\n"
  },
  {
    "path": "libs/utils/electron/src/lib/browser-window-utils.ts",
    "content": "import { BrowserWindow } from 'electron';\n\nfunction delay(ms: number): Promise<void> {\n  return new Promise((resolve) => {\n    setTimeout(resolve, ms);\n  });\n}\n\nasync function createWindow(partition: string, url: string) {\n  const bw: Electron.BrowserWindow = new BrowserWindow({\n    show: false,\n    webPreferences: {\n      partition: `persist:${partition}`,\n    },\n  });\n\n  try {\n    await bw.loadURL(url);\n  } catch (err) {\n    bw.destroy();\n    throw err;\n  }\n\n  return bw;\n}\n\nexport class BrowserWindowUtils {\n  static async getLocalStorage<T = object>(\n    partition: string,\n    url: string,\n    wait?: number,\n  ): Promise<T> {\n    const bw = await createWindow(partition, url);\n    try {\n      if (wait) {\n        await delay(wait);\n      }\n      return await bw.webContents.executeJavaScript(\n        'JSON.parse(JSON.stringify(localStorage))',\n      );\n    } catch (err) {\n      bw.destroy();\n      throw err;\n    } finally {\n      if (!bw.isDestroyed()) {\n        bw.destroy();\n      }\n    }\n  }\n\n  public static async runScriptOnPage<T>(\n    partition: string,\n    url: string,\n    script: string,\n    wait = 0,\n  ): Promise<T> {\n    const bw = await createWindow(partition, url);\n    try {\n      if (wait) {\n        await delay(wait);\n      }\n\n      // Using promise to handle errors. See more: https://github.com/electron/electron/pull/11158\n      const page = await bw.webContents.executeJavaScript(`\n      (function() {\n        try {\n          ${script}\n        } catch (e) {\n          return Promise.reject(e);\n        }\n      })()`);\n      return page;\n    } catch (err) {\n      const e = err as Error;\n      if (e.message) {\n        e.message = `Failed to run script on page: ${e.message}\\n\\nscript:\\n${script}\\n`;\n      }\n\n      bw.destroy();\n      throw e;\n    } finally {\n      if (!bw.isDestroyed()) {\n        bw.destroy();\n      }\n    }\n  }\n\n  public static async ping(partition: string, url: string): Promise<void> {\n    const bw = await createWindow(partition, url);\n    if (!bw.isDestroyed()) {\n      bw.destroy();\n    }\n  }\n\n  public static async getFormData(\n    partition: string,\n    url: string,\n    selector: { id?: string; custom?: string },\n  ): Promise<object> {\n    const bw = await createWindow(partition, url);\n    try {\n      return await bw.webContents.executeJavaScript(\n        `JSON.parse(JSON.stringify(Array.from(new FormData(${\n          selector.id\n            ? `document.getElementById('${selector.id}')`\n            : selector.custom\n        })).reduce((obj, [k, v]) => ({...obj, [k]: v}), {})))`,\n      );\n    } catch (err) {\n      bw.destroy();\n      throw err;\n    } finally {\n      if (!bw.isDestroyed()) {\n        bw.destroy();\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "libs/utils/electron/src/lib/postybirb-env-config.ts",
    "content": "import { app } from 'electron';\nimport minimist from 'minimist';\nimport { getStartupOptions } from './startup-options-electron';\n\nexport type AppEnvConfig = {\n  port: string;\n  headless: boolean;\n};\n\nfunction showHelp(): void {\n  // Get version from Electron app or fallback to known version\n  const version = app.getVersion() || '4.0.2';\n\n  const helpText = `\nPostyBirb v${version}\nA desktop application for posting to multiple websites from one simple form.\n\nUSAGE:\n  postybirb [OPTIONS]\n\nOPTIONS:\n  --port <number>     Set the server port (default: 9487)\n                      Port must be between 1024 and 65535\n                      Can also be set via POSTYBIRB_PORT environment variable\n\n  --headless          Run in headless mode (no GUI)\n                      Can also be set via POSTYBIRB_HEADLESS=true environment variable\n\n  --help, -h          Show this help message and exit\n\nEXAMPLES:\n  postybirb                    # Start with default settings\n  postybirb --port 8080        # Start on port 8080\n  postybirb --headless         # Start in headless mode\n  postybirb --port 8080 --headless  # Start on port 8080 in headless mode\n\nENVIRONMENT VARIABLES:\n  POSTYBIRB_PORT      Set the server port\n  POSTYBIRB_HEADLESS  Set to 'true' to run in headless mode\n\nFor more information, visit: https://github.com/mvdicarlo/postybirb\n`;\n\n  // eslint-disable-next-line no-console\n  console.log(helpText);\n  app.quit();\n}\n\nconst config: AppEnvConfig = {\n  port:\n    process.env.POSTYBIRB_PORT || getStartupOptions().port.toString() || '9487',\n  headless: process.env.POSTYBIRB_HEADLESS === 'true' || false,\n};\n\nconst args = minimist(process.argv.slice(process.defaultApp ? 2 : 1));\n\n// Handle help flag first\nif (args.help || args.h) {\n  showHelp();\n}\n\nif (args.port) {\n  config.port = args.port;\n}\nif (args.headless) {\n  config.headless = args.headless;\n}\n\nconst portNumber = parseInt(config.port, 10);\nif (!(portNumber >= 1024 && portNumber <= 65535)) {\n  // eslint-disable-next-line no-console\n  console.error(\n    'Invalid port number. Please provide a port number between 1024 and 65535.',\n  );\n  app.quit();\n}\n\nexport { config as PostyBirbEnvConfig };\n\n"
  },
  {
    "path": "libs/utils/electron/src/lib/remote-utils.ts",
    "content": "import { app } from 'electron';\nimport { readFileSync, statSync, writeFileSync } from 'fs';\nimport { readFile, stat, writeFile } from 'fs/promises';\nimport { join } from 'path';\nimport { v4 as uuidv4 } from 'uuid';\n\nexport type RemoteConfig = {\n  password: string;\n  enabled: boolean;\n};\n\n// Cache for remote config\nlet cachedConfig: RemoteConfig | null = null;\nlet cachedMtime: number | null = null;\nlet cacheCheckInterval: NodeJS.Timeout | null = null;\n\n// Check interval in milliseconds (1 minute)\nconst CACHE_CHECK_INTERVAL_MS = 60_000;\n\nfunction getRemoteConfigPath(): string {\n  return join(app.getPath('userData'), 'remote-config.json');\n}\n\nfunction createRemoteConfig(): Promise<void> {\n  const config: RemoteConfig = {\n    password: uuidv4(),\n    enabled: true,\n  };\n  return writeFile(getRemoteConfigPath(), JSON.stringify(config, null, 2), {\n    encoding: 'utf-8',\n  });\n}\n\nfunction createRemoteConfigSync(): void {\n  const config: RemoteConfig = {\n    password: uuidv4(),\n    enabled: true,\n  };\n  writeFileSync(getRemoteConfigPath(), JSON.stringify(config, null, 2), {\n    encoding: 'utf-8',\n  });\n}\n\nasync function ensureRemoteConfigExists(): Promise<void> {\n  try {\n    await stat(getRemoteConfigPath());\n  } catch {\n    await createRemoteConfig();\n  }\n}\n\n/**\n * Start background interval to check if config file has changed.\n * Clears the cache if mtime differs, so next getRemoteConfig() call will re-read.\n */\nfunction startCacheInvalidationCheck(): void {\n  if (cacheCheckInterval) return; // Already running\n\n  cacheCheckInterval = setInterval(async () => {\n    if (cachedMtime === null) return; // No cached value to invalidate\n\n    try {\n      const configPath = getRemoteConfigPath();\n      const fileStat = await stat(configPath);\n      const currentMtime = fileStat.mtimeMs;\n\n      if (currentMtime !== cachedMtime) {\n        // File has changed, clear cache\n        cachedConfig = null;\n        cachedMtime = null;\n      }\n    } catch (e) {\n      // eslint-disable-next-line no-console\n      console.error('Remote config cache invalidation', e);\n      // File might have been deleted, clear cache\n      cachedConfig = null;\n      cachedMtime = null;\n    }\n  }, CACHE_CHECK_INTERVAL_MS);\n}\n\nexport async function getRemoteConfig(): Promise<RemoteConfig> {\n  // Return cached value if available\n  if (cachedConfig !== null) {\n    return cachedConfig;\n  }\n\n  await ensureRemoteConfigExists();\n  const configPath = getRemoteConfigPath();\n\n  // Read file and get mtime\n  const [configContent, fileStat] = await Promise.all([\n    readFile(configPath, 'utf-8'),\n    stat(configPath),\n  ]);\n\n  const remoteConfig = JSON.parse(configContent) as RemoteConfig;\n\n  // Cache the config and mtime\n  cachedConfig = remoteConfig;\n  cachedMtime = fileStat.mtimeMs;\n\n  // Start background check if not already running\n  startCacheInvalidationCheck();\n\n  return remoteConfig;\n}\n\nexport function getRemoteConfigSync(): RemoteConfig | null {\n  if (cachedConfig !== null) {\n    return cachedConfig;\n  }\n\n  try {\n    const configPath = getRemoteConfigPath();\n\n    // Ensure config file exists\n    try {\n      statSync(configPath);\n    } catch {\n      createRemoteConfigSync();\n    }\n\n    const configContent = readFileSync(configPath, 'utf-8');\n    const fileStat = statSync(configPath);\n    const remoteConfig = JSON.parse(configContent) as RemoteConfig;\n\n    // Cache the config and mtime\n    cachedConfig = remoteConfig;\n    cachedMtime = fileStat.mtimeMs;\n\n    // Start background check if not already running\n    startCacheInvalidationCheck();\n\n    return remoteConfig;\n  } catch (e) {\n    // eslint-disable-next-line no-console\n    console.error('Failed to read remote config synchronously', e);\n    return null;\n  }\n}\n"
  },
  {
    "path": "libs/utils/electron/src/lib/startup-options-electron.ts",
    "content": "import { app } from 'electron';\nimport {\n  accessSync,\n  existsSync,\n  mkdirSync,\n  readFileSync,\n  writeFileSync,\n} from 'fs';\nimport { dirname, join } from 'path';\nimport { isWindows } from './utils-electron';\n\nexport type StartupOptions = {\n  startAppOnSystemStartup: boolean;\n  spellchecker: boolean;\n  appDataPath: string;\n  port: string;\n};\n\nconst FILE_PATH = join(app.getPath('userData'), 'startup.json');\nconst DOCUMENTS_PATH = join(app.getPath('documents'), 'PostyBirb');\nconst DEFAULT_APP_DATA_PATH =\n  isWindows() && DOCUMENTS_PATH.includes('OneDrive')\n    ? join(app.getPath('home'), 'Documents', 'PostyBirb')\n    : DOCUMENTS_PATH;\n\nconst DEFAULT_STARTUP_OPTIONS: StartupOptions = {\n  startAppOnSystemStartup: false,\n  spellchecker: true,\n  port: '9487',\n  appDataPath: DEFAULT_APP_DATA_PATH,\n};\n\nfunction init(): StartupOptions {\n  try {\n    if (existsSync(FILE_PATH) === false) {\n      return DEFAULT_STARTUP_OPTIONS;\n    }\n\n    const opts = JSON.parse(readFileSync(FILE_PATH, 'utf-8'));\n    if (opts) {\n      return { ...DEFAULT_STARTUP_OPTIONS, ...opts };\n    }\n    return DEFAULT_STARTUP_OPTIONS;\n  } catch {\n    return DEFAULT_STARTUP_OPTIONS;\n  }\n}\n\nlet startupOptions: StartupOptions;\nconst listeners: Array<(opts: StartupOptions) => void> = [];\n\nfunction saveStartupOptions(opts: StartupOptions) {\n  try {\n    const sOpts = JSON.stringify(opts);\n    writeFileSync(FILE_PATH, sOpts);\n    // eslint-disable-next-line no-console\n    console.log('Saved startup options', FILE_PATH);\n  } catch (error) {\n    // eslint-disable-next-line no-console\n    console.error('Attempting to save startup options failed:', error);\n  }\n}\n\nexport const getStartupOptions = (): StartupOptions => {\n  if (!startupOptions) {\n    startupOptions = init();\n    if (existsSync(FILE_PATH) === false) {\n      const dir = dirname(FILE_PATH);\n      try {\n        accessSync(dir);\n      } catch {\n        mkdirSync(dir, { recursive: true });\n      }\n      saveStartupOptions(startupOptions);\n    }\n  }\n  return { ...startupOptions };\n};\n\nexport function setStartupOptions(opts: Partial<StartupOptions>): void {\n  if (!startupOptions) {\n    startupOptions = init();\n  }\n  startupOptions = { ...getStartupOptions(), ...opts };\n  saveStartupOptions(startupOptions);\n  listeners.forEach((listener) => listener(startupOptions));\n}\n\nexport function onStartupOptionsUpdate(\n  listener: (opts: StartupOptions) => void,\n): void {\n  if (!listeners.includes(listener)) {\n    listeners.push(listener);\n  }\n}\n"
  },
  {
    "path": "libs/utils/electron/src/lib/utils-electron.spec.ts",
    "content": "import * as utilsElectron from './utils-electron';\n\ndescribe('utilsElectron', () => {\n  it('should work', () => {\n    expect(utilsElectron).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "libs/utils/electron/src/lib/utils-electron.ts",
    "content": "/**\n * https://www.electronjs.org/docs/api/session#sessionfrompartitionpartition-options\n *\n * @param {string} partitionId\n * @return {*}  {string}\n */\nexport function getPartitionKey(partitionId: string): string {\n  return `persist:${partitionId}`;\n}\n\nexport function isWindows(): boolean {\n  return process.platform === 'win32';\n}\n\nexport function isOSX(): boolean {\n  return process.platform === 'darwin';\n}\n\nexport function isLinux(): boolean {\n  return !(isWindows() || isOSX());\n}\n"
  },
  {
    "path": "libs/utils/electron/src/lib/utils-test.ts",
    "content": "export function IsTestEnvironment(): boolean {\n  return (process.env.NODE_ENV || '').toLowerCase() === 'test';\n}\n\nexport function IsDevelopmentEnvironment(): boolean {\n  return (process.env.NODE_ENV || '').toLowerCase() === 'development';\n}\n"
  },
  {
    "path": "libs/utils/electron/tsconfig.json",
    "content": "{\n  \"extends\": \"../../../tsconfig.base.json\",\n  \"files\": [],\n  \"include\": [],\n  \"references\": [\n    {\n      \"path\": \"./tsconfig.lib.json\"\n    },\n    {\n      \"path\": \"./tsconfig.spec.json\"\n    }\n  ]\n}\n"
  },
  {
    "path": "libs/utils/electron/tsconfig.lib.json",
    "content": "{\n  \"extends\": \"./tsconfig.json\",\n  \"compilerOptions\": {\n    \"module\": \"commonjs\",\n    \"outDir\": \"../../../dist/out-tsc\",\n    \"declaration\": true,\n    \"types\": [\"node\"]\n  },\n  \"exclude\": [\"**/*.spec.ts\", \"**/*.test.ts\", \"jest.config.ts\"],\n  \"include\": [\"**/*.ts\"]\n}\n"
  },
  {
    "path": "libs/utils/electron/tsconfig.spec.json",
    "content": "{\n  \"extends\": \"./tsconfig.json\",\n  \"compilerOptions\": {\n    \"outDir\": \"../../../dist/out-tsc\",\n    \"module\": \"commonjs\",\n    \"types\": [\"jest\", \"node\"]\n  },\n  \"include\": [\n    \"**/*.spec.ts\",\n    \"**/*.test.ts\",\n    \"**/*.spec.tsx\",\n    \"**/*.test.tsx\",\n    \"**/*.spec.js\",\n    \"**/*.test.js\",\n    \"**/*.spec.jsx\",\n    \"**/*.test.jsx\",\n    \"**/*.d.ts\",\n    \"jest.config.ts\"\n  ]\n}\n"
  },
  {
    "path": "libs/utils/file-type/.eslintrc.json",
    "content": "{\n  \"extends\": [\"../../../.eslintrc.js\"],\n  \"ignorePatterns\": [\"!**/*\"],\n  \"overrides\": [\n    {\n      \"files\": [\"*.ts\", \"*.tsx\", \"*.js\", \"*.jsx\"],\n      \"rules\": {}\n    },\n    {\n      \"files\": [\"*.ts\", \"*.tsx\"],\n      \"rules\": {}\n    },\n    {\n      \"files\": [\"*.js\", \"*.jsx\"],\n      \"rules\": {}\n    }\n  ]\n}\n"
  },
  {
    "path": "libs/utils/file-type/README.md",
    "content": "# utils-file-type\n\nThis library was generated with [Nx](https://nx.dev).\n"
  },
  {
    "path": "libs/utils/file-type/project.json",
    "content": "{\n  \"name\": \"utils-file-type\",\n  \"$schema\": \"../../../node_modules/nx/schemas/project-schema.json\",\n  \"sourceRoot\": \"libs/utils/file-type/src\",\n  \"targets\": {\n    \"lint\": {\n      \"executor\": \"@nx/eslint:lint\",\n      \"outputs\": [\"{options.outputFile}\"]\n    }\n  },\n  \"tags\": []\n}\n"
  },
  {
    "path": "libs/utils/file-type/src/index.ts",
    "content": "export * from './lib/calculate-image-resize';\nexport * from './lib/get-file-type';\nexport * from './lib/is-audio';\nexport * from './lib/is-image';\nexport * from './lib/is-text';\nexport * from './lib/is-video';\n\n"
  },
  {
    "path": "libs/utils/file-type/src/lib/calculate-image-resize.ts",
    "content": "import { IFileDimensions, ImageResizeProps } from '@postybirb/types';\n\n/**\n * Limits to check against a file's dimensions and size.\n */\nexport interface ImageResizeLimits {\n  /** Maximum width in pixels. */\n  maxWidth?: number;\n  /** Maximum height in pixels. */\n  maxHeight?: number;\n  /** Maximum file size in bytes. */\n  maxBytes?: number;\n}\n\n/**\n * Calculates resize props for an image file based on the given limits.\n * Returns `undefined` if the file does not exceed any limit.\n *\n * @param file - An object with `width`, `height`, and `size` (bytes).\n * @param limits - The maximum allowed dimensions and file size.\n * @returns Resize props if any limit is exceeded, otherwise `undefined`.\n */\nexport function calculateImageResize(\n  file: Pick<IFileDimensions, 'width' | 'height' | 'size'>,\n  limits: ImageResizeLimits,\n): ImageResizeProps | undefined {\n  const { maxWidth, maxHeight, maxBytes } = limits;\n\n  const exceedsWidth = maxWidth != null && file.width > maxWidth;\n  const exceedsHeight = maxHeight != null && file.height > maxHeight;\n  const exceedsSize = maxBytes != null && file.size > maxBytes;\n\n  if (!exceedsWidth && !exceedsHeight && !exceedsSize) {\n    return undefined;\n  }\n\n  const props: ImageResizeProps = {};\n\n  if (exceedsWidth) {\n    props.width = maxWidth;\n  }\n\n  if (exceedsHeight) {\n    props.height = maxHeight;\n  }\n\n  if (exceedsSize) {\n    props.maxBytes = maxBytes;\n  }\n\n  return props;\n}\n"
  },
  {
    "path": "libs/utils/file-type/src/lib/get-file-type.ts",
    "content": "import { FileType } from '@postybirb/types';\nimport { isAudio, supportsAudio } from './is-audio';\nimport { isImage, supportsImage } from './is-image';\nimport { isText, supportsText } from './is-text';\nimport { isVideo, supportsVideo } from './is-video';\n\nexport function getFileType(filenameOrExtension: string): FileType {\n  if (isImage(filenameOrExtension)) return FileType.IMAGE;\n  if (isText(filenameOrExtension)) return FileType.TEXT;\n  if (isVideo(filenameOrExtension)) return FileType.VIDEO;\n  if (isAudio(filenameOrExtension)) return FileType.AUDIO;\n\n  return FileType.UNKNOWN;\n}\n\nexport function getFileTypeFromMimeType(mimeType: string): FileType {\n  if (supportsImage(mimeType)) return FileType.IMAGE;\n  if (supportsText(mimeType)) return FileType.TEXT;\n  if (supportsVideo(mimeType)) return FileType.VIDEO;\n  if (supportsAudio(mimeType)) return FileType.AUDIO;\n\n  return FileType.UNKNOWN;\n}\n"
  },
  {
    "path": "libs/utils/file-type/src/lib/is-audio.ts",
    "content": "import { getMimeTypeForFile } from './mime-helper';\n\nconst SUPPORTED_MIME: string[] = [\n  'audio/3gpp',\n  'audio/3gpp2',\n  'audio/aac',\n  'audio/ac3',\n  'audio/adpcm',\n  'audio/aiff',\n  'audio/x-aiff',\n  'audio/amr',\n  'audio/amr-wb',\n  'audio/basic',\n  'audio/flac',\n  'audio/x-flac',\n  'audio/midi',\n  'audio/x-midi',\n  'audio/mp4',\n  'audio/mpeg',\n  'audio/mp3',\n  'audio/mpeg3',\n  'audio/x-mpeg-3',\n  'audio/m4a',\n  'audio/x-m4a',\n  'audio/ogg',\n  'audio/opus',\n  'audio/vnd.digital-winds',\n  'audio/vnd.dts',\n  'audio/vnd.dts.hd',\n  'audio/vnd.lucent.voice',\n  'audio/vnd.ms-playready.media.pya',\n  'audio/vnd.nuera.ecelp4800',\n  'audio/vnd.nuera.ecelp7470',\n  'audio/vnd.nuera.ecelp9600',\n  'audio/vnd.rip',\n  'audio/vnd.wave',\n  'audio/wav',\n  'audio/wave',\n  'audio/x-wav',\n  'audio/webm',\n  'audio/x-aac',\n  'audio/x-caf',\n  'audio/x-matroska',\n  'audio/x-mpegurl',\n  'audio/x-ms-wax',\n  'audio/x-ms-wma',\n  'audio/x-pn-realaudio',\n  'audio/x-pn-realaudio-plugin',\n  'audio/x-realaudio',\n];\n\nexport function supportsAudio(mimeType: string): boolean {\n  return SUPPORTED_MIME.includes(mimeType);\n}\n\nexport function isAudio(filenameOrExtension: string): boolean {\n  const mimeType = getMimeTypeForFile(filenameOrExtension);\n  return (\n    SUPPORTED_MIME.includes(mimeType ?? '') ||\n    supportsAudio(filenameOrExtension)\n  );\n}\n"
  },
  {
    "path": "libs/utils/file-type/src/lib/is-image.ts",
    "content": "import { getMimeTypeForFile } from './mime-helper';\n\n// https://mimetype.io/all-types/#image\nconst SUPPORTED_MIME: string[] = [\n  'image/apng',\n  'image/avif',\n  'image/bmp',\n  'image/x-bmp',\n  'image/x-ms-bmp',\n  'image/cgm',\n  'image/g3fax',\n  'image/gif',\n  'image/heic',\n  'image/heif',\n  'image/ief',\n  'image/jp2',\n  'image/jpeg',\n  'image/jpg',\n  'image/jpe',\n  'image/jfif',\n  'image/pjpeg',\n  'image/jxl',\n  'image/ktx',\n  'image/ktx2',\n  'image/png',\n  'image/x-png',\n  'image/vnd.mozilla.apng',\n  'image/prs.btif',\n  'image/svg+xml',\n  'image/tiff',\n  'image/tiff-fx',\n  'image/vnd.adobe.photoshop',\n  'image/vnd.djvu',\n  'image/vnd.dwg',\n  'image/vnd.dxf',\n  'image/vnd.fastbidsheet',\n  'image/vnd.fpx',\n  'image/vnd.fst',\n  'image/vnd.fujixerox.edmics-mmr',\n  'image/vnd.fujixerox.edmics-rlc',\n  'image/vnd.microsoft.icon',\n  'image/vnd.ms-modi',\n  'image/vnd.ms-photo',\n  'image/vnd.net-fpx',\n  'image/vnd.wap.wbmp',\n  'image/vnd.xiff',\n  'image/webp',\n  'image/x-adobe-dng',\n  'image/x-canon-cr2',\n  'image/x-canon-crw',\n  'image/x-cmu-raster',\n  'image/x-cmx',\n  'image/x-epson-erf',\n  'image/x-freehand',\n  'image/x-fuji-raf',\n  'image/x-icon',\n  'image/x-jng',\n  'image/x-kodak-dcr',\n  'image/x-kodak-k25',\n  'image/x-kodak-kdc',\n  'image/x-minolta-mrw',\n  'image/x-nikon-nef',\n  'image/x-olympus-orf',\n  'image/x-panasonic-raw',\n  'image/x-pcx',\n  'image/x-pentax-pef',\n  'image/x-pict',\n  'image/x-portable-anymap',\n  'image/x-portable-bitmap',\n  'image/x-portable-graymap',\n  'image/x-portable-pixmap',\n  'image/x-rgb',\n  'image/x-sigma-x3f',\n  'image/x-sony-arw',\n  'image/x-sony-sr2',\n  'image/x-sony-srf',\n  'image/x-tga',\n  'image/x-xbitmap',\n  'image/x-xpixmap',\n  'image/x-xwindowdump',\n];\n\nexport function supportsImage(mimeType: string): boolean {\n  return SUPPORTED_MIME.includes(mimeType);\n}\n\nexport function isImage(filenameOrExtension: string): boolean {\n  const mimeType = getMimeTypeForFile(filenameOrExtension);\n  return (\n    SUPPORTED_MIME.includes(mimeType ?? '') ||\n    supportsImage(filenameOrExtension)\n  );\n}\n"
  },
  {
    "path": "libs/utils/file-type/src/lib/is-text.ts",
    "content": "import { getMimeTypeForFile } from './mime-helper';\n\nconst SUPPORTED_MIME: string[] = [\n  'text/cache-manifest',\n  'text/calendar',\n  'text/coffeescript',\n  'text/css',\n  'text/csv',\n  'text/html',\n  'text/jade',\n  'text/javascript',\n  'text/ecmascript',\n  'text/javascript1.0',\n  'text/javascript1.1',\n  'text/javascript1.2',\n  'text/javascript1.3',\n  'text/javascript1.4',\n  'text/javascript1.5',\n  'text/jscript',\n  'text/jsx',\n  'text/less',\n  'text/livescript',\n  'text/x-ecmascript',\n  'text/x-javascript',\n  'text/json',\n  'text/markdown',\n  'text/x-markdown',\n  'text/mathml',\n  'text/x.gcode',\n  'text/x-gcode',\n  'text/n3',\n  'text/plain',\n  'text/prs.lines.tag',\n  'text/richtext',\n  'text/rtf',\n  'text/sgml',\n  'text/stylus',\n  'text/tab-separated-values',\n  'text/troff',\n  'text/turtle',\n  'text/uri-list',\n  'text/vcard',\n  'text/vnd.curl',\n  'text/vnd.curl.dcurl',\n  'text/vnd.curl.mcurl',\n  'text/vnd.curl.scurl',\n  'text/vnd.fly',\n  'text/vnd.fmi.flexstor',\n  'text/vnd.graphviz',\n  'text/vnd.in3d.3dml',\n  'text/vnd.in3d.spot',\n  'text/vnd.sun.j2me.app-descriptor',\n  'text/vnd.wap.si',\n  'text/vnd.wap.sl',\n  'text/vnd.wap.wml',\n  'text/vnd.wap.wmlscript',\n  'text/vtt',\n  'text/x-asm',\n  'text/x-c',\n  'text/x-component',\n  'text/x-fortran',\n  'text/x-handlebars-template',\n  'text/x-java-source',\n  'text/x-java',\n  'text/x-lua',\n  'text/x-nfo',\n  'text/x-opml',\n  'text/x-pascal',\n  'text/x-processing',\n  'text/x-python',\n  'text/x-sass',\n  'text/x-scss',\n  'text/x-setext',\n  'text/x-typescript',\n  'text/x-uuencode',\n  'text/x-vcalendar',\n  'text/x-vcard',\n  'text/xml',\n  'text/yaml',\n  'application/json',\n  'application/ld+json',\n  'application/msword',\n  'application/pdf',\n  'application/rtf',\n  'application/vnd.ms-excel',\n  'application/vnd.ms-powerpoint',\n  'application/vnd.oasis.opendocument.text',\n  'application/vnd.oasis.opendocument.spreadsheet',\n  'application/vnd.oasis.opendocument.presentation',\n  'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n  'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n  'application/vnd.openxmlformats-officedocument.presentationml.presentation',\n  'application/x-httpd-php',\n  'application/x-sh',\n  'application/x-yaml',\n  'application/xml',\n];\n\nexport function supportsText(mimeType: string): boolean {\n  return SUPPORTED_MIME.includes(mimeType);\n}\n\nexport function isText(filenameOrExtension: string): boolean {\n  const mimeType = getMimeTypeForFile(filenameOrExtension);\n  return (\n    SUPPORTED_MIME.includes(mimeType ?? '') || supportsText(filenameOrExtension)\n  );\n}\n"
  },
  {
    "path": "libs/utils/file-type/src/lib/is-video.ts",
    "content": "import { getMimeTypeForFile } from './mime-helper';\n\nconst SUPPORTED_MIME: string[] = [\n  'video/3gpp',\n  'video/3gpp2',\n  'video/av1',\n  'video/h261',\n  'video/h263',\n  'video/h264',\n  'video/h265',\n  'video/jpeg',\n  'video/jpm',\n  'video/mj2',\n  'video/mp2t',\n  'video/mp4',\n  'video/mpeg',\n  'video/ogg',\n  'video/quicktime',\n  'video/vnd.dece.hd',\n  'video/vnd.dece.mobile',\n  'video/vnd.dece.pd',\n  'video/vnd.dece.sd',\n  'video/vnd.dece.video',\n  'video/vnd.fvt',\n  'video/vnd.mpegurl',\n  'video/vnd.ms-playready.media.pyv',\n  'video/vnd.vivo',\n  'video/webm',\n  'video/x-f4v',\n  'video/x-fli',\n  'video/x-flv',\n  'video/x-m4v',\n  'video/x-matroska',\n  'video/x-mkv',\n  'video/x-ms-asf',\n  'video/x-ms-wm',\n  'video/x-ms-wmv',\n  'video/x-ms-wmx',\n  'video/x-ms-wvx',\n  'video/x-msvideo',\n  'video/x-sgi-movie',\n];\n\nexport function supportsVideo(mimeType: string): boolean {\n  return SUPPORTED_MIME.includes(mimeType);\n}\n\nexport function isVideo(filenameOrExtension: string): boolean {\n  const mimeType = getMimeTypeForFile(filenameOrExtension);\n  return (\n    SUPPORTED_MIME.includes(mimeType ?? '') ||\n    supportsVideo(filenameOrExtension)\n  );\n}\n"
  },
  {
    "path": "libs/utils/file-type/src/lib/mime-helper.ts",
    "content": "import { getType } from 'mime';\n\nexport function getMimeTypeForFile(filenameOrExtension: string): string | null {\n  return getType(filenameOrExtension);\n}\n"
  },
  {
    "path": "libs/utils/file-type/tsconfig.json",
    "content": "{\n  \"extends\": \"../../../tsconfig.base.json\",\n  \"compilerOptions\": {\n    \"module\": \"commonjs\",\n    \"forceConsistentCasingInFileNames\": true,\n    \"strict\": true,\n    \"noImplicitOverride\": true,\n    \"noPropertyAccessFromIndexSignature\": true,\n    \"noImplicitReturns\": true,\n    \"noFallthroughCasesInSwitch\": true\n  },\n  \"files\": [],\n  \"include\": [],\n  \"references\": [\n    {\n      \"path\": \"./tsconfig.lib.json\"\n    }\n  ]\n}\n"
  },
  {
    "path": "libs/utils/file-type/tsconfig.lib.json",
    "content": "{\n  \"extends\": \"./tsconfig.json\",\n  \"compilerOptions\": {\n    \"outDir\": \"../../../dist/out-tsc\",\n    \"declaration\": true,\n    \"types\": []\n  },\n  \"include\": [\"**/*.ts\"],\n  \"exclude\": [\"**/*.spec.ts\", \"**/*.test.ts\"]\n}\n"
  },
  {
    "path": "lingui.config.ts",
    "content": "import type { LinguiConfig } from '@lingui/conf';\n\nconst config: LinguiConfig = {\n  locales: ['en', 'ru', 'es', 'de', 'lt', 'ta', 'pt_BR'],\n  formatOptions: { lineNumbers: false },\n  catalogs: [\n    {\n      include: ['apps/postybirb-ui/src/', 'libs/translations/src/'],\n      path: 'lang/{locale}',\n    },\n  ],\n  fallbackLocales: { default: 'en' },\n};\n\nexport default config;\n"
  },
  {
    "path": "nx.json",
    "content": "{\n  \"generators\": {\n    \"@nx/react\": {\n      \"application\": {\n        \"style\": \"css\",\n        \"linter\": \"eslint\",\n        \"babel\": true\n      },\n      \"component\": {\n        \"style\": \"css\"\n      },\n      \"library\": {\n        \"style\": \"css\",\n        \"linter\": \"eslint\"\n      }\n    }\n  },\n  \"$schema\": \"./node_modules/nx/schemas/nx-schema.json\",\n  \"targetDefaults\": {\n    \"build\": {\n      \"dependsOn\": [\"^build\"],\n      \"inputs\": [\"production\", \"^production\"],\n      \"cache\": true\n    },\n    \"@nx/eslint:lint\": {\n      \"inputs\": [\"default\", \"{workspaceRoot}/.eslintrc.js\"],\n      \"cache\": true\n    },\n    \"@nx/jest:jest\": {\n      \"inputs\": [\"default\", \"^production\", \"{workspaceRoot}/jest.preset.js\"],\n      \"cache\": true,\n      \"options\": {\n        \"passWithNoTests\": true\n      },\n      \"configurations\": {\n        \"ci\": {\n          \"ci\": true,\n          \"codeCoverage\": true\n        }\n      }\n    }\n  },\n  \"namedInputs\": {\n    \"default\": [\"{projectRoot}/**/*\", \"sharedGlobals\"],\n    \"sharedGlobals\": [\n      \"{workspaceRoot}/babel.config.json\",\n      \"{workspaceRoot}/.github/workflows/ci.yml\"\n    ],\n    \"production\": [\n      \"default\",\n      \"!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)\",\n      \"!{projectRoot}/tsconfig.spec.json\",\n      \"!{projectRoot}/jest.config.[jt]s\",\n      \"!{projectRoot}/.eslintrc.js\"\n    ]\n  },\n  \"parallel\": 1,\n  \"useInferencePlugins\": false,\n  \"defaultBase\": \"main\"\n}\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"author\": {\n    \"name\": \"Michael DiCarlo\",\n    \"email\": \"postybirb@gmail.com\"\n  },\n  \"description\": \"PostyBirb is a desktop application used for posting to multiple websites from one simple form.\",\n  \"name\": \"postybirb\",\n  \"version\": \"4.0.28\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://mvdicarlo@github.com/mvdicarlo/postybirb.git\"\n  },\n  \"license\": \"BSD-3-Clause\",\n  \"private\": true,\n  \"main\": \"dist/apps/postybirb/main.js\",\n  \"scripts\": {\n    \"nx\": \"nx\",\n    \"start\": \"nx run-many -t serve --projects=postybirb,postybirb-ui --parallel\",\n    \"build\": \"nx run-many -t build --projects=postybirb,postybirb-ui --parallel\",\n    \"build:prod\": \"nx run-many -t build --configuration=production --projects=postybirb,postybirb-ui --parallel\",\n    \"lingui:extract\": \"lingui extract --clean\",\n    \"make\": \"electron-builder -w\",\n    \"electron-builder\": \"electron-builder\",\n    \"dist\": \"yarn build:prod && electron-builder\",\n    \"dist:win\": \"yarn build:prod && electron-builder --win\",\n    \"dist:mac\": \"yarn build:prod && electron-builder --mac\",\n    \"dist:linux\": \"yarn build:prod && electron-builder --linux\",\n    \"test:client-server\": \"nx test client-server --codeCoverage=true && rimraf test\",\n    \"test\": \"nx run-many -t test --parallel\",\n    \"lint\": \"nx run-many -t lint --parallel --fix\",\n    \"format\": \"prettier -w apps libs\",\n    \"typecheck\": \"nx run-many -t typecheck\",\n    \"affected:build\": \"nx affected: -t build\",\n    \"affected:test\": \"nx affected -t test\",\n    \"affected:lint\": \"nx affected -t lint --parallel\",\n    \"affected:typecheck\": \"nx affected -t typecheck\",\n    \"affected:dep-graph\": \"nx affected:dep-graph\",\n    \"affected\": \"nx affected\",\n    \"update\": \"nx migrate latest\",\n    \"dep-graph\": \"nx dep-graph\",\n    \"help\": \"nx help\",\n    \"postinstall\": \"exitzero electron-builder install-app-deps\",\n    \"setup\": \"run-s setup:husky\",\n    \"setup:husky\": \"yarn husky\",\n    \"generate:sql\": \"drizzle-kit generate\",\n    \"inject:app-insights\": \"node scripts/inject-app-insights.js\",\n    \"inject:app-insights:clear\": \"node scripts/inject-app-insights.js --clear\"\n  },\n  \"dependencies\": {\n    \"@atproto/api\": \"^0.15.7\",\n    \"@aws-crypto/sha256-js\": \"^5.2.0\",\n    \"@aws-sdk/client-s3\": \"^3.925.0\",\n    \"@aws-sdk/protocol-http\": \"^3.374.0\",\n    \"@aws-sdk/signature-v4\": \"^3.374.0\",\n    \"@bbob/preset-react\": \"^4.3.1\",\n    \"@bbob/react\": \"^4.3.1\",\n    \"@dnd-kit/core\": \"^6.3.1\",\n    \"@dnd-kit/sortable\": \"^10.0.0\",\n    \"@dnd-kit/utilities\": \"^3.2.2\",\n    \"@floating-ui/dom\": \"^1.0.0\",\n    \"@fullcalendar/core\": \"^6.1.15\",\n    \"@fullcalendar/daygrid\": \"^6.1.15\",\n    \"@fullcalendar/interaction\": \"^6.1.15\",\n    \"@fullcalendar/react\": \"^6.1.15\",\n    \"@fullcalendar/timegrid\": \"^6.1.15\",\n    \"@iarna/rtf-to-html\": \"^1.1.0\",\n    \"@lingui/core\": \"^5.5\",\n    \"@lingui/macro\": \"^5.5\",\n    \"@lingui/react\": \"^5.5\",\n    \"@mantine/core\": \"^8.3.12\",\n    \"@mantine/dates\": \"^8.3.12\",\n    \"@mantine/dropzone\": \"^8.3.12\",\n    \"@mantine/form\": \"^8.3.12\",\n    \"@mantine/hooks\": \"^8.3.12\",\n    \"@mantine/notifications\": \"^8.3.12\",\n    \"@mantine/spotlight\": \"^8.3.12\",\n    \"@microsoft/applicationinsights-web\": \"^3.3.10\",\n    \"@nestjs/common\": \"^10.4.16\",\n    \"@nestjs/core\": \"^10.4.16\",\n    \"@nestjs/platform-express\": \"^10.4.16\",\n    \"@nestjs/platform-socket.io\": \"^10.4.16\",\n    \"@nestjs/schedule\": \"^4\",\n    \"@nestjs/serve-static\": \"^4\",\n    \"@nestjs/swagger\": \"7.1.14\",\n    \"@nestjs/websockets\": \"^10.4.16\",\n    \"@tabler/icons-react\": \"^3.19.0\",\n    \"@tanstack/react-virtual\": \"^3.13.18\",\n    \"@tiptap/core\": \"^3.19.0\",\n    \"@tiptap/extension-blockquote\": \"^3.19.0\",\n    \"@tiptap/extension-bold\": \"^3.19.0\",\n    \"@tiptap/extension-bubble-menu\": \"^3.19.0\",\n    \"@tiptap/extension-bullet-list\": \"^3.19.0\",\n    \"@tiptap/extension-code\": \"^3.19.0\",\n    \"@tiptap/extension-code-block\": \"^3.19.0\",\n    \"@tiptap/extension-color\": \"^3.19.0\",\n    \"@tiptap/extension-document\": \"^3.19.0\",\n    \"@tiptap/extension-dropcursor\": \"^3.19.0\",\n    \"@tiptap/extension-gapcursor\": \"^3.19.0\",\n    \"@tiptap/extension-hard-break\": \"^3.19.0\",\n    \"@tiptap/extension-heading\": \"^3.19.0\",\n    \"@tiptap/extension-history\": \"^3.19.0\",\n    \"@tiptap/extension-horizontal-rule\": \"^3.19.0\",\n    \"@tiptap/extension-image\": \"^3.19.0\",\n    \"@tiptap/extension-italic\": \"^3.19.0\",\n    \"@tiptap/extension-link\": \"^3.19.0\",\n    \"@tiptap/extension-list\": \"^3.19.0\",\n    \"@tiptap/extension-list-item\": \"^3.19.0\",\n    \"@tiptap/extension-ordered-list\": \"^3.19.0\",\n    \"@tiptap/extension-paragraph\": \"^3.19.0\",\n    \"@tiptap/extension-placeholder\": \"^3.19.0\",\n    \"@tiptap/extension-strike\": \"^3.19.0\",\n    \"@tiptap/extension-text\": \"^3.19.0\",\n    \"@tiptap/extension-text-align\": \"^3.19.0\",\n    \"@tiptap/extension-text-style\": \"^3.19.0\",\n    \"@tiptap/extension-underline\": \"^3.19.0\",\n    \"@tiptap/extensions\": \"^3.19.0\",\n    \"@tiptap/html\": \"patch:@tiptap/html@npm%3A3.15.3#~/.yarn/patches/@tiptap-html-npm-3.15.3-a9641901db.patch\",\n    \"@tiptap/pm\": \"^3.19.0\",\n    \"@tiptap/react\": \"^3.19.0\",\n    \"@tiptap/suggestion\": \"^3.19.0\",\n    \"agent-base\": \"^7.1.4\",\n    \"applicationinsights\": \"3.12.1\",\n    \"async-mutex\": \"^0.5.0\",\n    \"better-sqlite3\": \"^12.4.1\",\n    \"class-transformer\": \"^0.5.1\",\n    \"class-validator\": \"^0.14.1\",\n    \"clsx\": \"^2.1.1\",\n    \"compression\": \"^1.7.4\",\n    \"croner\": \"^8.1.2\",\n    \"cronstrue\": \"^2.50.0\",\n    \"cropperjs\": \"^1.6.2\",\n    \"dayjs\": \"^1.11.13\",\n    \"drizzle-orm\": \"^0.44.7\",\n    \"electron-context-menu\": \"^3.1.1\",\n    \"electron-updater\": \"^6.3.9\",\n    \"express\": \"^4.21.0\",\n    \"fastq\": \"^1.17.1\",\n    \"filesize\": \"^10.1.6\",\n    \"form-data\": \"^4.0.0\",\n    \"form-urlencoded\": \"^6.1.5\",\n    \"gql-query-builder\": \"^3.8.0\",\n    \"happy-dom\": \"^20.3.0\",\n    \"hasha\": \"^5.2.2\",\n    \"history\": \"^5.3.0\",\n    \"html-entities\": \"^2.6.0\",\n    \"html-to-text\": \"^9.0.5\",\n    \"http-proxy-agent\": \"^7.0.2\",\n    \"https-proxy-agent\": \"^7.0.6\",\n    \"is-docker\": \"^4.0.0\",\n    \"js-beautify\": \"^1.15.1\",\n    \"lodash\": \"^4.17.21\",\n    \"loglayer\": \"^4.8.0\",\n    \"mammoth\": \"^1.8.0\",\n    \"megalodon\": \"^10.1.3\",\n    \"mime\": \"^3.0.0\",\n    \"minimist\": \"^1.2.8\",\n    \"moment\": \"^2.30.1\",\n    \"multer\": \"^1.4.5-lts.1\",\n    \"node-forge\": \"^1.3.1\",\n    \"node-html-parser\": \"^7.0.1\",\n    \"piscina\": \"^5.1.4\",\n    \"postcss\": \"8.4.38\",\n    \"postcss-import\": \"^14.0.2\",\n    \"postcss-inline-svg\": \"^6.0.0\",\n    \"react\": \"18.3.1\",\n    \"react-dom\": \"18.3.1\",\n    \"react-joyride\": \"^3.0.1\",\n    \"react-query\": \"^3\",\n    \"react-router\": \"6.17.0\",\n    \"react-router-dom\": \"6.17.0\",\n    \"react-use\": \"^17.5.1\",\n    \"reflect-metadata\": \"^0.1.13\",\n    \"regenerator-runtime\": \"^0.13.9\",\n    \"rxjs\": \"^7.3.0\",\n    \"sanitize-html\": \"^2.13.0\",\n    \"sharp\": \"^0.34.5\",\n    \"socket.io\": \"^4.8.0\",\n    \"socket.io-client\": \"^4.8.0\",\n    \"socks-proxy-agent\": \"^8.0.5\",\n    \"telegram\": \"^2.26.22\",\n    \"tinykeys\": \"^3.0.0\",\n    \"tippy.js\": \"^6.3.7\",\n    \"tslib\": \"2.5.2\",\n    \"turndown\": \"^7.2.0\",\n    \"twitter-api-v2\": \"^1.27.0\",\n    \"twitter-text\": \"^3.1.0\",\n    \"type-fest\": \"^4.26.1\",\n    \"uuid\": \"^10.0.0\",\n    \"winston\": \"^3.14.2\",\n    \"winston-daily-rotate-file\": \"^5.0.0\",\n    \"winston-transport\": \"^4.9.0\",\n    \"zustand\": \"^5.0.9\"\n  },\n  \"devDependencies\": {\n    \"@babel/core\": \"^7.14.5\",\n    \"@babel/plugin-proposal-decorators\": \"^7.27.1\",\n    \"@babel/plugin-transform-class-properties\": \"^7.27.1\",\n    \"@babel/preset-env\": \"7.12.13\",\n    \"@babel/preset-react\": \"^7.14.5\",\n    \"@babel/preset-typescript\": \"7.12.13\",\n    \"@commitlint/cli\": \"^19.5.0\",\n    \"@commitlint/config-conventional\": \"^19.5.0\",\n    \"@eslint-types/import\": \"^2.29.1\",\n    \"@eslint-types/typescript-eslint\": \"6.21.0\",\n    \"@kayahr/jest-electron-runner\": \"29.15.0\",\n    \"@lingui/cli\": \"^5.5\",\n    \"@lingui/swc-plugin\": \"^5.5.2\",\n    \"@lingui/vite-plugin\": \"^5.5\",\n    \"@nestjs/schematics\": \"10.0.1\",\n    \"@nestjs/testing\": \"^10.4.16\",\n    \"@nrwl/tao\": \"19.8.9\",\n    \"@nx/cypress\": \"19.8.9\",\n    \"@nx/eslint\": \"19.8.9\",\n    \"@nx/eslint-plugin\": \"19.8.9\",\n    \"@nx/jest\": \"19.8.9\",\n    \"@nx/js\": \"19.8.9\",\n    \"@nx/nest\": \"19.8.9\",\n    \"@nx/node\": \"19.8.9\",\n    \"@nx/react\": \"19.8.9\",\n    \"@nx/rollup\": \"19.8.9\",\n    \"@nx/vite\": \"19.8.9\",\n    \"@nx/web\": \"19.8.9\",\n    \"@nx/workspace\": \"19.8.9\",\n    \"@swc/core\": \"1.5.7\",\n    \"@swc/jest\": \"0.2.39\",\n    \"@testing-library/react\": \"15.0.6\",\n    \"@types/better-sqlite3\": \"^7.6.13\",\n    \"@types/cron\": \"^2.4.0\",\n    \"@types/eslint\": \"8\",\n    \"@types/hbs\": \"^4.0.4\",\n    \"@types/jest\": \"29.5.14\",\n    \"@types/lodash\": \"^4.17.9\",\n    \"@types/mime\": \"^3.0.3\",\n    \"@types/minimist\": \"^1.2.5\",\n    \"@types/node\": \"^20.16.10\",\n    \"@types/node-forge\": \"^1.3.11\",\n    \"@types/react\": \"18.3.1\",\n    \"@types/react-dom\": \"18.3.0\",\n    \"@types/react-router-dom\": \"^5.3.3\",\n    \"@types/sanitize-html\": \"2.9.0\",\n    \"@types/uuid\": \"^10.0.0\",\n    \"@typescript-eslint/eslint-plugin\": \"7.16.0\",\n    \"@typescript-eslint/parser\": \"7.16.0\",\n    \"@vitejs/plugin-react-swc\": \"3.11.0\",\n    \"autoprefixer\": \"10.4.13\",\n    \"babel-jest\": \"29.7.0\",\n    \"babel-plugin-macros\": \"^3.1.0\",\n    \"cypress\": \"^7.3.0\",\n    \"dotenv\": \"10.0.0\",\n    \"drizzle-kit\": \"^0.31.7\",\n    \"electron\": \"^40.8.4\",\n    \"electron-builder\": \"^24.13.3\",\n    \"electron-builder-squirrel-windows\": \"24.13.3\",\n    \"eslint\": \"8.57.1\",\n    \"eslint-config-airbnb\": \"^19.0.4\",\n    \"eslint-config-airbnb-typescript\": \"^18.0.0\",\n    \"eslint-config-prettier\": \"9.0.0\",\n    \"eslint-plugin-cypress\": \"2.13.4\",\n    \"eslint-plugin-import\": \"2.26.0\",\n    \"eslint-plugin-jest\": \"^27.9.0\",\n    \"eslint-plugin-jsx-a11y\": \"6.6.1\",\n    \"eslint-plugin-lingui\": \"^0.2.2\",\n    \"eslint-plugin-prettier\": \"5\",\n    \"eslint-plugin-react\": \"7.31.11\",\n    \"eslint-plugin-react-hooks\": \"4.6.0\",\n    \"eslint-plugin-testing-library\": \"^5.0.3\",\n    \"exitzero\": \"^1.0.1\",\n    \"hbs\": \"^4.2.0\",\n    \"husky\": \"^9.1.7\",\n    \"inquirer\": \"^12.3.0\",\n    \"jest\": \"29.7.0\",\n    \"jest-environment-jsdom\": \"29.7.0\",\n    \"jsdom\": \"~22.1.0\",\n    \"npm-run-all\": \"^4.1.5\",\n    \"nx\": \"19.8.9\",\n    \"nx-electron\": \"19.0.0\",\n    \"postcss-preset-mantine\": \"^1.17.0\",\n    \"postcss-simple-vars\": \"^7.0.1\",\n    \"prettier\": \"3\",\n    \"prettier-2\": \"npm:prettier@^2\",\n    \"shx\": \"^0.3.4\",\n    \"swagger-ui-express\": \"^4.1.6\",\n    \"swc_mut_cjs_exports\": \"^10.7.0\",\n    \"tailwindcss\": \"3.4.3\",\n    \"typescript\": \"^5.8.3\",\n    \"vite\": \"^5.4.19\",\n    \"vitest\": \"1.3.1\",\n    \"webpack\": \"^5.89.0\",\n    \"webpack-node-externals\": \"^3.0.0\"\n  },\n  \"resolutions\": {\n    \"glob\": \"9\",\n    \"electron\": \"^35\",\n    \"@azure/monitor-opentelemetry-exporter\": \"1.0.0-beta.38\",\n    \"jest-snapshot@npm:^29.7.0\": \"patch:jest-snapshot@npm%3A29.7.0#~/.yarn/patches/jest-snapshot-npm-29.7.0-15ef0a4ad6.patch\",\n    \"@swc/core\": \"1.13.1\",\n    \"@handlewithcare/prosemirror-inputrules@npm:0.1.3\": \"patch:@handlewithcare/prosemirror-inputrules@npm%3A0.1.3#~/.yarn/patches/@handlewithcare-prosemirror-inputrules-npm-0.1.3-897e37b56f.patch\",\n    \"strong-log-transformer@npm:^2.1.0\": \"patch:strong-log-transformer@npm%3A2.1.0#~/.yarn/patches/strong-log-transformer-npm-2.1.0-45addd9278.patch\"\n  },\n  \"packageManager\": \"yarn@4.9.4\"\n}\n"
  },
  {
    "path": "packaging-resources/entitlements.mac.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n    <dict>\n        <key>com.apple.security.cs.allow-jit</key>\n        <true/>\n        <key>com.apple.security.cs.allow-unsigned-executable-memory</key>\n        <true/>\n        <key>com.apple.security.cs.disable-library-validation</key>\n        <true/>\n        <key>com.apple.security.network.client</key>\n        <true/>\n        <key>com.apple.security.files.user-selected.read-write</key>\n        <true/>\n    </dict>\n</plist>"
  },
  {
    "path": "packaging-resources/installer.nsh",
    "content": "; PostyBirb NSIS installer script\n; Additional installer customizations\n\n!macro customInstall\n  ; Add any custom installation steps here\n  ; For example, registry entries, shortcuts, etc.\n!macroend\n\n!macro customUninstall  \n  ; Add any custom uninstallation steps here\n  ; For example, cleaning up registry entries, temp files, etc.\n!macroend\n\n!macro customHeader\n  ; Custom header definitions\n!macroend\n\n!macro customInit\n  ; Custom initialization\n!macroend\n\n!macro customUnInit\n  ; Custom uninstall initialization  \n!macroend"
  },
  {
    "path": "scripts/add-website/create-file-from-template.js",
    "content": "import fs from 'fs';\nimport hbs from 'hbs';\nimport path from 'path';\nimport url from 'url';\n\n/**\n * @param {import('./parse-add-website-input.js').AddWebsiteContext} data\n * @param {string} templateFileName\n * @param {string} folder\n * @param {string} fileName\n */\nexport function createFromTemplate(data, templateFileName, folder, fileName) {\n  const dirname = path.dirname(url.fileURLToPath(import.meta.url));\n  const templatePath = path.join(dirname, 'templates', templateFileName);\n  const template = hbs.handlebars.compile(\n    fs.readFileSync(templatePath, 'utf8'),\n  );\n\n  const filePath = path.join(folder, fileName);\n  console.log('Creating file:', filePath);\n\n  const content = template(data);\n  if (fs.existsSync(filePath)) {\n    console.error('File already exists:', filePath);\n    return fileName;\n  }\n\n  fs.writeFileSync(filePath, content);\n  console.log('File created:', filePath);\n  return fileName;\n}\n"
  },
  {
    "path": "scripts/add-website/create-website.js",
    "content": "import fs from 'fs';\nimport path from 'path';\nimport { createFromTemplate } from './create-file-from-template.js';\n\n/**\n * @param {import('./parse-add-website-input.js').AddWebsiteContext} data\n * @param {string} baseAppPath\n */\nexport function createWebsite(data, baseAppPath) {\n  let { website, pascalWebsiteName, hasFile, hasMessage } = data;\n  const websiteFolder = path.join(baseAppPath, website);\n  const websiteModelsFolder = path.join(baseAppPath, website, 'models');\n\n  try {\n    fs.mkdirSync(websiteFolder, { recursive: true });\n    fs.mkdirSync(websiteModelsFolder, { recursive: true });\n    const websiteFileName = createFromTemplate(\n      data,\n      'website.hbs',\n      websiteFolder,\n      `${website}.website.ts`,\n    );\n\n    createFromTemplate(\n      data,\n      'account-data.hbs',\n      websiteModelsFolder,\n      `${website}-account-data.ts`,\n    );\n\n    if (hasMessage) {\n      createFromTemplate(\n        data,\n        'message-submission.hbs',\n        websiteModelsFolder,\n        `${website}-message-submission.ts`,\n      );\n    }\n    if (hasFile) {\n      createFromTemplate(\n        data,\n        'file-submission.hbs',\n        websiteModelsFolder,\n        `${website}-file-submission.ts`,\n      );\n    }\n\n    console.log('File(s) created successfully!');\n\n    const indexFilePath = path.join(baseAppPath, 'index.ts');\n    let indexFileContent = fs.readFileSync(indexFilePath, 'utf8').trim();\n    indexFileContent += `\\r\\nexport { default as ${pascalWebsiteName} } from './${website}/${websiteFileName.replace('.ts', '')}';\\r\\n`;\n    fs.writeFileSync(indexFilePath, indexFileContent);\n\n    console.log('Index file updated successfully!');\n  } catch (err) {\n    console.error('Error creating file(s):', err);\n    // Rollback: delete created files and directories\n    fs.rmSync(websiteModelsFolder, { force: true, recursive: true });\n    fs.rmSync(websiteFolder, { force: true, recursive: true });\n  }\n}\n"
  },
  {
    "path": "scripts/add-website/parse-add-website-input.js",
    "content": "/**\n * @param {{\n *  websiteName: string;\n *  websiteUrl: string;\n *  submissionTypes: string[];\n *  fileFeatures: boolean;\n *  supportsTags: boolean;\n *  confirm: boolean;\n * }} answers\n */\nexport function parseAddWebsiteInput(answers) {\n  const { websiteName, submissionTypes, websiteUrl, supportsTags } = answers;\n\n  const website = websiteName.toLowerCase().trim();\n  return {\n    website,\n    pascalWebsiteName: website\n      .split('-')\n      .map((word) => word.charAt(0).toUpperCase() + word.slice(1))\n      .join(''),\n    hasFile: submissionTypes.includes('file'),\n    hasMessage: submissionTypes.includes('message'),\n    websiteUrl,\n    supportsTags,\n  };\n}\n\n/**\n * @typedef {ReturnType<typeof parseAddWebsiteInput>} AddWebsiteContext\n */\n"
  },
  {
    "path": "scripts/add-website/templates/account-data.hbs",
    "content": "import { SelectOption } from '@postybirb/form-builder'; \n\nexport type {{pascalWebsiteName}}AccountData = { \n  folders: SelectOption[];\n}\n"
  },
  {
    "path": "scripts/add-website/templates/file-submission.hbs",
    "content": "import { DescriptionField{{#if supportsTags}}{{else}}, TagField{{/if}} } from '@postybirb/form-builder';\nimport { DescriptionType, DescriptionValue{{#if supportsTags}}{{else}}, TagValue{{/if}} } from '@postybirb/types';\nimport { BaseWebsiteOptions } from '../../../models/base-website-options';\n\nexport class {{pascalWebsiteName}}FileSubmission extends BaseWebsiteOptions {\n  @DescriptionField({\n    descriptionType: DescriptionType.HTML\n  })\n  description: DescriptionValue;\n  {{#if supportsTags}}\n  {{else}}\n\n  @TagField({ hidden: true })\n  tags: TagValue;\n  {{/if}}\n}\n"
  },
  {
    "path": "scripts/add-website/templates/message-submission.hbs",
    "content": "import { DescriptionField{{#if supportsTags}}{{else}}, TagField {{/if}} } from '@postybirb/form-builder';\nimport { DescriptionType, DescriptionValue{{#if supportsTags}}{{else}}, TagValue{{/if}} } from '@postybirb/types';\nimport { BaseWebsiteOptions } from '../../../models/base-website-options';\n\nexport class {{pascalWebsiteName}}MessageSubmission extends BaseWebsiteOptions {\n  @DescriptionField({\n    descriptionType: DescriptionType.HTML\n  })\n  description: DescriptionValue;\n  {{#if supportsTags}}\n  {{else}}\n\n  @TagField({ hidden: true })\n  tags: TagValue;\n  {{/if}}\n}\n"
  },
  {
    "path": "scripts/add-website/templates/website.hbs",
    "content": "import { Http } from '@postybirb/http';\nimport {\n  ILoginState,\n  ImageResizeProps,\n  ISubmissionFile,\n  PostData,\n  PostResponse,\n  SimpleValidationResult,\n} from '@postybirb/types';\nimport { CancellableToken } from '../../../post/models/cancellable-token';\nimport { PostingFile } from '../../../post/models/posting-file';\nimport { UserLoginFlow } from '../../decorators/login-flow.decorator';\n{{#if hasFile}}\nimport { SupportsFiles } from '../../decorators/supports-files.decorator';\n{{/if}}\nimport { WebsiteMetadata } from '../../decorators/website-metadata.decorator';\nimport { DataPropertyAccessibility } from '../../models/data-property-accessibility';\n{{#if hasFile}}\nimport { FileWebsite } from '../../models/website-modifiers/file-website';\n{{/if}}\n{{#if hasMessage}}\nimport { MessageWebsite } from '../../models/website-modifiers/message-website';\n{{/if}}\nimport { PostBatchData } from '../../models/website-modifiers/file-website';\nimport { Website } from '../../website';\nimport { {{pascalWebsiteName}}AccountData } from './models/{{website}}-account-data';\n{{#if hasFile}}\nimport { {{pascalWebsiteName}}FileSubmission } from './models/{{website}}-file-submission';\n{{/if}}\n{{#if hasMessage}}\nimport { {{pascalWebsiteName}}MessageSubmission } from './models/{{website}}-message-submission';\n{{/if}}\n\n@WebsiteMetadata({\n  name: '{{website}}',\n  displayName: '{{website}}',\n})\n@UserLoginFlow('{{websiteUrl}}')\n@SupportsFiles(['image/png', 'image/jpeg'])\nexport default class {{pascalWebsiteName}} extends Website<{{pascalWebsiteName}}AccountData> implements\n  {{#if hasFile}}\n  FileWebsite<{{pascalWebsiteName}}FileSubmission>{{#if hasMessage}},{{/if}}\n  {{/if}}\n  {{#if hasMessage}}\n  MessageWebsite<{{pascalWebsiteName}}MessageSubmission>\n  {{/if}}\n{\n  protected BASE_URL = '{{websiteUrl}}';\n\n  public externallyAccessibleWebsiteDataProperties: DataPropertyAccessibility<{{pascalWebsiteName}}AccountData> =\n    {\n      folders: true\n    };\n\n  protected async onLogin(): Promise<ILoginState> {\n    if (this.account.name === 'test') {\n      this.loginState.logout();\n    }\n\n    return this.loginState.setLogin(true, 'TestUser');\n  }\n\n  {{#if hasFile}}\n  createFileModel(): {{pascalWebsiteName}}FileSubmission {\n    return new {{pascalWebsiteName}}FileSubmission();\n  }\n\n  calculateImageResize(file: ISubmissionFile): ImageResizeProps {\n    return undefined;\n  }\n\n  async onPostFileSubmission(\n    postData: PostData<{{pascalWebsiteName}}FileSubmission>,\n    files: PostingFile[],\n    cancellationToken: CancellableToken,\n    batch: PostBatchData\n  ): Promise<PostResponse> {\n    cancellationToken.throwIfCancelled();\n    const formData = {\n      file: files[0].toPostFormat(),\n      thumb: files[0].thumbnailToPostFormat(),\n      description: postData.options.description,\n      tags: postData.options.tags.join(', '),\n      title: postData.options.title,\n      rating: postData.options.rating,\n    };\n\n    const result = await Http.post<string>(`${this.BASE_URL}/submit`, {\n      partition: this.accountId,\n      data: formData,\n      type: 'multipart',\n    });\n\n    if (result.statusCode === 200) {\n      return PostResponse.fromWebsite(this).withAdditionalInfo(result.body);\n    }\n\n    return PostResponse.fromWebsite(this)\n      .withAdditionalInfo({\n        body: result.body,\n        statusCode: result.statusCode,\n      })\n      .withException(new Error('Failed to post'));\n  }\n\n  async onValidateFileSubmission(\n    postData: PostData<{{pascalWebsiteName}}FileSubmission>,\n  ): Promise<SimpleValidationResult> {\n    const validator = this.createValidator<{{pascalWebsiteName}}FileSubmission>();\n\n    return validator.result;\n  }\n  {{/if}}\n\n  {{#if hasMessage}}\n  createMessageModel(): {{pascalWebsiteName}}MessageSubmission {\n    return new {{pascalWebsiteName}}MessageSubmission();\n  }\n\n  async onPostMessageSubmission(\n    postData: PostData<{{pascalWebsiteName}}MessageSubmission>,\n    cancellationToken: CancellableToken,\n  ): Promise<PostResponse> {\n    cancellationToken.throwIfCancelled();\n    const formData = {\n      description: postData.options.description,\n      tags: postData.options.tags.join(', '),\n      title: postData.options.title,\n      rating: postData.options.rating,\n    };\n\n    const result = await Http.post<string>(`${this.BASE_URL}/submit`, {\n      partition: this.accountId,\n      data: formData,\n      type: 'multipart',\n    });\n\n    if (result.statusCode === 200) {\n      return PostResponse.fromWebsite(this).withAdditionalInfo(result.body);\n    }\n\n    return PostResponse.fromWebsite(this)\n      .withAdditionalInfo({\n        body: result.body,\n        statusCode: result.statusCode,\n      })\n      .withException(new Error('Failed to post'));\n  }\n\n  async onValidateMessageSubmission(\n    postData: PostData<{{pascalWebsiteName}}MessageSubmission>,\n  ): Promise<SimpleValidationResult> {\n    const validator = this.createValidator<{{pascalWebsiteName}}MessageSubmission>();\n\n    return validator.result;\n  }\n  {{/if}}\n}"
  },
  {
    "path": "scripts/add-website.js",
    "content": "import inquirer from 'inquirer';\nimport path from 'path';\nimport { createWebsite } from './add-website/create-website.js';\nimport { parseAddWebsiteInput } from './add-website/parse-add-website-input.js';\n\n/** @param {string} str */\nconst isDashCasedOrLowercase = (str) =>\n  /^[a-z]+(-[a-z]+)*$/.test(str) || /^[a-z]+$/.test(str);\n\nconst currentPath = path.resolve();\nconst baseAppPath = currentPath.includes('postybirb')\n  ? path.resolve(\n      'apps',\n      'client-server',\n      'src',\n      'app',\n      'websites',\n      'implementations',\n    )\n  : path.resolve(\n      '..',\n      'apps',\n      'client-server',\n      'src',\n      'app',\n      'websites',\n      'implementations',\n    );\n\ninquirer\n  .prompt([\n    {\n      type: 'input',\n      name: 'websiteName',\n      message: 'Enter the website name (dash-cased or all lowercase one word):',\n      validate: (input) =>\n        isDashCasedOrLowercase(input) ||\n        'Invalid website name. It must be in dash-case or all lowercase one word.',\n    },\n    {\n      type: 'input',\n      name: 'websiteUrl',\n      message: '(optional) Enter the website URL: (e.g. https://example.com)',\n      validate: (input) => {\n        if (!input) return true;\n        try {\n          new URL(input);\n          return true;\n        } catch (error) {\n          return 'Invalid URL format. Please enter a valid URL.';\n        }\n      },\n    },\n    {\n      type: 'checkbox',\n      name: 'submissionTypes',\n      message: 'Select the submission types for the website:',\n      choices: [\n        {\n          name: 'File Submissions (allows users to post files to the website)',\n          value: 'file',\n        },\n        {\n          name: 'Message Submissions (allows users to post messages to the website (e.g. blog posts, tweets))',\n          value: 'message',\n        },\n      ],\n      validate: (input) =>\n        input.length > 0 || 'You must select at least one submission type.',\n    },\n    {\n      type: 'checkbox',\n      name: 'fileFeatures',\n      when: (answers) => answers.submissionTypes.includes('file'),\n      message: 'Select the features for the website:',\n      choices: [\n        {\n          name: 'Image Submissions (allows users to post images to the website)',\n          value: 'image',\n        },\n        {\n          name: 'Video Submissions (allows users to post videos to the website)',\n          value: 'video',\n        },\n        {\n          name: 'Audio Submissions (allows users to post audio files to the website)',\n          value: 'audio',\n        },\n      ],\n    },\n    {\n      type: 'confirm',\n      name: 'supportsTags',\n      message: 'Does the website support tags / in-description tags?',\n    },\n    {\n      type: 'confirm',\n      name: 'confirm',\n      message: 'Are you sure you want to create the website?',\n    },\n  ])\n  .then((answers) => {\n    if (answers.confirm) {\n      createWebsite(parseAddWebsiteInput(answers), baseAppPath);\n    }\n  })\n  .catch((error) => {\n    console.error('Error:', error);\n  });\n"
  },
  {
    "path": "scripts/inject-app-insights.js",
    "content": "import fs from 'fs';\nimport path from 'path';\nimport { fileURLToPath } from 'url';\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = path.dirname(__filename);\n\nconst ROOT_DIR = path.resolve(__dirname, '..');\nconst TARGET_FILES = [\n  'apps/postybirb-ui/src/app-insights-ui.ts',\n  'libs/logger/src/lib/app-insights.ts',\n];\nconst PLACEHOLDER_STATEMENT = 'const appInsightsConnectionString: string | null = null;';\nconst STATEMENT_REGEX = /const appInsightsConnectionString(?::\\s*string\\s*\\|\\s*null)?\\s*=\\s*[^;]+;/;\n\nconst shouldClear = process.argv.includes('--clear');\nconst appInsightsKey = process.env.APP_INSIGHTS_KEY?.trim();\n\nif (!shouldClear && !appInsightsKey) {\n  console.log('[inject-app-insights] APP_INSIGHTS_KEY not provided; skipping.');\n  process.exit(0);\n}\n\nconst replacementStatement = shouldClear\n  ? PLACEHOLDER_STATEMENT\n  : `const appInsightsConnectionString: string | null = ${JSON.stringify(appInsightsKey)};`;\n\n/**\n * @param {string} relativePath\n */\nfunction updateFile(relativePath) {\n  const filePath = path.join(ROOT_DIR, relativePath);\n  const originalContent = fs.readFileSync(filePath, 'utf8');\n\n  if (!STATEMENT_REGEX.test(originalContent)) {\n    throw new Error(\n      `[inject-app-insights] Could not find placeholder in ${relativePath}.`,\n    );\n  }\n\n  const updatedContent = originalContent.replace(\n    STATEMENT_REGEX,\n    replacementStatement,\n  );\n\n  fs.writeFileSync(filePath, updatedContent, 'utf8');\n  console.log(\n    `[inject-app-insights] ${\n      shouldClear ? 'Cleared placeholder' : 'Injected key'\n    } in ${relativePath}.`,\n  );\n}\n\nTARGET_FILES.forEach(updateFile);\n"
  },
  {
    "path": "scripts/package.json",
    "content": "{\n  \"name\": \"scripts\",\n  \"version\": \"1.0.0\",\n  \"main\": \"add-website.js\",\n  \"license\": \"ISC\",\n  \"type\": \"module\"\n}\n"
  },
  {
    "path": "scripts/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"allowJs\": true,\n    \"checkJs\": true,\n    \"module\": \"Node18\",\n    \"moduleResolution\": \"node16\",\n    \"esModuleInterop\": true,\n    \"target\": \"ES2024\",\n    \"lib\": [\"ES2024\"],\n    \"noEmit\": true,\n    \"noImplicitAny\": true,\n    \"strict\": true\n  },\n  \"include\": [\"**/*.js\"]\n}\n"
  },
  {
    "path": "scripts/windows-signing/hasher.cjs",
    "content": "// @ts-nocheck\nconst path = require('path');\nconst fs = require('fs');\nconst crypto = require('crypto');\nconst { parse, stringify } = require('yaml');\nconst argv = require('yargs/yargs')(process.argv.slice(2))\n  .usage('Usage: $0 --path <filepath>')\n  .demandOption(['path']).argv;\n\nconst filePath = path.normalize(argv.path);\nconsole.log(\n  `\\x1b[32m[INFO]\\x1b[0m Processing file at path: \\x1b[33m${filePath}\\x1b[0m`,\n);\n\nconst yamlPath = path.join(filePath, `latest.yml`);\nconsole.log(\n  `\\x1b[32m[INFO]\\x1b[0m Reading YAML file from: \\x1b[33m${yamlPath}\\x1b[0m`,\n);\nconst yaml = parse(fs.readFileSync(yamlPath, 'utf8'));\nconst version = yaml.version;\nconst existingHash = yaml.sha512;\nconsole.log(\n  `\\x1b[32m[INFO]\\x1b[0m\\x1b[36m Version:\\x1b[0m ${version}\\x1b[36m Existing Hash:\\x1b[0m ${existingHash}`,\n);\nconst target = yaml.files[0].url;\n\nfunction hashFile(file, algorithm = 'sha512', encoding = 'base64', options) {\n  console.log(`\\x1b[32m[INFO]\\x1b[0m Hashing file: \\x1b[33m${file}\\x1b[0m`);\n  return new Promise((resolve, reject) => {\n    const hash = crypto.createHash(algorithm);\n    hash.on('error', reject).setEncoding(encoding);\n    fs.createReadStream(\n      file,\n      Object.assign({}, options, {\n        highWaterMark: 1024 * 1024,\n        /* better to use more memory but hash faster */\n      }),\n    )\n      .on('error', reject)\n      .on('open', () =>\n        console.log(\n          `\\x1b[32m[INFO]\\x1b[0m Started reading file: \\x1b[33m${file}\\x1b[0m`,\n        ),\n      )\n      .on('end', () => {\n        console.log(\n          `\\x1b[32m[INFO]\\x1b[0m Finished reading file: \\x1b[33m${file}\\x1b[0m`,\n        );\n        hash.end();\n        resolve(hash.read());\n      })\n      .pipe(hash, {\n        end: false,\n      });\n  });\n}\n\nhashFile(path.join(filePath, target))\n  .then((hash) => {\n    console.log(`\\x1b[32m[INFO]\\x1b[0m Computed hash: \\x1b[33m${hash}\\x1b[0m`);\n    if (existingHash === hash) {\n      throw new Error('Hashes are the same');\n    }\n    console.log(`\\x1b[32m[INFO]\\x1b[0m Updating YAML with new hash...\\n`);\n    yaml.sha512 = hash;\n    yaml.files[0].sha512 = hash;\n    console.log(`\\x1b[32m[INFO]\\x1b[0m New YAML content:\\n${stringify(yaml)}`);\n    fs.writeFileSync(yamlPath, stringify(yaml));\n    console.log(`\\x1b[32m[INFO]\\x1b[0m YAML file updated successfully.`);\n  })\n  .catch((err) => {\n    console.error('\\x1b[31m[ERROR]\\x1b[0m', err);\n    throw err;\n  });\n"
  },
  {
    "path": "scripts/windows-signing/pull-and-sign.ps1",
    "content": "# This script is used by the mvdicarlo to manually sign the artifacts because of the certs limitations.\n\nfunction Print-Banner {\n    $banner = '\n********************************************************************************************************\n*    $$$$$$$\\                        $$\\               $$$$$$$\\  $$\\           $$\\                     *\n*    $$  __$$\\                       $$ |              $$  __$$\\ \\__|          $$ |                    *\n*    $$ |  $$ | $$$$$$\\   $$$$$$$\\ $$$$$$\\   $$\\   $$\\ $$ |  $$ |$$\\  $$$$$$\\  $$$$$$$\\                *\n*    $$$$$$$  |$$  __$$\\ $$  _____|\\_$$  _|  $$ |  $$ |$$$$$$$\\ |$$ |$$  __$$\\ $$  __$$\\               *\n*    $$  ____/ $$ /  $$ |\\$$$$$$\\    $$ |    $$ |  $$ |$$  __$$\\ $$ |$$ |  \\__|$$ |  $$ |              *\n*    $$ |      $$ |  $$ | \\____$$\\   $$ |$$\\ $$ |  $$ |$$ |  $$ |$$ |$$ |      $$ |  $$ |              *\n*    $$ |      \\$$$$$$  |$$$$$$$  |  \\$$$$  |\\$$$$$$$ |$$$$$$$  |$$ |$$ |      $$$$$$$  |              *\n*    \\__|       \\______/ \\_______/    \\____/  \\____$$ |\\_______/ \\__|\\__|      \\_______/               *\n*                                            $$\\   $$ |                                                *\n*                                            \\$$$$$$  |                                                *\n*                                             \\______/                                                 *\n*     $$$$$$\\  $$\\                     $$\\                           $$$$$$$$\\                  $$\\    *\n*    $$  __$$\\ \\__|                    \\__|                          \\__$$  __|                 $$ |   *\n*    $$ /  \\__|$$\\  $$$$$$\\  $$$$$$$\\  $$\\ $$$$$$$\\   $$$$$$\\           $$ | $$$$$$\\   $$$$$$\\  $$ |   *\n*    \\$$$$$$\\  $$ |$$  __$$\\ $$  __$$\\ $$ |$$  __$$\\ $$  __$$\\          $$ |$$  __$$\\ $$  __$$\\ $$ |   *\n*     \\____$$\\ $$ |$$ /  $$ |$$ |  $$ |$$ |$$ |  $$ |$$ /  $$ |         $$ |$$ /  $$ |$$ /  $$ |$$ |   *\n*    $$\\   $$ |$$ |$$ |  $$ |$$ |  $$ |$$ |$$ |  $$ |$$ |  $$ |         $$ |$$ |  $$ |$$ |  $$ |$$ |   *\n*    \\$$$$$$  |$$ |\\$$$$$$$ |$$ |  $$ |$$ |$$ |  $$ |\\$$$$$$$ |         $$ |\\$$$$$$  |\\$$$$$$  |$$ |   *\n*     \\______/ \\__| \\____$$ |\\__|  \\__|\\__|\\__|  \\__| \\____$$ |         \\__| \\______/  \\______/ \\__|   *\n*                  $$\\   $$ |                        $$\\   $$ |                                        *\n*                  \\$$$$$$  |                        \\$$$$$$  |                                        *\n*                   \\______/                          \\______/                                         *\n********************************************************************************************************\n'\n    Write-Host $banner\n}\n\nPrint-Banner\n\n# Step 1: Find the latest draft release from a repo\n$repo = \"mvdicarlo/postybirb\"\n$signingDir = \"signing\"\n$latestDraftRelease = gh release list --repo $repo --limit '1' | Select-Object -First 1\n\n$parts = $latestDraftRelease.split(\"`t\")\n$releaseId = \"v\" + $parts[0]\n$isDraft = $parts[1] -eq \"Draft\"\n\nWrite-Host \"Found release $releaseId (Draft: $isDraft)\"\nif (-not $isDraft) {\n    Write-Host \"Release is not a draft. Exiting.\"\n    exit 1\n}\n\n# Delete the signing directory if it exists\nif (Test-Path -Path $signingDir) {\n    Remove-Item -Path $signingDir -Recurse -Force\n}\nNew-Item -ItemType Directory -Path $signingDir\n\n# Step 2: Download all the files that have .exe, or is latest.yml\n$assets = gh release view $releaseId --repo $repo --json assets | ConvertFrom-Json\n$assets.assets | ForEach-Object {\n    if ($_.name -match \"\\.exe$\" -or $_.name -eq \"latest.yml\") {\n        gh release download $releaseId --repo $repo --pattern $_.name --dir $signingDir\n    }\n}\n\n# Step 3: Add a stub function for me to fill out\nfunction Sign-Files {\n    param (\n        [string]$filePath\n    )\n    Write-Host \"Signing $filePath\"\n    & \"C:\\Program Files (x86)\\Windows Kits\\10\\bin\\10.0.22621.0\\x86\\signtool.exe\" sign /tr http://timestamp.sectigo.com /td sha256 /fd sha256 /a $filePath\n}\n\n# Step 4: Upload the files back up to the draft release\nGet-ChildItem -Path $signingDir -Filter *.exe | ForEach-Object {\n    $filePath = $_.FullName\n    Sign-Files -filePath $filePath\n}\n\n$fullSigningDirPath = Join-Path -Path (Get-Location) -ChildPath $signingDir\ntry {\n    $latestYmlPath = Join-Path -Path $fullSigningDirPath -ChildPath \"latest.yml\"\n    $latestYmlHash = Get-FileHash -Path $latestYmlPath -Algorithm SHA256\n    node hasher.cjs --path $fullSigningDirPath\n\n    $updatedYmlHash = Get-FileHash -Path $latestYmlPath -Algorithm SHA256\n    if ($latestYmlHash.Hash -eq $updatedYmlHash.Hash) {\n        throw \"The hash of latest.yml has not changed after running the Node.js script.\"\n    }\n}\ncatch {\n    Write-Host \"An error occurred while executing the Node.js script: $_\"\n    return\n}\n\nGet-ChildItem -Path $signingDir | ForEach-Object {\n    $filePath = $_.FullName\n    Write-Host \"Uploading $filePath to release $releaseId\"\n    gh release upload $releaseId --clobber --repo $repo $filePath\n}"
  },
  {
    "path": "tsconfig.base.json",
    "content": "{\n  \"compileOnSave\": false,\n  \"compilerOptions\": {\n    \"esModuleInterop\": true,\n    \"sourceMap\": true,\n    \"declaration\": false,\n    \"moduleResolution\": \"node\",\n    \"emitDecoratorMetadata\": true,\n    \"experimentalDecorators\": true,\n    \"importHelpers\": true,\n    \"target\": \"es2015\",\n    \"module\": \"esnext\",\n    \"lib\": [\"es2017\", \"dom\"],\n    \"skipLibCheck\": true,\n    \"skipDefaultLibCheck\": true,\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@postybirb/database\": [\"libs/database/src/index.ts\"],\n      \"@postybirb/form-builder\": [\"libs/form-builder/src/index.ts\"],\n      \"@postybirb/fs\": [\"libs/fs/src/index.ts\"],\n      \"@postybirb/http\": [\"libs/http/src/index.ts\"],\n      \"@postybirb/logger\": [\"libs/logger/src/index.ts\"],\n      \"@postybirb/socket-events\": [\"libs/socket-events/src/index.ts\"],\n      \"@postybirb/translations\": [\"libs/translations/src/index.ts\"],\n      \"@postybirb/types\": [\"libs/types/src/index.ts\"],\n      \"@postybirb/utils/electron\": [\"libs/utils/electron/src/index.ts\"],\n      \"@postybirb/utils/file-type\": [\"libs/utils/file-type/src/index.ts\"],\n      \"@tiptap/react/menus\": [\"node_modules/@tiptap/react/dist/menus/index.d.ts\"]\n    }\n  },\n  \"exclude\": [\"node_modules\", \"tmp\"]\n}\n"
  }
]