From f40273a19bf2efebd85565e441def3f89023aae3 Mon Sep 17 00:00:00 2001 From: SHOO Date: Wed, 1 May 2024 10:24:17 +0900 Subject: [PATCH] Initial commit --- .github/workflows/main.yml | 393 +++ .github/workflows/pr.yml | 420 +++ .github/workflows/release.yml | 189 ++ .github/workflows/runner.d | 777 +++++ .github/workflows/status.yml | 386 +++ .gitignore | 36 + LICENSE | 23 + README.md | 133 + dscanner.ini | 206 ++ dub.json | 18 + examples/disp_timeline/dub.json | 8 + examples/disp_timeline/src/main.d | 16 + src/bsky/_internal/attr.d | 1228 +++++++ src/bsky/_internal/httpc.d | 618 ++++ src/bsky/_internal/json.d | 2062 ++++++++++++ src/bsky/_internal/misc.d | 359 +++ src/bsky/_internal/package.d | 13 + src/bsky/auth.d | 702 ++++ src/bsky/client.d | 2852 +++++++++++++++++ src/bsky/data.d | 157 + src/bsky/lexicons/app/bsky/embed.d | 295 ++ src/bsky/lexicons/app/bsky/feed.d | 82 + src/bsky/lexicons/app/bsky/package.d | 12 + src/bsky/lexicons/com/atproto/package.d | 10 + src/bsky/lexicons/com/atproto/repo.d | 22 + src/bsky/lexicons/data.d | 51 + src/bsky/lexicons/package.d | 21 + src/bsky/package.d | 34 + src/bsky/post.d | 568 ++++ src/bsky/user.d | 229 ++ .../032bc000-f6f8-4965-a9c7-b66570112870.json | 6 + .../04cdf227-7339-486b-bd12-515453cee504.json | 5 + .../0b8503cb-4eb7-45ee-8487-80260a3c9284.json | 4 + .../0b877230-5d39-4aa1-ab65-b3e5ed2bd23a.json | 3 + .../2db7023a-b2d2-447d-bfce-9e417a17bdac.json | 3 + .../2de5c4b1-09ec-41e7-90ad-add0448b262d.json | 1 + .../38ac37c4-c30b-4458-9c62-8c4abe8d71e9.json | 3 + .../43927f09-ea3f-4ae5-a226-beaf564d2622.json | 5 + .../49ed9c5d-d355-4f3a-81fb-cab01d1c7e64.json | 3 + .../657d70c5-eb4d-4b33-ab35-86a1589c2e9a.json | 3 + .../65b058a6-d7eb-414a-8bd8-625331524b6e.json | 5 + .../72a91fe8-1f30-4ebc-a42d-6617642dcbfe.json | 4 + .../89f1adc0-187a-446f-8bae-9c21639622b6.json | 3 + .../906a0151-0e10-4cbc-8e42-a8138271a180.json | 1 + .../9dbe5f82-d6a2-4d85-949d-bd6369b2feb5.json | 3 + .../a2a5d059-987f-4f7e-bc7d-db5c7e61519a.json | 3 + .../ba2b202c-5657-4b3d-97df-abaff951206c.json | 5 + .../ce07a958-e5e3-4c3b-94f8-8b1ad427ab0b.json | 4 + tests/.ut-data_source/d-logo.png | Bin 0 -> 77512 bytes tests/.ut-data_source/d-man.png | Bin 0 -> 13979 bytes .../d0fa2d8d-9f15-48c8-a3e2-28fcc42ed881.json | 5 + .../d8b6ad01-f85e-4d8e-bbd0-66347c7025c5.json | 3 + .../f2862017-1e04-4cf4-b445-4f95ab1962e3.json | 5 + .../f405c6cd-4fbb-4919-abac-f067dbd51d26.json | 3 + .../fe3b5003-a909-496f-a5de-97b1186f7bba.json | 1 + .../ff5bc22f-4dff-480c-a8ee-9faf352a69c7.json | 4 + tests/build_examples.d | 26 + 57 files changed, 12031 insertions(+) create mode 100644 .github/workflows/main.yml create mode 100644 .github/workflows/pr.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/runner.d create mode 100644 .github/workflows/status.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 dscanner.ini create mode 100644 dub.json create mode 100644 examples/disp_timeline/dub.json create mode 100644 examples/disp_timeline/src/main.d create mode 100644 src/bsky/_internal/attr.d create mode 100644 src/bsky/_internal/httpc.d create mode 100644 src/bsky/_internal/json.d create mode 100644 src/bsky/_internal/misc.d create mode 100644 src/bsky/_internal/package.d create mode 100644 src/bsky/auth.d create mode 100644 src/bsky/client.d create mode 100644 src/bsky/data.d create mode 100644 src/bsky/lexicons/app/bsky/embed.d create mode 100644 src/bsky/lexicons/app/bsky/feed.d create mode 100644 src/bsky/lexicons/app/bsky/package.d create mode 100644 src/bsky/lexicons/com/atproto/package.d create mode 100644 src/bsky/lexicons/com/atproto/repo.d create mode 100644 src/bsky/lexicons/data.d create mode 100644 src/bsky/lexicons/package.d create mode 100644 src/bsky/package.d create mode 100644 src/bsky/post.d create mode 100644 src/bsky/user.d create mode 100644 tests/.ut-data_source/032bc000-f6f8-4965-a9c7-b66570112870.json create mode 100644 tests/.ut-data_source/04cdf227-7339-486b-bd12-515453cee504.json create mode 100644 tests/.ut-data_source/0b8503cb-4eb7-45ee-8487-80260a3c9284.json create mode 100644 tests/.ut-data_source/0b877230-5d39-4aa1-ab65-b3e5ed2bd23a.json create mode 100644 tests/.ut-data_source/2db7023a-b2d2-447d-bfce-9e417a17bdac.json create mode 100644 tests/.ut-data_source/2de5c4b1-09ec-41e7-90ad-add0448b262d.json create mode 100644 tests/.ut-data_source/38ac37c4-c30b-4458-9c62-8c4abe8d71e9.json create mode 100644 tests/.ut-data_source/43927f09-ea3f-4ae5-a226-beaf564d2622.json create mode 100644 tests/.ut-data_source/49ed9c5d-d355-4f3a-81fb-cab01d1c7e64.json create mode 100644 tests/.ut-data_source/657d70c5-eb4d-4b33-ab35-86a1589c2e9a.json create mode 100644 tests/.ut-data_source/65b058a6-d7eb-414a-8bd8-625331524b6e.json create mode 100644 tests/.ut-data_source/72a91fe8-1f30-4ebc-a42d-6617642dcbfe.json create mode 100644 tests/.ut-data_source/89f1adc0-187a-446f-8bae-9c21639622b6.json create mode 100644 tests/.ut-data_source/906a0151-0e10-4cbc-8e42-a8138271a180.json create mode 100644 tests/.ut-data_source/9dbe5f82-d6a2-4d85-949d-bd6369b2feb5.json create mode 100644 tests/.ut-data_source/a2a5d059-987f-4f7e-bc7d-db5c7e61519a.json create mode 100644 tests/.ut-data_source/ba2b202c-5657-4b3d-97df-abaff951206c.json create mode 100644 tests/.ut-data_source/ce07a958-e5e3-4c3b-94f8-8b1ad427ab0b.json create mode 100644 tests/.ut-data_source/d-logo.png create mode 100644 tests/.ut-data_source/d-man.png create mode 100644 tests/.ut-data_source/d0fa2d8d-9f15-48c8-a3e2-28fcc42ed881.json create mode 100644 tests/.ut-data_source/d8b6ad01-f85e-4d8e-bbd0-66347c7025c5.json create mode 100644 tests/.ut-data_source/f2862017-1e04-4cf4-b445-4f95ab1962e3.json create mode 100644 tests/.ut-data_source/f405c6cd-4fbb-4919-abac-f067dbd51d26.json create mode 100644 tests/.ut-data_source/fe3b5003-a909-496f-a5de-97b1186f7bba.json create mode 100644 tests/.ut-data_source/ff5bc22f-4dff-480c-a8ee-9faf352a69c7.json create mode 100644 tests/build_examples.d diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..c687137 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,393 @@ +name: main + +on: + push: + branches: + - main + +jobs: + # 各テストのジョブは以下の表に従って作成しています。 + # matrixを使って全て行うと大変なので、要所を搾って実施します。 + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 1. Windows x86 dmd o x x x x + # 2. Windows x86 ldc x o o x x + # 3. Windows x86 dmd-master x x x x x + # 4. Windows x86 ldc-master x x x x x + # 5. Windows x86_64 dmd o o x x o + # 6. Windows x86_64 ldc o x o o x + # 7. Windows x86_64 dmd-master x x x x x + # 8. Windows x86_64 ldc-master o o o x x + # 9. Ubuntu x86 dmd x x x x x + # 10. Ubuntu x86 ldc o x o x x + # 11. Ubuntu x86 dmd-master x x x x x + # 12. Ubuntu x86 ldc-master x x x x x + # 13. Ubuntu x86_64 dmd o x o O x + # 14. Ubuntu x86_64 ldc o o x x o + # 15. Ubuntu x86_64 dmd-master o o o x x + # 16. Ubuntu x86_64 ldc-master x x x x x + # 17. macOS x86 dmd x x x x x + # 18. macOS x86 ldc x x x x x + # 19. macOS x86 dmd-master x x x x x + # 20. macOS x86 ldc-master x x x x x + # 21. macOS x86_64 dmd o o x x o + # 22. macOS x86_64 ldc o x o o x + # 23. macOS x86_64 dmd-master x x x x x + # 24. macOS x86_64 ldc-master x x x x x + + # 各テストジョブは以下のテンプレを加工して作成します。 + # 例は test-linux-x86_64-ldc-latest を参照してください。 + # また upload-codecov はテストジョブが全てパスしてから + # 実行されるようにするため、テストジョブを追加する場合は + # upload-codecov の needs も忘れず追加してください。 + + # テンプレ: + #test-${OS}-${ARCH}-${COMPILER}: + # name: test-${OS}-${ARCH}-${COMPILER} + # runs-on: ${OS} + # steps: + # - uses: actions/checkout@v2 + # - name: Install D compiler + # uses: dlang-community/setup-dlang@v1 + # with: + # compiler: ${COMPILER} + # # UT:テストをする場合は以下を実行 + # - name: Run unit tests + # run: rdmd ./.github/workflows/runner.d -a=${ARCH} --mode=unit-test + # # TT:テストをする場合は以下を実行 + # - name: Run unit tests + # run: rdmd ./.github/workflows/runner.d -a=${ARCH} --mode=integration-test + # # BLD:ビルドをする場合は以下を実行 + # - name: Build tests + # run: dub build -a=${ARCH} -b=release -c=default + # # DOC:ドキュメント生成をする場合は以下を実行 + # - name: Generate document tests + # run: rdmd ./.github/workflows/runner.d -a=${ARCH} --mode=generate-document + # # DOC:ドキュメントを記録する場合は以下を実行(Artifactに6か月保管されます) + # - name: Upload generated pages + # uses: actions/upload-artifact@v4 + # with: + # name: docs + # path: docs + # # COV:カバレッジを記録する場合は以下を実行(Artifactに6か月保管されます) + # - name: Upload coverage result + # uses: actions/upload-artifact@v4 + # with: + # name: coverage-${OS} + # path: .cov + # include-hidden-files: true + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 1. Windows x86 dmd o x x x x + test-windows-x86-dmd-latest: + name: test-windows-x86-dmd-latest + runs-on: windows-latest + steps: + - uses: actions/checkout@v2 + - name: Install D compiler + uses: dlang-community/setup-dlang@v1 + with: + compiler: dmd-latest + - name: Run unit tests + run: rdmd ./.github/workflows/runner.d -a=x86 --mode=unit-test --exdubopts=--build-mode=singleFile + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 2. Windows x86 ldc x o o x x + test-windows-x86-ldc-latest: + name: test-windows-x86-ldc-latest + runs-on: windows-latest + steps: + - uses: actions/checkout@v2 + - name: Install D compiler + uses: dlang-community/setup-dlang@v1 + with: + compiler: ldc-latest + - name: Run integration tests + run: rdmd ./.github/workflows/runner.d -a=x86 --mode=integration-test + - name: Build tests + run: dub build -a=x86 -b=release -c=default + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 3. Windows x86 dmd-master x x x x x + # do-nothing + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 4. Windows x86 ldc-master x x x x x + # do-nothing + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 5. Windows x86_64 dmd o o x x o + test-windows-x86_64-dmd-latest: + name: test-windows-x86_64-dmd-latest + runs-on: windows-latest + steps: + - uses: actions/checkout@v2 + - name: Install D compiler + uses: dlang-community/setup-dlang@v1 + with: + compiler: dmd-latest + - name: Run unit tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=unit-test + - name: Run integration tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=integration-test + - name: Upload coverage result + uses: actions/upload-artifact@v4 + with: + name: coverage-windows + path: .cov + include-hidden-files: true + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 6. Windows x86_64 ldc o x o o x + test-windows-x86_64-ldc-latest: + name: test-windows-x86_64-ldc-latest + runs-on: windows-latest + steps: + - uses: actions/checkout@v2 + - name: Install D compiler + uses: dlang-community/setup-dlang@v1 + with: + compiler: ldc-latest + - name: Run unit tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=unit-test + - name: Build tests + run: dub build -a=x86_64 -b=release -c=default + - name: Generate document tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=generate-document + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 7. Windows x86_64 dmd-master x x x x x + # do-nothing + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 8. Windows x86_64 ldc-master o o o x x + test-windows-x86_64-ldc-master: + name: test-windows-x86_64-ldc-master + runs-on: windows-latest + steps: + - uses: actions/checkout@v2 + - name: Install D compiler + uses: dlang-community/setup-dlang@v1 + with: + compiler: ldc-master + gh_token: ${{ secrets.GITHUB_TOKEN }} + - name: Run unit tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=unit-test + - name: Run integration tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=integration-test + - name: Build tests + run: dub build -a=x86_64 -b=release -c=default + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 9. Ubuntu x86 dmd x x x x x + # do-nothing + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 10. Ubuntu x86 ldc o x o x x + test-linux-x86-ldc-latest: + name: test-linux-x86-ldc-latest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Install D compiler + uses: dlang-community/setup-dlang@v1 + with: + compiler: ldc-latest + - name: Install gcc-multilib + run: | + sudo apt update + sudo apt install gcc-multilib + - name: Run unit tests + run: rdmd ./.github/workflows/runner.d -a=x86 --mode=unit-test + - name: Build tests + run: dub build -a=x86 -b=release -c=default + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 11. Ubuntu x86 dmd-master x x x x x + # do-nothing + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 12. Ubuntu x86 ldc-master x x x x x + # do-nothing + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 13. Ubuntu x86_64 dmd o x o O x + test-linux-x86_64-dmd-latest: + name: test-linux-x86_64-dmd-latest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Install D compiler + uses: dlang-community/setup-dlang@v1 + with: + compiler: dmd-latest + - name: Run unit tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=unit-test + - name: Build tests + run: dub build -a=x86_64 -b=release -c=default + - name: Generate document tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=generate-document + - name: Upload generated pages + uses: actions/upload-artifact@v4 + with: + name: docs + path: docs + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 14. Ubuntu x86_64 ldc o o x x o + test-linux-x86_64-ldc-latest: + name: test-linux-x86_64-ldc-latest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Install D compiler + uses: dlang-community/setup-dlang@v1 + with: + compiler: ldc-latest + - name: Run unit tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=unit-test + - name: Run integration tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=integration-test + - name: Upload coverage result + uses: actions/upload-artifact@v4 + with: + name: coverage-linux + path: .cov + include-hidden-files: true + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 15. Ubuntu x86_64 dmd-master o o o x x + test-linux-x86_64-dmd-master: + name: test-linux-x86_64-dmd-master + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Install D compiler + uses: dlang-community/setup-dlang@v1 + with: + #compiler: dmd-master ... change to beta tempolarily + compiler: dmd-beta + - name: Run unit tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=unit-test + - name: Run integration tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=integration-test + - name: Build tests + run: dub build -a=x86_64 -b=release -c=default + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 16. Ubuntu x86_64 ldc-master x x x x x + # do-nothing + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 17. macOS x86 dmd x x x x x + # do-nothing + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 18. macOS x86 ldc x x x x x + # do-nothing + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 19. macOS x86 dmd-master x x x x x + # do-nothing + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 20. macOS x86 ldc-master x x x x x + # do-nothing + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 21. macOS x86_64 dmd o o x x o + test-macos-x86_64-dmd-latest: + name: test-macos-x86_64-dmd-latest + runs-on: macos-12 + steps: + - uses: actions/checkout@v2 + - name: Install D compiler + uses: dlang-community/setup-dlang@v1 + with: + compiler: dmd-latest + - name: Run unit tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=unit-test + - name: Run integration tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=integration-test + - name: Upload coverage result + uses: actions/upload-artifact@v4 + with: + name: coverage-macos12 + path: .cov + include-hidden-files: true + + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 22. macOS x86_64 ldc o x o o x + test-macos-x86_64-ldc-latest: + name: test-macos-x86_64-ldc-latest + runs-on: macos-12 + steps: + - uses: actions/checkout@v2 + - name: Install D compiler + uses: dlang-community/setup-dlang@v1 + with: + compiler: ldc-latest + - name: Run unit tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=unit-test + - name: Build tests + run: dub build -a=x86_64 -b=release -c=default + - name: Generate document tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=generate-document + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 23. macOS x86_64 dmd-master x x x x x + # do-nothing + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 24. macOS x86_64 ldc-master x x x x x + # do-nothing + + # Upload coverage to Codecov + upload-codecov: + name: upload-codecov + needs: [test-windows-x86-dmd-latest, test-windows-x86-ldc-latest, test-windows-x86_64-dmd-latest, test-windows-x86_64-ldc-latest, test-windows-x86_64-ldc-master, test-linux-x86-ldc-latest, test-linux-x86_64-dmd-latest, test-linux-x86_64-ldc-latest, test-linux-x86_64-dmd-master, test-macos-x86_64-dmd-latest, test-macos-x86_64-ldc-latest] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Download coverage result + uses: actions/download-artifact@v4 + with: + name: coverage-windows + path: coverage-windows + - name: Upload coverage to Codecov + run: bash <(curl -s https://codecov.io/bash) -t ${{ secrets.CODECOV_TOKEN }} -s coverage-windows + - name: Download coverage result + uses: actions/download-artifact@v4 + with: + name: coverage-linux + path: coverage-linux + - name: Upload coverage to Codecov + run: bash <(curl -s https://codecov.io/bash) -t ${{ secrets.CODECOV_TOKEN }} -s coverage-linux + - name: Download coverage result + uses: actions/download-artifact@v4 + with: + name: coverage-macos12 + path: coverage-macos12 + - name: Upload coverage to Codecov + run: bash <(curl -s https://codecov.io/bash) -t ${{ secrets.CODECOV_TOKEN }} -s coverage-macos12 + + # Deploy Pages + deproy-pages: + name: deploy-pages + needs: upload-codecov + runs-on: ubuntu-latest + steps: + - name: Download generated pages + uses: actions/download-artifact@v4 + with: + name: docs + path: docs + - name: Deploy pages + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + allow_empty_commit: false + publish_dir: docs + publish_branch: gh-pages + user_name: GitHub Actions Bot + user_email: <> diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..a3c6f26 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,420 @@ +name: PullRequest +on: + pull_request: + +jobs: + # 各テストのジョブは以下の表に従って作成しています。 + # matrixを使って全て行うと大変なので、要所を搾って実施します。 + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 1. Windows x86 dmd o x x x x + # 2. Windows x86 ldc x o O x x + # 3. Windows x86 dmd-master x x x x x + # 4. Windows x86 ldc-master x x x x x + # 5. Windows x86_64 dmd o o x x o + # 6. Windows x86_64 ldc o x O o x + # 7. Windows x86_64 dmd-master x x x x x + # 8. Windows x86_64 ldc-master o o o x x + # 9. Ubuntu x86 dmd x x x x x + # 10. Ubuntu x86 ldc o x o x x + # 11. Ubuntu x86 dmd-master x x x x x + # 12. Ubuntu x86 ldc-master x x x x x + # 13. Ubuntu x86_64 dmd o x o O x + # 14. Ubuntu x86_64 ldc o o O x o + # 15. Ubuntu x86_64 dmd-master o o o x x + # 16. Ubuntu x86_64 ldc-master x x x x x + # 17. macOS x86 dmd x x x x x + # 18. macOS x86 ldc x x x x x + # 19. macOS x86 dmd-master x x x x x + # 20. macOS x86 ldc-master x x x x x + # 21. macOS x86_64 dmd o o x x o + # 22. macOS x86_64 ldc o x O o x + # 23. macOS x86_64 dmd-master x x x x x + # 24. macOS x86_64 ldc-master x x x x x + + # 各テストジョブは以下のテンプレを加工して作成します。 + # 例は test-linux-x86_64-ldc-latest を参照してください。 + # また upload-codecov はテストジョブが全てパスしてから + # 実行されるようにするため、テストジョブを追加する場合は + # upload-codecov の needs も忘れず追加してください。 + + # テンプレ: + #test-${OS}-${ARCH}-${COMPILER}: + # name: test-${OS}-${ARCH}-${COMPILER} + # runs-on: ${OS} + # steps: + # - uses: actions/checkout@v2 + # - name: Install D compiler + # uses: dlang-community/setup-dlang@v1 + # with: + # compiler: ${COMPILER} + # # UT:テストをする場合は以下を実行 + # - name: Run unit tests + # run: rdmd ./.github/workflows/runner.d -a=${ARCH} --mode=unit-test + # # TT:テストをする場合は以下を実行 + # - name: Run unit tests + # run: rdmd ./.github/workflows/runner.d -a=${ARCH} --mode=integration-test + # # BLD:ビルドをする場合は以下を実行 + # - name: Build tests + # run: dub build -a=${ARCH} -b=release -c=default + # # BLD:ビルド結果をアーカイブする場合は以下を実行 + # - name: Archive tests + # id: create_archive + # run: rdmd ./.github/workflows/runner.d -a=${ARCH} --mode=create-archive + # # DOC:ドキュメント生成をする場合は以下を実行 + # - name: Generate document tests + # run: rdmd ./.github/workflows/runner.d -a=${ARCH} --mode=generate-document + # # BLD:ビルド結果のアーカイブを記録する場合は以下を実行 + # - name: Upload generated archive + # uses: actions/upload-artifact@v1 + # with: + # name: ${OS}-${ARCH}-bainary + # path: ${{ steps.create_archive.outputs.ARCNAME }} + # # DOC:ドキュメントを記録する場合は以下を実行(Artifactに6か月保管されます) + # - name: Upload generated pages + # uses: actions/upload-artifact@v1 + # with: + # name: docs + # path: docs + # # COV:カバレッジを記録する場合は以下を実行(Artifactに6か月保管されます) + # - name: Upload coverage result + # uses: actions/upload-artifact@v1 + # with: + # name: coverage-${OS} + # path: .cov + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 1. Windows x86 dmd o x x x x + test-windows-x86-dmd-latest: + name: test-windows-x86-dmd-latest + runs-on: windows-latest + steps: + - uses: actions/checkout@v2 + - name: Install D compiler + uses: dlang-community/setup-dlang@v1 + with: + compiler: dmd-latest + - name: Run unit tests + run: rdmd ./.github/workflows/runner.d -a=x86 --mode=unit-test + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 2. Windows x86 ldc x o O x x + test-windows-x86-ldc-latest: + name: test-windows-x86-ldc-latest + runs-on: windows-latest + steps: + - uses: actions/checkout@v2 + - name: Install D compiler + uses: dlang-community/setup-dlang@v1 + with: + compiler: ldc-latest + - name: Run integration tests + run: rdmd ./.github/workflows/runner.d -a=x86 --mode=integration-test + - name: Build tests + run: dub build -a=x86 -b=release -c=default + - name: Archive tests + id: create_archive + run: rdmd ./.github/workflows/runner.d -a=x86 --mode=create-archive + - name: Upload generated archive + uses: actions/upload-artifact@v1 + with: + name: windows-x86-binary + path: ${{ steps.create_archive.outputs.ARCNAME }} + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 3. Windows x86 dmd-master x x x x x + # do-nothing + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 4. Windows x86 ldc-master x x x x x + # do-nothing + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 5. Windows x86_64 dmd o o x x o + test-windows-x86_64-dmd-latest: + name: test-windows-x86_64-dmd-latest + runs-on: windows-latest + steps: + - uses: actions/checkout@v2 + - name: Install D compiler + uses: dlang-community/setup-dlang@v1 + with: + compiler: dmd-latest + - name: Run unit tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=unit-test + - name: Run integration tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=integration-test + - name: Upload coverage result + uses: actions/upload-artifact@v1 + with: + name: coverage-windows + path: .cov + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 6. Windows x86_64 ldc o x O o x + test-windows-x86_64-ldc-latest: + name: test-windows-x86_64-ldc-latest + runs-on: windows-latest + steps: + - uses: actions/checkout@v2 + - name: Install D compiler + uses: dlang-community/setup-dlang@v1 + with: + compiler: ldc-latest + - name: Run unit tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=unit-test + - name: Build tests + run: dub build -a=x86_64 -b=release -c=default + - name: Archive tests + id: create_archive + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=create-archive + - name: Generate document tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=generate-document + - name: Upload generated archive + uses: actions/upload-artifact@v1 + with: + name: windows-x86_64-binary + path: ${{ steps.create_archive.outputs.ARCNAME }} + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 7. Windows x86_64 dmd-master x x x x x + # do-nothing + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 8. Windows x86_64 ldc-master o o o x x + test-windows-x86_64-ldc-master: + name: test-windows-x86_64-ldc-master + runs-on: windows-latest + steps: + - uses: actions/checkout@v2 + - name: Install D compiler + uses: dlang-community/setup-dlang@v1 + with: + compiler: ldc-master + gh_token: ${{ secrets.GITHUB_TOKEN }} + - name: Run unit tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=unit-test + - name: Run integration tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=integration-test + - name: Build tests + run: dub build -a=x86_64 -b=release -c=default + - name: Archive tests + id: create_archive + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=create-archive + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 9. Ubuntu x86 dmd x x x x x + # do-nothing + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 10. Ubuntu x86 ldc o x o x x + test-linux-x86-ldc-latest: + name: test-linux-x86-ldc-latest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Install D compiler + uses: dlang-community/setup-dlang@v1 + with: + compiler: ldc-latest + - name: Install gcc-multilib + run: | + sudo apt update + sudo apt install gcc-multilib + - name: Run unit tests + run: rdmd ./.github/workflows/runner.d -a=x86 --mode=unit-test + - name: Build tests + run: dub build -a=x86 -b=release -c=default + - name: Archive tests + id: create_archive + run: rdmd ./.github/workflows/runner.d -a=x86 --mode=create-archive + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 11. Ubuntu x86 dmd-master x x x x x + # do-nothing + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 12. Ubuntu x86 ldc-master x x x x x + # do-nothing + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 13. Ubuntu x86_64 dmd o x o O x + test-linux-x86_64-dmd-latest: + name: test-linux-x86_64-dmd-latest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Install D compiler + uses: dlang-community/setup-dlang@v1 + with: + compiler: dmd-latest + - name: Run unit tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=unit-test + - name: Build tests + run: dub build -a=x86_64 -b=release -c=default + - name: Archive tests + id: create_archive + run: rdmd ./.github/workflows/runner.d -a=x86 --mode=create-archive + - name: Generate document tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=generate-document + - name: Upload generated pages + uses: actions/upload-artifact@v1 + with: + name: docs + path: docs + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 14. Ubuntu x86_64 ldc o o O x o + test-linux-x86_64-ldc-latest: + name: test-linux-x86_64-ldc-latest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Install D compiler + uses: dlang-community/setup-dlang@v1 + with: + compiler: ldc-latest + - name: Run unit tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=unit-test + - name: Run integration tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=integration-test + - name: Build tests + run: dub build -a=x86_64 -b=release -c=default + - name: Archive tests + id: create_archive + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=create-archive + - name: Upload coverage result + uses: actions/upload-artifact@v1 + with: + name: coverage-linux + path: .cov + - name: Upload generated archive + uses: actions/upload-artifact@v1 + with: + name: linux-x86_64-binary + path: ${{ steps.create_archive.outputs.ARCNAME }} + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 15. Ubuntu x86_64 dmd-master o o o x x + test-linux-x86_64-dmd-master: + name: test-linux-x86_64-dmd-master + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Install D compiler + uses: dlang-community/setup-dlang@v1 + with: + compiler: dmd-master + - name: Run unit tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=unit-test + - name: Run integration tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=integration-test + - name: Build tests + run: dub build -a=x86_64 -b=release -c=default + - name: Archive tests + id: create_archive + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=create-archive + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 16. Ubuntu x86_64 ldc-master x x x x x + # do-nothing + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 17. macOS x86 dmd x x x x x + # do-nothing + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 18. macOS x86 ldc x x x x x + # do-nothing + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 19. macOS x86 dmd-master x x x x x + # do-nothing + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 20. macOS x86 ldc-master x x x x x + # do-nothing + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 21. macOS x86_64 dmd o o x x o + test-macos-x86_64-dmd-latest: + name: test-macos-x86_64-dmd-latest + runs-on: macos-12 + steps: + - uses: actions/checkout@v2 + - name: Install D compiler + uses: dlang-community/setup-dlang@v1 + with: + compiler: dmd-latest + - name: Run unit tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=unit-test + - name: Run integration tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=integration-test + - name: Upload coverage result + uses: actions/upload-artifact@v1 + with: + name: coverage-osx + path: .cov + + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 22. macOS x86_64 ldc o x O o x + test-macos-x86_64-ldc-latest: + name: test-macos-x86_64-ldc-latest + runs-on: macos-12 + steps: + - uses: actions/checkout@v2 + - name: Install D compiler + uses: dlang-community/setup-dlang@v1 + with: + compiler: ldc-latest + - name: Run unit tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=unit-test + - name: Build tests + run: dub build -a=x86_64 -b=release -c=default + - name: Archive tests + id: create_archive + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=create-archive + - name: Generate document tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=generate-document + - name: Upload generated archive + uses: actions/upload-artifact@v1 + with: + name: osx-x86_64-bainary + path: ${{ steps.create_archive.outputs.ARCNAME }} + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 23. macOS x86_64 dmd-master x x x x x + # do-nothing + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 24. macOS x86_64 ldc-master x x x x x + # do-nothing + + # Upload coverage to Codecov + upload-codecov: + name: upload-codecov + needs: [test-windows-x86-dmd-latest, test-windows-x86-ldc-latest, test-windows-x86_64-dmd-latest, test-windows-x86_64-ldc-latest, test-windows-x86_64-ldc-master, test-linux-x86-ldc-latest, test-linux-x86_64-dmd-latest, test-linux-x86_64-ldc-latest, test-linux-x86_64-dmd-master, test-macos-x86_64-dmd-latest, test-macos-x86_64-ldc-latest] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Download coverage result + uses: actions/download-artifact@v1 + with: + name: coverage-windows + path: coverage-windows + - name: Upload coverage to Codecov + run: bash <(curl -s https://codecov.io/bash) -t ${{ secrets.CODECOV_TOKEN }} -s coverage-windows + - name: Download coverage result + uses: actions/download-artifact@v1 + with: + name: coverage-linux + path: coverage-linux + - name: Upload coverage to Codecov + run: bash <(curl -s https://codecov.io/bash) -t ${{ secrets.CODECOV_TOKEN }} -s coverage-linux + - name: Download coverage result + uses: actions/download-artifact@v1 + with: + name: coverage-osx + path: coverage-osx + - name: Upload coverage to Codecov + run: bash <(curl -s https://codecov.io/bash) -t ${{ secrets.CODECOV_TOKEN }} -s coverage-osx diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..abba74d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,189 @@ +name: Release +on: + push: + tags: + - v* + +jobs: + # No. OS ARCH COMPILER + # 2. Windows x86 ldc + # 6. Windows x86_64 ldc + # 14. Ubuntu x86_64 ldc + # 22. macOS x86_64 ldc + + # No. OS ARCH COMPILER + # 2. Windows x86 ldc + create-windows-x86: + name: create-windows-x86 + runs-on: windows-latest + steps: + - uses: actions/checkout@v2 + - name: Install D compiler + uses: dlang-community/setup-dlang@v1 + with: + compiler: ldc-latest + - name: Build binary + run: dub build -a=x86 -b=release -c=default + - name: Create archive + id: create_archive + run: rdmd ./.github/workflows/runner.d -a=x86 --mode=create-archive + - name: Upload created archive + uses: actions/upload-artifact@v1 + with: + name: windows-x86-binary + path: ${{ steps.create_archive.outputs.ARCNAME }} + + # No. OS ARCH COMPILER + # 6. Windows x86_64 ldc + create-windows-x86_64: + name: create-windows-x86_64 + runs-on: windows-latest + steps: + - uses: actions/checkout@v2 + - name: Install D compiler + uses: dlang-community/setup-dlang@v1 + with: + compiler: ldc-latest + - name: Build binary + run: dub build -a=x86_64 -b=release -c=default + - name: Create archive + id: create_archive + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=create-archive + - name: Upload created archive + uses: actions/upload-artifact@v1 + with: + name: windows-x86_64-binary + path: ${{ steps.create_archive.outputs.ARCNAME }} + + # No. OS ARCH COMPILER + # 14. Ubuntu x86_64 ldc + create-linux-x86_64: + name: create-linux-x86_64 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Install D compiler + uses: dlang-community/setup-dlang@v1 + with: + compiler: ldc-latest + - name: Build bianry + run: dub build -a=x86_64 -b=release -c=default + - name: Create archive + id: create_archive + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=create-archive + - name: Generate document + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=generate-document + - name: Upload created archive + uses: actions/upload-artifact@v1 + with: + name: linux-x86_64-binary + path: ${{ steps.create_archive.outputs.ARCNAME }} + - name: Upload generated pages + uses: actions/upload-artifact@v1 + with: + name: docs + path: docs + + # No. OS ARCH COMPILER + # 22. macOS x86_64 ldc + create-macos-x86_64: + name: create-macos-x86_64 + runs-on: macos-12 + steps: + - uses: actions/checkout@v2 + - name: Install D compiler + uses: dlang-community/setup-dlang@v1 + with: + compiler: ldc-latest + - name: Build binary + run: dub build -a=x86_64 -b=release -c=default + - name: Create archive + id: create_archive + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=create-archive + - name: Upload created archive + uses: actions/upload-artifact@v1 + with: + name: osx-x86_64-binary + path: ${{ steps.create_archive.outputs.ARCNAME }} + + # Deploy Pages + create-release: + name: create-release + needs: [create-windows-x86, create-windows-x86_64, create-linux-x86_64, create-macos-x86_64] + runs-on: ubuntu-latest + steps: + - name: Get Names + id: get_names + run: | + echo ::set-output name=TAGNAME::${GITHUB_REF#refs/tags/} + echo ::set-output name=PROJNAME::${GITHUB_REPOSITORY#$GITHUB_ACTOR/} + - name: Download windows-x86-binary + uses: actions/download-artifact@v1 + with: + name: windows-x86-binary + path: ./ + - name: Download windows-x86_64-binary + uses: actions/download-artifact@v1 + with: + name: windows-x86_64-binary + path: ./ + - name: Download linux-x86_64-binary + uses: actions/download-artifact@v1 + with: + name: linux-x86_64-binary + path: ./ + - name: Download osx-x86_64-binary + uses: actions/download-artifact@v1 + with: + name: osx-x86_64-binary + path: ./ + - name: Create Release + id: create_release + uses: actions/create-release@v1.0.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref }} + release_name: Release ${{ github.ref }} + draft: false + prerelease: false + - name: Upload Release Asset Win32 + id: upload-release-asset-win32 + uses: actions/upload-release-asset@v1.0.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ${{ steps.get_names.outputs.PROJNAME }}-${{ steps.get_names.outputs.TAGNAME }}-windows-x86.zip + asset_name: ${{ steps.get_names.outputs.PROJNAME }}-${{ steps.get_names.outputs.TAGNAME }}-windows-x86.zip + asset_content_type: application/zip + - name: Upload Release Asset Win64 + id: upload-release-asset-win64 + uses: actions/upload-release-asset@v1.0.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ${{ steps.get_names.outputs.PROJNAME }}-${{ steps.get_names.outputs.TAGNAME }}-windows-x86_64.zip + asset_name: ${{ steps.get_names.outputs.PROJNAME }}-${{ steps.get_names.outputs.TAGNAME }}-windows-x86_64.zip + asset_content_type: application/zip + - name: Upload Release Asset Linux + id: upload-release-asset-linux + uses: actions/upload-release-asset@v1.0.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ${{ steps.get_names.outputs.PROJNAME }}-${{ steps.get_names.outputs.TAGNAME }}-linux-x86_64.tar.gz + asset_name: ${{ steps.get_names.outputs.PROJNAME }}-${{ steps.get_names.outputs.TAGNAME }}-linux-x86_64.tar.gz + asset_content_type: application/x-gzip + - name: Upload Release Asset OSX + id: upload-release-asset-osx + uses: actions/upload-release-asset@v1.0.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ${{ steps.get_names.outputs.PROJNAME }}-${{ steps.get_names.outputs.TAGNAME }}-macos-x86_64.tar.gz + asset_name: ${{ steps.get_names.outputs.PROJNAME }}-${{ steps.get_names.outputs.TAGNAME }}-macos-x86_64.tar.gz + asset_content_type: application/x-gzip diff --git a/.github/workflows/runner.d b/.github/workflows/runner.d new file mode 100644 index 0000000..0aa1bb5 --- /dev/null +++ b/.github/workflows/runner.d @@ -0,0 +1,777 @@ +import std; + +/// +struct Defines +{ +static: + /// ドキュメントジェネレータを指定します。 + /// gendocのバージョンが更新されたら変更してください。 + immutable documentGenerator = "gendoc"; + + /// テスト対象にするサブパッケージを指定します。 + /// サブパッケージが追加されたらここにも追加してください。 + immutable string integrationTestCaseDir = "tests"; + + /// テスト対象にするサブパッケージを指定します。 + /// サブパッケージが追加されたらここにも追加してください。 + immutable string[] subPkgs = []; +} + +/// +struct Config +{ + /// + string os; + /// + string arch; + /// + string compiler; + /// + string hostArch; + /// + string targetArch; + /// + string hostCompiler; + /// + string targetCompiler; + /// + string archiveSuffix; + /// + string scriptDir = __FILE__.dirName(); + /// + string projectName; + /// + string refName; + /// + string[] integrationTestTargets; +} +/// +__gshared Config config; + +/// +int main(string[] args) +{ + string mode; + import core.stdc.stdio; + setvbuf(stdout, null, _IONBF, 0); + + version (Windows) {config.os = "windows";} + else version (linux) {config.os = "linux";} + else version (OSX) {config.os = "osx";} + else static assert(0, "Unsupported OS"); + + version (Windows) {config.archiveSuffix = ".zip";} + else version (linux) {config.archiveSuffix = ".tar.gz";} + else version (OSX) {config.archiveSuffix = ".tar.gz";} + else static assert(0, "Unsupported OS"); + + version (D_LP64) {config.arch = "x86_64";} + else {config.arch = "x86";} + + version (DigitalMars) {config.compiler = "dmd";} + else version (LDC) {config.compiler = "ldc2";} + else version (GNU) {config.compiler = "gdc";} + else static assert(0, "Unsupported Compiler"); + + config.projectName = environment.get("GITHUB_REPOSITORY").chompPrefix(environment.get("GITHUB_ACTOR") ~ "/"); + config.refName = getRefName(); + + config.hostArch = config.arch; + config.targetArch = config.arch; + config.hostCompiler = config.compiler; + config.targetCompiler = config.compiler; + + string tmpHostArch, tmpTargetArch, tmpHostCompiler, tmpTargetCompiler; + string[] exDubOpts; + + args.getopt( + "a|arch", &config.arch, + "os", &config.os, + "host-arch", &tmpHostArch, + "target-arch", &tmpTargetArch, + "c|compiler", &config.compiler, + "host-compiler", &tmpHostCompiler, + "target-compiler", &tmpTargetCompiler, + "archive-suffix", &config.archiveSuffix, + "m|mode", &mode, + "t|integration-test-targets", &config.integrationTestTargets, + "exdubopts", &exDubOpts); + + config.hostArch = tmpHostArch ? tmpHostArch : config.arch; + config.targetArch = tmpTargetArch ? tmpTargetArch : config.arch; + config.hostCompiler = tmpHostCompiler ? tmpHostCompiler : config.compiler; + config.targetCompiler = tmpTargetCompiler ? tmpTargetCompiler : config.compiler; + + switch (mode.toLower) + { + case "unit-test": + case "unittest": + case "ut": + unitTest(exDubOpts); + break; + case "integration-test": + case "integrationtest": + case "it": + case "tt": + integrationTest(exDubOpts); + break; + case "test": + unitTest(exDubOpts); + integrationTest(exDubOpts); + break; + case "create-release-build": + case "createreleasebuild": + case "release-build": + case "releasebuild": + case "build": + createReleaseBuild(exDubOpts); + break; + case "create-archive": + case "createarchive": + createArchive(); + break; + case "create-document": + case "createdocument": + case "create-document-test": + case "createdocumenttest": + case "generate-document": + case "generatedocument": + case "generate-document-test": + case "generatedocumenttest": + case "gendoc": + case "docs": + case "doc": + generateDocument(); + break; + case "all": + unitTest(exDubOpts); + integrationTest(exDubOpts); + createReleaseBuild(exDubOpts); + createArchive(); + generateDocument(); + break; + default: + enforce(0, "Unknown mode: " ~ mode); + break; + } + return 0; +} + +/// +void unitTest(string[] exDubOpts = null) +{ + string[string] env; + auto covdir = config.scriptDir.buildNormalizedPath("../../.cov"); + if (!covdir.exists) + mkdirRecurse(covdir); + auto covopt = [ + "--DRT-covopt=dstpath:" ~ covdir.absolutePath(), + "--DRT-covopt=srcpath:" ~ config.scriptDir.absolutePath().buildNormalizedPath("../.."), + "--DRT-covopt=merge:1"]; + env.addCurlPath(); + writeln("#######################################"); + writeln("## Unit Test ##"); + writeln("#######################################"); + exec(["dub", + "test", + "-a", config.hostArch, + "--coverage", + "--compiler", config.hostCompiler] + ~ exDubOpts ~ ["--"] ~ covopt, + null, env); + foreach (pkgName; Defines.subPkgs) + { + exec(["dub", + "test", + ":" ~ pkgName, + "-a", config.hostArch, + "--coverage", + "--compiler", config.hostCompiler] + ~ exDubOpts ~ ["--"] ~ covopt, + null, env); + } +} + +/// +void generateDocument() +{ + string[string] env; + env.addCurlPath(); + exec(["dub", "run", Defines.documentGenerator, "-y", + "--", + "-a=x86_64", "-b=release", "-c=default"], null, env); +} + +/// +void createReleaseBuild(string[] exDubOpts = null) +{ + exec(["dub", + "build", + "-a", config.hostArch, + "-b=unittest-cov", + "-c=default", + "--compiler", config.hostCompiler] ~ exDubOpts); +} + + +/// +void integrationTest(string[] exDubOpts = null) +{ + string[string] env; + env.addCurlPath(); + auto covdir = config.scriptDir.buildNormalizedPath("../../.cov").absolutePath(); + if (!covdir.exists) + mkdirRecurse(covdir); + + auto covopt = [ + "--DRT-covopt=dstpath:" ~ covdir.absolutePath(), + "--DRT-covopt=srcpath:" ~ config.scriptDir.absolutePath().buildNormalizedPath("../.."), + "--DRT-covopt=merge:1"]; + + bool dirTest(string entry) + { + auto expMap = [ + "project_root": config.scriptDir.absolutePath().buildNormalizedPath("../.."), + "test_dir": entry.absolutePath().buildNormalizedPath(), + ]; + auto getOpts(string defaultname, string optfile, string ignorefile) + { + struct Opt + { + string name; + string dubWorkDir; + string[] dubArgs; + string workDir; + string[] args; + string[string] env; + } + if (entry.buildPath(ignorefile).exists) + return Opt[].init; + if (!entry.buildPath(optfile).exists) + return [Opt("default", entry, [], entry, [], env)]; + Opt[] ret; + import std.file: read; + auto jvRoot = parseJSON(cast(string)read(entry.buildPath(optfile))); + foreach (i, jvOpt; jvRoot.array) + { + auto dat = Opt(text(defaultname, i), entry, [], entry, [], env); + if (auto str = jvOpt.getStr("name", expMap)) + dat.name = str; + if (auto str = jvOpt.getStr("dubWorkDir", expMap)) + dat.dubWorkDir = str; + dat.dubArgs = jvOpt.getAry("dubArgs", expMap); + if (auto str = jvOpt.getStr("workDir", expMap)) + dat.workDir = str; + dat.args = jvOpt.getAry("args", expMap); + foreach (k, v; jvOpt.getObj("env", expMap)) + dat.env[k] = v; + ret ~= dat; + } + return ret; + } + if (entry.isDir) + { + auto buildOpts = getOpts("build", ".build_opts", ".no_build"); + auto testOpts = getOpts("test", ".test_opts", ".no_test"); + auto runOpts = getOpts("run", ".run_opts", ".no_run"); + auto no_coverage = entry.buildPath(".no_coverage").exists; + auto dubCommonArgs = [ + "-a", config.targetArch, + "--compiler", config.targetCompiler] ~ exDubOpts; + foreach (buildOpt; buildOpts) + { + dispLog("INFO", entry.baseName, "build test for " ~ buildOpt.name); + auto dubArgs = (buildOpt.dubArgs.length > 0 ? dubCommonArgs ~ buildOpt.dubArgs : dubCommonArgs); + exec(["dub", "build", "-b=release", "--root=" ~ buildOpt.dubWorkDir] ~ dubArgs, + buildOpt.workDir, buildOpt.env); + } + foreach (testOpt; testOpts) + { + dispLog("INFO", entry.baseName, "unittest for " ~ testOpt.name); + auto dubArgs = (testOpt.dubArgs.length > 0 ? dubCommonArgs ~ testOpt.dubArgs : dubCommonArgs) + ~ (!no_coverage ? ["--coverage"] : null); + auto exeArgs = ["--"] ~ (!no_coverage ? covopt : null); + exec(["dub", "test", "--root=" ~ testOpt.dubWorkDir] ~ dubArgs ~ exeArgs, + testOpt.workDir, testOpt.env); + } + foreach (runOpt; runOpts) + { + dispLog("INFO", entry.baseName, "run test for " ~ runOpt.name); + auto dubArgs = (runOpt.dubArgs.length > 0 ? dubCommonArgs ~ runOpt.dubArgs : dubCommonArgs) + ~ (!no_coverage ? ["-b=cov"] : ["-b=debug"]) ~ ["--root=" ~ runOpt.dubWorkDir]; + auto exeArgs = runOpt.args ~ (!no_coverage ? covopt : null); + auto desc = cmd(["dub", "describe", "--verror"] ~ dubArgs, null, runOpt.env).parseJSON(); + auto targetExe = buildNormalizedPath( + desc["packages"][0]["path"].str, + desc["packages"][0]["targetPath"].str, + desc["packages"][0]["targetFileName"].str); + exec(["dub", "build"] ~ dubArgs); + exec([targetExe] ~ exeArgs, runOpt.workDir, runOpt.env); + } + return !(buildOpts.length == 0 && testOpts.length == 0 && runOpts.length == 0); + } + else switch (entry.extension) + { + case ".d": + // rdmd + dispLog("INFO", entry.baseName, "rdmd script test"); + exec(["rdmd", entry.baseName], entry.dirName, env); + return true; + break; + case ".sh": + // $SHELLまたはbashがあれば + if (auto sh = environment.get("SHELL")) + { + dispLog("INFO", entry.baseName, "shell script test"); + exec([sh, entry], entry.dirName, env); + return true; + } + if (auto sh = searchPath("bash")) + { + dispLog("INFO", entry.baseName, "bash shell script test"); + exec([sh, entry], entry.dirName, env); + return true; + } + break; + case ".bat": + // %COMSPEC%があれば + if (auto sh = environment.get("COMSPEC")) + { + dispLog("INFO", entry.baseName, "commandline batch test"); + exec([sh, entry], entry.dirName, env); + return true; + } + break; + case ".ps1": + // pwsh || powershellがあれば + if (auto sh = searchPath("pwsh")) + { + dispLog("INFO", entry.baseName, "powershell script test"); + exec([sh, entry], entry.dirName, env); + return true; + } + else if (auto sh = searchPath("powershell")) + { + dispLog("INFO", entry.baseName, "powershell script test"); + exec([sh, entry], entry.dirName, env); + return true; + } + break; + case ".py": + // python || python3があれば + if (auto sh = searchPath("python")) + { + dispLog("INFO", entry.baseName, "python script test"); + exec([sh, entry], entry.dirName, env); + return true; + } + else if (auto sh = searchPath("python3")) + { + dispLog("INFO", entry.baseName, "python3 script test"); + exec([sh, entry], entry.dirName, env); + return true; + } + break; + default: + // なにもしない + } + return false; + } + bool subPkgTest(string pkgName, string confName) + { + auto dubCommonArgs = [ + "-a", config.targetArch, + "--compiler", config.targetCompiler, + "-b", "cov"] ~ exDubOpts; + string descStr; + try + { + descStr = cmd(["dub", "describe", ":" ~ pkgName, "-c", confName, "--verror"] ~ dubCommonArgs, null, env); + dubCommonArgs ~= ["-c", confName]; + } + catch (Exception) + { + descStr = cmd(["dub", "describe", ":" ~ pkgName, "--verror"] ~ dubCommonArgs, null, env); + } + auto desc = descStr.parseJSON(); + if (desc["packages"][0]["targetType"].str != "executable") + return false; + auto targetExe = buildNormalizedPath( + desc["packages"][0]["path"].str, + desc["packages"][0]["targetPath"].str, + desc["packages"][0]["targetFileName"].str); + exec(["dub", "build", ":" ~ pkgName] ~ dubCommonArgs, null, env); + exec([targetExe], null, env); + return true; + } + + struct Result + { + string name; + bool executed; + Exception exception; + } + + Result[] dirTests; + Result[] subpkgTests; + if (Defines.integrationTestCaseDir.exists) + { + writeln("#######################################"); + writeln("## Test Directory Entries ##"); + writeln("#######################################"); + foreach (de; dirEntries(Defines.integrationTestCaseDir, SpanMode.shallow)) + { + // 隠しファイルはスキップする + if (de.name.baseName.startsWith(".")) + continue; + // ターゲット指定がある場合は、ターゲット指定されている場合だけ実行 + if (config.integrationTestTargets.length > 0 + && !config.integrationTestTargets.canFind(de.baseName.stripExtension)) + continue; + auto res = Result(de.name.baseName); + dispLog("INFO", de.name.baseName, "Directory test start"); + try + res.executed = dirTest(de.name); + catch (Exception e) + res.exception = e; + dispLog(res.exception ? "FAILED" : "SUCCESS", de.name.baseName); + dirTests ~= res; + } + } + if (Defines.subPkgs.length) + { + writeln("#######################################"); + writeln("## Test SubPackages ##"); + writeln("#######################################"); + foreach (pkgName; Defines.subPkgs) + { + // ターゲット指定がある場合は、ターゲット指定されている場合だけ実行 + if (config.integrationTestTargets.length > 0 + && !config.integrationTestTargets.canFind("::" ~ pkgName)) + continue; + dispLog("INFO", pkgName, "Subpackages test start"); + auto res = Result(pkgName); + try + res.executed = subPkgTest(pkgName, "unittest"); + catch (Exception e) + res.exception = e; + dispLog(res.exception ? "FAILED" : "SUCCESS", pkgName); + subpkgTests ~= res; + } + } + + if (dirTests.length > 0 || subpkgTests.length > 0) + { + stdout.flush(); + writeln("#######################################"); + writeln("## Integration Test Summary ##"); + writeln("#######################################"); + } + bool failed; + if (dirTests.length > 0) + { + writeln("##### Test Summary of Directory Entries"); + writefln("Failed: %s / %s", dirTests.count!(a => !!a.exception), dirTests.length); + writefln("Succeeded: %s / %s", dirTests.count!(a => a.executed), dirTests.length); + writefln("Skipped: %s / %s", dirTests.count!(a => !a.executed && !a.exception), dirTests.length); + foreach (res; dirTests) + { + if (res.exception) + { + writefln("[X] %s: %s", res.name, res.exception.msg); + failed = true; + } + else if (res.executed) + { + writefln("[O] %s", res.name); + } + else + { + writefln("[-] %s", res.name); + } + } + } + if (subpkgTests.length > 0) + { + writeln("##### Test Summary of SubPackages"); + writefln("Failed: %s / %s", subpkgTests.count!(a => !!a.exception), subpkgTests.length); + writefln("Succeeded: %s / %s", subpkgTests.count!(a => a.executed), subpkgTests.length); + writefln("Skipped: %s / %s", subpkgTests.count!(a => !a.executed && !a.exception), subpkgTests.length); + foreach (res; subpkgTests) + { + if (res.exception) + { + failed = true; + writefln("[X] %s: %s", res.name, res.exception.msg); + } + else if (res.executed) + { + writefln("[O] %s", res.name); + } + else + { + writefln("[-] %s", res.name); + } + } + } + enforce(!failed, "Integration test was failed."); +} + + +/// +void createArchive() +{ + import std.file; + if (!"build".exists) + return; + auto archiveName = format!"%s-%s-%s-%s%s"( + config.projectName, config.refName, config.os, config.arch, config.archiveSuffix); + scope (success) + writeln("::set-output name=ARCNAME::", archiveName); + version (Windows) + { + auto zip = new ZipArchive; + foreach (de; dirEntries("build", SpanMode.depth)) + { + if (de.isDir) + continue; + auto m = new ArchiveMember; + m.expandedData = cast(ubyte[])std.file.read(de.name); + m.name = de.name.absolutePath.relativePath(absolutePath("build")); + m.time = de.name.timeLastModified(); + m.fileAttributes = de.name.getAttributes(); + m.compressionMethod = CompressionMethod.deflate; + zip.addMember(m); + } + std.file.write(archiveName, zip.build()); + } + else + { + string abs(string file, string base) + { + return file.absolutePath.relativePath(absolutePath(base)); + } + void mv(string from, string to) + { + if (from.isDir) + return; + if (!to.dirName.exists) + mkdirRecurse(to.dirName); + std.file.rename(from, to); + } + mv("build/gendoc", "archive-tmp/bin/gendoc"); + foreach (de; dirEntries("build/ddoc", SpanMode.depth)) + mv(de.name, buildPath("archive-tmp/etc/.gendoc/ddoc", abs(de.name, "build/ddoc"))); + foreach (de; dirEntries("build/source_docs", SpanMode.depth)) + mv(de.name, buildPath("archive-tmp/etc/.gendoc/docs", abs(de.name, "build/source_docs"))); + exec(["tar", "cvfz", buildPath("..", archiveName), "-C", "."] + ~ dirEntries("archive-tmp", "*", SpanMode.shallow) + .map!(de => abs(de.name, "archive-tmp")).array, "archive-tmp"); + } +} + +/// +void exec(string[] args, string workDir = null, string[string] env = null) +{ + import std.process, std.stdio; + writefln!"> %s"(escapeShellCommand(args)); + auto pid = spawnProcess(args, env, std.process.Config.none, workDir ? workDir : "."); + auto res = pid.wait(); + enforce(res == 0, format!"Execution was failed[code=%d]."(res)); +} +/// +void exec(string args, string workDir = null, string[string] env = null) +{ + import std.process, std.stdio; + writefln!"> %s"(args); + auto pid = spawnShell(args, env, std.process.Config.none, workDir ? workDir : "."); + auto res = pid.wait(); + enforce(res == 0, format!"Execution was failed[code=%d]."(res)); +} +/// +string cmd(string[] args, string workDir = null, string[string] env = null) +{ + import std.process; + writefln!"> %s"(escapeShellCommand(args)); + auto res = execute(args, env, std.process.Config.none, size_t.max, workDir); + enforce(res.status == 0, format!"Execution was failed[code=%d]."(res.status)); + return res.output; +} +/// +string cmd(string args, string workDir = null, string[string] env = null) +{ + import std.process; + writefln!"> %s"(args); + auto res = executeShell(args, env, std.process.Config.none, size_t.max, workDir); + enforce(res.status == 0, format!"Execution was failed[code=%d]."(res.status)); + return res.output; +} + +/// +string getRefName() +{ + auto ghref = environment.get("GITHUB_REF"); + enum keyBranche = "refs/heads/"; + enum keyTag = "refs/heads/"; + enum keyPull = "refs/heads/"; + if (ghref.startsWith(keyBranche)) + return ghref[keyBranche.length..$]; + if (ghref.startsWith(keyTag)) + return ghref[keyTag.length..$]; + if (ghref.startsWith(keyPull)) + return "pr" ~ ghref[keyPull.length..$]; + return cmd(["git", "describe", "--tags", "--always"]).chomp; +} + +/// +string[] getPaths(string[string] env) +{ + version (Windows) + return env.get("Path", env.get("PATH", env.get("path", null))).split(";"); + else + return env.get("PATH", null).split(":"); +} +/// +string[] getPaths() +{ + version (Windows) + return environment.get("Path").split(";"); + else + return environment.get("PATH").split(":"); +} + +/// +void setPaths(string[string] env, string[] paths) +{ + version (Windows) + env["Path"] = paths.join(";"); + else + env["PATH"] = paths.join(":"); +} + +/// +void setPaths(string[] paths) +{ + version (Windows) + environment["Path"] = paths.join(";"); + else + environment["PATH"] = paths.join(":"); +} + +/// +string searchPath(string name, string[] dirs = null) +{ + if (name.length == 0) + return name; + if (name.isAbsolute()) + return name; + + foreach (dir; dirs.chain(getPaths())) + { + version (Windows) + auto bin = dir.buildPath(name).setExtension(".exe"); + else + auto bin = dir.buildPath(name); + if (bin.exists) + return bin; + } + return name; +} + +/// +void addCurlPath(ref string[string] env) +{ + env[null] = null; + env.remove(null); + if (config.os == "windows" && config.arch == "x86_64") + { + auto bin64dir = searchDCompiler().dirName.buildNormalizedPath("../bin64"); + if (bin64dir.exists && bin64dir.isDir) + env["Path"] = bin64dir ~ ";" ~ environment.get("Path").chomp(";"); + } + else if (config.os == "windows" && config.arch == "x86") + { + auto bin32dir = searchDCompiler().dirName.buildNormalizedPath("../bin"); + if (bin32dir.exists && bin32dir.isDir) + env["Path"] = bin32dir ~ ";" ~ environment.get("Path").chomp(";"); + } +} + +/// +string searchDCompiler() +{ + auto compiler = config.compiler; + if (compiler.absolutePath.exists) + return compiler.absolutePath; + compiler = compiler.searchPath(); + if (compiler.exists) + return compiler; + + auto dc = searchPath(environment.get("DC")); + if (dc.exists) + return dc; + + auto dmd = searchPath(environment.get("DMD")); + if (dmd.exists) + return dmd; + + return "dmd"; +} + +/// +string expandMacro(string str, string[string] map) +{ + return str.replaceAll!( + a => map.get(a[1], environment.get(a[1], null))) + (regex(r"\$\{(.+?)\}", "g")); +} +/// +string getStr(JSONValue jv, string name, string[string] map, string defaultValue = null) +{ + if (name !in jv) + return defaultValue; + return expandMacro(jv[name].str, map); +} +/// +string[] getAry(JSONValue jv, string name, string[string] map, string[] defaultValue = null) +{ + if (name !in jv) + return defaultValue; + return jv[name].array.map!(v => expandMacro(v.str, map)).array; +} +/// +string[string] getObj(JSONValue jv, string name, string[string] map, string[string] defaultValue = null) +{ + if (name !in jv) + return defaultValue; + string[string] ret; + foreach (k, v; jv[name].object) + ret[k] = expandMacro(v.str, map); + return ret; +} + +/// +void dispLog(string severity, string name, string text = null) +{ + uint colorcode; + switch (severity) + { + case "INFO": + colorcode = 33; // yellow + break; + case "ERROR": + case "FAILED": + colorcode = 31; // red + break; + case "SUCCESS": + colorcode = 36; // cyan + break; + default: + colorcode = 37; // white + break; + } + writefln("\u001b[%02dm[%s]%s\u001b[0m%s%s", colorcode, severity, + name.length > 0 ? " " ~ name : name, + name.length > 0 && text.length > 0 ? ":" : null, + text.length > 0 ? " " ~ text : null); +} diff --git a/.github/workflows/status.yml b/.github/workflows/status.yml new file mode 100644 index 0000000..c12401a --- /dev/null +++ b/.github/workflows/status.yml @@ -0,0 +1,386 @@ +name: status + +on: + schedule: + # ステータスの更新は毎日12:00(UTC)=21:00(JST)=05:00(シアトル) + - cron: 0 12 * * * + +jobs: + # 各テストのジョブは以下の表に従って作成しています。 + # matrixを使って全て行うと大変なので、要所を搾って実施します。 + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 1. Windows x86 dmd o x x x x + # 2. Windows x86 ldc x o o x x + # 3. Windows x86 dmd-master x x x x x + # 4. Windows x86 ldc-master x x x x x + # 5. Windows x86_64 dmd o o x x o + # 6. Windows x86_64 ldc o x o o x + # 7. Windows x86_64 dmd-master x x x x x + # 8. Windows x86_64 ldc-master o o o x x + # 9. Ubuntu x86 dmd x x x x x + # 10. Ubuntu x86 ldc o x o x x + # 11. Ubuntu x86 dmd-master x x x x x + # 12. Ubuntu x86 ldc-master x x x x x + # 13. Ubuntu x86_64 dmd o x o O x + # 14. Ubuntu x86_64 ldc o o x x o + # 15. Ubuntu x86_64 dmd-master o o o x x + # 16. Ubuntu x86_64 ldc-master x x x x x + # 17. macOS x86 dmd x x x x x + # 18. macOS x86 ldc x x x x x + # 19. macOS x86 dmd-master x x x x x + # 20. macOS x86 ldc-master x x x x x + # 21. macOS x86_64 dmd o o x x o + # 22. macOS x86_64 ldc o x o o x + # 23. macOS x86_64 dmd-master x x x x x + # 24. macOS x86_64 ldc-master x x x x x + + # 各テストジョブは以下のテンプレを加工して作成します。 + # 例は test-linux-x86_64-ldc-latest を参照してください。 + # また upload-codecov はテストジョブが全てパスしてから + # 実行されるようにするため、テストジョブを追加する場合は + # upload-codecov の needs も忘れず追加してください。 + + # テンプレ: + #test-${OS}-${ARCH}-${COMPILER}: + # name: test-${OS}-${ARCH}-${COMPILER} + # runs-on: ${OS} + # steps: + # - uses: actions/checkout@v2 + # with: + # ref: master + # - name: Install D compiler + # uses: dlang-community/setup-dlang@v1 + # with: + # compiler: ${COMPILER} + # # UT:テストをする場合は以下を実行 + # - name: Run unit tests + # run: rdmd ./.github/workflows/runner.d -a=${ARCH} --mode=unit-test + # # TT:テストをする場合は以下を実行 + # - name: Run unit tests + # run: rdmd ./.github/workflows/runner.d -a=${ARCH} --mode=integration-test + # # BLD:ビルドをする場合は以下を実行 + # - name: Build tests + # run: dub build -a=${ARCH} -b=release -c=default + # # DOC:ドキュメント生成をする場合は以下を実行 + # - name: Generate document tests + # run: rdmd ./.github/workflows/runner.d -a=${ARCH} --mode=generate-document + # # DOC:ドキュメントを記録する場合は以下を実行(Artifactに6か月保管されます) + # - name: Upload generated pages + # uses: actions/upload-artifact@v2 + # with: + # name: docs + # path: docs + # # COV:カバレッジを記録する場合は以下を実行(Artifactに6か月保管されます) + # - name: Upload coverage result + # uses: actions/upload-artifact@v2 + # with: + # name: coverage-${OS} + # path: .cov + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 1. Windows x86 dmd o x x x x + test-windows-x86-dmd-latest: + name: test-windows-x86-dmd-latest + runs-on: windows-latest + steps: + - uses: actions/checkout@v2 + with: + ref: master + - name: Install D compiler + uses: dlang-community/setup-dlang@v1 + with: + compiler: dmd-latest + - name: Run unit tests + run: rdmd ./.github/workflows/runner.d -a=x86 --mode=unit-test + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 2. Windows x86 ldc x o o x x + test-windows-x86-ldc-latest: + name: test-windows-x86-ldc-latest + runs-on: windows-latest + steps: + - uses: actions/checkout@v2 + with: + ref: master + - name: Install D compiler + uses: dlang-community/setup-dlang@v1 + with: + compiler: ldc-latest + - name: Run integration tests + run: rdmd ./.github/workflows/runner.d -a=x86 --mode=integration-test + - name: Build tests + run: dub build -a=x86 -b=release -c=default + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 3. Windows x86 dmd-master x x x x x + # do-nothing + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 4. Windows x86 ldc-master x x x x x + # do-nothing + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 5. Windows x86_64 dmd o o x x o + test-windows-x86_64-dmd-latest: + name: test-windows-x86_64-dmd-latest + runs-on: windows-latest + steps: + - uses: actions/checkout@v2 + with: + ref: master + - name: Install D compiler + uses: dlang-community/setup-dlang@v1 + with: + compiler: dmd-latest + - name: Run unit tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=unit-test + - name: Run integration tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=integration-test + - name: Upload coverage result + uses: actions/upload-artifact@v2 + with: + name: coverage-windows + path: .cov + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 6. Windows x86_64 ldc o x o o x + test-windows-x86_64-ldc-latest: + name: test-windows-x86_64-ldc-latest + runs-on: windows-latest + steps: + - uses: actions/checkout@v2 + with: + ref: master + - name: Install D compiler + uses: dlang-community/setup-dlang@v1 + with: + compiler: ldc-latest + - name: Run unit tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=unit-test + - name: Build tests + run: dub build -a=x86_64 -b=release -c=default + - name: Generate document tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=generate-document + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 7. Windows x86_64 dmd-master x x x x x + # do-nothing + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 8. Windows x86_64 ldc-master o o o x x + test-windows-x86_64-ldc-master: + name: test-windows-x86_64-ldc-master + runs-on: windows-latest + steps: + - uses: actions/checkout@v2 + with: + ref: master + - name: Install D compiler + uses: dlang-community/setup-dlang@v1 + with: + compiler: ldc-master + gh_token: ${{ secrets.GITHUB_TOKEN }} + - name: Run unit tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=unit-test + - name: Run integration tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=integration-test + - name: Build tests + run: dub build -a=x86_64 -b=release -c=default + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 9. Ubuntu x86 dmd x x x x x + # do-nothing + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 10. Ubuntu x86 ldc o x o x x + test-linux-x86-ldc-latest: + name: test-linux-x86-ldc-latest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + ref: master + - name: Install D compiler + uses: dlang-community/setup-dlang@v1 + with: + compiler: ldc-latest + - name: Install gcc-multilib + run: | + sudo apt update + sudo apt install gcc-multilib + - name: Run unit tests + run: rdmd ./.github/workflows/runner.d -a=x86 --mode=unit-test + - name: Build tests + run: dub build -a=x86 -b=release -c=default + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 11. Ubuntu x86 dmd-master x x x x x + # do-nothing + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 12. Ubuntu x86 ldc-master x x x x x + # do-nothing + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 13. Ubuntu x86_64 dmd o x o O x + test-linux-x86_64-dmd-latest: + name: test-linux-x86_64-dmd-latest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + ref: master + - name: Install D compiler + uses: dlang-community/setup-dlang@v1 + with: + compiler: dmd-latest + - name: Run unit tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=unit-test + - name: Build tests + run: dub build -a=x86_64 -b=release -c=default + - name: Generate document tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=generate-document + - name: Upload generated pages + uses: actions/upload-artifact@v2 + with: + name: docs + path: docs + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 14. Ubuntu x86_64 ldc o o x x o + test-linux-x86_64-ldc-latest: + name: test-linux-x86_64-ldc-latest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + ref: master + - name: Install D compiler + uses: dlang-community/setup-dlang@v1 + with: + compiler: ldc-latest + - name: Run unit tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=unit-test + - name: Run integration tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=integration-test + - name: Upload coverage result + uses: actions/upload-artifact@v2 + with: + name: coverage-linux + path: .cov + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 15. Ubuntu x86_64 dmd-master o o o x x + test-linux-x86_64-dmd-master: + name: test-linux-x86_64-dmd-master + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + ref: master + - name: Install D compiler + uses: dlang-community/setup-dlang@v1 + with: + #compiler: dmd-master ... change to beta tempolarily + compiler: dmd-beta + - name: Run unit tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=unit-test + - name: Run integration tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=integration-test + - name: Build tests + run: dub build -a=x86_64 -b=release -c=default + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 16. Ubuntu x86_64 ldc-master x x x x x + # do-nothing + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 17. macOS x86 dmd x x x x x + # do-nothing + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 18. macOS x86 ldc x x x x x + # do-nothing + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 19. macOS x86 dmd-master x x x x x + # do-nothing + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 20. macOS x86 ldc-master x x x x x + # do-nothing + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 21. macOS x86_64 dmd o o x x o + test-macos-x86_64-dmd-latest: + name: test-macos-x86_64-dmd-latest + runs-on: macos-12 + steps: + - uses: actions/checkout@v2 + with: + ref: master + - name: Install D compiler + uses: dlang-community/setup-dlang@v1 + with: + compiler: dmd-latest + - name: Run unit tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=unit-test + - name: Run integration tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=integration-test + - name: Upload coverage result + uses: actions/upload-artifact@v2 + with: + name: coverage-osx + path: .cov + + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 22. macOS x86_64 ldc o x o o x + test-macos-x86_64-ldc-latest: + name: test-macos-x86_64-ldc-latest + runs-on: macos-12 + steps: + - uses: actions/checkout@v2 + with: + ref: master + - name: Install D compiler + uses: dlang-community/setup-dlang@v1 + with: + compiler: ldc-latest + - name: Run unit tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=unit-test + - name: Build tests + run: dub build -a=x86_64 -b=release -c=default + - name: Generate document tests + run: rdmd ./.github/workflows/runner.d -a=x86_64 --mode=generate-document + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 23. macOS x86_64 dmd-master x x x x x + # do-nothing + + # No. OS ARCH COMPILER UT TT BLD DOC COV + # 24. macOS x86_64 ldc-master x x x x x + # do-nothing + + # Deploy Pages + deproy-pages: + name: deploy-pages + needs: [test-windows-x86-dmd-latest, test-windows-x86-ldc-latest, test-windows-x86_64-dmd-latest, test-windows-x86_64-ldc-latest, test-windows-x86_64-ldc-master, test-linux-x86-ldc-latest, test-linux-x86_64-dmd-latest, test-linux-x86_64-ldc-latest, test-linux-x86_64-dmd-master, test-macos-x86_64-dmd-latest, test-macos-x86_64-ldc-latest] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download generated pages + uses: actions/download-artifact@v2 + with: + name: docs + path: docs + - name: Deploy pages + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + allow_empty_commit: false + publish_dir: docs + publish_branch: gh-pages + user_name: GitHub Actions Bot + user_email: <> diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..29ab113 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +.dub +docs.json +__dummy.html +docs/ +/bsky +bsky.so +bsky.dylib +bsky.dll +bsky.a +bsky.lib +bsky-test-* +*.exe +*.pdb +*.o +*.obj +*.lst +.* +*.json +*.sqlite +*.csv +*.log + +# for tests +!/tests/*/dub.json + +# for examples +!/examples/*/dub.json + +# for unittest data source +!/tests/.ut-data_source +/tests/.ut-data_source/* +!/tests/.ut-data_source/*.json +!/tests/.ut-data_source/*.png +!/tests/.ut-data_source/*.jpg +!/tests/.ut-data_source/*.txt +!/tests/.ut-data_source/*.csv diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..36b7cd9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,23 @@ +Boost Software License - Version 1.0 - August 17th, 2003 + +Permission is hereby granted, free of charge, to any person or organization +obtaining a copy of the software and accompanying documentation covered by +this license (the "Software") to use, reproduce, display, distribute, +execute, and transmit the Software, and to prepare derivative works of the +Software, and to permit third-parties to whom the Software is furnished to +do so, all subject to the following: + +The copyright notices in the Software and this entire statement, including +the above license grant, this restriction and the following disclaimer, +must be included in all copies of the Software, in whole or in part, and +all derivative works of the Software, unless such copies or derivative +works are solely in the form of machine-executable object code generated by +a source language processor. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT +SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE +FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7e45e1e --- /dev/null +++ b/README.md @@ -0,0 +1,133 @@ +# Abstract +The client library of [Bluesky](https://bsky.app/). + +# Usage +```sh +dub add bsky +``` + +# Examlple codes +```d +import std.stdio, std.process, std.range; +import bsky; +auto client = new Bluesky; +client.login(environment.get("BSKY_LOGINID"), environment.get("BSKY_LOGINPASS")); +scope (exit) + client.logout(); +foreach (post; client.timeline.take(100).toMessages) + writeln(i"$(post.postBy.displayName) < $(post.text)"); +``` + +[examples](./examples) + +# List of Supported API +| API | Supported functions | +|:--------------------------------|:--------------------| +| app.bsky.actor.getPreferences | UNSUPPORTED | +| app.bsky.actor.getProfile | bsky.client.Bluesky.getProfile
bsky.client.Bluesky.profile | +| app.bsky.actor.getProfiles | bsky.client.Bluesky.getProfiles
bsky.client.Bluesky.fetchProfiles
bsky.client.Bluesky.profiles | +| app.bsky.feed.getActorFeeds | UNSUPPORTED | +| app.bsky.feed.getActorLikes | UNSUPPORTED | +| app.bsky.feed.getAuthorFeed | bsky.client.Bluesky.getAuthorFeed
bsky.client.Bluesky.fetchAuthorFeed
bsky.client.Bluesky.authorFeed | +| app.bsky.feed.getFeedGenerator | UNSUPPORTED | +| app.bsky.feed.getFeedGenerators | UNSUPPORTED | +| app.bsky.feed.getFeed | bsky.client.Bluesky.getFeed
bsky.client.Bluesky.fetchFeed
bsky.client.Bluesky.feed | +| app.bsky.feed.getLikes | bsky.client.Bluesky.getLikes
bsky.client.Bluesky.fetchLikeUsers
bsky.client.Bluesky.likeUsers | +| app.bsky.feed.getListFeed | bsky.client.Bluesky.getListFeed
bsky.client.Bluesky.fetchListFeed
bsky.client.Bluesky.listFeed | +| app.bsky.feed.getPostThread | UNSUPPORTED | +| app.bsky.feed.getPosts | bsky.client.Bluesky.getPosts
bsky.client.Bluesky.fetchPosts
bsky.client.Bluesky.getPostItems | +| app.bsky.feed.getRepostedBy | bsky.client.Bluesky.getRepostedBy
bsky.client.Bluesky.fetchRepostedBy
bsky.client.Bluesky.repostedByUsers | +| app.bsky.feed.getSuggestedFeeds | UNSUPPORTED | +| app.bsky.feed.getTimeline | bsky.client.Bluesky.getTimeline
bsky.client.Bluesky.fetchTimeline
bsky.client.Bluesky.timeline | +| app.bsky.feed.searchPosts | bsky.client.Bluesky.searchPosts
bsky.client.Bluesky.fetchSearchPosts
bsky.client.Bluesky.searchPostItems | +| app.bsky.graph.getBlocks | UNSUPPORTED | +| app.bsky.graph.getFollowers | bsky.client.Bluesky.getFollowers
bsky.client.Bluesky.fetchFollowers
bsky.client.Bluesky.followers | +| app.bsky.graph.getFollows | bsky.client.Bluesky.getFollows
bsky.client.Bluesky.fetchFollows
bsky.client.Bluesky.follows | +| app.bsky.graph.getListBlocks | UNSUPPORTED | +| app.bsky.graph.getListMutes | UNSUPPORTED | +| app.bsky.graph.getList | UNSUPPORTED | +| app.bsky.graph.getLists | UNSUPPORTED | +| app.bsky.graph.getMutes | UNSUPPORTED | +| app.bsky.graph.getSuggestedFollowsByActor | UNSUPPORTED | +| app.bsky.graph.muteActorList | UNSUPPORTED | +| app.bsky.graph.muteActor | UNSUPPORTED | +| app.bsky.graph.unmuteActorList | UNSUPPORTED | +| app.bsky.graph.unmuteActor | UNSUPPORTED | +| app.bsky.labeler.getServices | UNSUPPORTED | +| app.bsky.notification.getUnreadCount | UNSUPPORTED | +| app.bsky.notification.listNotifications | UNSUPPORTED | +| app.bsky.notification.registerPush | UNSUPPORTED | +| app.bsky.notification.updateSeen | UNSUPPORTED | +| com.atproto.admin.deleteAccount | UNSUPPORTED | +| com.atproto.admin.disableAccountInvites | UNSUPPORTED | +| com.atproto.admin.disableInviteCodes | UNSUPPORTED | +| com.atproto.admin.enableAccountInvites | UNSUPPORTED | +| com.atproto.admin.getAccountInfo | UNSUPPORTED | +| com.atproto.admin.getInviteCodes | UNSUPPORTED | +| com.atproto.admin.getSubjectStatus | UNSUPPORTED | +| com.atproto.admin.updateAccountEmail | UNSUPPORTED | +| com.atproto.admin.updateAccountHandle | UNSUPPORTED | +| com.atproto.admin.updateAccountPassword | UNSUPPORTED | +| com.atproto.admin.updateSubjectStatus | UNSUPPORTED | +| com.atproto.identity.getRecommendedDidCredentials | UNSUPPORTED | +| com.atproto.identity.requestPlcOperationSignature | UNSUPPORTED | +| com.atproto.identity.resolveHandle | bsky.client.Bluesky.resolveHandle | +| com.atproto.identity.signPlcOperation | UNSUPPORTED | +| com.atproto.identity.submitPlcOperation | UNSUPPORTED | +| com.atproto.identity.updateHandle | UNSUPPORTED | +| com.atproto.moderation.createReport | UNSUPPORTED | +| com.atproto.repo.applyWrites | UNSUPPORTED | +| com.atproto.repo.createRecord | bsky.client.Bluesky.createRecord
bsky.client.Bluesky.sendPost
bsky.client.Bluesky.sendReplyPost
bsky.client.Bluesky.sendQuotePost
bsky.client.Bluesky.markLike
bsky.client.Bluesky.repost | +| com.atproto.repo.deleteRecord | bsky.client.Bluesky.deletePost
bsky.client.Bluesky.deleteLike
bsky.client.Bluesky.deleteRepost | +| com.atproto.repo.getRecord | UNSUPPORTED | +| com.atproto.repo.importRepo | UNSUPPORTED | +| com.atproto.repo.listMissingBlobs | UNSUPPORTED | +| com.atproto.repo.listRecords | UNSUPPORTED | +| com.atproto.repo.putRecord | UNSUPPORTED | +| com.atproto.repo.uploadBlob | bsky.client.Bluesky.uploadBlob
bsky.client.Bluesky.sendPost | +| com.atproto.server.activateAccount | UNSUPPORTED | +| com.atproto.server.checkAccountStatus | UNSUPPORTED | +| com.atproto.server.confirmEmail | UNSUPPORTED | +| com.atproto.server.createAccount | UNSUPPORTED | +| com.atproto.server.createAppPassword | UNSUPPORTED | +| com.atproto.server.createInviteCode | UNSUPPORTED | +| com.atproto.server.createInviteCodes | UNSUPPORTED | +| com.atproto.server.createSession | bsky.auth.AtprotoAuth.createSeession
bsky.client.Bluesky.login | +| com.atproto.server.deactivateAccount | UNSUPPORTED | +| com.atproto.server.deleteAccount | UNSUPPORTED | +| com.atproto.server.deleteSession | bsky.auth.AtprotoAuth.deleteSession
bsky.client.Bluesky.logout | +| com.atproto.server.describeServer | UNSUPPORTED | +| com.atproto.server.getAccountInviteCodes | UNSUPPORTED | +| com.atproto.server.getServiceAuth | UNSUPPORTED | +| com.atproto.server.getSession | bsky.auth.AtprotoAuth.updateSession | +| com.atproto.server.listAppPasswords | UNSUPPORTED | +| com.atproto.server.refreshSession | bsky.auth.AtprotoAuth.refreshSession
bsky.auth.AtprotoAuth.updateSession | +| com.atproto.server.requestAccountDelete | UNSUPPORTED | +| com.atproto.server.requestEmailConfirmation | UNSUPPORTED | +| com.atproto.server.requestEmailUpdate | UNSUPPORTED | +| com.atproto.server.requestPasswordReset | UNSUPPORTED | +| com.atproto.server.reserveSigningKey | UNSUPPORTED | +| com.atproto.server.revokeAppPassword | UNSUPPORTED | +| com.atproto.server.updateEmail | UNSUPPORTED | +| com.atproto.sync.getBlob | UNSUPPORTED | +| com.atproto.sync.getBlocks | UNSUPPORTED | +| com.atproto.sync.getLatestCommit | UNSUPPORTED | +| com.atproto.sync.getRecord | UNSUPPORTED | +| com.atproto.sync.getRepo | UNSUPPORTED | +| com.atproto.sync.listBlobs | UNSUPPORTED | +| com.atproto.sync.listRepos | UNSUPPORTED | +| tools.ozone.communication.createTemplate | UNSUPPORTED | +| tools.ozone.communication.deleteTemplate | UNSUPPORTED | +| tools.ozone.communication.listTemplates | UNSUPPORTED | +| tools.ozone.communication.updateTemplate | UNSUPPORTED | +| tools.ozone.moderation.emitEvent | UNSUPPORTED | +| tools.ozone.moderation.getEvent | UNSUPPORTED | +| tools.ozone.moderation.getRecord | UNSUPPORTED | +| tools.ozone.moderation.getRepo | UNSUPPORTED | +| tools.ozone.moderation.queryEvents | UNSUPPORTED | +| tools.ozone.moderation.queryStatuses | UNSUPPORTED | +| tools.ozone.moderation.searchRepos | UNSUPPORTED | + +# LICENSE +Boost Software License - Version 1.0 +- For detail is here: [LICENSE](./LICENSE) diff --git a/dscanner.ini b/dscanner.ini new file mode 100644 index 0000000..3930d11 --- /dev/null +++ b/dscanner.ini @@ -0,0 +1,206 @@ +; Configure which static analysis checks are enabled +[analysis.config.StaticAnalysisConfig] +; Check variable, class, struct, interface, union, and function names against t +; he Phobos style guide +style_check="enabled" +; Check for array literals that cause unnecessary allocation +enum_array_literal_check="enabled" +; Check for poor exception handling practices +exception_check="disabled" +; Check for use of the deprecated 'delete' keyword +delete_check="enabled" +; Check for use of the deprecated floating point operators +float_operator_check="enabled" +; Check number literals for readability +number_style_check="disabled" +; Checks that opEquals, opCmp, toHash, and toString are either const, immutable +; , or inout. +object_const_check="enabled" +; Checks for .. expressions where the left side is larger than the right. +backwards_range_check="enabled" +; Checks for if statements whose 'then' block is the same as the 'else' block +if_else_same_check="enabled" +; Checks for some problems with constructors +constructor_check="enabled" +; Checks for unused variables +unused_variable_check="enabled" +; Checks for unused labels +unused_label_check="enabled" +; Checks for unused function parameters +unused_parameter_check="disabled" +; Checks for duplicate attributes +duplicate_attribute="enabled" +; Checks that opEquals and toHash are both defined or neither are defined +opequals_tohash_check="enabled" +; Checks for subtraction from .length properties +length_subtraction_check="enabled" +; Checks for methods or properties whose names conflict with built-in propertie +; s +builtin_property_names_check="enabled" +; Checks for confusing code in inline asm statements +asm_style_check="enabled" +; Checks for confusing logical operator precedence +logical_precedence_check="enabled" +; Checks for undocumented public declarations +undocumented_declaration_check="enabled" +; Checks for poor placement of function attributes +function_attribute_check="enabled" +; Checks for use of the comma operator +comma_expression_check="enabled" +; Checks for local imports that are too broad. Only accurate when checking code +; used with D versions older than 2.071.0 +local_import_check="disabled" +; Checks for variables that could be declared immutable +could_be_immutable_check="disabled" +; Checks for redundant expressions in if statements +redundant_if_check="enabled" +; Checks for redundant parenthesis +redundant_parens_check="enabled" +; Checks for mismatched argument and parameter names +mismatched_args_check="enabled" +; Checks for labels with the same name as variables +label_var_same_name_check="enabled" +; Checks for lines longer than 120 characters +long_line_check="enabled" +; Checks for assignment to auto-ref function parameters +auto_ref_assignment_check="disabled" +; Checks for incorrect infinite range definitions +incorrect_infinite_range_check="enabled" +; Checks for asserts that are always true +useless_assert_check="enabled" +; Check for uses of the old-style alias syntax +alias_syntax_check="enabled" +; Checks for else if that should be else static if +static_if_else_check="enabled" +; Check for unclear lambda syntax +lambda_return_check="enabled" +; Check for auto function without return statement +auto_function_check="enabled" +; Check for sortedness of imports +imports_sortedness="disabled" +; Check for explicitly annotated unittests +explicitly_annotated_unittests="enabled" +; Check for properly documented public functions (Returns, Params) +properly_documented_public_functions="disabled" +; Check for useless usage of the final attribute +final_attribute_check="disabled" +; Check for virtual calls in the class constructors +vcall_in_ctor="enabled" +; Check for useless user defined initializers +useless_initializer="disabled" +; Check allman brace style +allman_braces_check="disabled" +; Check for redundant attributes +redundant_attributes_check="enabled" +; Check public declarations without a documented unittest +has_public_example="disabled" +; Check for asserts without an explanatory message +assert_without_msg="disabled" +; Check indent of if constraints +if_constraints_indent="disabled" +; Check for @trusted applied to a bigger scope than a single function +trust_too_much="enabled" +; Check for redundant storage classes on variable declarations +redundant_storage_classes="enabled" +; ModuleFilters for selectively enabling (+std) and disabling (-std.internal) i +; ndividual checks + +[analysis.config.ModuleFilters] +; Exclude/Import modules +style_check="-bindbc.icu.bindings" +; Exclude/Import modules +enum_array_literal_check="" +; Exclude/Import modules +exception_check="" +; Exclude/Import modules +delete_check="" +; Exclude/Import modules +float_operator_check="" +; Exclude/Import modules +number_style_check="" +; Exclude/Import modules +object_const_check="" +; Exclude/Import modules +backwards_range_check="" +; Exclude/Import modules +if_else_same_check="" +; Exclude/Import modules +constructor_check="" +; Exclude/Import modules +unused_variable_check="" +; Exclude/Import modules +unused_label_check="" +; Exclude/Import modules +unused_parameter_check="" +; Exclude/Import modules +duplicate_attribute="" +; Exclude/Import modules +opequals_tohash_check="" +; Exclude/Import modules +length_subtraction_check="" +; Exclude/Import modules +builtin_property_names_check="" +; Exclude/Import modules +asm_style_check="" +; Exclude/Import modules +logical_precedence_check="" +; Exclude/Import modules +undocumented_declaration_check="" +; Exclude/Import modules +function_attribute_check="" +; Exclude/Import modules +comma_expression_check="" +; Exclude/Import modules +local_import_check="" +; Exclude/Import modules +could_be_immutable_check="" +; Exclude/Import modules +redundant_if_check="" +; Exclude/Import modules +redundant_parens_check="" +; Exclude/Import modules +mismatched_args_check="" +; Exclude/Import modules +label_var_same_name_check="" +; Exclude/Import modules +long_line_check="" +; Exclude/Import modules +auto_ref_assignment_check="" +; Exclude/Import modules +incorrect_infinite_range_check="" +; Exclude/Import modules +useless_assert_check="" +; Exclude/Import modules +alias_syntax_check="" +; Exclude/Import modules +static_if_else_check="" +; Exclude/Import modules +lambda_return_check="" +; Exclude/Import modules +auto_function_check="" +; Exclude/Import modules +imports_sortedness="" +; Exclude/Import modules +explicitly_annotated_unittests="" +; Exclude/Import modules +properly_documented_public_functions="" +; Exclude/Import modules +final_attribute_check="" +; Exclude/Import modules +vcall_in_ctor="" +; Exclude/Import modules +useless_initializer="" +; Exclude/Import modules +allman_braces_check="" +; Exclude/Import modules +redundant_attributes_check="" +; Exclude/Import modules +has_public_example="" +; Exclude/Import modules +assert_without_msg="" +; Exclude/Import modules +if_constraints_indent="" +; Exclude/Import modules +trust_too_much="" +; Exclude/Import modules +redundant_storage_classes="" diff --git a/dub.json b/dub.json new file mode 100644 index 0000000..e45db94 --- /dev/null +++ b/dub.json @@ -0,0 +1,18 @@ +{ + "authors": [ + "SHOO" + ], + "copyright": "Copyright © 2024, SHOO", + "description": "Bluesky client", + "license": "BSL-1.0", + "name": "bsky", + "dependencies": { + "voile": {"version": "*", "optional": true} + }, + "importPaths": ["src"], + "sourcePaths": ["src"], + "buildTypes": { + "vscode-debug": { "buildOptions": ["debugMode", "debugInfoC", "unittests"] }, + "vscode-debug-cov": { "buildOptions": ["debugMode", "coverage", "debugInfoC", "unittests"] } + } +} diff --git a/examples/disp_timeline/dub.json b/examples/disp_timeline/dub.json new file mode 100644 index 0000000..4c75416 --- /dev/null +++ b/examples/disp_timeline/dub.json @@ -0,0 +1,8 @@ +{ + "name": "disp_timeline", + "importPaths": ["."], + "sourcePaths": ["src"], + "dependencies": { + "bsky": {"path": "../.."} + } +} diff --git a/examples/disp_timeline/src/main.d b/examples/disp_timeline/src/main.d new file mode 100644 index 0000000..b760c4c --- /dev/null +++ b/examples/disp_timeline/src/main.d @@ -0,0 +1,16 @@ +/******************************************************************************* + * Example of README.md + */ +module src.main; + +void main() +{ + import std.stdio, std.process, std.range; + import bsky; + auto client = new Bluesky; + client.login(environment.get("BSKY_LOGINID"), environment.get("BSKY_LOGINPASS")); + scope (exit) + client.logout(); + foreach (post; client.timeline.take(100).toMessages) + writeln(i"$(post.postBy.displayName) < $(post.text)"); +} \ No newline at end of file diff --git a/src/bsky/_internal/attr.d b/src/bsky/_internal/attr.d new file mode 100644 index 0000000..0f70339 --- /dev/null +++ b/src/bsky/_internal/attr.d @@ -0,0 +1,1228 @@ +/******************************************************************************* + * Internal attributes + */ +module bsky._internal.attr; + +package(bsky): + +version (Have_voile) { + public import voile.attr; +} +else: + + + +import std.traits; +import std.meta; + +// from phobos private template in std.traits +private template isDesiredUDA(alias attribute) +{ + template isDesiredUDA(alias toCheck) + { + static if (is(typeof(attribute)) && !__traits(isTemplate, attribute)) + { + static if (__traits(compiles, toCheck == attribute)) + enum isDesiredUDA = toCheck == attribute; + else + enum isDesiredUDA = false; + } + else static if (is(typeof(toCheck))) + { + static if (__traits(isTemplate, attribute)) + enum isDesiredUDA = isInstanceOf!(attribute, typeof(toCheck)); + else + enum isDesiredUDA = is(typeof(toCheck) == attribute); + } + else static if (__traits(isTemplate, attribute)) + enum isDesiredUDA = isInstanceOf!(attribute, toCheck); + else + enum isDesiredUDA = is(toCheck == attribute); + } +} + +/******************************************************************************* + * 関数のパラメータに付与されたUDAを取り出す。 + * + * Params: + * Func = 関数 + * i = 引数の番号(最初の引数は0番目) + * attr = UDAの種類を指定できます(指定しないとすべて返します) + * Returns: + * UDAのタプルが返ります + */ +template getParameterUDAs(alias Func, size_t i) +{ + static if (__traits(compiles, { static assert(__traits(getAttributes, Parameters!Func[i]).length > 0); })) + { + alias getParameterUDAs = __traits(getAttributes, Parameters!Func[i]); + } + else static if (__traits(compiles, __traits(getAttributes, Parameters!Func[i..i+1]))) + { + alias getParameterUDAs = __traits(getAttributes, Parameters!Func[i..i+1]); + } + else + { + alias getParameterUDAs = AliasSeq!(); + } +} +/// ditto +alias getParameterUDAs(alias Func, size_t i, alias attr) = Filter!(isDesiredUDA!attr, getParameterUDAs!(Func, i)); +/// +@safe @nogc nothrow pure unittest +{ + @(30) struct S {} + enum Test; + alias lambda = (@(10) int x, @(15) @(Test) long y, int z, S s) => x; + + alias uda1 = getParameterUDAs!(lambda, 0); + static assert(uda1.length == 1); + static assert(uda1[0] == 10); + alias uda2 = getParameterUDAs!(lambda, 1); + static assert(uda2.length == 2); + static assert(uda2[0] == 15); + static assert(is(uda2[1] == Test)); + alias uda3 = getParameterUDAs!(lambda, 2); + static assert(uda3.length == 0); + alias uda4 = getParameterUDAs!(lambda, 3); + static assert(uda4.length == 1); + static assert(uda4[0] == 30); + + static assert(getParameterUDAs!(lambda, 0, int).length == 1); + static assert(getParameterUDAs!(lambda, 0, Test).length == 0); + static assert(getParameterUDAs!(lambda, 1, Test).length == 1); +} + + +/******************************************************************************* + * 関数のパラメータに付与されたUDAのうち、型についたUDAを取り出す。 + * + * Params: + * Func = 関数 + * i = 引数の番号(最初の引数は0番目) + * attr = UDAの種類を指定できます(指定しないとすべて返します) + * Returns: + * UDAのタプルが返ります + */ +template getParameterTypeUDAs(alias Func, size_t i) +{ + static if (__traits(compiles, __traits(getAttributes, Parameters!Func[i]))) + { + alias getParameterTypeUDAs = __traits(getAttributes, Parameters!Func[i]); + } + else + { + alias getParameterTypeUDAs = AliasSeq!(); + } +} +/// ditto +alias getParameterTypeUDAs(alias Func, size_t i, alias attr) + = Filter!(isDesiredUDA!attr, getParameterTypeUDAs!(Func, i)); +/// +@safe @nogc nothrow pure unittest +{ + @(30) struct S {} + enum Test; + alias lambda = (@(10) int x, @(15) @(Test) long y, int z, S s) => x; + + alias uda1 = getParameterTypeUDAs!(lambda, 0); + static assert(uda1.length == 0); + alias uda2 = getParameterTypeUDAs!(lambda, 1); + static assert(uda2.length == 0); + alias uda3 = getParameterTypeUDAs!(lambda, 2); + static assert(uda3.length == 0); + alias uda4 = getParameterTypeUDAs!(lambda, 3); + static assert(uda4.length == 1); + static assert(uda4[0] == 30); +} + +/******************************************************************************* + * 関数のパラメータに付与されたUDAのうち、引数についたUDAを取り出す。 + * + * Params: + * Func = 関数 + * i = 引数の番号(最初の引数は0番目) + * attr = UDAの種類を指定できます(指定しないとすべて返します) + * Returns: + * UDAのタプルが返ります + */ +template getParameterArgUDAs(alias Func, size_t i) +{ + enum bool notFoundInType(alias val) = staticIndexOf!(val, getParameterTypeUDAs!(Func, i)) == -1; + alias getParameterArgUDAs = Filter!(notFoundInType, getParameterUDAs!(Func, i)); +} +/// ditto +alias getParameterArgUDAs(alias Func, size_t i, alias attr) = Filter!(isDesiredUDA!attr, getParameterArgUDAs!(Func, i)); +/// +@safe @nogc nothrow pure unittest +{ + @(30) struct S {} + enum Test; + alias lambda = (@(10) int x, @(15) @(Test) long y, int z, S s) => x; + + alias uda1 = getParameterArgUDAs!(lambda, 0); + static assert(uda1.length == 1); + static assert(uda1[0] == 10); + alias uda2 = getParameterArgUDAs!(lambda, 1); + static assert(uda2.length == 2); + static assert(uda2[0] == 15); + static assert(is(uda2[1] == Test)); + alias uda3 = getParameterArgUDAs!(lambda, 2); + static assert(uda3.length == 0); + alias uda4 = getParameterArgUDAs!(lambda, 3); + static assert(uda4.length == 0); + + static assert(getParameterUDAs!(lambda, 0, int).length == 1); + static assert(getParameterUDAs!(lambda, 0, Test).length == 0); + static assert(getParameterUDAs!(lambda, 1, Test).length == 1); +} + + +/******************************************************************************* + * 関数のパラメータにUDAが付与されているか確認します + * + * Params: + * Func = 関数 + * i = 引数の番号(最初の引数は0番目) + * attr = チェックするUDA + * Returns: + * UDAがあったらtrue + */ +enum bool hasParameterUDA(alias Func, size_t i, alias attr) = getParameterUDAs!(Func, i, attr).length != 0; +/// +@safe @nogc nothrow pure unittest +{ + @(30) struct S {} + enum Test; + alias lambda = (@(10) int x, @(15) @Test long y, int z, S s) => x; + + static assert( hasParameterUDA!(lambda, 0, 10)); + static assert(!hasParameterUDA!(lambda, 0, 15)); + static assert( hasParameterUDA!(lambda, 1, 15)); + static assert( hasParameterUDA!(lambda, 1, Test)); + static assert(!hasParameterUDA!(lambda, 2, 15)); + static assert( hasParameterUDA!(lambda, 3, 30)); +} + + +/******************************************************************************* + * 関数のパラメータに付与されたUDAのうち、型にUDAがついているか確認します + * + * Params: + * Func = 関数 + * i = 引数の番号(最初の引数は0番目) + * attr = チェックするUDA + * Returns: + * UDAがあったらtrue + */ +enum bool hasParameterTypeUDA(alias Func, size_t i, alias attr) = getParameterTypeUDAs!(Func, i, attr).length != 0; +/// +@safe @nogc nothrow pure unittest +{ + @(30) struct S {} + enum Test; + alias lambda = (@(10) int x, @(15) @Test long y, int z, S s) => x; + + static assert(!hasParameterTypeUDA!(lambda, 0, 10)); + static assert(!hasParameterTypeUDA!(lambda, 0, 15)); + static assert(!hasParameterTypeUDA!(lambda, 1, 15)); + static assert(!hasParameterTypeUDA!(lambda, 1, Test)); + static assert(!hasParameterTypeUDA!(lambda, 2, 15)); + static assert( hasParameterTypeUDA!(lambda, 3, 30)); +} + + +/******************************************************************************* + * 関数のパラメータに付与されたUDAのうち、引数にUDAがついているか確認します + * + * Params: + * Func = 関数 + * i = 引数の番号(最初の引数は0番目) + * attr = チェックするUDA + * Returns: + * UDAがあったらtrue + */ +enum bool hasParameterArgUDA(alias Func, size_t i, alias attr) = getParameterArgUDAs!(Func, i, attr).length != 0; +/// +@safe @nogc nothrow pure unittest +{ + @(30) struct S {} + enum Test; + alias lambda = (@(10) int x, @(15) @Test long y, int z, S s) => x; + + static assert( hasParameterArgUDA!(lambda, 0, 10)); + static assert(!hasParameterArgUDA!(lambda, 0, 15)); + static assert( hasParameterArgUDA!(lambda, 1, 15)); + static assert( hasParameterArgUDA!(lambda, 1, Test)); + static assert(!hasParameterArgUDA!(lambda, 2, 15)); + static assert(!hasParameterArgUDA!(lambda, 3, 30)); +} + + +private enum Ignore {init} + +/******************************************************************************* + * Attribute marking ignore data + */ +enum Ignore ignore = Ignore.init; + +/// +enum bool hasIgnore(alias value) = hasUDA!(value, Ignore); + +/// +@safe unittest +{ + struct A { int test; @ignore int foo; } + struct B { int test; } + A a; + B b; + static assert(!hasIgnore!(a.test)); + static assert(!hasIgnore!(b.test)); + static assert( hasIgnore!(a.foo)); +} + +private struct IgnoreIf(alias func) {} +/******************************************************************************* + * Attribute marking conditional ignore data + */ +alias ignoreIf(alias func) = IgnoreIf!func; + +/// +enum bool isIgnoreIf(alias uda) = isInstanceOf!(IgnoreIf, uda); +/// +enum bool hasIgnoreIf(alias symbol) = Filter!(isIgnoreIf, __traits(getAttributes, symbol)).length > 0; +/// +template getPredIgnoreIf(alias value) +{ + static if (isIgnoreIf!value) + { + // UDAから関数を取り出す + alias getPredIgnoreIf = TemplateArgsOf!value[0]; + } + else + { + // シンボルからUDAを取り出す + alias uda = Filter!(isIgnoreIf, __traits(getAttributes, value))[0]; + // UDAから関数を取り出す + alias getPredIgnoreIf = TemplateArgsOf!uda[0]; + } +} + +private enum Essential {init} + +/******************************************************************************* + * Attribute marking essential field + */ +enum Essential essential = Essential.init; + +/// +enum bool hasEssential(alias value) = hasUDA!(value, Essential); + +/// +@safe unittest +{ + struct A { int test; @essential int foo; } + struct B { int test; } + A a; + B b; + static assert(!hasEssential!(a.test)); + static assert(!hasEssential!(b.test)); + static assert( hasEssential!(a.foo)); +} + + +private enum Key {init} + +/******************************************************************************* + * Attribute marking essential field + */ +enum Key key = Key.init; + +/// +enum bool hasKey(alias value) = hasUDA!(value, Key); + +/// +enum bool isKeyMember(T, string member) = hasKey!(__traits(getMember, T, member)); + +/// +alias getKeyMemberNames(T) = Filter!(ApplyLeft!(isKeyMember, T), FieldNameTuple!T); + +/// +enum bool hasKeyMember(T) = Filter!(ApplyLeft!(isKeyMember, T), FieldNameTuple!T).length != 0; + +/// +enum string getKeyMemberName(T) = Filter!(ApplyLeft!(isKeyMember, T), FieldNameTuple!T)[0]; + +/// +@safe unittest +{ + struct A { int test; @key int foo; } + struct B { int test; } + A a; + B b; + static assert(!hasKey!(a.test)); + static assert(!hasKey!(b.test)); + static assert( hasKey!(a.foo)); + static assert( hasKeyMember!A); + static assert(!hasKeyMember!B); + static assert(getKeyMemberNames!A == AliasSeq!("foo")); + static assert(getKeyMemberName!A == "foo"); +} + + + + +private struct Name +{ + string name; +} + +/******************************************************************************* + * Attribute forcing field name + */ +Name name(string name) pure nothrow @nogc @safe +{ + return Name(name); +} +/// ditto +enum Name name(string n) = Name(n); + +/// +enum bool hasName(alias value) = hasUDA!(value, Name); + +/// +template getName(alias value) +if (hasName!value) +{ + enum string getName = getUDAs!(value, Name)[0].name; +} + +/// +@safe unittest +{ + struct A { int test; @name("test") int foo; } + struct B { @name!"foo" int test; } + A a; + B b; + static assert(!hasName!(a.test)); + static assert( hasName!(a.foo)); + static assert( hasName!(b.test)); + static assert(getName!(a.foo) == "test"); + static assert(getName!(b.test) == "foo"); +} + +private struct Value(T) +{ + T value; +} + +/******************************************************************************* + * Attribute forcing field value + */ +Value!T value(T)(T val) pure nothrow @nogc @safe +{ + return Value!T(val); +} +/// ditto +enum Value!(typeof(v)) value(alias v) = Value!(typeof(v))(v); + +/// +template hasValue(args...) +{ + static if (args.length == 1) + { + enum bool hasValue = hasUDA!(args[0], Value); + } + else static if (args.length == 2 && isType!(args[1])) + { + enum bool hasValue = hasUDA!(args[0], Value!(args[1])); + } + else static assert(0); +} + +/// +template getValues(args...) +{ + enum getVal(alias v) = v.value; + static if (args.length == 1) + { + alias getValues = staticMap!(getVal, getUDAs!(args[0], Value)); + } + else static if (args.length == 2 && isType!(args[1])) + { + alias getValues = staticMap!(getVal, getUDAs!(args[0], Value!(args[1]))); + } + else static assert(0); +} + +/// +template getValue(alias value) +if (hasValue!value) +{ + enum getValue = getUDAs!(value, Value)[0].value; +} + +/// +@safe unittest +{ + struct A { int test; @value("test") int foo; } + struct B { @value!1 int test; } + A a; + B b; + static assert(!hasValue!(a.test)); + static assert( hasValue!(a.foo)); + static assert( hasValue!(b.test)); + static assert( hasValue!(b.test, int)); + static assert(getValue!(a.foo) == "test"); + static assert(getValue!(b.test) == 1); +} + +/// +struct ConvBy(alias T){} + +/// +alias convBy = ConvBy; + +/// +template isConvByAttr(alias Attr) +{ + static if (isInstanceOf!(convBy, Attr)) + { + enum bool isConvByAttr = true; + } + else static if (is(typeof(Attr.to)) && is(typeof(Attr.from))) + { + enum bool isConvByAttr = true; + } + else + { + enum bool isConvByAttr = false; + } +} + +/// +template getConvByAttr(alias Attr) +if (isConvByAttr!Attr) +{ + static if (isInstanceOf!(convBy, Attr)) + { + alias getConvByAttr = TemplateArgsOf!(Attr)[0]; + } + else static if (is(typeof(Attr.to)) && is(typeof(Attr.from))) + { + alias getConvByAttr = Attr; + } + else static assert(0); +} + + +/// +alias ProxyList(alias value) = staticMap!(getConvByAttr, Filter!(isConvByAttr, __traits(getAttributes, value))); + +/// +template getConvBy(alias value) +{ + private alias _list = ProxyList!value; + static assert(_list.length <= 1, `Only single serialization proxy is allowed`); + alias getConvBy = _list[0]; +} + +/// +template hasConvBy(alias value) +{ + private enum _listLength = ProxyList!value.length; + static assert(_listLength <= 1, `Only single serialization proxy is allowed`); + enum bool hasConvBy = _listLength == 1; +} + +@safe unittest +{ + struct Proxy + { + static string to(ref int value) { return null; } + static int from(string value) { return 0; } + } + struct A + { + @convBy!Proxy int a; + @(42) int b; + @(42) @convBy!Proxy int c; + } + static assert(isConvByAttr!(__traits(getAttributes, A.a))); + static assert(hasConvBy!(A.a)); + static assert(is(getConvBy!(A.a) == Proxy)); + + static assert(!hasConvBy!(A.b)); + static assert(hasConvBy!(A.c)); +} + +private enum ConvStyle +{ + none, + type1, // Ret dst = proxy.to(value); / Val dst = proxy.from(value); + type2, // Ret dst = proxy.to!Ret(value); / Val dst = proxy.from!Val(value); + type3, // Ret dst; proxy.to(value, dst); / Val dst; proxy.from(value, dst); + type4, // Ret dst = proxy(value); / Val dst = proxy(value); + type5, // Ret dst = proxy!Ret(value); / Val dst = proxy!Val(value); + type6, // Ret dst; proxy(value, dst); / Val dst; proxy(value, dst); +} + +private template getConvToStyle(alias value, Ret) +if (hasConvBy!value) +{ + alias proxy = getConvBy!value; + alias Val = typeof(value); + static if (is(typeof(proxy.to(lvalueOf!Val)) : Ret)) + { + // Ret dst = proxy.to(value); + enum getConvToStyle = ConvStyle.type1; + } + else static if (is(typeof(proxy.to!Ret(lvalueOf!Val)) : Ret)) + { + // Ret dst = proxy.to!Ret(value); + enum getConvToStyle = ConvStyle.type2; + } + else static if (is(typeof(proxy.to(lvalueOf!Val, lvalueOf!Ret))) + && !is(typeof(proxy.to(lvalueOf!Val, rvalueOf!Ret)))) + { + // Ret dst; proxy.to(value, dst); + enum getConvToStyle = ConvStyle.type3; + } + else static if (is(typeof(proxy(lvalueOf!Val)) : Ret)) + { + // Ret dst = proxy(value); + enum getConvToStyle = ConvStyle.type4; + } + else static if (is(typeof(proxy!Ret(lvalueOf!Val)) : Ret)) + { + // Ret dst = proxy!Ret(value); + enum getConvToStyle = ConvStyle.type5; + } + else static if (is(typeof(proxy(lvalueOf!Val, lvalueOf!Ret))) + && !is(typeof(proxy(lvalueOf!Val, rvalueOf!Ret)))) + { + // Ret dst; proxy(value, dst); + enum getConvToStyle = ConvStyle.type6; + } + else + { + // no match + enum getConvToStyle = ConvStyle.none; + } +} + +/// +template canConvTo(alias value, T) +{ + static if (hasConvBy!value) + { + enum bool canConvTo = getConvToStyle!(value, T) != ConvStyle.none; + } + else + { + enum bool canConvTo = false; + } +} + + +/// +template convTo(alias value, Dst) +if (canConvTo!(value, Dst)) +{ + alias proxy = getConvBy!value; + alias Val = typeof(value); + enum convToStyle = getConvToStyle!(value, Dst); + static if (convToStyle == ConvStyle.type1) + { + static Dst convTo()(auto ref Val v) + { + return proxy.to(v); + } + static Dst convTo()(auto const ref Val v) + { + return proxy.to(v); + } + } + else static if (convToStyle == ConvStyle.type2) + { + static Dst convTo()(auto ref Val v) + { + return proxy.to!Dst(v); + } + static Dst convTo()(auto const ref Val v) + { + return proxy.to!Dst(v); + } + } + else static if (convToStyle == ConvStyle.type3) + { + static Dst convTo()(auto ref Val v) + { + Dst dst = void; proxy.to(v, dst); return dst; + } + static Dst convTo()(auto const ref Val v) + { + Dst dst = void; proxy.to(v, dst); return dst; + } + } + else static if (convToStyle == ConvStyle.type4) + { + static Dst convTo()(auto ref Val v) + { + return proxy(v); + } + static Dst convTo()(auto const ref Val v) + { + return proxy(v); + } + } + else static if (convToStyle == ConvStyle.type5) + { + static Dst convTo()(auto ref Val v) + { + return proxy!Dst(v); + } + static Dst convTo()(auto const ref Val v) + { + return proxy!Dst(v); + } + } + else static if (convToStyle == ConvStyle.type6) + { + static Dst convTo()(auto ref Val v) + { + Dst dst = void; proxy(v, dst); return dst; + } + static Dst convTo()(auto const ref Val v) + { + Dst dst = void; proxy(v, dst); return dst; + } + } + else static assert(0); +} + +/// +template getConvFromStyle(alias value, Src) +if (hasConvBy!value) +{ + alias proxy = getConvBy!value; + alias Val = typeof(value); + static if (is(typeof(proxy.from(lvalueOf!Src)) : Val)) + { + // Val dst = proxy.from(value); + enum getConvFromStyle = ConvStyle.type1; + } + else static if (is(typeof(proxy.from!Val(lvalueOf!Src)) : Val)) + { + // Val dst = proxy.from!Val(value); + enum getConvFromStyle = ConvStyle.type2; + } + else static if (is(typeof(proxy.from(lvalueOf!Src, lvalueOf!Val))) + && !is(typeof(proxy.from(lvalueOf!Src, rvalueOf!Val)))) + { + // Val dst; proxy.from(value, dst); + enum getConvFromStyle = ConvStyle.type3; + } + else static if (is(typeof(proxy(lvalueOf!Src)) : Val)) + { + // Val dst = proxy(value); + enum getConvFromStyle = ConvStyle.type4; + } + else static if (is(typeof(proxy!Val(lvalueOf!Src)) : Val)) + { + // Val dst = proxy!Val(value); + enum getConvFromStyle = ConvStyle.type5; + } + else static if (is(typeof(proxy(lvalueOf!Src, lvalueOf!Val))) + && !is(typeof(proxy(lvalueOf!Src, rvalueOf!Val)))) + { + // Val dst; proxy(value, dst); + enum getConvFromStyle = ConvStyle.type6; + } + else + { + // no match + enum getConvFromStyle = ConvStyle.none; + } +} + +/// +template canConvFrom(alias value, T) +{ + static if (hasConvBy!value) { + enum bool canConvFrom = getConvFromStyle!(value, T) != ConvStyle.none; + } + else + { + enum bool canConvFrom = false; + } +} + +/// +template convFrom(alias value, Src) +if (canConvFrom!(value, Src)) +{ + alias proxy = getConvBy!value; + alias Val = typeof(value); + enum convFromStyle = getConvFromStyle!(value, Src); + static if (convFromStyle == ConvStyle.type1) + { + static Val convFrom()(auto ref Src v) + { + return proxy.from(v); + } + static Val convFrom()(auto const ref Src v) + { + return proxy.from(v); + } + } + else static if (convFromStyle == ConvStyle.type2) + { + static Val convFrom()(auto ref Src v) + { + return proxy.from!Val(v); + } + static Val convFrom()(auto const ref Src v) + { + return proxy.from!Val(v); + } + } + else static if (convFromStyle == ConvStyle.type3) + { + static Val convFrom()(auto ref Src v) + { + Val dst = void; proxy.from(v, dst); return dst; + } + static Val convFrom()(auto const ref Src v) + { + Val dst = void; proxy.from(v, dst); return dst; + } + } + else static if (convFromStyle == ConvStyle.type4) + { + static Val convFrom()(auto ref Src v) + { + return proxy(v); + } + static Val convFrom()(auto const ref Src v) + { + return proxy(v); + } + } + else static if (convFromStyle == ConvStyle.type5) + { + static Val convFrom()(auto ref Src v) + { + return proxy!Val(v); + } + static Val convFrom()(auto const ref Src v) + { + return proxy!Val(v); + } + } + else static if (convFromStyle == ConvStyle.type6) + { + static Val convFrom()(auto ref Src v) + { + Val dst = void; + proxy(v, dst); + return dst; + } + static Val convFrom()(auto const ref Src v) + { + Val dst = void; + proxy(v, dst); + return dst; + } + } + else static assert(0); +} + +/// +template convertTo(alias value) +{ + alias proxy = getConvBy!value; + alias Val = typeof(value); + static void convertTo(Dst)(auto ref Val src, ref Dst dst) + if (canConvTo!(value, Dst)) + { + enum convToStyle = getConvToStyle!(value, Dst); + static if (convToStyle == ConvStyle.type1) + { + dst = proxy.to(src); + } + else static if (convToStyle == ConvStyle.type2) + { + dst = proxy.to!Dst(src); + } + else static if (convToStyle == ConvStyle.type3) + { + proxy.to(src, dst); + } + else static if (convToStyle == ConvStyle.type4) + { + dst = proxy(src); + } + else static if (convToStyle == ConvStyle.type5) + { + dst = proxy!Dst(src); + } + else static if (convToStyle == ConvStyle.type6) + { + proxy(src, dst); + } + else static assert(0); + } + static void convertTo(Dst)(auto const ref Val src, ref Dst dst) + if (canConvTo!(value, Dst)) + { + enum convToStyle = getConvToStyle!(value, Dst); + static if (convToStyle == ConvStyle.type1) + { + dst = proxy.to(src); + } + else static if (convToStyle == ConvStyle.type2) + { + dst = proxy.to!Dst(src); + } + else static if (convToStyle == ConvStyle.type3) + { + proxy.to(src, dst); + } + else static if (convToStyle == ConvStyle.type4) + { + dst = proxy(src); + } + else static if (convToStyle == ConvStyle.type5) + { + dst = proxy!Dst(src); + } + else static if (convToStyle == ConvStyle.type6) + { + proxy(src, dst); + } + else static assert(0); + } +} + +/// +template convertFrom(alias value) +{ + alias proxy = getConvBy!value; + alias Val = typeof(value); + static void convertFrom(Src)(auto ref Src src, ref Val dst) + if (canConvFrom!(value, Src)) + { + enum convFromStyle = getConvFromStyle!(value, Src); + static if (convFromStyle == ConvStyle.type1) + { + dst = proxy.from(src); + } + else static if (convFromStyle == ConvStyle.type2) + { + dst = proxy.from!Val(src); + } + else static if (convFromStyle == ConvStyle.type3) + { + proxy.from(src, dst); + } + else static if (convFromStyle == ConvStyle.type4) + { + dst = proxy(src); + } + else static if (convFromStyle == ConvStyle.type5) + { + dst = proxy!Val(src); + } + else static if (convFromStyle == ConvStyle.type6) + { + proxy(src, dst); + } + else static assert(0); + } + static void convertFrom(Src)(auto const ref Src src, ref Val dst) + if (canConvFrom!(value, Src)) + { + enum convFromStyle = getConvFromStyle!(value, Src); + static if (convFromStyle == ConvStyle.type1) + { + dst = proxy.from(src); + } + else static if (convFromStyle == ConvStyle.type2) + { + dst = proxy.from!Val(src); + } + else static if (convFromStyle == ConvStyle.type3) + { + proxy.from(src, dst); + } + else static if (convFromStyle == ConvStyle.type4) + { + dst = proxy(src); + } + else static if (convFromStyle == ConvStyle.type5) + { + dst = proxy!Val(src); + } + else static if (convFromStyle == ConvStyle.type6) + { + proxy(src, dst); + } + else static assert(0); + } +} + + +/// +enum isConvertible(alias value, T) = canConvTo!(value, T) && canConvFrom!(value, T); + + +@system unittest +{ + import std.conv; + alias toInt = std.conv.to!int; + struct Proxy1 + { + static string to(ref int value) + { + return text(value) ~ "1"; + } + static int from(string value) + { + return toInt(value) + 111; + } + } + struct Proxy2 + { + static T to(T)(ref int value) + { + return text(value) ~ "2"; + } + static T from(T)(string value) + { + return toInt(value) + 222; + } + } + struct Proxy3 + { + static void to(int value, ref string dst) @safe + { + dst = text(value) ~ "3"; + } + static void from(string value, ref int dst) @safe + { + dst = toInt(value) + 333; + } + } + static string proxy4to(int value) @safe + { + return text(value) ~ "4"; + } + static int proxy4from(string value) @safe + { + return toInt(value) + 444; + } + static T proxy5to(T)(int value) @safe + { + return text(value) ~ "5"; + } + static T proxy5from(T)(string value) + { + return toInt(value) + 555; + } + static void proxy6to(int src, ref string dst) + { + dst = text(src) ~ "6"; + } + static void proxy6from(string src, ref int dst) + { + dst = toInt(src) + 666; + } + static void proxy7to(T)(int src, ref T dst) + { + dst = text(src) ~ "7"; + } + static void proxy7from(T)(string src, ref T dst) + { + dst = toInt(src) + 777; + } + struct Proxy8 + { + static void to(int src, ref int dst) + { + dst = 0; + } + static void to(int src, ref string dst) + { + dst = text(src) ~ "8"; + } + static void from(string src, ref int dst) + { + dst = toInt(src) + 888; + } + static void from(string src, ref string dst) + { + dst = null; + } + } + static void proxy9(int src) + { + + } + static string proxy10(int src) + { + return null; + } + struct A + { + @convBy!Proxy1 int a; + @convBy!Proxy2 int b; + @convBy!Proxy3 int c; + @convBy!proxy4to int d1; + @convBy!proxy5to int e1; + @convBy!proxy6to int f1; + @convBy!proxy7to int g1; + @convBy!(Proxy8.to) int h1; + @convBy!proxy4from int d2; + @convBy!proxy5from int e2; + @convBy!proxy6from int f2; + @convBy!proxy7from int g2; + @convBy!(Proxy8.from) int h2; + @convBy!Proxy8 int h; + @convBy!proxy9 int i; + int j; + @convBy!proxy10 int k; + } + static assert(getConvToStyle!(A.a, string) == ConvStyle.type1); + static assert(getConvToStyle!(A.b, string) == ConvStyle.type2); + static assert(getConvToStyle!(A.c, string) == ConvStyle.type3); + static assert(getConvToStyle!(A.d1, string) == ConvStyle.type4); + static assert(getConvToStyle!(A.e1, string) == ConvStyle.type5); + static assert(getConvToStyle!(A.f1, string) == ConvStyle.type6); + static assert(getConvToStyle!(A.g1, string) == ConvStyle.type6); + static assert(getConvToStyle!(A.h1, string) == ConvStyle.type6); + + static assert(getConvFromStyle!(A.a, string) == ConvStyle.type1); + static assert(getConvFromStyle!(A.b, string) == ConvStyle.type2); + static assert(getConvFromStyle!(A.c, string) == ConvStyle.type3); + static assert(getConvFromStyle!(A.d2, string) == ConvStyle.type4); + static assert(getConvFromStyle!(A.e2, string) == ConvStyle.type5); + static assert(getConvFromStyle!(A.f2, string) == ConvStyle.type6); + static assert(getConvFromStyle!(A.g2, string) == ConvStyle.type6); + static assert(getConvFromStyle!(A.h2, string) == ConvStyle.type6); + + static assert(getConvToStyle!(A.h, string) == ConvStyle.type3); + static assert(getConvFromStyle!(A.h, string) == ConvStyle.type3); + static assert(getConvToStyle!(A.i, string) == ConvStyle.none); + static assert(!__traits(compiles, getConvToStyle!(A.j, string))); + static assert( canConvTo!(A.a, string)); + static assert(!canConvTo!(A.i, string)); + static assert(!canConvTo!(A.j, string)); + static assert( canConvFrom!(A.a, string)); + static assert(!canConvFrom!(A.i, string)); + static assert(!canConvFrom!(A.j, string)); + static assert( isConvertible!(A.a, string)); + static assert(!isConvertible!(A.a, real)); + static assert( canConvTo!(A.k, string)); + static assert(!canConvFrom!(A.k, string)); + static assert(!isConvertible!(A.k, string)); + + A foo; + foo.a = 10; + foo.b = 20; + foo.c = 30; + foo.d1 = 40; + foo.e1 = 50; + foo.f1 = 60; + foo.g1 = 70; + foo.h1 = 80; + + string str_a; + string str_b; + string str_c; + string str_d1; + string str_e1; + string str_f1; + string str_g1; + string str_h1; + + assert(convTo!(foo.a, string)(foo.a) == "101"); + assert(convTo!(foo.b, string)(foo.b) == "202"); + assert(convTo!(foo.c, string)(foo.c) == "303"); + assert(convTo!(foo.d1, string)(foo.d1) == "404"); + assert(convTo!(foo.e1, string)(foo.e1) == "505"); + assert(convTo!(foo.f1, string)(foo.f1) == "606"); + assert(convTo!(foo.g1, string)(foo.g1) == "707"); + assert(convTo!(foo.h1, string)(foo.h1) == "808"); + + convertTo!(foo.a )(foo.a, str_a); + convertTo!(foo.b )(foo.b, str_b); + convertTo!(foo.c )(foo.c, str_c); + convertTo!(foo.d1)(foo.d1, str_d1); + convertTo!(foo.e1)(foo.e1, str_e1); + convertTo!(foo.f1)(foo.f1, str_f1); + convertTo!(foo.g1)(foo.g1, str_g1); + convertTo!(foo.h1)(foo.h1, str_h1); + + assert(str_a == "101"); + assert(str_b == "202"); + assert(str_c == "303"); + assert(str_d1 == "404"); + assert(str_e1 == "505"); + assert(str_f1 == "606"); + assert(str_g1 == "707"); + assert(str_h1 == "808"); + + assert(convFrom!(foo.a, string)("1000") == 1111); + assert(convFrom!(foo.b, string)("1000") == 1222); + assert(convFrom!(foo.c, string)("1000") == 1333); + assert(convFrom!(foo.d2, string)("1000") == 1444); + assert(convFrom!(foo.e2, string)("1000") == 1555); + assert(convFrom!(foo.f2, string)("1000") == 1666); + assert(convFrom!(foo.g2, string)("1000") == 1777); + assert(convFrom!(foo.h2, string)("1000") == 1888); + + convertFrom!(foo.a)( "1000", foo.a ); + convertFrom!(foo.b)( "1000", foo.b ); + convertFrom!(foo.c)( "1000", foo.c ); + convertFrom!(foo.d2)( "1000", foo.d2 ); + convertFrom!(foo.e2)( "1000", foo.e2 ); + convertFrom!(foo.f2)( "1000", foo.f2 ); + convertFrom!(foo.g2)( "1000", foo.g2 ); + convertFrom!(foo.h2)( "1000", foo.h2 ); + + assert(foo.a == 1111); + assert(foo.b == 1222); + assert(foo.c == 1333); + assert(foo.d2 == 1444); + assert(foo.e2 == 1555); + assert(foo.f2 == 1666); + assert(foo.g2 == 1777); + assert(foo.h2 == 1888); +} + +@safe unittest +{ + import std.datetime, std.json; + /// + static struct AttrConverter + { + /// + JSONValue function(in SysTime v) to; + /// + SysTime function(in JSONValue v) from; + } + + AttrConverter converter(SysTime function(in JSONValue) from, JSONValue function(in SysTime) to) + { + return AttrConverter(to, from); + } + static struct A + { + @converter(jv=>SysTime.fromISOExtString(jv.str), v =>JSONValue(v.toISOExtString())) + SysTime time; + } + static assert(hasConvBy!(A.time)); + static assert(getConvToStyle!(A.time, JSONValue) == ConvStyle.type1); + static assert(getConvFromStyle!(A.time, JSONValue) == ConvStyle.type1); +} diff --git a/src/bsky/_internal/httpc.d b/src/bsky/_internal/httpc.d new file mode 100644 index 0000000..4c7cc2c --- /dev/null +++ b/src/bsky/_internal/httpc.d @@ -0,0 +1,618 @@ +/******************************************************************************* + * HTTP client default implimentation + */ +module bsky._internal.httpc; + +import std.json; + +/******************************************************************************* + * Dummy http client for test + */ +interface HttpClientBase +{ + /*************************************************************************** + * + */ + JSONValue get(string url, string[string] param, string delegate() @safe getBearer) @safe; + + /*************************************************************************** + * + */ + JSONValue post(string url, immutable(ubyte)[] data, string mimeType, string delegate() @safe getBearer) @safe; + + /*************************************************************************** + * + */ + uint getLastStatusCode() const @safe; + + /*************************************************************************** + * + */ + string getLastStatusReason() const @safe; +} + +/******************************************************************************* + * Dummy http client for test + */ +class DummyHttpClient(): HttpClientBase +{ + /*************************************************************************** + * + */ + struct HttpRequest + { + /// + string url; + /// + string query; + /// + string mimeType; + /// + string method; + /// + immutable(ubyte)[] bodyBinary; + /// + string toString()() const @safe + { + import std.format, std.array; + auto app = appender!string(); + app.formattedWrite!"url=%s\n"(url); + app.formattedWrite!"query=%s\n"(query); + app.formattedWrite!"mimeType=%s\n"(mimeType); + switch (mimeType) + { + case "text/plain": + case "text/html": + case "application/x-www-form-urlencoded": + app.formattedWrite!"body=%s"(cast(string)bodyBinary); + break; + case "application/json": + auto jv = parseJSON(cast(string)bodyBinary); + app.formattedWrite!"body=%s"(jv.toPrettyString(JSONOptions.doNotEscapeSlashes)); + break; + default: + app.formattedWrite!"body=%-(%s%)"(bodyBinary); + } + return app.data; + } + } + /*************************************************************************** + * + */ + struct HttpResult + { + /// + HttpRequest request; + /// + uint code; + /// + string reason; + /// + string mimeType; + /// + JSONValue response; + /// + string toString()() const @safe + { + import std.format, std.array; + auto app = appender!string(); + app.formattedWrite!"status=%d %s\n"(code, reason); + app.formattedWrite!"mimeType=%s\n"(mimeType); + switch (mimeType) + { + case "text/plain": + case "text/html": + case "application/json": + case "application/x-www-form-urlencoded": + app.formattedWrite!"body=%s"(response.toPrettyString(JSONOptions.doNotEscapeSlashes)); + break; + default: + import std.base64; + app.formattedWrite!"bodyBinary=%-(%s%)"(Base64.decode(response.str)); + } + return app.data; + } + } +private: + HttpResult[] _results; + HttpResult _lastResult; + size_t _resultPos; +public: + /*************************************************************************** + * + */ + void addResult(HttpResult[] result) @safe + { + _results ~= result; + } + /// ditto + void addResult(HttpResult result) @safe + { + addResult([result]); + } + /// ditto + void addResult(uint statusCode = 200, string reason = "Success", JSONValue jv = JSONValue.init) @trusted + { + if (jv.type == JSONType.array) + { + foreach (v; jv.array) + addResult(statusCode, reason, v); + } + else + { + addResult(HttpResult(HttpRequest.init, statusCode, reason, "application/json", jv)); + } + } + /// ditto + void addResult(uint statusCode = 200, string reason = "Success", string mimeType, immutable(ubyte)[] dat) @safe + { + import std.base64; + addResult(HttpResult(HttpRequest.init, statusCode, reason, mimeType, JSONValue(Base64.encode(dat)))); + } + /// ditto + void addResult(JSONValue jv) @trusted + { + if (jv.type == JSONType.array) + { + foreach (v; jv.array) + addResult(v); + return; + } + if (jv.type == JSONType.object && "body" in jv) + { + import bsky._internal.json; + addResult(HttpResult( + HttpRequest.init, + jv.getValue("code", uint(200)), + jv.getValue("reason", "OK"), + jv.getValue("mimeType", "application/json"), + jv.getValue("body", JSONValue.init))); + } + else + { + addResult(200, "OK", jv); + } + } + + /*************************************************************************** + * + */ + void clearResult() @safe + { + _results = null; + _lastResult = HttpResult.init; + _resultPos = 0; + } + + /*************************************************************************** + * + */ + this() @safe + { + _results = null; + _lastResult = HttpResult.init; + } + + /*************************************************************************** + * + */ + const(HttpResult)[] results() const @safe + { + return _results[0.._resultPos]; + } + + /*************************************************************************** + * + */ + override JSONValue get(string url, string[string] param, string delegate() @safe getBearer) @trusted + { + import std.uri, std.string; + if (_results.length == 0) + return JSONValue.init; + + string[] queries; + foreach (k, v; param) + queries ~= encodeComponent(k) ~ "=" ~ encodeComponent(v); + + if (getBearer) + getBearer(); + _results[_resultPos].request.url = url; + _results[_resultPos].request.query = queries.join("&"); + _results[_resultPos].request.method = "GET"; + _lastResult = _results[_resultPos]; + _resultPos++; + return _lastResult.response; + } + + /*************************************************************************** + * + */ + override JSONValue post(string url, immutable(ubyte)[] data, string mimeType, + string delegate() @safe getBearer) @trusted + { + if (_results.length == 0) + return JSONValue.init; + if (getBearer) + getBearer(); + _results[_resultPos].request.url = url; + _results[_resultPos].request.mimeType = mimeType; + _results[_resultPos].request.bodyBinary = data; + _results[_resultPos].request.method = "POST"; + _lastResult = _results[_resultPos]; + _resultPos++; + return _lastResult.response; + } + + /*************************************************************************** + * + */ + override uint getLastStatusCode() const @safe + { + return _results.length == 0 ? 0 : _results[_resultPos-1].code; + } + + /*************************************************************************** + * + */ + override string getLastStatusReason() const @safe + { + return _results.length == 0 ? "" : _results[_resultPos-1].reason; + } +} + +/******************************************************************************* + * Curl HTTP Client + * + * Attension: + * Instantiation creates a dependency on Curl; note the Curl license. + */ +class CurlHttpClient(): HttpClientBase +{ +private: + import std.net.curl; + import std.array; + import std.json; + import std.uri; + HTTP _client; + Appender!(ubyte[]) _contentsBuffer; + HTTP.StatusLine _lastStatus; + + void delegate(string url, string query) _onGetReq; + void delegate(string url, string msg, string exmsg) _onGetErr; + void delegate(string url, uint code, string reason, string[string] headers, string mimeType, + const(ubyte)[] bodyBinary) _onGetRes; + void delegate(string url, string mimeType, const(ubyte)[] bodyBinary) _onPostReq; + void delegate(string url, string msg, string exmsg) _onPostErr; + void delegate(string url, uint code, string reason, string[string] headers, string mimeType, + const(ubyte)[] bodyBinary) _onPostRes; + + pragma(inline) void onGetReq(string url, string query) + { + debug if (_onGetReq) + _onGetReq(url, query); + } + + pragma(inline) void onGetErr(string url, string msg, string exmsg) + { + debug if (_onGetErr) + _onGetErr(url, msg, exmsg); + } + + pragma(inline) void onGetRes(string url, uint code, string reason, string[string] headers, + const(ubyte)[] bodyBinary) + { + debug if (_onGetRes) + _onGetRes(url, code, reason, headers, + "content-type" in headers ? headers["content-type"] : null, + bodyBinary); + } + + pragma(inline) void onPostReq(string url, string mimeType, const(ubyte)[] bodyBinary) + { + debug if (_onPostReq) + _onPostReq(url, mimeType, bodyBinary); + } + + pragma(inline) void onPostErr(string url, string msg, string exmsg) + { + debug if (_onPostErr) + _onPostErr(url, msg, exmsg); + } + + pragma(inline) void onPostRes(string url, uint code, string reason, string[string] headers, + const(ubyte)[] bodyBinary) + { + debug if (_onPostRes) + _onPostRes(url, code, reason, headers, + "content-type" in headers ? headers["content-type"] : null, + bodyBinary); + } + + import std.logger; + import std.stdio; + + pragma(inline) string bodyToString(string mimeType, const(ubyte)[] bodyBinary) + { + import std.string; + import std.format; + switch (mimeType.split(";")[0].toLower) + { + case "text/plain": + case "text/html": + case "application/json": + case "application/x-www-form-urlencoded": + return cast(string)bodyBinary.idup; + default: + return format!"%-(%02X%)"(bodyBinary); + } + } + + pragma(inline) JSONValue bodyToJson(string mimeType, const(ubyte)[] bodyBinary) + { + import std.string; + import std.format; + switch (mimeType.split(";")[0].toLower) + { + case "text/plain": + case "text/html": + case "application/x-www-form-urlencoded": + return JSONValue(cast(string)bodyBinary.idup); + break; + case "application/json": + return parseJSON(cast(const char[])bodyBinary.idup); + break; + default: + import std.base64; + import std.format; + return JSONValue(Base64.encode(bodyBinary)); + } + } + +public: + /*************************************************************************** + * + */ + this() @trusted + { + _client = HTTP(); + _client.onReceive = (ubyte[] dat){ + _contentsBuffer.put(dat); + return dat.length; + }; + } + /// ditto + this(Logger logger) @trusted + { + this(); + setLogger(logger); + } + + /*************************************************************************** + * + */ + final void setLogger(Logger logger) @trusted + { + auto oldGetReq = _onGetReq; + auto oldGetErr = _onGetErr; + auto oldGetRes = _onGetRes; + auto oldPostReq = _onPostReq; + auto oldPostErr = _onPostErr; + auto oldPostRes = _onPostRes; + + _onGetReq = (string url, string query) + { + import std.string; + if (oldGetReq) + oldGetReq(url, query); + logger.tracef("HTTP GET Request:\nurl=%s\nquery=%s", url, query.chompPrefix("?")); + }; + _onGetErr = (string url, string msg, string exmsg) + { + if (oldGetErr) + oldGetErr(url, msg, exmsg); + logger.warningf("HTTP GET Error: %s", msg); + }; + _onGetRes = (string url, uint code, string reason, string[string] headers, string mimeType, + const(ubyte)[] bodyBinary) + { + if (oldGetRes) + oldGetRes(url, code, reason, headers, mimeType, bodyBinary); + logger.infof("HTTP GET: url=%s status=%d %s length=%d", url, code, reason, bodyBinary.length); + logger.tracef("HTTP GET Response:\nheaders=%s\nbody=%s", headers, bodyToString(mimeType, bodyBinary)); + }; + _onPostReq = (string url, string mimeType, const(ubyte)[] bodyBinary) + { + if (oldPostReq) + oldPostReq(url, mimeType, bodyBinary); + logger.tracef("HTTP POST Request:\nurl=%s\nparameters=%s", url, bodyToString(mimeType, bodyBinary)); + }; + _onPostErr = (string url, string msg, string exmsg) + { + if (oldPostErr) + oldPostErr(url, msg, exmsg); + logger.warningf("HTTP POST Error: %s", msg); + }; + _onPostRes = (string url, uint code, string reason, string[string] headers, string mimeType, + const(ubyte)[] bodyBinary) + { + if (oldPostRes) + oldPostRes(url, code, reason, headers, mimeType, bodyBinary); + logger.infof("HTTP POST: url=%s status=%d %s length=%d", url, code, reason, bodyBinary.length); + logger.tracef("HTTP POST Response:\nheaders=%s\nmimeType=%s\nbody=%s", + headers, mimeType, bodyToString(mimeType, bodyBinary)); + }; + } + + /*************************************************************************** + * + */ + final void setResponseJsonRecorder(string file) @trusted + { + setResponseJsonRecorder(File(file, "w+b")); + } + /// ditto + final void setResponseJsonRecorder(File recorder) @trusted + { + import std.algorithm; + auto oldGetRes = _onGetRes; + auto oldPostRes = _onPostRes; + static class Recoder + { + File file; + this(File f) @trusted + { + file = f; + file.seek(0, SEEK_END); + if (file.tell() == 0) + { + file.rawWrite("[]\n"); + file.flush(); + file.seek(-2, SEEK_END); + file.flush(); + } + else + { + file.seek(-3, SEEK_END); + } + } + ~this() @trusted + { + file.close(); + } + void add(JSONValue jv) + { + import std.format; + file.lock(LockType.readWrite); + scope (exit) + file.unlock(); + char[1] buf; + file.rawRead(buf[]); + auto writer = file.lockingBinaryWriter(); + if (buf[0] != '\n') + { + file.seek(-2, SEEK_END); + writer.put("\n"); + } + else + { + file.seek(-3, SEEK_END); + writer.put(",\n"); + } + writer.put(jv.toString(JSONOptions.doNotEscapeSlashes)); + writer.put("\n]\n"); + file.seek(-3, SEEK_END); + } + } + auto rec = new Recoder(recorder); + + _onGetRes = (string url, uint code, string reason, string[string] headers, string mimeType, + const(ubyte)[] bodyBinary) + { + import bsky._internal.json; + if (oldGetRes) + oldGetRes(url, code, reason, headers, mimeType, bodyBinary); + JSONValue jv; + jv.setValue("code", code); + jv.setValue("reason", reason); + jv.setValue("mimeType", mimeType); + jv.setValue("body", bodyToJson(mimeType, bodyBinary)); + rec.add(jv); + }; + _onPostRes = (string url, uint code, string reason, string[string] headers, string mimeType, + const(ubyte)[] bodyBinary) + { + import bsky._internal.json; + if (oldPostRes) + oldPostRes(url, code, reason, headers, mimeType, bodyBinary); + JSONValue jv; + jv.setValue("code", code); + jv.setValue("reason", reason); + jv.setValue("mimeType", mimeType); + jv.setValue("body", bodyToJson(mimeType, bodyBinary)); + rec.add(jv); + }; + } + + /*************************************************************************** + * + */ + override JSONValue get(string url, string[string] param, string delegate() @safe getBearer) @trusted + { + string[] queries; + import std.uri; + import std.string; + foreach (k, v; param) + queries ~= encodeComponent(k) ~ "=" ~ encodeComponent(v); + _client.clearRequestHeaders(); + _contentsBuffer.shrinkTo(0); + auto query = queries.length > 0 ? "?" ~ queries.join("&") : null; + _client.url = url ~ query; + _client.postData = null; + _client.method = HTTP.Method.get; + _client.addRequestHeader("Accept", "application/json"); + if (getBearer) + { + if (auto bearer = getBearer()) + _client.addRequestHeader("Authorization", "Bearer " ~ bearer); + } + onGetReq(url, query.chompPrefix("?")); + try _client.perform(); + catch (Exception e) + { + onGetErr(url, e.msg, e.toString); + throw e; + } + _lastStatus = _client.statusLine; + onGetRes(url, _lastStatus.code, _lastStatus.reason, _client.responseHeaders, _contentsBuffer.data); + return parseJSON(cast(char[])_contentsBuffer.data); + } + + /*************************************************************************** + * + */ + override JSONValue post(string url, immutable(ubyte)[] data, string mimeType, + string delegate() @safe getBearer) @trusted + { + _client.clearRequestHeaders(); + _contentsBuffer.shrinkTo(0); + _client.url = url; + if (data.length == 0) + _client.postData = null; + else + _client.setPostData(data, mimeType); + _client.method = HTTP.Method.post; + _client.addRequestHeader("Accept", "application/json"); + if (getBearer) + { + if (auto bearer = getBearer()) + _client.addRequestHeader("Authorization", "Bearer " ~ bearer); + } + onPostReq(url, mimeType, data); + try _client.perform(); + catch (Exception e) + { + onPostErr(url, e.msg, e.toString); + throw e; + } + _lastStatus = _client.statusLine; + onPostRes(url, _lastStatus.code, _lastStatus.reason, _client.responseHeaders, _contentsBuffer.data); + return parseJSON(cast(char[])_contentsBuffer.data); + } + + /*************************************************************************** + * + */ + override uint getLastStatusCode() const @safe + { + return _lastStatus.code; + } + + /*************************************************************************** + * + */ + override string getLastStatusReason() const @safe + { + return _lastStatus.reason; + } +} + diff --git a/src/bsky/_internal/json.d b/src/bsky/_internal/json.d new file mode 100644 index 0000000..abdb3e4 --- /dev/null +++ b/src/bsky/_internal/json.d @@ -0,0 +1,2062 @@ +/******************************************************************************* + * Internal JSON helpers + */ +module bsky._internal.json; + +package(bsky): + +version (Have_voile) { + public import voile.json; +} +else: + + + +import std.json, std.traits, std.meta, std.conv, std.array; +import std.typecons: Rebindable; +import std.sumtype: SumType, isSumType; +import std.typecons: Tuple; +import bsky._internal.attr; +import bsky._internal.misc; + + +/******************************************************************************* + * JSONValueデータを得る + */ +JSONValue json(T)(auto const ref T[] x) @property +if (isSomeString!(T[])) +{ + return JSONValue(to!string(x)); +} +/// +@system unittest +{ + dstring dstr = "あいうえお"; + wstring wstr = "かきくけこ"; + string str = "さしすせそ"; + auto dstrjson = dstr.json; + auto wstrjson = wstr.json; + auto strjson = str.json; + assert(dstrjson.type == JSONType.string); + assert(wstrjson.type == JSONType.string); + assert(strjson.type == JSONType.string); + assert(dstrjson.str == "あいうえお"); + assert(wstrjson.str == "かきくけこ"); + assert(strjson.str == "さしすせそ"); +} + + +/// ditto +JSONValue json(T)(auto const ref T x) @property +if ((isIntegral!T && !is(T == enum)) + || isFloatingPoint!T + || is(Unqual!T == bool)) +{ + return JSONValue(x); +} +/// +@system unittest +{ + bool bt = true; + bool bf; + auto btjson = bt.json; + auto bfjson = bf.json; + assert(btjson.type == JSONType.true_); + assert(bfjson.type == JSONType.false_); +} +/// +@system unittest +{ + import std.typetuple; + foreach (T; TypeTuple!(ubyte, byte, ushort, short, uint, int, ulong, long)) + { + T x = 123; + auto xjson = x.json; + static if (isUnsigned!T) + { + assert(xjson.type == JSONType.uinteger); + assert(xjson.uinteger == 123); + } + else + { + assert(xjson.type == JSONType.integer); + assert(xjson.integer == 123); + } + } + foreach (T; TypeTuple!(float, double, real)) + { + T x = 0.125; + auto xjson = x.json; + assert(xjson.type == JSONType.float_); + assert(xjson.floating == 0.125); + } +} + +/// ditto +JSONValue json(T)(auto const ref T x) @property +if (is(T == enum)) +{ + return JSONValue(x.to!string()); +} +/// +@system unittest +{ + enum EnumType + { + a, b, c + } + auto a = EnumType.a; + auto ajson = a.json; + assert(ajson.type == JSONType.string); + assert(ajson.str == "a"); +} + +/// ditto +JSONValue json(T)(auto const ref T[] ary) @property +if (!isSomeString!(T[]) && isArray!(T[])) +{ + auto app = appender!(JSONValue[])(); + JSONValue v; + foreach (x; ary) + { + app.put(x.json); + } + v.array = app.data; + return v; +} +/// +@system unittest +{ + auto ary = [1,2,3]; + auto aryjson = ary.json; + assert(aryjson.type == JSONType.array); + assert(aryjson[0].type == JSONType.integer); + assert(aryjson[1].type == JSONType.integer); + assert(aryjson[2].type == JSONType.integer); + assert(aryjson[0].integer == 1); + assert(aryjson[1].integer == 2); + assert(aryjson[2].integer == 3); +} +/// +@system unittest +{ + auto ary = ["ab","cd","ef"]; + auto aryjson = ary.json; + assert(aryjson.type == JSONType.array); + assert(aryjson[0].type == JSONType.string); + assert(aryjson[1].type == JSONType.string); + assert(aryjson[2].type == JSONType.string); + assert(aryjson[0].str == "ab"); + assert(aryjson[1].str == "cd"); + assert(aryjson[2].str == "ef"); +} + +/// +@system unittest +{ + struct A + { + int a = 123; + JSONValue json() const @property + { + return JSONValue(["a": JSONValue(a)]); + } + void json(JSONValue v) @property + { + a = v.getValue("a", 123); + } + } + auto ary = [A(1),A(2),A(3)]; + auto aryjson = ary.json; + assert(aryjson.type == JSONType.array); + assert(aryjson[0].type == JSONType.object); + assert(aryjson[1].type == JSONType.object); + assert(aryjson[2].type == JSONType.object); + assert(aryjson[0]["a"].type == JSONType.integer); + assert(aryjson[1]["a"].type == JSONType.integer); + assert(aryjson[2]["a"].type == JSONType.integer); + assert(aryjson[0]["a"].integer == 1); + assert(aryjson[1]["a"].integer == 2); + assert(aryjson[2]["a"].integer == 3); +} + +/// ditto +JSONValue json(Value, Key)(auto const ref Value[Key] aa) @property +if (isSomeString!Key && is(typeof({auto v = Value.init.json;}))) +{ + auto ret = JSONValue((JSONValue[string]).init); + static if (is(Key: const string)) + { + foreach (key, val; aa) + ret.object[key] = val.json; + } + else + { + foreach (key, val; aa) + v.object[key.to!string] = val.json; + } + return ret; +} +/// +@system unittest +{ + int[string] val; + val["xxx"] = 10; + auto jv = val.json; + assert(jv["xxx"].integer == 10); +} + + +/// ditto +JSONValue json(JV)(auto const ref JV v) @property + if (is(JV: const JSONValue)) +{ + return cast(JSONValue)v; +} + + +private void _setValue(T)(ref JSONValue v, ref string name, ref T val) + if (is(typeof(val.json))) +{ + if (v.type != JSONType.object || !v.object) + { + v = [name: val.json]; + } + else + { + auto x = v.object; + x[name] = val.json; + v = x; + } +} + + +/******************************************************************************* + * JSONValueデータの操作 + */ +void setValue(T)(ref JSONValue v, string name, T val) pure nothrow @trusted +{ + try + { + assumePure!(_setValue!T)(v, name, val); + } + catch (Throwable) + { + } +} + +/// +@system unittest +{ + JSONValue json; + json.setValue("dat", 123); + assert(json.type == JSONType.object); + assert("dat" in json.object); + assert(json["dat"].type == JSONType.integer); + assert(json["dat"].integer == 123); +} + + + +/// +@system unittest +{ + enum Type + { + foo, bar, + } + JSONValue json; + json.setValue("type", Type.foo); + assert(json.type == JSONType.object); + assert("type" in json.object); + assert(json["type"].type == JSONType.string); + assert(json["type"].str == "foo"); +} + +/// +@system unittest +{ + struct A + { + int a = 123; + JSONValue json() const @property + { + JSONValue v; + v.setValue("a", a); + return v; + } + } + A a; + a.a = 321; + JSONValue json; + json.setValue("a", a); +} + + +/// +@system unittest +{ + JSONValue json; + json.setValue("test", "あいうえお"); + static assert(is(typeof(json.getValue("test", "かきくけこ"d)) == dstring)); + static assert(is(typeof(json.getValue("test", "かきくけこ"w)) == wstring)); + static assert(is(typeof(json.getValue("test", "かきくけこ"c)) == string)); + assert(json.getValue("test", "かきくけこ"d) == "あいうえお"d); + assert(json.getValue("test", "かきくけこ"w) == "あいうえお"w); + assert(json.getValue("test", "かきくけこ"c) == "あいうえお"c); + assert(json.getValue("hoge", "かきくけこ"c) == "かきくけこ"c); +} + + +/// +bool fromJson(T)(in JSONValue src, ref T dst) +if (isSomeString!T) +{ + if (src.type == JSONType.string) + { + static if (is(T: string)) + { + dst = src.str; + } + else + { + dst = to!T(src.str); + } + return true; + } + return false; +} +/// +@system unittest +{ + auto jv = JSONValue("xxx"); + string dst; + auto res = fromJson(jv, dst); + assert(res); + assert(dst == "xxx"); +} + + +/// ditto +bool fromJson(T)(in JSONValue src, ref T dst) + if (isIntegral!T && !is(T == enum)) +{ + if (src.type == JSONType.integer) + { + dst = cast(T)src.integer; + return true; + } + else if (src.type == JSONType.uinteger) + { + dst = cast(T)src.uinteger; + return true; + } + return false; +} +/// +@system unittest +{ + auto jv = JSONValue(10); + int dst; + auto res = fromJson(jv, dst); + assert(res); + assert(dst == 10); +} + +/// ditto +bool fromJson(T)(in JSONValue src, ref T dst) + if (isFloatingPoint!T) +{ + switch (src.type) + { + case JSONType.float_: + dst = cast(T)src.floating; + return true; + case JSONType.integer: + dst = cast(T)src.integer; + return true; + case JSONType.uinteger: + dst = cast(T)src.uinteger; + return true; + default: + return false; + } +} +/// +@system unittest +{ + import std.math: isClose; + auto jv = JSONValue(10.0); + double dst; + auto res = fromJson(jv, dst); + assert(res); + assert(dst.isClose(10.0)); +} + +/// ditto +bool fromJson(T)(in JSONValue src, ref T dst) +if (is(T == struct) + && !is(Unqual!T: JSONValue)) +{ + static if (__traits(compiles, { dst.json = src; })) + { + dst.json = src; + } + else static foreach (memberIdx, member; T.tupleof) + {{ + static if (!hasIgnore!member) + { + static if (hasName!member) + { + enum fieldName = getName!member; + } + else + { + enum fieldName = __traits(identifier, member); + } + static if (hasConvBy!member) + { + static if (hasEssential!member) + { + dst.tupleof[memberIdx] = convFrom!(member, JSONValue)(src[fieldName]); + } + else + { + if (auto pJsonValue = fieldName in src) + { + try + dst.tupleof[memberIdx] = convFrom!(member, JSONValue)(*pJsonValue); + catch (Exception e) + { + /* ignore */ + } + } + } + } + else static if (__traits(compiles, fromJson(src[fieldName], dst.tupleof[memberIdx]))) + { + static if (hasEssential!member) + { + if (!fromJson(src[fieldName], dst.tupleof[memberIdx])) + return false; + } + else + { + import std.algorithm: move; + auto tmp = src.getValue(fieldName, dst.tupleof[memberIdx]); + move(tmp, dst.tupleof[memberIdx]); + } + } + else + { + return false; + } + } + }} + return true; +} +/// +@system unittest +{ + auto jv = JSONValue(["x": 10, "y": 20]); + static struct Point{ int x, y; } + Point pt; + auto res = fromJson(jv, pt); + assert(res); + assert(pt.x == 10); + assert(pt.y == 20); +} + +/// ditto +bool fromJson(T)(in JSONValue src, ref T dst) +if (is(T == class)) +{ + if (src.type == JSONType.object) + { + if (!dst) + dst = new T; + dst.json = src; + return true; + } + return false; +} + +/// ditto +bool fromJson(T)(in JSONValue src, ref T dst) + if (is(T == enum)) +{ + if (src.type == JSONType.string) + { + dst = to!T(src.str); + return true; + } + return false; +} + +/// ditto +bool fromJson(T)(in JSONValue src, ref T dst) + if (is(T == bool)) +{ + if (src.type == JSONType.true_) + { + dst = true; + return true; + } + else if (src.type == JSONType.false_) + { + dst = false; + return true; + } + return false; +} + +/// ditto +bool fromJson(T)(in JSONValue src, ref T dst) + if (!isSomeString!(T) && isDynamicArray!(T)) +{ + alias E = ForeachType!T; + if (src.type == JSONType.array) + { + dst = (dst.length >= src.array.length) ? dst[0..src.array.length]: new E[src.array.length]; + foreach (ref i, ref e; src.array) + { + if (!fromJson(e, dst[i])) + return false; + } + return true; + } + return false; +} + +/// ditto +bool fromJson(Value, Key)(in JSONValue src, ref Value[Key] dst) + if (isSomeString!Key && is(typeof({ JSONValue val; cast(void)fromJson(val, dst[Key.init]); }))) +{ + if (src.type == JSONType.object) + { + foreach (key, ref val; src.object) + { + static if (is(Key: const string)) + { + Value tmp; + if (!fromJson(val, tmp)) + return false; + dst[key] = tmp; + } + else + { + Value tmp; + if (!fromJson(val, tmp)) + return false; + dst[to!Key(key)] = tmp; + } + } + return true; + } + return false; +} + +/// ditto +bool fromJson(T)(in JSONValue src, ref T dst) + if (is(Unqual!T == JSONValue)) +{ + dst = src; + return true; +} + + +private T _getValue(T)(in JSONValue v, string name, lazy scope T defaultVal = T.init) +{ + if (auto x = name in v.object) + { + static if (is(T == struct) + && !is(Unqual!T: JSONValue) + && __traits(compiles, lvalueOf!T.json(rvalueOf!JSONValue))) + { + auto ret = T.init; + ret.json = *x; + return ret; + } + else static if (is(T == class)) + { + auto ret = new T; + ret.json = *x; + return ret; + } + else static if (!isSomeString!(T) && isDynamicArray!(T)) + { + Unqual!(ForeachType!T)[] tmp; + return fromJson(*x, tmp) ? cast(T)tmp : defaultVal; + } + else + { + T tmp; + return fromJson(*x, tmp) ? tmp : defaultVal; + } + } + return defaultVal; +} + +/// +T getValue(T)(in JSONValue v, string name, lazy scope T defaultVal = T.init) nothrow pure @trusted +{ + import bsky._internal.misc: assumePure; + try + { + return assumePure(&_getValue!(Unqual!T))(v, name, defaultVal); + } + catch(Throwable) + { + } + try + { + return defaultVal; + } + catch (Throwable) + { + } + return T.init; +} + + +/// +@system unittest +{ + JSONValue json; + json.setValue("test", 123); + static assert(is(typeof(json.getValue("test", 654UL)) == ulong)); + static assert(is(typeof(json.getValue("test", 654U)) == uint)); + static assert(is(typeof(json.getValue("test", cast(byte)12)) == byte)); + assert(json.getValue("test", 654UL) == 123); + assert(json.getValue("test", 654U) == 123); + assert(json.getValue("test", cast(byte)12) == 123); + assert(json.getValue("hoge", cast(byte)12) == 12); +} + + +/// +@system unittest +{ + JSONValue json; + json.setValue("test", 0.125); + static assert(is(typeof(json.getValue("test", 0.25f)) == float)); + static assert(is(typeof(json.getValue("test", 0.25)) == double)); + static assert(is(typeof(json.getValue("test", 0.25L)) == real)); + assert(json.getValue("test", 0.25f) == 0.125f); + assert(json.getValue("test", 0.25) == 0.125); + assert(json.getValue("test", 0.25L) == 0.125L); + assert(json.getValue("hoge", 0.25L) == 0.25L); +} + + +/// +@system unittest +{ + struct A + { + int a = 123; + JSONValue json() const @property + { + JSONValue v; + v.setValue("a", a); + return v; + } + void json(JSONValue v) @property + { + a = v.getValue("a", 123); + } + } + A a; + a.a = 321; + JSONValue json; + json.setValue("a", a); + static assert(is(typeof(json.getValue("a", A(456))) == A)); + assert(json.getValue("a", A(456)).a == 321); + assert(json.getValue("b", A(456)).a == 456); +} + + +/// +@system unittest +{ + static class A + { + int a = 123; + JSONValue json() const @property + { + JSONValue v; + v.setValue("a", a); + return v; + } + void json(JSONValue v) @property + { + a = v.getValue("a", 123); + } + } + auto a = new A; + a.a = 321; + JSONValue json; + json.setValue("a", a); + static assert(is(typeof(json.getValue!A("a")) == A)); + assert(json.getValue!A("a").a == 321); + assert(json.getValue!A("b") is null); +} + + +/// +@system unittest +{ + enum Type + { + foo, bar + } + JSONValue json; + json.setValue("a", Type.bar); + static assert(is(typeof(json.getValue("a", Type.foo)) == Type)); + assert(json.getValue("a", Type.foo) == Type.bar); + assert(json.getValue("b", Type.foo) == Type.foo); +} + + +/// +@system unittest +{ + JSONValue json; + json.setValue("t", true); + json.setValue("f", false); + static assert(is(typeof(json.getValue("t", true)) == bool)); + static assert(is(typeof(json.getValue("f", false)) == bool)); + assert(json.getValue("t", true) == true); + assert(json.getValue("f", true) == false); + assert(json.getValue("t", false) == true); + assert(json.getValue("f", false) == false); + assert(json.getValue("x", true) == true); + assert(json.getValue("x", false) == false); +} + + +/// +@system unittest +{ + JSONValue json; + json.setValue("test1", [1,2,3]); + auto x = json.getValue("test1", [2,3,4]); + + static assert(is(typeof(json.getValue("test1", [2,3,4])) == int[])); + assert(json.getValue("test1", [2,3,4]) == [1,2,3]); + assert(json.getValue("test1x", [2,3,4]) == [2,3,4]); + + json.setValue("test2", [0.5,0.25,0.125]); + static assert(is(typeof(json.getValue("test2", [0.5,0.5,0.5])) == double[])); + assert(json.getValue("test2", [0.5,0.5,0.5]) == [0.5,0.25,0.125]); + assert(json.getValue("test2x", [0.5,0.5,0.5]) == [0.5,0.5,0.5]); + + json.setValue("test3", ["ab","cd","ef"]); + static assert(is(typeof(json.getValue("test3", ["あい","うえ","おか"])) == string[])); + assert(json.getValue("test3", ["あい","うえ","おか"]) == ["ab","cd","ef"]); + assert(json.getValue("test3x", ["あい","うえ","おか"]) == ["あい","うえ","おか"]); + + json.setValue("test4", [true, false, true]); + static assert(is(typeof(json.getValue("test4", [false, true, true])) == bool[])); + assert(json.getValue("test4", [false, true, true]) == [true, false, true]); + assert(json.getValue("test4x", [false, true, true]) == [false, true, true]); +} + + +/// +@system unittest +{ + static struct A + { + int a = 123; + JSONValue json() const @property + { + JSONValue v; + v.setValue("a", a); + return v; + } + void json(JSONValue v) @property + { + assert(v.type == JSONType.object); + a = v.getValue("a", 123); + } + } + auto a = [A(1),A(2),A(3)]; + JSONValue json; + json.setValue("a", a); + static assert(is(typeof(json.getValue("a", [A(4),A(5),A(6)])) == A[])); + assert(json.getValue("a", [A(4),A(5),A(6)]) == [A(1),A(2),A(3)]); + assert(json.getValue("b", [A(4),A(5),A(6)]) == [A(4),A(5),A(6)]); +} + + + +/// +alias JSONValueArray = Rebindable!(const(JSONValue[])); +/// +alias JSONValueObject = Rebindable!(const(JSONValue[string])); + +/******************************************************************************* + * + */ +bool getArray(JSONValue json, string name, ref JSONValueArray ary) +{ + if (json.type != JSONType.object) + return false; + if (auto p = name in json) + { + if (p.type != JSONType.array) + return false; + ary = p.array; + return true; + } + return false; +} + +/******************************************************************************* + * + */ +bool getObject(JSONValue json, string name, ref JSONValueObject object) +{ + if (json.type != JSONType.object) + return false; + if (auto p = name in json) + { + if (p.type != JSONType.object) + return false; + object = p.object; + return true; + } + return false; +} + +/// +struct AttrConverter(T) +{ + /// + T function(in JSONValue v) from; + /// + JSONValue function(in T v) to; +} + +/******************************************************************************* + * Attribute converting method + */ +AttrConverter!T converter(T)(T function(in JSONValue) from, JSONValue function(in T) to) +{ + return AttrConverter!T(from, to); +} + + + + +private enum isJSONizableRaw(T) = is(typeof({ + T val; + JSONValue jv= val.json; + cast(void)fromJson(jv, val); +})); + +// +private template uniqueKey(ST, string name, uint num = 0) +if (isSumType!ST) +{ + enum string candidate = num == 0 ? name : text(name, num); + + static if (anySatisfy!(ApplyRight!(hasMember, candidate), ST.Types)) + { + enum string uniqueKey = uniqueKey!(ST, name, num+1); + } + else + { + enum string uniqueKey = candidate; + } +} + + +private bool _isNotEq(string[] rhs, string[] lhs) @safe +{ + import std.algorithm: canFind; + bool ret = true; + foreach (k; rhs) + ret &= lhs.canFind(k); + return !ret; +} +@safe unittest +{ + assert(_isNotEq(["a", "b"], ["a", "c"])); + assert(!_isNotEq(["a", "b"], ["a", "b"])); + assert(!_isNotEq(["a", "b"], ["b", "a"])); +} +private bool _isUniq(string[] keyMembers, string[][] anotherKeyMembers) @safe +{ + bool ret = true; + foreach (keys; anotherKeyMembers) + ret &= _isNotEq(keyMembers, keys); + return ret; +} +@safe unittest +{ + assert(_isUniq(["a", "b"], [["a", "c"], ["a", "d"]])); + assert(!_isUniq(["a", "b"], [["a", "b"], ["a", "d"]])); + assert(!_isUniq(["a", "b"], [["b", "a"], ["a", "d"]])); +} +private bool _isAllUniq(string[][] keyMembers) @safe +{ + bool ret = true; + foreach (idx; 0..keyMembers.length) + ret &= _isUniq(keyMembers[idx], keyMembers[idx+1..$]); + return ret; +} +@safe unittest +{ + assert(_isAllUniq([["a", "b"], ["a", "c"], ["a", "d"]])); + assert(!_isAllUniq([["a", "b"], ["a", "b"], ["a", "d"]])); + assert(!_isAllUniq([["a", "b"], ["b", "a"], ["a", "d"]])); +} + + +private enum _isKeyAllUnique(Types...) = () +{ + string[][] members; + static foreach (Type; Types) + members ~= [getKeyMemberNames!Type]; + return _isAllUniq(members); +}(); + +@safe unittest +{ + struct A { @key int a; @key int b; int c; } + struct B { @key int a; int b; @key int c; } + struct C { int a; @key int b; @key int c; } + struct D { @key int a; @key int b; @key int c; } + static assert(_isKeyAllUnique!(A, B)); + static assert(_isKeyAllUnique!(A, C)); + static assert(!_isKeyAllUnique!(A, D)); +} + + +/+ +// キーの数が同じで、キーの名称が同じで、キーの型が同じならそのキー名を返す +private template getKeys(Types...) +{ + + enum string[][] allKeyMembers = () + { + string[][] ret; + static foreach (T; Types) + ret ~= [getKeyMemberNames!T]; + return ret; + }(); + + enum bool isSameNames(string[] rhs, string[] lhs) = rhs == lhs; + + template getKeyMemberTypes(size_t i, string[] keyMembers) + { + alias getMemberType(string member) = typeof(__traits(getMember, MemberTypes[i], member)); + alias getKeyMemberTypes = staticMap!(getMemberType, aliasSeqOf!keyMembers); + } + + static if (allKeyMembers.length == 0) + { + // キーがない + alias getKeys = AliasSeq!(); + } + else static if (Filter!(ApplyLeft!(isSameNames, allKeyMembers[0]),aliasSeqOf!allKeyMembers).length + != allKeyMembers.length) + { + // キーの数や名称が違う + alias getKeys = AliasSeq!(); + } + else static if ( + !() { + // すべてのキーの型が同じか判定する + bool ret = true; + alias firstKeyMemberTypes = getKeyMemberTypes!(0, allKeyMembers[0]); + static foreach (i, keyMembers; allKeyMembers) + {{ + alias keyMemberTypes = getKeyMemberTypes!(i, keyMembers); + static foreach (j; 0..keyMemberTypes.length) + ret &= is(keyMemberTypes[j] == firstKeyMemberTypes[j]); + }} + return ret; + }()) + { + // キーの型が違う + alias getKeys = AliasSeq!(); + } + else + { + // キーの数も名称も型もすべて同じ + alias getKeys = aliasSeqOf!(allKeyMembers[0]); + } +} + +@system unittest +{ + struct A{ @key int a; int b; } + struct B{ @key int a; int c; } + struct C{ int a; @key int b; @key int c; } + struct D{ int a; @key int b; @key int c; } + static assert(getKeys!(A, B) == AliasSeq!("a")); + static assert(getKeys!(A, C).length == 0); + static assert(getKeys!(C, D) == AliasSeq!("b", "c")); +} ++/ + +private struct Kind +{ + string key; + JSONValue value; +} + +/// +auto kind(T)(string name, T value) +{ + import bsky._internal.attr: v = value; + return v(Kind(name, JSONValue(value))); +} + +/// ditto +auto kind(T)(T value) +{ + import bsky._internal.attr: v = value; + return v(Kind("kind", JSONValue(value))); +} + +private alias _getKinds(T, string uk, alias tag) = aliasSeqOf!(() +{ + Kind[] ret; + static if (hasValue!(T, Kind)) + { + ret = [getValues!(T, Kind)]; + } + else static if (getKeyMemberNames!T.length > 0) + { + static foreach (member; getKeyMemberNames!T) + { + static foreach (val; getValues!(__traits(getMember, T, member))) + { + static if (hasName!(__traits(getMember, T, member))) + ret ~= Kind(getName!(__traits(getMember, T, member)), JSONValue(val)); + else + ret ~= Kind(member, JSONValue(val)); + } + } + } + else + { + // UDAがない場合、type, kind, tagをさがす + static if (is(typeof(T.type) : string)) + ret ~= Kind("type", JSONValue(T.stringof)); + else static if (is(typeof(T.kind) : string)) + ret ~= Kind("kind", JSONValue(T.stringof)); + else static if (is(typeof(T.tag) : typeof(tag))) + ret ~= Kind("tag", JSONValue(tag)); + } + if (ret.length == 0) + ret ~= Kind(uk, JSONValue(tag)); + return ret; +}()); + + + +private JSONValue _serializeToJsonImpl(Types...)(in SumType!Types dat) +{ + import std.sumtype: match; + return dat.match!( (_) => _.serializeToJson() ); +} + +@system unittest +{ + alias MU = SumType!(int, string); + MU dat = 10; + auto mujson = _serializeToJsonImpl(dat); + assert(mujson.type == JSONType.integer); + assert(mujson.integer == 10); + + dat = "xxx"; + mujson = _serializeToJsonImpl(dat); + assert(mujson.type == JSONType.string); + assert(mujson.str == "xxx"); +} + +@system unittest +{ + struct A{ @key int a; int b; } + struct B{ int a; @key int c; } + + SumType!(A, B) dat1 = A(1, 10); + auto mujson1 = _serializeToJsonImpl(dat1); + assert(mujson1.type == JSONType.object); + assert(mujson1["a"].type == JSONType.integer); + assert(mujson1["a"].integer == 1); + assert(mujson1["b"].type == JSONType.integer); + assert(mujson1["b"].integer == 10); +} + +// +private JSONValue _serializeToJsonImpl(Types...)(in Tuple!Types dat) @trusted +{ + import std.meta: allSatisfy; + enum bool isAvailableFieldName(string fieldName) = fieldName.length > 0; + static if (allSatisfy!(isAvailableFieldName, Tuple!Types.fieldNames)) + { + // すべてに名前がついている場合 + auto ret = JSONValue.emptyObject; + static foreach (idx, memberName; Tuple!Types.fieldNames) + ret.setValue(memberName, serializeToJson(dat[idx])); + return ret; + } + else + { + // 名前のないフィールドがある場合は名前を無視して配列にしてしまう + auto ret = JSONValue.emptyArray; + static foreach (idx; 0..Tuple!Types.length) + ret.array ~= serializeToJson(dat[idx]); + return ret; + } +} + +@safe unittest +{ + auto dat1 = Tuple!(int, "test", string, "data")(10, "test"); + auto js1 = _serializeToJsonImpl(dat1); + assert(js1.type == JSONType.object); + assert("test" in js1); + assert(js1["test"].type == JSONType.integer); + assert(js1["test"].integer == 10); + assert("data" in js1); + assert(js1["data"].type == JSONType.string); + assert(js1["data"].str == "test"); + + auto dat2 = Tuple!(int, string)(10, "test"); + auto js2 = _serializeToJsonImpl(dat2); + assert(js2.type == JSONType.array); + assert((() @trusted => js2.array.length)() == 2); + assert(js2[0].type == JSONType.integer); + assert(js2[0].integer == 10); + assert(js2[1].type == JSONType.string); + assert(js2[1].str == "test"); +} + +/******************************************************************************* + * serialize data to JSON + */ +JSONValue serializeToJson(T)(in T data) +{ + static if (isJSONizableRaw!T) + { + return data.json; + } + else static if (is(typeof(_serializeToJsonImpl(data)): JSONValue)) + { + return _serializeToJsonImpl(data); + } + else static if (isArray!T) + { + JSONValue[] jvAry; + auto len = data.length; + jvAry.length = len; + foreach (idx; 0..len) + jvAry[idx] = serializeToJson(data[idx]); + return JSONValue(jvAry); + } + else static if (isAssociativeArray!T) + { + JSONValue[string] jvObj; + foreach (pair; data.byPair) + jvObj[pair.key.to!string()] = serializeToJson(pair.value); + return JSONValue(jvObj); + } + else + { + JSONValue ret; + static foreach (memberIdx, member; T.tupleof) + {{ + static if (!hasIgnore!member) + { + static if (hasIgnoreIf!member) + { + if (!getPredIgnoreIf!member(data)) + { + static if (hasName!member) + { + enum fieldName = getName!member; + } + else + { + enum fieldName = __traits(identifier, member); + } + static if (hasConvBy!member) + { + ret[fieldName] = convTo!(member, JSONValue)(data.tupleof[memberIdx]); + } + else static if (isJSONizableRaw!(typeof(member))) + { + ret[fieldName] = data.tupleof[memberIdx].json; + } + else + { + ret[fieldName] = serializeToJson(data.tupleof[memberIdx]); + } + } + } + else + { + static if (hasName!member) + { + enum fieldName = getName!member; + } + else + { + enum fieldName = __traits(identifier, member); + } + static if (hasConvBy!member) + { + ret[fieldName] = convTo!(member, JSONValue)(data.tupleof[memberIdx]); + } + else static if (isJSONizableRaw!(typeof(member))) + { + ret[fieldName] = data.tupleof[memberIdx].json; + } + else + { + ret[fieldName] = serializeToJson(data.tupleof[memberIdx]); + } + } + } + }} + return ret; + } +} + +/// ditto +string serializeToJsonString(T)(in T data, JSONOptions options = JSONOptions.none) +{ + return serializeToJson(data).toPrettyString(options); +} + +/// ditto +void serializeToJsonFile(T)(in T data, string jsonfile, JSONOptions options = JSONOptions.none) +{ + import std.file, std.encoding; + auto contents = serializeToJsonString(data, options); + std.file.write(jsonfile, contents); +} + +// +private void _deserializeFromJsonImpl(Types...)(ref SumType!Types dat, in JSONValue json) +{ + import std.sumtype: canMatch, match; + alias MU = SumType!Types; + final switch (json.type) + { + case JSONType.null_: + () @trusted { dat = MU.init; }(); + break; + case JSONType.string: + static if (canMatch!(MU, string)) + () @trusted { dat = json.str; }(); + break; + case JSONType.integer: + static if (canMatch!(MU, long)) + () @trusted { dat = json.integer; }(); + else static if (canMatch!(MU, int)) + () @trusted { dat = json.integer; }(); + else static if (canMatch!(MU, short)) + () @trusted { dat = json.integer; }(); + else static if (canMatch!(MU, byte)) + () @trusted { dat = json.integer; }(); + break; + case JSONType.uinteger: + static if (canMatch!(MU, ulong)) + () @trusted { dat = json.uinteger; }(); + else static if (canMatch!(MU, uint)) + () @trusted { dat = json.uinteger; }(); + else static if (canMatch!(MU, ushort)) + () @trusted { dat = json.uinteger; }(); + else static if (canMatch!(MU, ubyte)) + () @trusted { dat = json.uinteger; }(); + break; + case JSONType.float_: + static if (canMatch!(MU, real)) + () @trusted { dat = json.floating; }(); + else static if (canMatch!(MU, double)) + () @trusted { dat = json.floating; }(); + else static if (canMatch!(MU, float)) + () @trusted { dat = json.floating; }(); + break; + case JSONType.array: + // 配列型の候補を選択 + alias AryTypes = Filter!(isArray, MU.Types); + static if (AryTypes.length == 0) + { + // 配列型がないなら無視 + return; + } + else static if (AryTypes.length == 1) + { + // 配列型が1つならそれを最優先で選択 + () @trusted { dat = deserializeFromJson!(AryTypes[0])(tmp, json); }(); + return; + } + else + { + // 配列型が複数ある場合は1つ目のデータの要素で決定 + if (json.array.length == 0) + () @trusted { dat = MU.init; }(); + import std.meta; + alias ElementTypes = staticMap!(ForeachType, AryTypes); + SumType!ElementTypes datElm; + datElm.deserializeFromJson(json.array[0]); + import std.sumtype: match; + datElm.match!( + (_){ + alias AryType = MU.Types[staticIndexOf!(typeof(_), ElementTypes)]; + () @trusted { dat = json.deserializeFromJson!AryType(); }(); + } + ); + return; + } + assert(0); + case JSONType.object: + // オブジェクト型の候補を選択 + enum bool isObjType(T) = isAggregateType!T || isAssociativeArray!T; + alias ObjTypes = Filter!(isObjType, Types); + static if (ObjTypes.length == 0) + { + // オブジェクト型がないなら無視 + } + else static if (ObjTypes.length == 1) + { + // オブジェクト型が1つならそれを最優先で選択 + () @trusted { dst = deserializeFromJson!(ObjTypes[0])(json); }(); + } + else + { + // キーメンバーがすべて違う場合は、キーメンバーを持っている型を使用する + static if (_isKeyAllUnique!ObjTypes) + { + static foreach (ObjType; ObjTypes) + {{ + bool matchKeys = true; + static foreach (memberName; getKeyMemberNames!ObjType) + matchKeys &= cast(bool)(memberName in json); + if (matchKeys) + { + () @trusted { dat = deserializeFromJson!ObjType(json); }(); + return; + } + }} + } + else + { + // オブジェクト型が複数ある場合はキーデータの要素で決定 + static foreach (idx; 0..ObjTypes.length) + { + // キーメンバーをすべて持っている型を探す + static foreach (kind; _getKinds!(ObjTypes[idx], uniqueKey!(MU, "_tag"), staticIndexOf!(ObjTypes[idx], MU.Types))) + { + if (auto v = kind.key in json) + { + if (*v == kind.value) + { + () @trusted { dat = deserializeFromJson!(ObjTypes[idx])(json); }(); + return; + } + } + } + } + } + } + break; + case JSONType.true_: + case JSONType.false_: + static if (anySatisfy!(isBoolean, Types)) + () @trusted { dat = src.boolean; }(); + break; + } +} +// +@system unittest +{ + struct A{ @key @value!1 int a; int b; } + struct B{ @key @value!2 int a; int c; } + struct C{ int a; @key @value!1 int b; @key @value!1 int c; } + struct D{ int a; int b; @key int c; } + import std.sumtype: match; + SumType!(A, B) dat1; + auto mujson1 = JSONValue(["a": JSONValue(1), "b": JSONValue(10)]); + _deserializeFromJsonImpl(dat1, mujson1); + auto result = dat1.match!( + (A a) => 1, + (B b) => 2, + ); + assert(result == 1); + + SumType!(A[], B[]) dat2; + auto mujson2 = JSONValue([JSONValue(["a": 1]), JSONValue(["b": 10])]); + _deserializeFromJsonImpl(dat2, mujson2); + result = dat2.match!( + (A[] a) => 1, + (B[] b) => 2, + ); + assert(result == 1); + + SumType!(A, D) dat3; + auto mujson3 = JSONValue(["c": 10]); + _deserializeFromJsonImpl(dat3, mujson3); + result = dat3.match!( + (A a) => 1, + (D b) => 2, + ); + assert(result == 2); +} + + +private void _deserializeFromJsonImpl(Types...)(ref Tuple!Types dst, in JSONValue src) @trusted +{ + import std.meta: allSatisfy; + enum bool isAvailableFieldName(string fieldName) = fieldName.length > 0; + static if (allSatisfy!(isAvailableFieldName, Tuple!Types.fieldNames)) + { + // すべてに名前がついている場合 + static foreach (idx, memberName; Tuple!Types.fieldNames) + dst[idx].deserializeFromJson(src.getValue!JSONValue(memberName)); + } + else + { + // 名前のないフィールドがある場合は名前を無視して配列にしてしまう + if (src.type == JSONType.array && src.array.length == Tuple!Types.Types.length) + static foreach (idx, Type; Tuple!Types.Types) + dst[idx].deserializeFromJson(src[idx]); + } +} + +@safe unittest +{ + Tuple!(int, "test", string, "data") dat1; + auto js1 = JSONValue(["test": JSONValue(10), "data": JSONValue("test")]); + dat1._deserializeFromJsonImpl(js1); + assert(dat1.test == 10); + assert(dat1.data == "test"); + + Tuple!(int, string) dat2; + auto js2 = JSONValue([JSONValue(10), JSONValue("test")]); + dat2._deserializeFromJsonImpl(js2); + assert(dat2[0] == 10); + assert(dat2[1] == "test"); +} + +/******************************************************************************* + * deserialize data from JSON + */ +void deserializeFromJson(T)(ref T data, in JSONValue json) +{ + static if (isJSONizableRaw!T) + { + cast(void)fromJson(json, data); + } + else static if (__traits(compiles, _deserializeFromJsonImpl(data, json))) + { + _deserializeFromJsonImpl(data, json); + } + else static if (isArray!T) + { + if (json.type != JSONType.array) + return; + auto jvAry = (() @trusted => json.array)(); + static if (isDynamicArray!T) + data.length = jvAry.length; + foreach (idx, ref dataElm; data) + deserializeFromJson(dataElm, jvAry[idx]); + } + else static if (isAssociativeArray!T) + { + if (json.type != JSONType.object) + return; + data.clear(); + alias KeyType = typeof(data.byKey.front); + alias ValueType = typeof(data.byValue.front); + foreach (pair; (() @trusted => json.object)().byPair) + { + import std.algorithm: move; + data.update(pair.key.to!KeyType(), + { + ValueType ret; + deserializeFromJson(ret, pair.value); + return ret.move(); + }, (ref ValueType ret) + { + deserializeFromJson(ret, pair.value); + return ret; + }); + } + } + else + { + static foreach (memberIdx, member; T.tupleof) + {{ + static if (!hasIgnore!member) + { + static if (hasName!member) + { + enum fieldName = getName!member; + } + else + { + enum fieldName = __traits(identifier, member); + } + static if (hasConvBy!member) + { + static if (hasEssential!member) + { + data.tupleof[memberIdx] = convFrom!(member, JSONValue)(json[fieldName]); + } + else + { + if (auto pJsonValue = fieldName in json) + { + try + data.tupleof[memberIdx] = convFrom!(member, JSONValue)(*pJsonValue); + catch (Exception e) + { + /* ignore */ + } + } + + } + } + else static if (isJSONizableRaw!(typeof(member))) + { + static if (hasEssential!member) + { + cast(void)fromJson(json[fieldName], data.tupleof[memberIdx]); + } + else + { + import std.algorithm: move; + auto tmp = json.getValue(fieldName, data.tupleof[memberIdx]); + move(tmp, data.tupleof[memberIdx]); + } + } + else + { + static if (hasEssential!member) + { + deserializeFromJson(data.tupleof[memberIdx], json[fieldName]); + } + else + { + if (auto pJsonValue = fieldName in json) + deserializeFromJson(data.tupleof[memberIdx], *pJsonValue); + } + } + } + }} + } +} + +/// ditto +T deserializeFromJson(T)(in JSONValue jv) +{ + T ret; + ret.deserializeFromJson(jv); + return ret; +} + +/// ditto +void deserializeFromJsonString(T)(ref T data, string jsonContents) +{ + deserializeFromJson(data, parseJSON(jsonContents)); +} + +/// ditto +T deserializeFromJsonString(T)(string jsonContents) +{ + T ret; + ret.deserializeFromJsonString(jsonContents); + return ret; +} + +/// ditto +void deserializeFromJsonFile(T)(ref T data, string jsonFile) +{ + import std.file; + deserializeFromJsonString(data, std.file.readText(jsonFile)); +} + +/// ditto +T deserializeFromJsonFile(T)(string jsonFile) +{ + T ret; + ret.deserializeFromJsonFile(jsonFile); + return ret; +} + +@system unittest +{ + enum EnumVal + { + val1, + val2 + } + struct Data + { + EnumVal val; + } + Data data1 = Data(EnumVal.val1), data2 = Data(EnumVal.val2); + auto jv = data1.serializeToJson(); + data2.deserializeFromJson(jv); + assert(data1.val == data2.val); +} + + +@system unittest +{ + struct Data + { + string[uint] map; + } + Data data1 = Data([1: "1"]); + Data data2 = Data([2: "2"]); + auto jv = data1.serializeToJson(); + data2.deserializeFromJson(jv); + assert(1 in data1.map); + assert(1 in data2.map); + assert(2 !in data2.map); + assert(data2.map[1] == "1"); +} + +@system unittest +{ + static struct Data + { + string data1; + @ignoreIf!(dat => dat.data2.length == 0) + string data2; + } + Data data1 = Data("aaa", null); + auto jv = data1.serializeToJson(); + assert("data2" !in jv); +} + + + +/// +JSONValue deepCopy(in JSONValue v) @property +{ + final switch (v.type) + { + case JSONType.null_: + case JSONType.string: + case JSONType.integer: + case JSONType.uinteger: + case JSONType.float_: + case JSONType.true_: + case JSONType.false_: + return v; + case JSONType.object: + JSONValue[string] ret; + foreach (key, val; v.object) + ret[key] = deepCopy(val); + return JSONValue(ret); + case JSONType.array: + auto ret = appender!(JSONValue[]); + foreach (e; v.array) + ret ~= deepCopy(e); + return JSONValue(ret.data); + } +} + +@system unittest +{ + auto jv1 = JSONValue(["a": "A"]); + auto jv2 = jv1; + auto jv3 = jv1.deepCopy(); + jv1["a"] = "XXX"; + assert(jv1["a"].str == "XXX"); + assert(jv2["a"].str == "XXX"); + assert(jv3["a"].str == "A"); +} +@system unittest +{ + auto jv1 = JSONValue(["a": ["A", "B", "C"]]); + auto jv2 = jv1; + auto jv3 = jv1.deepCopy(); + jv1["a"][0] = "XXX"; + assert(jv1["a"][0].str == "XXX"); + assert(jv2["a"][0].str == "XXX"); + assert(jv3["a"][0].str == "A"); +} + +/******************************************************************************* + * JWT + */ +struct JWTValue +{ +private: + import std.digest.hmac; + import std.digest.sha; + import std.exception: enforce; + import std.string: representation; + immutable(ubyte)[] _key; + JSONValue _payload; +public: + /*************************************************************************** + * + */ + string type; + + /*************************************************************************** + * + */ + enum Algorithm + { + HS256, HS384, HS512 + } + /// ditto + Algorithm algorithm = Algorithm.HS256; + + + /*************************************************************************** + * + */ + this(const(char)[] jwt, const(ubyte)[] key) + { + import std.base64; + alias B64 = Base64URLNoPadding; + auto jwtElms = split(jwt, '.'); + enforce(jwtElms.length == 3, "Unknown format"); + auto header = parseJSON(cast(const(char)[])B64.decode(jwtElms[0])); + type = enforce(header.getValue("typ", string.init), "Unknown format"); + switch (header.getValue("alg", string.init)) + { + case "HS256": + algorithm = Algorithm.HS256; + break; + case "HS384": + algorithm = Algorithm.HS384; + break; + case "HS512": + algorithm = Algorithm.HS512; + break; + default: + enforce(false, "Unsupported algorithm"); + } + + static immutable verrmsg = "JWT verification is failed"; + final switch (algorithm) + { + case Algorithm.HS256: + enforce(B64.encode((jwtElms[0] ~ "." ~ jwtElms[1]).representation.hmac!SHA256(key)) == jwtElms[2], verrmsg); + break; + case Algorithm.HS384: + enforce(B64.encode((jwtElms[0] ~ "." ~ jwtElms[1]).representation.hmac!SHA384(key)) == jwtElms[2], verrmsg); + break; + case Algorithm.HS512: + enforce(B64.encode((jwtElms[0] ~ "." ~ jwtElms[1]).representation.hmac!SHA512(key)) == jwtElms[2], verrmsg); + break; + } + + _key = key.idup; + _payload = parseJSON(cast(const(char)[])B64.decode(jwtElms[1])); + } + + /// ditto + this(const(char)[] jwt, const(char)[] key) + { + this(jwt, key.representation); + } + + /// ditto + this(const(char)[] typ, Algorithm algo, const(ubyte)[] key, JSONValue payload) @safe + { + type = typ.idup; + algorithm = algo; + _key = key.idup; + _payload = payload; + } + + /// ditto + this(const(char)[] typ, Algorithm algo, const(char)[] key, JSONValue payload) @safe + { + this(typ, algo, key.representation, payload); + } + + /// ditto + this(const(char)[] typ, Algorithm algo, const(ubyte)[] key, JSONValue[string] payload) @safe + { + this(typ, algo, key, JSONValue(payload)); + } + + /// ditto + this(const(char)[] typ, Algorithm algo, const(char)[] key, JSONValue[string] payload) @safe + { + this(typ, algo, key.representation, payload); + } + + /// ditto + this(const(char)[] typ, Algorithm algo, const(ubyte)[] key) @safe + { + this(typ, algo, key, JSONValue.init); + } + + /// ditto + this(const(char)[] typ, Algorithm algo, const(char)[] key) @safe + { + this(typ, algo, key.representation); + } + + /// ditto + this(Algorithm algo, const(ubyte)[] key, JSONValue payload) @safe + { + this("JWT", algo, key, payload); + } + + /// ditto + this(Algorithm algo, const(char)[] key, JSONValue payload) @safe + { + this(algo, key.representation, payload); + } + + /// ditto + this(Algorithm algo, const(ubyte)[] key, JSONValue[string] payload) @safe + { + this(algo, key, JSONValue(payload)); + } + + /// ditto + this(Algorithm algo, const(char)[] key, JSONValue[string] payload) @safe + { + this(algo, key.representation, payload); + } + + /// ditto + this(Algorithm algo, const(ubyte)[] key) @safe + { + this("JWT", algo, key); + } + + /// ditto + this(Algorithm algo, const(char)[] key) @safe + { + this(algo, key.representation); + } + + + /*************************************************************************** + * + */ + void key(string key) + { + _key = key.representation; + } + /// dittp + void key(const(ubyte)[] key) + { + _key = key.idup; + } + + /*************************************************************************** + * + */ + ref inout(JSONValue) opIndex(string name) return inout + { + return _payload[name]; + } + + /*************************************************************************** + * + */ + void opIndexAssign(T)(auto ref T value, string name) return + { + _payload[name] = value; + } + + /*************************************************************************** + * + */ + ref inout(JSONValue) payload() return inout + { + return _payload; + } + + /*************************************************************************** + * + */ + string toString() const + { + string ret; + import std.conv: text; + import std.base64; + alias B64 = Base64Impl!('+', '/', Base64.NoPadding); + + ret ~= B64.encode(text(`{"alg":"`, algorithm, `","typ":"`, type, `"}`).representation); + ret ~= "."; + ret ~= B64.encode(_payload.toString().representation); + + final switch (algorithm) + { + case Algorithm.HS256: + return ret ~ "." ~ cast(string)B64.encode(ret.representation.hmac!SHA256(_key)); + case Algorithm.HS384: + return ret ~ "." ~ cast(string)B64.encode(ret.representation.hmac!SHA384(_key)); + case Algorithm.HS512: + return ret ~ "." ~ cast(string)B64.encode(ret.representation.hmac!SHA512(_key)); + } + } +} + +/// ditto +@system unittest +{ + import std.exception; + static immutable testjwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + ~".eyJ0ZXN0a2V5IjoidGVzdHZhbHVlIn0" + ~".AXHSKa2ubvg6jMckkYaWgCXluhOamfFDk8y163X4DPs"; + + auto jwt = JWTValue(JWTValue.Algorithm.HS256, "testsecret"); + jwt["testkey"] = "testvalue"; + assert(jwt.toString() == testjwt); + + auto jwt2 = JWTValue(testjwt, "testsecret"); + assert(jwt2["testkey"].str == "testvalue"); + assert(jwt2.toString() == jwt.toString()); + + assertThrown(JWTValue(testjwt, "testsecret2")); +} + +/******************************************************************************* + * + */ +void setValue(T)(ref JWTValue dat, string name, T val) +{ + dat._payload.setValue(name, val); +} + +/******************************************************************************* + * + */ +T getValue(T)(in JWTValue dat, string name, lazy T defaultVal) +{ + return dat._payload.getValue(name, defaultVal); +} + +@safe unittest +{ + auto jwt = JWTValue(JWTValue.Algorithm.HS256, "test"); + assert(jwt.type == "JWT"); + jwt = JWTValue("Test", JWTValue.Algorithm.HS256, "test", ["test": JSONValue("test")]); + assert(jwt.type == "Test"); + assert(jwt.getValue("test", "x") == "test"); + jwt = JWTValue(JWTValue.Algorithm.HS256, "test", ["test": JSONValue("test")]); + assert(jwt.type == "JWT"); + assert(jwt.getValue("test", "x") == "test"); + jwt = JWTValue(JWTValue.Algorithm.HS256, "test", JSONValue(["test": "test"])); + assert(jwt.type == "JWT"); + assert(jwt.getValue("test", "x") == "test"); + jwt = JWTValue("Test", JWTValue.Algorithm.HS256, "test", JSONValue(["test": "test"])); + assert(jwt.type == "Test"); + assert(jwt.getValue("test", "x") == "test"); + jwt = JWTValue("Test", JWTValue.Algorithm.HS256, "test"); + assert(jwt.type == "Test"); + assert(jwt.getValue("test", "x") == "x"); +} + + +/******************************************************************************* + * シリアライズ/デシリアライズ + */ +JWTValue serializeToJwt(T)(in T data, JWTValue.Algorithm algo, const(ubyte)[] key) +{ + auto ret = JWTValue(algo, key); + ret._payload = serializeToJson(data); + return ret; +} + +/// ditto +JWTValue serializeToJwt(T)(in T data, JWTValue.Algorithm algo, const(char)[] key) +{ + import std.string: representation; + return serializeToJwt(data, algo, key.representation); +} + +/// ditto +JWTValue serializeToJwt(T)(in T data, const(ubyte)[] key) +{ + return serializeToJwt(data, JWTValue.Algorithm.HS256, key); +} + +/// ditto +JWTValue serializeToJwt(T)(in T data, const(char)[] key) +{ + import std.string: representation; + return serializeToJwt(data, key.representation); +} + +/// ditto +string serializeToJwtString(T)(in T data, JWTValue.Algorithm algo, const(ubyte)[] key) +{ + return serializeToJwt(data, algo, key).toString(); +} + +/// ditto +string serializeToJwtString(T)(in T data, JWTValue.Algorithm algo, const(char)[] key) +{ + import std.string: representation; + return serializeToJwtString(data, algo, key.representation); +} + +/// ditto +string serializeToJwtString(T)(in T data, const(ubyte)[] key) +{ + return serializeToJwtString(data, JWTValue.Algorithm.HS256, key); +} + +/// ditto +string serializeToJwtString(T)(in T data, const(char)[] key) +{ + import std.string: representation; + return serializeToJwtString(data, key.representation); +} + +/// ditto +void deserializeFromJwt(T)(ref T data, JWTValue jwt) +{ + deserializeFromJson(data, jwt._payload); +} + +/// ditto +void deserializeFromJwtString(T)(ref T data, const(char)[] jwt, const(char)[] key) +{ + deserializeFromJson(data, JWTValue(jwt, key)._payload); +} + + +/// ditto +@system unittest +{ + import std.exception; + static immutable testjwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + ~".eyJ0ZXN0a2V5IjoidGVzdHZhbHVlIn0" + ~".AXHSKa2ubvg6jMckkYaWgCXluhOamfFDk8y163X4DPs"; + + struct Dat { string testkey; } + auto dat = Dat("testvalue"); + + auto jwt1 = serializeToJwt(dat, JWTValue.Algorithm.HS256, "testsecret"); + assert(jwt1.toString() == testjwt); + auto jwt2 = serializeToJwt(dat, "testsecret"); + assert(jwt2.toString() == testjwt); + assert(serializeToJwtString(dat, JWTValue.Algorithm.HS256, "testsecret") == testjwt); + assert(serializeToJwtString(dat, "testsecret") == testjwt); + + Dat dat2; + dat2.deserializeFromJwt(jwt1); + assert(dat2 == dat); + + Dat dat3; + dat3.deserializeFromJwtString(testjwt, "testsecret"); + assert(dat3 == dat); +} diff --git a/src/bsky/_internal/misc.d b/src/bsky/_internal/misc.d new file mode 100644 index 0000000..abddd94 --- /dev/null +++ b/src/bsky/_internal/misc.d @@ -0,0 +1,359 @@ +/******************************************************************************* + * Internal misc functions + */ +module bsky._internal.misc; + +package(bsky): + +import std.json: JSONValue; +import std.datetime: SysTime; +import std.traits; +import bsky._internal.json: converter; + +/******************************************************************************* + * SysTime converter user data attribute + */ +enum systimeConverter = converter!SysTime( + jv=>SysTime.fromISOExtString(jv.str), + v =>JSONValue(v.toISOExtString())); + + +version (unittest) package(bsky) T readDataSource(T = immutable(ubyte)[])(string fileName) @trusted +{ + import std.file, std.path; + return cast(T)std.file.read("tests/.ut-data_source".buildPath(fileName)); +} + +version (unittest) package(bsky) T readDataSource(T: string)(string fileName) @trusted +{ + import std.file, std.path; + return std.file.readText("tests/.ut-data_source".buildPath(fileName)); +} + +version (unittest) package(bsky) T readDataSource(T: JSONValue)(string fileName) @trusted +{ + import std.json; + return readDataSource!string(fileName).parseJSON(); +} + +version (Have_voile) { + public import voile.misc: assumePure; + public import voile.sync: ManagedShared; +} +else: + + +/******************************************************************************* + * + */ +auto ref assumeAttr(alias fn, alias attrs, Args...)(auto ref Args args) +if (isFunction!fn) +{ + alias Func = SetFunctionAttributes!(typeof(&fn), functionLinkage!fn, attrs); +// if (!__ctfe) +// { +// alias dgTy = SetFunctionAttributes!(void function(string), "D", attrs); +// debug { (cast(dgTy)&disp)(typeof(fn).stringof); } +// } + return (cast(Func)&fn)(args); +} + +/// ditto +auto ref assumeAttr(alias fn, alias attrs, Args...)(auto ref Args args) +if (__traits(isTemplate, fn) && isCallable!(fn!Args)) +{ + alias Func = SetFunctionAttributes!(typeof(&(fn!Args)), functionLinkage!(fn!Args), attrs); +// if (!__ctfe) +// { +// alias dgTy = SetFunctionAttributes!(void function(string), "D", attrs); +// debug { (cast(dgTy)&disp)(typeof(fn!Args).stringof); } +// } + return (cast(Func)&fn!Args)(args); +} + +/// ditto +auto assumeAttr(alias attrs, Fn)(Fn t) + if (isFunctionPointer!Fn || isDelegate!Fn) +{ + return cast(SetFunctionAttributes!(Fn, functionLinkage!Fn, attrs)) t; +} + +/******************************************************************************* + * + */ +template getFunctionAttributes(T...) +{ + alias fn = T[0]; + static if (T.length == 1 && (isFunctionPointer!(T[0]) || isDelegate!(T[0]))) + { + enum getFunctionAttributes = functionAttributes!fn; + } + else static if (!is(typeof(fn!(T[1..$])))) + { + enum getFunctionAttributes = functionAttributes!(fn); + } + else + { + enum getFunctionAttributes = functionAttributes!(fn!(T[1..$])); + } +} + +/******************************************************************************* + * + */ +auto ref assumePure(alias fn, Args...)(auto ref Args args) +{ + return assumeAttr!(fn, getFunctionAttributes!(fn, Args) | FunctionAttribute.pure_, Args)(args); +} + +/// ditto +auto assumePure(T)(T t) +if (imported!"std.traits".isFunctionPointer!T || isDelegate!T) +{ + return assumeAttr!(getFunctionAttributes!T | FunctionAttribute.pure_)(t); +} + +/******************************************************************************* + * + */ +auto ref assumeNogc(alias fn, Args...)(auto ref Args args) +{ + return assumeAttr!(fn, getFunctionAttributes!(fn, Args) | FunctionAttribute.nogc, Args)(args); +} + +/// ditto +auto assumeNogc(T)(T t) + if (isFunctionPointer!T || isDelegate!T) +{ + return assumeAttr!(getFunctionAttributes!T | FunctionAttribute.nogc)(t); +} + + +/******************************************************************************* + * + */ +auto ref assumeNothrow(alias fn, Args...)(auto ref Args args) +{ + return assumeAttr!(fn, getFunctionAttributes!(fn, Args) | FunctionAttribute.nothrow_, Args)(args); +} + +/// ditto +auto assumeNothrow(T)(T t) + if (isFunctionPointer!T || isDelegate!T) +{ + return assumeAttr!(getFunctionAttributes!T | FunctionAttribute.nothrow_)(t); +} + + + +/******************************************************************************* + * 管理された共有資源 + * + * + */ +class ManagedShared(T): Object.Monitor +{ +private: + import std.exception; + import core.sync.mutex; + static struct MonitorProxy + { + Object.Monitor link; + } + MonitorProxy _proxy; + Mutex _mutex; + size_t _locked; + T _data; + void _initData(bool initLocked) + { + _proxy.link = this; + this.__monitor = &_proxy; + _mutex = new Mutex(); + if (initLocked) + lock(); + } +public: + + /*************************************************************************** + * コンストラクタ + * + * sharedのコンストラクタを呼んだ場合の初期状態は共有資源(unlockされた状態) + * 非sharedのコンストラクタを呼んだ場合の初期状態は非共有資源(lockされた状態) + */ + this()() @trusted + { + // これはひどい + (cast(void delegate(bool) pure)&_initData)(true); + } + + /// ditto + this()() @trusted shared + { + (cast(void delegate(bool) pure)(&(cast()this)._initData))(false); + } + + + /*************************************************************************** + * + */ + inout(Mutex) mutex() pure nothrow @nogc inout @property + { + return _mutex; + } + + + /*************************************************************************** + * + */ + shared(inout(Mutex)) mutex() pure nothrow @nogc shared inout @property + { + return _mutex; + } + + + /*************************************************************************** + * ロックされたデータを得る + * + * この戻り値が破棄されるときにRAIIで自動的にロックが解除される。 + * また、戻り値はロックされた共有資源へ、非共有資源としてアクセス可能な参照として使用できる。 + */ + auto locked() @safe @property // @suppress(dscanner.confusing.function_attributes) + { + lock(); + static struct LockedData + { + private: + T* _data; + void delegate() _unlock; + public: + ref inout(T) dataRef() inout @property { return *_data; } + @disable this(this); + ~this() @trusted + { + if (_unlock) + _unlock(); + } + alias dataRef this; + } + return LockedData(&_data, &unlock); + } + /// ditto + auto locked() @trusted shared inout @property + { + return (cast()this).locked(); + } + + + /*************************************************************************** + * ロックを試行する。 + * + * Returns: + * すでにロックしているならtrue + * ロックされていなければロックしてtrue + * 別のスレッドにロックされていてロックできなければfalse + */ + bool tryLock() @safe + { + auto tmp = (() @trusted => _mutex.tryLock())(); + // ロックされていなければ _locked を操作することは許されない + if (tmp) + _locked++; + return tmp; + } + /// ditto + bool tryLock() @trusted shared + { + return (cast()this).tryLock(); + } + + + /*************************************************************************** + * ロックする。 + */ + void lock() @safe + { + _mutex.lock(); + _locked++; + } + /// ditto + void lock() @trusted shared + { + (cast()this).lock(); + } + + + /*************************************************************************** + * ロック解除する。 + */ + void unlock() @safe + { + _locked--; + _mutex.unlock(); + } + /// ditto + void unlock() @trusted shared + { + (cast()this).unlock(); + } + + + /*************************************************************************** + * 非共有資源としてアクセスする + */ + ref T asUnshared() inout @property + { + enforce(_locked != 0); + return *cast(T*)&_data; + } + /// ditto + ref T asUnshared() shared inout @property + { + enforce(_locked != 0); + return *cast(T*)&_data; + } + + + /*************************************************************************** + * 共有資源としてアクセスする + */ + ref shared(T) asShared() inout @property + { + return *cast(shared(T)*)&_data; + } + /// ditto + ref shared(T) asShared() shared inout @property + { + return *cast(shared(T)*)&_data; + } +} + + +/******************************************************************************* + * + */ +ManagedShared!T managedShared(T)(T dat) +{ + import std.algorithm: move; + auto s = new ManagedShared!T; + s.asUnshared = dat.move(); + return s; +} + +/// ditto +ManagedShared!T managedShared(T, Args...)(Args args) +{ + auto s = new ManagedShared!T; + static if (Args.length == 0 && is(typeof(s.asUnshared.__ctor()))) + { + s.asUnshared.__ctor(); + } + else static if (is(typeof(s.asUnshared.__ctor(args)))) + { + s.asUnshared.__ctor(args); + } + else static if (is(T == struct) && is(typeof(T(args)))) + { + s.asUnshared = T(args); + } + return s; +} diff --git a/src/bsky/_internal/package.d b/src/bsky/_internal/package.d new file mode 100644 index 0000000..c4940f1 --- /dev/null +++ b/src/bsky/_internal/package.d @@ -0,0 +1,13 @@ +/******************************************************************************* + * Internal package + */ +module bsky._internal; + +/// +package(bsky) import bsky._internal.attr; +/// ditto +package(bsky) import bsky._internal.json; +/// ditto +package(bsky) import bsky._internal.misc; +/// ditto +package(bsky) import bsky._internal.httpc; diff --git a/src/bsky/auth.d b/src/bsky/auth.d new file mode 100644 index 0000000..03c8181 --- /dev/null +++ b/src/bsky/auth.d @@ -0,0 +1,702 @@ +/******************************************************************************* + * Atproto authenticator + * + * License: BSL-1.0 + */ +module bsky.auth; + +import std.exception; +import std.json; +import std.datetime; +import bsky._internal; + +/******************************************************************************* + * Login information + * + */ +struct LoginInfo +{ + /*************************************************************************** + * + */ + string identifier; + /*************************************************************************** + * + */ + string password; +} + +/******************************************************************************* + * Session information + * + */ +struct SessionInfo +{ + /*************************************************************************** + * + */ + struct DidDoc + { + /*********************************************************************** + * + */ + @name("@context") string[] atContext; + /*********************************************************************** + * + */ + string id; + /*********************************************************************** + * + */ + string[] alsoKnownAs; + /*********************************************************************** + * + */ + struct VerificationMethod + { + /******************************************************************* + * + */ + string id; + /******************************************************************* + * + */ + string type; + /******************************************************************* + * + */ + string contoroller; + /******************************************************************* + * + */ + string publicKeyMultibase; + } + /// ditto + VerificationMethod[] verificationMethod; + /*********************************************************************** + * + */ + struct Service + { + /******************************************************************* + * + */ + string id; + /******************************************************************* + * + */ + string type; + /******************************************************************* + * + */ + string serviceEndpoint; + } + /// ditto + Service[] service; + /*********************************************************************** + * + */ + void opAssign(in DidDoc lhs) @safe + { + atContext = lhs.atContext.dup; + id = lhs.id; + alsoKnownAs = lhs.alsoKnownAs.dup; + verificationMethod = lhs.verificationMethod.dup; + service = lhs.service.dup; + } + } + /// ditto + DidDoc didDoc; + /*************************************************************************** + * + */ + string handle; + /*************************************************************************** + * + */ + string did; + /*************************************************************************** + * + */ + string accessJwt; + /*************************************************************************** + * + */ + string refreshJwt; + /*************************************************************************** + * + */ + string email; + /*************************************************************************** + * + */ + bool emailConfirmed; + /*************************************************************************** + * + */ + bool active; + /*************************************************************************** + * + */ + void opAssign(in SessionInfo lhs) @safe + { + didDoc = lhs.didDoc; + handle = lhs.handle; + did = lhs.did; + accessJwt = lhs.accessJwt; + refreshJwt = lhs.refreshJwt; + email = lhs.email; + emailConfirmed = lhs.emailConfirmed; + } + /*************************************************************************** + * + */ + static SessionInfo fromJsonString(string json) @safe + { + SessionInfo ret; + ret.deserializeFromJsonString(json); + return ret; + } + /// ditto + static SessionInfo fromJson(JSONValue json) @safe + { + SessionInfo ret; + ret.deserializeFromJson(json); + return ret; + } + /*************************************************************************** + * + */ + JSONValue toJson() const @safe + { + return this.serializeToJson(); + } + /// ditto + string toJsonString() const @safe + { + return toJson.toString(); + } +} + +/******************************************************************************* + * Create dummy session for testing + * + */ +version (unittest) package(bsky) SessionInfo _createDummySession( + string did = "did:plc:dummy", + string handle = "dummy.dummy.dummy", + string email = "dummy@dummy.dummy", + DateTime expireAt = DateTime(2999, 12, 31, 23, 59, 59), + DateTime refreshUntil = DateTime(2999, 12, 31, 23, 59, 59)) @trusted +{ + SessionInfo info; + info.did = did; + info.didDoc.id = did; + info.didDoc.service = [SessionInfo.DidDoc.Service("#atproto_pds", + "AtprotoPersonalDataServer", "https://hydnum.us-west.host.bsky.network")]; + info.didDoc.verificationMethod = [SessionInfo.DidDoc.VerificationMethod(did ~ "#atproto", + "Multikey", did, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")]; + info.didDoc.alsoKnownAs = ["at://" ~ handle]; + info.didDoc.atContext = [ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/multikey/v1", + "https://w3id.org/security/suites/secp256k1-2019/v1" + ]; + import std.datetime; + import std.string; + auto currTime = Clock.currTime; + import bsky._internal.json: JWTValue; + info.accessJwt = JWTValue("at+jwt", JWTValue.Algorithm.HS256, "dummy".representation, [ + "scope": JSONValue("com.atproto.access"), + "sub": JSONValue(did), + "iat": JSONValue(currTime.toUnixTime!long()), + "exp": JSONValue(new SysTime(expireAt).toUnixTime!long()), + "aud": JSONValue("did:web:hydnum.us-west.host.bsky.network"), + ]).toString(); + info.refreshJwt = JWTValue("refresh+jwt", JWTValue.Algorithm.HS256, "dummy".representation, [ + "scope": JSONValue("com.atproto.refresh"), + "sub": JSONValue(did), + "aud": JSONValue("did:web:bsky.social"), + "jti": JSONValue("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + "iat": JSONValue(currTime.toUnixTime!long()), + "exp": JSONValue(new SysTime(refreshUntil).toUnixTime!long()), + ]).toString(); + info.active = true; + return info; +} + +/******************************************************************************* + * Bluesky authenticator + * + */ +class AtprotoAuth +{ +private: + import std.array: Appender; + import std.datetime: SysTime; + import std.typecons: Tuple; + import bsky._internal.httpc; + import bsky._internal.misc; + string _endpoint; + ManagedShared!HttpClientBase _httpClient; + ManagedShared!SessionInfo _session; + struct HttpResult + { + JSONValue response; + uint code; + string reason; + } + + HttpResult _get(string path, string[string] param, string bearer = null) @safe + { + HttpResult ret; + auto url = _endpoint ~ path; + with (_httpClient.locked) + { + ret.response = get(url, param, () @safe => bearer); + ret.code = getLastStatusCode; + ret.reason = getLastStatusReason; + } + return ret; + } + + HttpResult _post(string path, JSONValue param, string bearer = null) @safe + { + import std.string: representation; + HttpResult ret; + auto url = _endpoint ~ path; + auto p = param is JSONValue.init ? (immutable(ubyte)[]).init : param.toJSON().representation; + with (_httpClient.locked) + { + ret.response = post(url, p, "application/json", () @safe => bearer); + ret.code = getLastStatusCode; + ret.reason = getLastStatusReason; + } + return ret; + } + +public: + /*************************************************************************** + * Constructor + */ + this(Client = CurlHttpClient!())(string endpoint = "https://bsky.social", Client client = new Client) @safe + { + this(endpoint, cast(HttpClientBase)client); + } + /// ditto + this(Client: HttpClientBase)(string endpoint = "https://bsky.social", Client client) @safe + { + _httpClient = new ManagedShared!HttpClientBase; + () @trusted { _httpClient.asUnshared = client; }(); + _endpoint = endpoint; + _session = new shared ManagedShared!SessionInfo(); + } + /// ditto + this(Client = CurlHttpClient!())(string endpoint = "https://bsky.social", Client client = new Client) shared @safe + { + this(endpoint, cast(HttpClientBase)client); + } + /// ditto + this(Client: HttpClientBase)(string endpoint = "https://bsky.social", Client client) shared @safe + { + _httpClient = new ManagedShared!HttpClientBase; + () @trusted { _httpClient.asUnshared = client; }(); + _endpoint = endpoint; + _session = new shared ManagedShared!SessionInfo(); + } + + /*************************************************************************** + * createSession API + */ + void createSession(LoginInfo info) @trusted + { + auto param = info.serializeToJson(); + auto res = _post("/xrpc/com.atproto.server.createSession", param); + synchronized (_session) + _session.asUnshared.deserializeFromJson(res.response); + } + /// ditto + void createSession(LoginInfo info) shared @trusted + { + (cast()this).createSession(info); + } + /// ditto + void createSession(string id, string password) @safe + { + createSession(LoginInfo(id, password)); + } + /// ditto + void createSession(string id, string password) shared @trusted + { + (cast()this).createSession(id, password); + } + + /*************************************************************************** + * refreshSession API + */ + void refreshSession() @trusted + { + enforce(available); + Appender!(ubyte[]) app; + synchronized (_session) with (_session.asUnshared) + { + auto res = _post("/xrpc/com.atproto.server.refreshSession", JSONValue.init, refreshJwt); + enforce(res.code == 200, res.reason ~ "\n\n" + ~ res.response.getValue("error", "Error") + ~ res.response.getValue("message", ": Unknown error occurred.")); + + static struct RefreshSessionInfo + { + string accessJwt; + string refreshJwt; + string handle; + string did; + SessionInfo.DidDoc didDoc; + } + RefreshSessionInfo refreshData; + refreshData.deserializeFromJson(res.response); + accessJwt = refreshData.accessJwt; + refreshJwt = refreshData.refreshJwt; + handle = refreshData.handle; + did = refreshData.did; + didDoc = refreshData.didDoc; + } + } + /// ditto + void refreshSession() shared + { + (cast()this).refreshSession(); + } + + /*************************************************************************** + * deleteSession API + */ + void deleteSession() @trusted + { + if (!available) + return; + + synchronized (_session) + { + auto refreshJwt = _session.asUnshared.refreshJwt; + auto res = _post("/xrpc/com.atproto.server.deleteSession", JSONValue.init, refreshJwt); + enforce(res.code == 200, res.reason ~ "\n\n" + ~ res.response.getValue("error", "Error") + ~ res.response.getValue("message", ": Unknown error occurred.")); + _session.asUnshared = SessionInfo.init; + } + } + /// ditto + void deleteSession() shared @trusted + { + (cast()this).deleteSession(); + } + + /*************************************************************************** + * Update strategy + */ + enum UpdateStrategy + { + /*********************************************************************** + * + */ + force, + /*********************************************************************** + * + */ + expired, + /*********************************************************************** + * + */ + before5min, + /*********************************************************************** + * + */ + herf + } + + /*************************************************************************** + * Update session information + */ + void updateSession() @trusted + { + if (!available) + return; + synchronized (_session) + { + auto res = _get("/xrpc/com.atproto.server.getSession", null, _session.asUnshared.accessJwt); + // 400エラー(ExpiredToken)だった場合はリフレッシュトークンを使って更新を試みる + if (res.code == 400 && res.response.getValue("error", "") == "ExpiredToken") + { + refreshSession(); + res = _get("/xrpc/com.atproto.server.getSession", null, _session.asUnshared.accessJwt); + } + enforce(res.code == 200, res.reason ~ "\n\n" + ~ res.response.getValue("error", "Error") + ~ res.response.getValue("message", ": Unknown error occurred.")); + static struct UpdateSessionInfo + { + string handle; + string did; + string email; + bool emailConfirmed; + SessionInfo.DidDoc didDoc; + } + UpdateSessionInfo dat; + dat.deserializeFromJson(res.response); + _session.asUnshared.handle = dat.handle; + _session.asUnshared.did = dat.did; + _session.asUnshared.email = dat.email; + _session.asUnshared.emailConfirmed = dat.emailConfirmed; + _session.asUnshared.didDoc = dat.didDoc; + } + } + /// ditto + void updateSession() shared @trusted + { + (cast()this).updateSession(); + } + /// ditto + void updateSession(UpdateStrategy strategy) @trusted + { + import std.datetime: Clock, Duration, minutes; + if (strategy == UpdateStrategy.force) + return updateSession(); + auto exp = expire; + auto currTime = Clock.currTime; + final switch (strategy) + { + case UpdateStrategy.force: + assert(0); + case UpdateStrategy.expired: + if (currTime >= exp) + return updateSession(); + break; + case UpdateStrategy.herf: + if (currTime > exp - ((currTime - exp) / 2)) + return updateSession(); + break; + case UpdateStrategy.before5min: + if (currTime > exp - 5.minutes) + return updateSession(); + break; + } + } + /// ditto + void updateSession(UpdateStrategy strategy) shared @trusted + { + (cast()this).updateSession(strategy); + } + + /*************************************************************************** + * refreshSession API + */ + void restoreSession(in SessionInfo sessionInfo) @trusted + { + synchronized (_session) + _session.asUnshared = sessionInfo; + } + /// ditto + void restoreSession(in SessionInfo sessionInfo) shared @trusted + { + (cast()this).restoreSession(sessionInfo); + } + /// ditto + void restoreSessionFromTokens(string accessJwt, string refreshJwt) @trusted + { + synchronized (_session) + { + _session.asUnshared.accessJwt = accessJwt; + _session.asUnshared.refreshJwt = refreshJwt; + } + updateSession(); + } + /// ditto + void restoreSessionFromTokens(string accessJwt, string refreshJwt) shared @trusted + { + (cast()this).restoreSessionFromTokens(accessJwt, refreshJwt); + } + /// ditto + void restoreSessionFromRefreshToken(string refreshJwt) @trusted + { + synchronized (_session) + { + _session.asUnshared.refreshJwt = refreshJwt; + refreshSession(); + updateSession(); + } + } + /// ditto + void restoreSessionFromRefreshToken(string refreshJwt) shared @trusted + { + (cast()this).restoreSessionFromRefreshToken(refreshJwt); + } + + /*************************************************************************** + * refreshSession API + */ + const(SessionInfo) storeSession() const @trusted + { + synchronized (_session) + return _session.asUnshared; + } + /// ditto + const(SessionInfo) storeSession() const shared @trusted + { + return (cast()this).storeSession(); + } + /// ditto + string storeSessionOnlyRefreshToken() const @trusted + { + synchronized (_session) + return _session.asUnshared.refreshJwt; + } + /// ditto + string storeSessionOnlyRefreshToken() const shared @trusted + { + return (cast()this).storeSessionOnlyRefreshToken(); + } + /// ditto + Tuple!(string, "accessJwt", string, "refreshJwt") storeSessionOnlyToken() const @trusted + { + synchronized (_session) with (_session.asUnshared) + return typeof(return)(accessJwt, refreshJwt); + } + /// ditto + Tuple!(string, "accessJwt", string, "refreshJwt") storeSessionOnlyToken() const shared @trusted + { + return (cast()this).storeSessionOnlyToken(); + } + + /*************************************************************************** + * Available + */ + bool available() const @trusted + { + synchronized (_session) + return _session.asUnshared.accessJwt.length > 0; + } + /// ditto + bool available() const shared @trusted + { + return (cast()this).available(); + } + + /*************************************************************************** + * createSession API + */ + string bearer() const @trusted + { + synchronized (_session) + return _session.asUnshared.accessJwt; + } + /// ditto + string bearer() const shared @trusted + { + return (cast()this).bearer(); + } + + /*************************************************************************** + * createSession API + */ + string did() const @trusted + { + synchronized (_session) + return _session.asUnshared.did; + } + /// ditto + string did() const shared @trusted + { + return (cast()this).did(); + } + + /*************************************************************************** + * Expire duration of auth + */ + SysTime expire() const @safe + { + import std.base64; + import std.string: split; + import std.json: parseJSON; + import std.exception; + alias B64 = Base64Impl!('+', '/', Base64.NoPadding); + string jwt; + synchronized (_session) + jwt = (() @trusted => _session.asUnshared)().accessJwt; + auto values = jwt.split("."); + enforce(values.length == 3); + auto jv = parseJSON((() @trusted => cast(string)B64.decode(values[1]))()); + return SysTime.fromUnixTime(jv.getValue("exp", ulong(0))).toLocalTime(); + } + /// ditto + SysTime expire() const shared @trusted + { + return (cast()this).expire(); + } + /// ditto + SysTime refreshExpire() const @safe + { + import std.base64; + import std.string: split; + import std.json: parseJSON; + import std.exception; + alias B64 = Base64Impl!('+', '/', Base64.NoPadding); + string jwt; + synchronized (_session) + jwt = (() @trusted => _session.asUnshared)().refreshJwt; + auto values = jwt.split("."); + enforce(values.length == 3); + auto jv = parseJSON((() @trusted => cast(string)B64.decode(values[1]))()); + return SysTime.fromUnixTime(jv.getValue("exp", ulong(0))).toLocalTime(); + } + /// ditto + SysTime refreshExpire() const shared @trusted + { + return (cast()this).refreshExpire(); + } +} + +debug (ProvisioningDataSource) version (unittest) +{ + /// unittest only + package(bsky) shared AtprotoAuth g_utAuth; + + shared static this() + { + import std.process; + string id = environment.get("BSKYUT_LOGINID"); + string pass = environment.get("BSKYUT_LOGINPASS"); + if (id !is null && pass !is null) + g_utAuth = new shared AtprotoAuth(); + try if (g_utAuth) + g_utAuth.createSession(id, pass); + catch (Exception e) + g_utAuth = null; + } + + shared static ~this() + { + if (g_utAuth) + g_utAuth.deleteSession(); + } + +} + +@safe unittest +{ + auto auth = new shared AtprotoAuth(client: new DummyHttpClient!()); + auto session = _createDummySession(); + auth.restoreSession(session); + assert(auth.storeSessionOnlyRefreshToken == session.refreshJwt); + assert(auth.storeSession.accessJwt == session.accessJwt); + assert(auth.refreshExpire.year == 2999); + assert(auth.expire.year == 2999); + + auto jv = session.toJson(); + auto dstSession = SessionInfo.fromJson(jv); + assert(session == dstSession); + + auto jvstr = session.toJsonString(); + auto dstSession2 = SessionInfo.fromJsonString(jvstr); + assert(dstSession2 == dstSession); +} diff --git a/src/bsky/client.d b/src/bsky/client.d new file mode 100644 index 0000000..e6309ff --- /dev/null +++ b/src/bsky/client.d @@ -0,0 +1,2852 @@ +/******************************************************************************* + * Bluesky client + * + * License: BSL-1.0 + */ +module bsky.client; + +import std.array; +import std.json; +import std.exception; +import std.sumtype; +import std.string; +import bsky.user; +import bsky.auth; +import bsky.post; +import bsky.data; +import bsky._internal; + +/******************************************************************************* + * + */ +static struct FetchRange(T) +{ +private: + string delegate(JSONValue jv) @safe _getCursor; + JSONValue[] delegate(JSONValue jv) @safe _getElements; + JSONValue delegate(string[string] param) @safe _httpGet; + JSONValue[] _fetchedElements; + string _cursor; + string[string] _params; + void _fetch() @safe + { + if (_cursor.length > 0) + _params["cursor"] = _cursor; + auto res = _httpGet(_params); + _fetchedElements = _getElements(res); + if (_fetchedElements.length == 0) + { + _cursor = null; + return; + } + auto newCursor = _getCursor(res); + if (newCursor.length == 0 || (newCursor.length > 0 && newCursor == _cursor)) + { + _cursor = null; + return; + } + _cursor = newCursor; + } +public: + /*************************************************************************** + * + */ + bool empty() const @safe + { + return _fetchedElements.length == 0 && _cursor.length == 0; + } + /*************************************************************************** + * + */ + T front() const @trusted + { + T ret; + ret.deserializeFromJson(_fetchedElements[0]); + return ret; + } + /*************************************************************************** + * + */ + void popFront() @safe + { + _fetchedElements = _fetchedElements[1..$]; + if (_fetchedElements.length == 0 && _cursor.length > 0) + _fetch(); + } + /*************************************************************************** + * + */ + void setFetchLength(size_t len) @safe + { + import std.conv: to; + _params["limit"] = len.to!string; + } +} + + +private void _parseFacetImpl(alias getDid)(ref JSONValue dst, string text) @safe +{ + import std.regex; + enum reFacet = ctRegex!(r"(?:^|(?<=\s|\())(" + ~ r"@(?:[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])?" + ~ r")|(" + ~ r"https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}" + ~ r"\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*[-a-zA-Z0-9@%_\+~#//=])?" + ~ r")|(" + ~ r"(?:^|(?<=\s))(?:#[^\d\s]\S*)(?:(?=\s)|$)" + ~ r")"); + foreach (m; text.matchAll(reFacet)) + { + auto jv = JSONValue.emptyObject; + jv.setValue("index", JSONValue([ + "byteStart": m.pre.length, + "byteEnd": m.pre.length + m.hit.length])); + if (m[1].length > 0) + jv.setValue("features", JSONValue([JSONValue([ + "$type": "app.bsky.richtext.facet#mention", + "did": getDid(m.hit)])])); + if (m[2].length > 0) + jv.setValue("features", JSONValue([JSONValue([ + "$type": "app.bsky.richtext.facet#link", + "uri": m[2]])])); + if (m[3].length > 0) + jv.setValue("features", JSONValue([JSONValue([ + "$type": "app.bsky.richtext.facet#tag", + "tag": m[3][1..$]])])); + (() @trusted => dst.array ~= jv)(); + } +} +@system unittest +{ + alias getDid = (handle) @safe => "did:plc:test"; + auto jv = JSONValue.emptyArray; + _parseFacetImpl!getDid(jv, "test"); + assert(jv.array.length == 0); + jv = JSONValue.emptyArray; + _parseFacetImpl!getDid(jv, "test https://dlang.org test"); + assert(jv.array.length == 1); + assert(jv[0]["features"][0]["$type"].str == "app.bsky.richtext.facet#link"); + assert(jv[0]["features"][0]["uri"].str == "https://dlang.org"); + assert(jv[0]["index"]["byteStart"].uinteger == 5); + assert(jv[0]["index"]["byteEnd"].uinteger == 22); + jv = JSONValue.emptyArray; + _parseFacetImpl!getDid(jv, "test https://dlang.org @test.bsky.social test #test test #end"); + assert(jv.array.length == 4); + assert(jv[0]["features"][0]["$type"].str == "app.bsky.richtext.facet#link"); + assert(jv[0]["features"][0]["uri"].str == "https://dlang.org"); + assert(jv[0]["index"]["byteStart"].uinteger == 5); + assert(jv[0]["index"]["byteEnd"].uinteger == 22); + assert(jv[1]["features"][0]["$type"].str == "app.bsky.richtext.facet#mention"); + assert(jv[1]["features"][0]["did"].str == "did:plc:test"); + assert(jv[1]["index"]["byteStart"].uinteger == 23); + assert(jv[1]["index"]["byteEnd"].uinteger == 40); + assert(jv[2]["features"][0]["$type"].str == "app.bsky.richtext.facet#tag"); + assert(jv[2]["features"][0]["tag"].str == "test"); + assert(jv[2]["index"]["byteStart"].uinteger == 46); + assert(jv[2]["index"]["byteEnd"].uinteger == 51); + assert(jv[3]["features"][0]["$type"].str == "app.bsky.richtext.facet#tag"); + assert(jv[3]["features"][0]["tag"].str == "end"); + assert(jv[3]["index"]["byteStart"].uinteger == 57); + assert(jv[3]["index"]["byteEnd"].uinteger == 61); +} + +/******************************************************************************* + * Bluesky client + */ +class Bluesky +{ +private: + import std.concurrency; + import bsky.lexicons; + import bsky._internal.httpc; + import core.internal.gc.impl.conservative.gc; +public: + /// + alias ReplyRef = app.bsky.feed.Post.ReplyRef; + /// + alias PostRef = com.atproto.StrongRef; + /// + alias Post = bsky.post.Post; + +private: + string _endpoint = "https://bsky.social"; + shared AtprotoAuth _auth; + shared AutoUpdateStrategy _updateStrategy; + Tid _tidUpdateTokens; + HttpClientBase _httpClient; + + void _autoUpdateSession() shared @safe + { + import core.atomic; + final switch (_updateStrategy.atomicLoad) + { + case AutoUpdateStrategy.none: + break; + case AutoUpdateStrategy.herf: + _auth.updateSession(AtprotoAuth.UpdateStrategy.herf); + break; + case AutoUpdateStrategy.before5min: + _auth.updateSession(AtprotoAuth.UpdateStrategy.before5min); + break; + case AutoUpdateStrategy.expired: + _auth.updateSession(AtprotoAuth.UpdateStrategy.expired); + break; + } + } + void _autoUpdateSession() @trusted + { + (cast(shared)this)._autoUpdateSession(); + } + + string _getBearer() @safe + { + _autoUpdateSession(); + return _auth.bearer; + } + + JSONValue _get(string path, string[string] param = null) @safe + { + return _httpClient.get(_endpoint ~ path, param, &_getBearer); + } + + JSONValue _post(string path, immutable(ubyte)[] data, string mimeType) @safe + { + return _httpClient.post(_endpoint ~ path, data, mimeType, &_getBearer); + } + + JSONValue _post(string path, JSONValue data) @safe + { + auto p = data is JSONValue.init ? (immutable(ubyte)[]).init : data.toJSON().representation; + return _post(path, p, "application/json"); + } + + void _enforceHttpRes(JSONValue res) @trusted + { + enforce(_httpClient.getLastStatusCode() == 200, _httpClient.getLastStatusReason() ~ "\n\n" + ~ res.getValue("error", "Error") ~ ": " + ~ res.getValue("message", "Unknown error occurred.")); + } + + string _fetchSequencialData( + string delegate(JSONValue jv) @safe getCursor, + JSONValue[] delegate(JSONValue jv) @safe getElements, + void delegate(JSONValue[] jv) @safe append, + string path, string cursor, size_t len, string[string] params) @safe + { + import std.conv; + import std.algorithm: min; + + size_t remain = len; + auto query = params.dup; + query["limit"] = min(remain + 10, 100).to!string; + if (cursor !is null) + query["cursor"] = cursor; + string oldCursor = cursor; + while (remain > 0) + { + auto res = _get(path, query); + _enforceHttpRes(res); + auto elements = getElements(res); + if (elements.length == 0) + break; + auto newCursor = getCursor(res); + auto fetchLen = min(remain, elements.length); + if (newCursor.length == 0) + { + append(elements[0..fetchLen]); + return null; + } + if (newCursor.length > 0 && newCursor == oldCursor) + { + append(elements[0..fetchLen]); + return newCursor; + } + append(elements[0..fetchLen]); + remain -= fetchLen; + oldCursor = newCursor; + query["cursor"] = newCursor; + query["limit"] = len == size_t.max ? "100" : min(remain + 10, 100).to!string; + } + return oldCursor; + } + + FetchRange!T _makeFetchRange(T)( + string delegate(JSONValue jv) @safe getCursor, + JSONValue[] delegate(JSONValue jv) @safe getElements, + JSONValue delegate(string[string] param) @safe httpGet, + string cursor, size_t limit, string[string] params) @safe + { + import std.conv: to; + auto ret = FetchRange!T(getCursor, getElements, httpGet, null, cursor, params.dup); + ret._params["limit"] = limit.to!string; + ret._fetch(); + return ret; + } + FetchRange!T _makeFetchRange(T)( + string delegate(JSONValue jv) @safe getCursor, + JSONValue[] delegate(JSONValue jv) @safe getElements, + string path, string cursor, size_t limit, string[string] params) @safe + { + import std.conv: to; + JSONValue httpGet(string[string] param) @safe + { + auto res = _get(path, param); + _enforceHttpRes(res); + return res; + } + return _makeFetchRange!T(getCursor, getElements, &httpGet, cursor, limit, params); + } + + void _parseFacet(ref JSONValue dst, string text) @safe + { + _parseFacetImpl!((h) @safe => resolveHandle(h))(dst, text); + } + + /*************************************************************************** + * 自動セッションアップデート + * + * 以下を使用すると定期的にセッションのアクセストークン更新を行う + * しかしながら、開始と終了のタイミングをうまくコントロールするのが難しい + * ため使用を一旦保留。 + */ + version (none) + void _entryIntervalUpdateTokens() shared + { + import std.datetime; + import core.atomic; + SysTime tim = Clock.currTime; + tim += 1.hours; + bool running; + while (running && !receiveTimeout(500.msecs, + (bool cond) { running = false; }, + (Duration dur) { tim += dur; })) + { + if (tim < Clock.currTime) + { + _autoUpdateSession(); + tim += 1.hours; + } + } + } + +public: + /*************************************************************************** + * Constructor + */ + this(Client = CurlHttpClient!())(string endpoint, AtprotoAuth auth = null, Client client = new Client) @trusted + { + this(endpoint, cast(shared)auth, client); + } + /// ditto + this(Client: HttpClientBase)(string endpoint, AtprotoAuth auth = null, Client client) @trusted + { + this(endpoint, cast(shared)auth, client); + } + /// ditto + this(Client = CurlHttpClient!())(string endpoint, shared AtprotoAuth auth = null, Client client = new Client) @safe + { + this(endpoint, auth, cast(HttpClientBase)client); + } + /// ditto + this(Client: HttpClientBase)(string endpoint, shared AtprotoAuth auth = null, Client client) @safe + { + _httpClient = client; + _endpoint = endpoint; + _auth = auth; + } + /// ditto + this(Client = CurlHttpClient!())(AtprotoAuth auth, Client client = new Client) @trusted + { + this(cast(shared)auth, client); + } + /// ditto + this(Client: HttpClientBase)(AtprotoAuth auth, Client client) @trusted + { + this(cast(shared)auth, client); + } + /// ditto + this(Client = CurlHttpClient!())(shared AtprotoAuth auth, Client client = new Client) @safe + { + this(auth, cast(HttpClientBase)client); + } + /// ditto + this(Client: HttpClientBase)(shared AtprotoAuth auth, Client client) @safe + { + this("https://bsky.social", auth, client); + } + /// ditto + this(Client = CurlHttpClient!())(Client client = new Client) @safe + { + this(cast(HttpClientBase)client); + } + /// ditto + this(Client: HttpClientBase)(Client client) @safe + { + this(AtprotoAuth.init, client); + } + + + /*************************************************************************** + * Login + */ + void login(AtprotoAuth auth) @trusted + { + login(cast(shared)auth); + } + /// ditto + void login(shared AtprotoAuth auth) @safe + in (auth) + { + _auth = auth; + } + /// ditto + void login(Client = CurlHttpClient!())(string id, string password, Client client = null) @safe + { + login(id, password, cast(HttpClientBase)client); + } + /// ditto + void login(Client: HttpClientBase)(string id, string password, Client client) @safe + { + if (!_auth) + login(new AtprotoAuth(_endpoint, client ? client : _httpClient)); + _auth.createSession(id, password); + } + + /*************************************************************************** + * Logout + */ + void logout() @safe + { + _auth.deleteSession(); + } + + // login/logout + @safe unittest + { + scope client = _createDummyClient(null, null, null, null); + client.httpc.addResult(_createDummySession("did:plc:2qfqobqz6dzrfa3jv74i6k6m", "dxutjikmg579.hfor.org").toJson); + client.login("dxutjikmg579.hfor.org", "dummy", client.httpc); + with (client.req) + { + assert(method == "POST"); + assert(url == `https://bsky.social/xrpc/com.atproto.server.createSession`); + assert(query == ``); + assert(bodyBinary == `{"identifier":"dxutjikmg579.hfor.org","password":"dummy"}`.representation); + } + assert(client.available); + client.httpc.clearResult(); + client.httpc.addResult(JSONValue.init); + client.logout(); + with (client.req) + { + assert(method == "POST"); + assert(url == `https://bsky.social/xrpc/com.atproto.server.deleteSession`); + assert(query == ``); + assert(bodyBinary == ``.representation); + } + assert(!client.available); + } + + /*************************************************************************** + * Account availability + */ + bool available() const @safe + { + return _auth ? _auth.available : false; + } + + /*************************************************************************** + * Auto update access token types + */ + enum AutoUpdateStrategy + { + /*********************************************************************** + * + */ + none, + /*********************************************************************** + * + */ + herf, + /*********************************************************************** + * + */ + before5min, + /*********************************************************************** + * + */ + expired, + } + + /*************************************************************************** + * Auto update access tokens + */ + void autoUpdateAccessTokens(bool cond) @safe + { + autoUpdateAccessTokens(cond ? AutoUpdateStrategy.expired : AutoUpdateStrategy.none); + } + + /// ditto + void autoUpdateAccessTokens(AutoUpdateStrategy type) @safe + { + import core.atomic; + _updateStrategy.atomicStore(type); + } + + /// ditto + AutoUpdateStrategy autoUpdateAccessTokens() const @safe + { + import core.atomic; + return _updateStrategy.atomicLoad; + } + + // autoUpdateAccessTokens + @safe unittest + { + import std.datetime; + scope client = _createDummyClient(null, null, null, null); + auto sessionA = _createDummySession("did:plc:2qfqobqz6dzrfa3jv74i6k6m", "dxutjikmg579.hfor.org", + expireAt: DateTime(2000, 1, 1, 0, 0, 0)); + client.httpc.addResult(sessionA.toJson); + client.login("dxutjikmg579.hfor.org", "dummy", client.httpc); + with (client.req) + { + assert(method == "POST"); + assert(url == `https://bsky.social/xrpc/com.atproto.server.createSession`); + assert(query == ``); + assert(bodyBinary == `{"identifier":"dxutjikmg579.hfor.org","password":"dummy"}`.representation); + } + assert(client.bsky._auth.storeSessionOnlyToken.accessJwt == sessionA.accessJwt); + assert(client.bsky._auth.storeSessionOnlyToken.refreshJwt == sessionA.refreshJwt); + client.httpc.clearResult(); + + client.autoUpdateAccessTokens = true; + assert(client.autoUpdateAccessTokens == AutoUpdateStrategy.expired); + // 1. getSessionで400エラー(ExpiredToken) + client.httpc.addResult(400, "ExpiredToken", JSONValue(["error": "ExpiredToken"])); + // 2. refreshSessionで200 + auto sessionB = _createDummySession("did:plc:2qfqobqz6dzrfa3jv74i6k6m", "dxutjikmg579.hfor.org"); + assert(sessionA.accessJwt != sessionB.accessJwt); + client.httpc.addResult(JSONValue([ + "accessJwt": sessionB.accessJwt, + "refreshJwt": sessionB.refreshJwt, + "handle": sessionB.handle, + "did": sessionB.did])); + // 3. getSessionで200 + client.httpc.addResult(sessionB.toJson); + cast(void)client.bsky._getBearer(); + with (client.req(0)) + { + assert(method == "GET"); + assert(url == `https://bsky.social/xrpc/com.atproto.server.getSession`); + assert(query == ``); + } + with (client.req(1)) + { + assert(method == "POST"); + assert(url == `https://bsky.social/xrpc/com.atproto.server.refreshSession`); + assert(query == ``); + } + with (client.req(2)) + { + assert(method == "GET"); + assert(url == `https://bsky.social/xrpc/com.atproto.server.getSession`); + assert(query == ``); + } + assert(client.bsky._auth.storeSessionOnlyToken.accessJwt == sessionB.accessJwt); + assert(client.bsky._auth.storeSessionOnlyToken.refreshJwt == sessionB.refreshJwt); + } + + + /*************************************************************************** + * Profile + * + * - getProfile : Raw API execution. + * - profile : Get target profile. + * - getProfiles : Raw API execution. + * - fetchProfiles : Execute multiple APIs to get all data. + * - profiles : Range to perform API execution when needed. + * + * Params: + * name = did or handle, default is user of current session. + * names = did or handle list + * actors = did or handle list, max length is 25 + */ + JSONValue getProfile(string name = null) @safe + { + return _get("/xrpc/app.bsky.actor.getProfile", ["actor": name is null ? _auth.did : name]); + } + /// ditto + Profile profile(string name = null) @trusted + { + Profile ret; + ret.deserializeFromJson(getProfile(name)); + return ret; + } + /// ditto + JSONValue getProfiles(string[] actors) @trusted + in (actors.length > 0 && actors.length <= 25) + { + import std.algorithm: map; + import std.uri: encodeComponent; + import std.conv: to; + import std.format: format; + auto encNames = actors.map!(name => name.encodeComponent()).array; + auto path = format!"/xrpc/app.bsky.actor.getProfiles?%-(actors=%s&%)"(encNames); + auto ret = _get(path, null); + _enforceHttpRes(ret); + return ret; + } + /// ditto + Profile[] fetchProfiles(string[] names) @safe + { + import std.range; + auto ret = new Profile[names.length]; + size_t pos = 0; + foreach (chunkOfNames; names.chunks(25)) + { + auto res = getProfiles(chunkOfNames); + if (auto profs = "profiles" in res) + { + () @trusted { + foreach (size_t i, ref p; *profs) + ret[pos++].deserializeFromJson(p); + } (); + } + } + return ret[0..pos]; + } + /// ditto + FetchRange!Profile profiles(string[] names) @safe + { + import std.algorithm: min, map; + import std.conv: to; + import std.uri: encodeComponent; + size_t cursorIdx; + auto encNames = names.map!(name => name.encodeComponent()).array; + JSONValue httpGet(string[string]) + { + auto idx = cursorIdx; + auto idxEnd = min(idx + 25, names.length); + import std.format: format; + auto path = format!"/xrpc/app.bsky.actor.getProfiles?%-(actors=%s&%)"(encNames[idx..idxEnd]); + auto jv = _get(path, null); + _enforceHttpRes(jv); + cursorIdx = idxEnd; + return jv; + } + return _makeFetchRange!Profile( + jv => cursorIdx < names.length ? names[cursorIdx] : null, + jv => jv.getValue!(JSONValue[])("profiles"), + &httpGet, null, 100, null); + } + + // getProfiles + @safe unittest + { + scope client = _createDummyClient("2de5c4b1-09ec-41e7-90ad-add0448b262d"); + auto prof = client.profile; + with (client.req) + { + assert(method == "GET"); + assert(url == `https://bsky.social/xrpc/app.bsky.actor.getProfile`); + assert(query == `actor=did%3Aplc%3A2qfqobqz6dzrfa3jv74i6k6m`); + assert(bodyBinary == ``.representation); + } + assert(prof.handle == "dxutjikmg579.hfor.org"); + } + + // getProfiles (multi) + @safe unittest + { + scope client = _createDummyClient("0b877230-5d39-4aa1-ab65-b3e5ed2bd23a"); + auto prof = client.profiles(["krzblhls379.vkn.io", "upqbv134.esi.org", "zrlhj265.zlrc.io"]).array; + with (client.req) + { + assert(method == "GET"); + assert(url == `https://bsky.social/xrpc/app.bsky.actor.getProfiles` + ~ `?actors=krzblhls379.vkn.io&actors=upqbv134.esi.org&actors=zrlhj265.zlrc.io`); + assert(query == ``); + assert(bodyBinary == ``.representation); + } + assert(prof.length == 3); + + client.resetDataSource("0b877230-5d39-4aa1-ab65-b3e5ed2bd23a"); + auto prof2 = client.fetchProfiles(["krzblhls379.vkn.io", "upqbv134.esi.org", "zrlhj265.zlrc.io"]); + with (client.req) + { + assert(method == "GET"); + assert(url == `https://bsky.social/xrpc/app.bsky.actor.getProfiles` + ~ `?actors=krzblhls379.vkn.io&actors=upqbv134.esi.org&actors=zrlhj265.zlrc.io`); + assert(query == ``); + assert(bodyBinary == ``.representation); + } + assert(prof2.length == 3); + assert(prof2[0].handle == "krzblhls379.vkn.io"); + } + + /*************************************************************************** + * Followers + * + * - getFollowers : Raw API execution. + * - fetchFollowers : Execute multiple APIs to get all data. + * - followers : Range to perform API execution when needed. + * + * Params: + * name = did or handle, default is user of current session. + * actor = did or handle, default is user of current session. + * cursor = Cursor for sequential data retrieval. + * len = Number of data to be acquired at one time. + * limit = Number to retrieve in a single API run. + */ + JSONValue getFollowers(string actor, string cursor = null, size_t limit = 100) @safe + { + import std.conv: to; + return _get("/xrpc/app.bsky.graph.getFollowers", cursor is null + ? ["actor": actor, "limit": limit.to!string()] + : ["actor": actor, "limit": limit.to!string(), "cursor": cursor]); + } + /// ditto + User[] fetchFollowers(string name, size_t len = 100) @safe + { + User[] ret; + _fetchSequencialData( + (jv) @safe => jv.getValue!string("cursor", null), + (jv) @safe => jv.getValue!(JSONValue[])("followers"), + (jv) @trusted { + ret.length = ret.length + jv.length; + foreach (i; 0..jv.length) + ret[$ - jv.length + i].deserializeFromJson(jv[i]); + }, + "/xrpc/app.bsky.graph.getFollowers", null, len, ["actor": name]); + return ret; + } + /// ditto + User[] fetchFollowers() @safe + { + return fetchFollowers(_auth.did); + } + /// ditto + FetchRange!User followers(string name, size_t limit = 100) @safe + { + return _makeFetchRange!User( + jv => jv.getValue!string("cursor", null), + jv => jv.getValue!(JSONValue[])("followers"), + "/xrpc/app.bsky.graph.getFollowers", null, limit, ["actor": name]); + } + /// ditto + FetchRange!User followers() @safe + { + return followers(_auth.did); + } + + // followers/getFollowers + @safe unittest + { + import std.range; + scope client = _createDummyClient("2db7023a-b2d2-447d-bfce-9e417a17bdac"); + auto prof = client.followers("upqbv134.esi.org", limit: 10).take(5).array; + with (client.req) + { + assert(method == "GET"); + assert(url == `https://bsky.social/xrpc/app.bsky.graph.getFollowers`); + assert(query == `limit=10&actor=upqbv134.esi.org`); + assert(bodyBinary == ``.representation); + } + assert(prof.length == 5); + } + + // fetchFollowers + @safe unittest + { + import std.range; + scope client = _createDummyClient("2db7023a-b2d2-447d-bfce-9e417a17bdac"); + auto prof = client.fetchFollowers("upqbv134.esi.org", 10); + with (client.req) + { + assert(method == "GET"); + assert(url == `https://bsky.social/xrpc/app.bsky.graph.getFollowers`); + assert(query == `limit=20&actor=upqbv134.esi.org`, query); + assert(bodyBinary == ``.representation); + } + assert(prof.length == 10); + } + + /*************************************************************************** + * Follows + * + * - getFollows : Raw API execution + * - fetchFollows : Execute multiple APIs to get all data. + * - follows : Range to perform API execution when needed. + * + * Params: + * name = did or handle, default is user of current session. + * actor = did or handle, default is user of current session. + * cursor = Cursor for sequential data retrieval. + * limit = Number to retrieve in a single API run. + */ + JSONValue getFollows(string actor, string cursor = null, size_t limit = 50) @safe + { + import std.conv: to; + return _get("/xrpc/app.bsky.graph.getFollows", cursor is null + ? ["actor": actor, "limit": limit.to!string()] + : ["actor": actor, "limit": limit.to!string(), "cursor": cursor]); + } + /// ditto + User[] fetchFollows(string name, size_t len = 100) @safe + { + User[] ret; + void append(JSONValue[] jv) + { + ret.length = ret.length + jv.length; + foreach (i; 0..jv.length) + ret[$ - jv.length + i].deserializeFromJson(jv[i]); + } + _fetchSequencialData( + (jv) @safe => jv.getValue!string("cursor", null), + (jv) @safe => jv.getValue!(JSONValue[])("follows"), + (jv) @trusted { + ret.length = ret.length + jv.length; + foreach (i; 0..jv.length) + ret[$ - jv.length + i].deserializeFromJson(jv[i]); + }, + "/xrpc/app.bsky.graph.getFollows", null, len, ["actor": name]); + return ret; + } + /// ditto + User[] fetchFollows() @safe + { + return fetchFollows(_auth.did); + } + /// ditto + FetchRange!User follows(string name, size_t limit = 100) @safe + { + return _makeFetchRange!User( + (jv) @safe => jv.getValue!string("cursor", null), + (jv) @safe => jv.getValue!(JSONValue[])("follows"), + "/xrpc/app.bsky.graph.getFollows", null, limit, ["actor": name]); + } + /// ditto + FetchRange!User follows() @safe + { + return follows(_auth.did); + } + + // follows/getFollows + @safe unittest + { + import std.range; + scope client = _createDummyClient("a2a5d059-987f-4f7e-bc7d-db5c7e61519a"); + auto prof = client.follows("upqbv134.esi.org", limit: 10).take(5).array; + with (client.req) + { + assert(method == "GET"); + assert(url == `https://bsky.social/xrpc/app.bsky.graph.getFollows`); + assert(query == `limit=10&actor=upqbv134.esi.org`); + assert(bodyBinary == ``.representation); + } + assert(prof.length == 5); + } + // fetchFollows + @safe unittest + { + import std.range; + scope client = _createDummyClient("a2a5d059-987f-4f7e-bc7d-db5c7e61519a"); + auto prof = client.fetchFollows("upqbv134.esi.org", len: 5); + with (client.req) + { + assert(method == "GET"); + assert(url == `https://bsky.social/xrpc/app.bsky.graph.getFollows`); + assert(query == `limit=15&actor=upqbv134.esi.org`); + assert(bodyBinary == ``.representation); + } + assert(prof.length == 5); + } + + /*************************************************************************** + * Result of get timeline + */ + struct TimelineResult + { + /// + Feed[] feed; + /// + string cursor; + } + /*************************************************************************** + * Get timeline + * + * - getTimeline : Raw API execution. + * - fetchTimeline : Execute multiple APIs to get all data. + * - timeline : Range to perform API execution when needed. + * + * Params + * cursor = Cursor for sequential data retrieval. + * len = Number of data to be acquired at one time. + * limit = Number to retrieve in a single API run. + */ + JSONValue getTimeline(string cursor, size_t limit) @safe + { + import std.conv: to; + auto res = _get("/xrpc/app.bsky.feed.getTimeline", cursor is null + ? ["limit": limit.to!string()] + : ["limit": limit.to!string(), "cursor": cursor]); + _enforceHttpRes(res); + return res; + } + /// ditto + TimelineResult fetchTimeline(string cursor, size_t len = 100) @safe + { + TimelineResult ret; + ret.cursor = _fetchSequencialData( + (jv) @safe => jv.getValue!string("cursor", null), + (jv) @safe => jv.getValue!(JSONValue[])("feed"), + (jv) @trusted { + ret.feed.length = ret.feed.length + jv.length; + foreach (i; 0..jv.length) + ret.feed[$ - jv.length + i].deserializeFromJson(jv[i]); + }, + "/xrpc/app.bsky.feed.getTimeline", cursor, len, null); + return ret; + } + /// ditto + Feed[] fetchTimeline(size_t len) @safe + { + return fetchTimeline(null, len).feed; + } + /// ditto + FetchRange!Feed timeline(string cursor, size_t limit = 100) @safe + { + return _makeFetchRange!Feed( + jv => jv.getValue!string("cursor", null), + jv => jv.getValue!(JSONValue[])("feed"), + "/xrpc/app.bsky.feed.getTimeline", cursor, limit, null); + } + /// ditto + FetchRange!Feed timeline(size_t limit = 100) @safe + { + return timeline(null, limit); + } + + // timeline/getTimeline + @safe unittest + { + import std.range; + scope client = _createDummyClient("657d70c5-eb4d-4b33-ab35-86a1589c2e9a"); + auto timeline = client.timeline(limit: 10).take(5).array; + with (client.req) + { + assert(method == "GET"); + assert(url == `https://bsky.social/xrpc/app.bsky.feed.getTimeline`); + assert(query == `limit=10`); + assert(bodyBinary == ``.representation); + } + assert(timeline.length == 5); + } + + // fetchTimeline + @safe unittest + { + import std.range; + scope client = _createDummyClient("657d70c5-eb4d-4b33-ab35-86a1589c2e9a"); + auto timeline = client.fetchTimeline(len: 5); + with (client.req) + { + assert(method == "GET"); + assert(url == `https://bsky.social/xrpc/app.bsky.feed.getTimeline`); + assert(query == `limit=15`); + assert(bodyBinary == ``.representation); + } + assert(timeline.length == 5); + } + + /// + alias AuthorFeedResult = TimelineResult; + /*************************************************************************** + * Posts and reposts by any user + * + * - getAuthorFeed : + * - fetchAuthorFeed : + * - authorFeed : + * + * Params: + * name = did or handle, default is user of current session. + * actor = did or handle, default is user of current session. + * + */ + JSONValue getAuthorFeed(string actor, string cursor, size_t limit = 50) @safe + { + import std.conv: to; + auto res = _get("/xrpc/app.bsky.feed.getAuthorFeed", cursor is null + ? ["actor": actor, "limit": limit.to!string()] + : ["actor": actor, "limit": limit.to!string(), "cursor": cursor]); + _enforceHttpRes(res); + return res; + } + /// ditto + AuthorFeedResult fetchAuthorFeed(string name, string cursor, size_t len = 100) @safe + { + AuthorFeedResult ret; + ret.cursor = _fetchSequencialData( + (jv) @safe => jv.getValue!string("cursor", null), + (jv) @safe => jv.getValue!(JSONValue[])("feed"), + (jv) @trusted { + ret.feed.length = ret.feed.length + jv.length; + foreach (i; 0..jv.length) + ret.feed[$ - jv.length + i].deserializeFromJson(jv[i]); + }, + "/xrpc/app.bsky.feed.getAuthorFeed", cursor, len, null); + return ret; + } + /// ditto + FetchRange!Feed authorFeed(string name, string cursor, size_t limit = 100) @safe + { + return _makeFetchRange!Feed( + (jv) @safe => jv.getValue!string("cursor", null), + (jv) @safe => jv.getValue!(JSONValue[])("feed"), + "/xrpc/app.bsky.feed.getAuthorFeed", cursor, limit, ["actor": name]); + } + /// ditto + FetchRange!Feed authorFeed(string name, size_t limit = 100) @safe + { + return authorFeed(name, null, limit); + } + /// ditto + FetchRange!Feed authorFeed(size_t limit = 100) @safe + { + return authorFeed(_auth.did, null, limit); + } + + // getAuthorFeed + @safe unittest + { + import std.range; + scope client = _createDummyClient("89f1adc0-187a-446f-8bae-9c21639622b6"); + auto items = client.authorFeed().take(3); + with (client.req) + { + assert(method == "GET"); + assert(url == `https://bsky.social/xrpc/app.bsky.feed.getAuthorFeed`); + assert(query == `limit=100&actor=did%3Aplc%3A2qfqobqz6dzrfa3jv74i6k6m`, query); + assert(bodyBinary == ``.representation); + } + assert(items.walkLength == 2); + } + + /// + alias FeedResult = TimelineResult; + /*************************************************************************** + * Posts by any feed + */ + JSONValue getFeed(string feedUri, string cursor, size_t limit = 50) @safe + { + import std.conv: to; + auto res = _get("/xrpc/app.bsky.feed.getFeed", cursor is null + ? ["feed": feedUri, "limit": limit.to!string()] + : ["feed": feedUri, "limit": limit.to!string(), "cursor": cursor]); + _enforceHttpRes(res); + return res; + } + /// ditto + FeedResult fetchFeed(string feedUri, string cursor, size_t len = 100) @safe + { + FeedResult ret; + ret.cursor = _fetchSequencialData( + (jv) @safe => jv.getValue!string("cursor", null), + (jv) @safe => jv.getValue!(JSONValue[])("feed"), + (jv) @trusted { + ret.feed.length = ret.feed.length + jv.length; + foreach (i; 0..jv.length) + ret.feed[$ - jv.length + i].deserializeFromJson(jv[i]); + }, + "/xrpc/app.bsky.feed.getFeed", cursor, len, null); + return ret; + } + /// ditto + FetchRange!Feed feed(string feedUri, string cursor, size_t limit = 100) @safe + { + return _makeFetchRange!Feed( + (jv) @safe => jv.getValue!string("cursor", null), + (jv) @safe => jv.getValue!(JSONValue[])("feed"), + "/xrpc/app.bsky.feed.getFeed", cursor, limit, ["feed": feedUri]); + } + /// ditto + FetchRange!Feed feed(string feedUri, size_t limit = 100) @safe + { + return feed(feedUri, null, limit); + } + + /// + alias ListFeedResult = TimelineResult; + /*************************************************************************** + * Posts by any feed + * + * + */ + JSONValue getListFeed(string listUri, string cursor, size_t limit = 50) @safe + { + import std.conv: to; + auto res = _get("/xrpc/app.bsky.feed.getListFeed", cursor is null + ? ["list": listUri, "limit": limit.to!string()] + : ["list": listUri, "limit": limit.to!string(), "cursor": cursor]); + _enforceHttpRes(res); + return res; + } + /// ditto + auto fetchListFeed(string listUri, string cursor, size_t len) @safe + { + ListFeedResult ret; + ret.cursor = _fetchSequencialData( + (jv) @safe => jv.getValue!string("cursor", null), + (jv) @safe => jv.getValue!(JSONValue[])("feed"), + (jv) @trusted { + ret.feed.length = ret.feed.length + jv.length; + foreach (i; 0..jv.length) + ret.feed[$ - jv.length + i].deserializeFromJson(jv[i]); + }, + "/xrpc/app.bsky.feed.getListFeed", cursor, len, null); + return ret; + } + /// ditto + FetchRange!Feed listFeed(string listUri, string cursor, size_t limit = 100) @safe + { + return _makeFetchRange!Feed( + (jv) @safe => jv.getValue!string("cursor", null), + (jv) @safe => jv.getValue!(JSONValue[])("feed"), + "/xrpc/app.bsky.feed.getListFeed", cursor, limit, ["list": listUri]); + } + /// ditto + FetchRange!Feed listFeed(string listUri, size_t limit = 100) @safe + { + return listFeed(listUri, null, limit); + } + + /*************************************************************************** + * Result of search posts + */ + struct SearchPostsResult + { + /// + string cursor; + /// + size_t hitsTotal; + /// + Post[] posts; + } + /*************************************************************************** + * Search posts + */ + JSONValue searchPosts(string query, string cursor, size_t limit = 50) @safe + { + import std.conv: to; + auto res = _get("/xrpc/app.bsky.feed.searchPosts", cursor is null + ? ["q": query, "limit": limit.to!string()] + : ["q": query, "limit": limit.to!string(), "cursor": cursor]); + _enforceHttpRes(res); + return res; + } + /// ditto + SearchPostsResult fetchSearchPosts(string query, string cursor, size_t len = 100) @safe + { + SearchPostsResult ret; + ret.cursor = _fetchSequencialData( + (jv) @safe { + ret.hitsTotal = jv.getValue!size_t("hitsTotal", 0); + return jv.getValue!string("cursor", null); + }, + (jv) @safe => jv.getValue!(JSONValue[])("posts"), + (jv) @trusted { + ret.posts.length = ret.posts.length + jv.length; + foreach (i; 0..jv.length) + ret.posts[$ - jv.length + i].deserializeFromJson(jv[i]); + }, + "/xrpc/app.bsky.feed.searchPosts", cursor, len, ["q": query]); + return ret; + } + /// ditto + Post[] fetchSearchPosts(string query, size_t len = 100) @safe + { + return fetchSearchPosts(query, null, len).posts; + } + /// ditto + FetchRange!Post searchPostItems(string query, string cursor, size_t limit = 100) @safe + { + return _makeFetchRange!Post( + (jv) @safe => jv.getValue!string("cursor", null), + (jv) @safe => jv.getValue!(JSONValue[])("posts"), + "/xrpc/app.bsky.feed.searchPosts", cursor, limit, ["q": query]); + } + /// ditto + FetchRange!Post searchPostItems(string query, size_t limit = 100) @safe + { + return searchPostItems(query, null, limit); + } + + // searchPostItems/searchPosts + @safe unittest + { + import std.range; + scope client = _createDummyClient("906a0151-0e10-4cbc-8e42-a8138271a180"); + auto items = client.searchPostItems("#dlang", 5).take(3).array; + with (client.req) + { + assert(method == "GET"); + assert(url == `https://bsky.social/xrpc/app.bsky.feed.searchPosts`); + assert(query == `limit=5&q=%23dlang`); + assert(bodyBinary == ``.representation); + } + assert(items.length == 3); + } + + // fetchSearchPosts + @safe unittest + { + import std.range; + scope client = _createDummyClient("906a0151-0e10-4cbc-8e42-a8138271a180"); + auto items = client.fetchSearchPosts("#dlang", len: 5); + with (client.req) + { + assert(method == "GET"); + assert(url == `https://bsky.social/xrpc/app.bsky.feed.searchPosts`); + assert(query == `limit=15&q=%23dlang`); + assert(bodyBinary == ``.representation); + } + assert(items.length == 5); + } + + /*************************************************************************** + * + */ + JSONValue getPosts(string[] uris) @safe + in (uris.length > 0 && uris.length <= 25) + { + import std.algorithm: map; + import std.uri: encodeComponent; + import std.format: format; + auto encUrls = uris.map!(name => name.encodeComponent()).array; + auto path = format!"/xrpc/app.bsky.feed.getPosts?%-(uris=%s&%)"(encUrls); + auto ret = _get(path, null); + _enforceHttpRes(ret); + return ret; + } + /// ditto + Post[] fetchPosts(string[] uris) @safe + { + return getPostItems(uris).array; + } + /// ditto + auto getPostItems(Range)(Range uris) @safe + if (is(imported!"std.range".ElementType!Range: string)) + { + import std.range: chunks; + import std.algorithm: map, cache, joiner; + return uris.chunks(25).map!( (uriChunk) + { + auto posts = getPosts(uriChunk[]); + auto pJv = enforce("posts" in posts); + enforce(pJv.type == JSONType.array); + return (() @trusted => (*pJv).deserializeFromJson!(Post[]))(); + }).cache.joiner; + } + // getPosts + @safe unittest + { + scope client = _createDummyClient("fe3b5003-a909-496f-a5de-97b1186f7bba"); + auto items = client.getPostItems([ + "at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/5ni6rkonpzlx2", + "at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/hyq6lbnl45len"]).array; + with (client.req) + { + assert(method == "GET"); + assert(url == `https://bsky.social/xrpc/app.bsky.feed.getPosts` + ~ `?uris=at%3A%2F%2Fdid%3Aplc%3Avibjcyg6myvxdi4ezdrhcsuo%2Fapp.bsky.feed.post%2F5ni6rkonpzlx2` + ~ `&uris=at%3A%2F%2Fdid%3Aplc%3Avibjcyg6myvxdi4ezdrhcsuo%2Fapp.bsky.feed.post%2Fhyq6lbnl45len`); + assert(query == ``); + assert(bodyBinary == ``.representation); + } + assert(items.length == 2); + assert(items[0].author.handle == "krzblhls379.vkn.io"); + assert(items[0].record["text"].str == "ほな、試しにもう一回言うてみ。"); + assert(items[0].record["reply"]["parent"]["uri"].str + == "at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/5ni6rkonpzlx2"); + assert(items[1].author.handle == "krzblhls379.vkn.io"); + assert(items[1].record["text"].str == "こんにちは、青空さん!\n空の色、めっちゃ綺麗なブルーやな。\n" + ~ "まるで、海原に浮かぶ船みたいや。\nそやけど、その船はどこへ行くんやろ?\n" + ~ "もしかしたら、宝島を目指してるんかもしれへんな。"); + } + + /*************************************************************************** + * + */ + struct GetRepostResult + { + /// + User[] repostedBy; + /// + string cursor; + } + + /*************************************************************************** + * Users who has reposted the post + */ + JSONValue getRepostedBy(string uri, string cursor, size_t limit = 50) @safe + { + import std.conv: to; + auto res = _get("/xrpc/app.bsky.feed.getRepostedBy", cursor is null + ? ["uri": uri, "limit": limit.to!string()] + : ["uri": uri, "limit": limit.to!string(), "cursor": cursor]); + _enforceHttpRes(res); + return res; + } + /// ditto + GetRepostResult fetchRepostedByUsers(string uri, string cursor, size_t len = 100) @safe + { + GetRepostResult ret; + ret.cursor = _fetchSequencialData( + (jv) @safe => jv.getValue!string("cursor", null), + (jv) @safe => jv.getValue!(JSONValue[])("repostedBy"), + (jv) @trusted { + ret.repostedBy.length = ret.repostedBy.length + jv.length; + foreach (i; 0..jv.length) + ret.repostedBy[$ - jv.length + i].deserializeFromJson(jv[i]); + }, + "/xrpc/app.bsky.feed.getRepostedBy", cursor, len, ["uri": uri]); + return ret; + } + /// ditto + User[] fetchRepostedByUsers(string uri, size_t len = 100) @safe + { + return fetchRepostedByUsers(uri, null, len).repostedBy; + } + /// ditto + FetchRange!User repostedByUsers(string uri, string cursor, size_t limit = 100) @safe + { + return _makeFetchRange!User( + (jv) @safe => jv.getValue!string("cursor", null), + (jv) @safe => jv.getValue!(JSONValue[])("repostedBy"), + "/xrpc/app.bsky.feed.getRepostedBy", cursor, limit, ["uri": uri]); + } + /// ditto + FetchRange!User repostedByUsers(string uri, size_t limit = 100) @safe + { + return repostedByUsers(uri, null, limit); + } + + // repostedByUsers/getRepostedBy + @safe unittest + { + import std.range; + scope client = _createDummyClient("d8b6ad01-f85e-4d8e-bbd0-66347c7025c5"); + auto items = client.repostedByUsers( + "at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.feed.post/2mbuau2ygfu4w").array; + with (client.req) + { + assert(method == "GET"); + assert(url == `https://bsky.social/xrpc/app.bsky.feed.getRepostedBy`); + assert(query == `limit=100` + ~ `&uri=at%3A%2F%2Fdid%3Aplc%3Amhz3szj7pcjfpzv7pylcmlgx%2Fapp.bsky.feed.post%2F2mbuau2ygfu4w`); + assert(bodyBinary == ``.representation); + } + assert(items.length == 2); + } + + // fetchRepostedBy + @safe unittest + { + import std.range; + scope client = _createDummyClient("d8b6ad01-f85e-4d8e-bbd0-66347c7025c5"); + auto items = client.fetchRepostedByUsers( + "at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.feed.post/2mbuau2ygfu4w", + len: 5); + with (client.req) + { + assert(method == "GET"); + assert(url == `https://bsky.social/xrpc/app.bsky.feed.getRepostedBy`); + assert(query == `limit=15` + ~ `&uri=at%3A%2F%2Fdid%3Aplc%3Amhz3szj7pcjfpzv7pylcmlgx%2Fapp.bsky.feed.post%2F2mbuau2ygfu4w`); + assert(bodyBinary == ``.representation); + } + assert(items.length == 2); + } + + /*************************************************************************** + * + */ + struct Like + { + /// + @systimeConverter + SysTime indexedAt; + /// + @systimeConverter + SysTime createdAt; + /// + User actor; + } + /// ditto + struct GetLikeResult + { + /// + Like[] likes; + /// + string cursor; + } + + /*************************************************************************** + * Users who has liked the post + */ + JSONValue getLikes(string uri, string cursor, size_t limit = 50) @safe + { + import std.conv: to; + auto res = _get("/xrpc/app.bsky.feed.getLikes", cursor is null + ? ["uri": uri, "limit": limit.to!string()] + : ["uri": uri, "limit": limit.to!string(), "cursor": cursor]); + _enforceHttpRes(res); + return res; + } + /// ditto + GetLikeResult fetchLikeUsers(string uri, string cursor, size_t len = 100) @safe + { + import std.conv; + GetLikeResult ret; + ret.cursor = _fetchSequencialData( + (jv) @safe => jv.getValue!string("cursor", null), + (jv) @safe => jv.getValue!(JSONValue[])("likes"), + (jv) @trusted { + ret.likes.length = ret.likes.length + jv.length; + foreach (i; 0..jv.length) + ret.likes[$ - jv.length + i].deserializeFromJson(jv[i]); + }, + "/xrpc/app.bsky.feed.getLikes", cursor, len, ["uri": uri]); + return ret; + } + /// ditto + Like[] fetchLikeUsers(string uri, size_t len = 100) @safe + { + return fetchLikeUsers(uri, null, len).likes; + } + /// ditto + FetchRange!Like likeUsers(string uri, string cursor, size_t limit = 100) @safe + { + return _makeFetchRange!Like( + (jv) @safe => jv.getValue!string("cursor", null), + (jv) @safe => jv.getValue!(JSONValue[])("likes"), + "/xrpc/app.bsky.feed.getLikes", cursor, limit, ["uri": uri]); + } + /// ditto + FetchRange!Like likeUsers(string uri, size_t limit = 100) @safe + { + return likeUsers(uri, null, limit); + } + + // likeUsers/getLikes + @safe unittest + { + import std.range; + scope client = _createDummyClient("38ac37c4-c30b-4458-9c62-8c4abe8d71e9"); + auto items = client.likeUsers("at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.feed.post/2mbuau2ygfu4w").array; + with (client.req) + { + assert(method == "GET"); + assert(url == `https://bsky.social/xrpc/app.bsky.feed.getLikes`); + assert(query == `limit=100` + ~ `&uri=at%3A%2F%2Fdid%3Aplc%3Amhz3szj7pcjfpzv7pylcmlgx%2Fapp.bsky.feed.post%2F2mbuau2ygfu4w`); + assert(bodyBinary == ``.representation); + } + assert(items.length == 3); + } + + // fetchLikeUsers + @safe unittest + { + import std.range; + scope client = _createDummyClient("38ac37c4-c30b-4458-9c62-8c4abe8d71e9"); + auto items = client.fetchLikeUsers("at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.feed.post/2mbuau2ygfu4w", + len: 5); + with (client.req) + { + assert(method == "GET"); + assert(url == `https://bsky.social/xrpc/app.bsky.feed.getLikes`); + assert(query == `limit=15` + ~ `&uri=at%3A%2F%2Fdid%3Aplc%3Amhz3szj7pcjfpzv7pylcmlgx%2Fapp.bsky.feed.post%2F2mbuau2ygfu4w`); + assert(bodyBinary == ``.representation); + } + assert(items.length == 3); + } + + /*************************************************************************** + * Resolve handle + * + * Params: + * handle = handle of user + * Returns: + * did + */ + string resolveHandle(string handle) @safe + { + auto res = _get("/xrpc/com.atproto.identity.resolveHandle", ["handle": handle]); + _enforceHttpRes(res); + return res.getValue("did", ""); + } + + // resolveHandle + @safe unittest + { + auto client = _createDummyClient(); + client.httpc.addResult(JSONValue(["did": "did:plc:mhz3szj7pcjfpzv7pylcmlgx"])); + auto did = client.resolveHandle("upqbv134.esi.org"); + with (client.req) + { + assert(method == "GET"); + assert(url == `https://bsky.social/xrpc/com.atproto.identity.resolveHandle`); + assert(query == `handle=upqbv134.esi.org`); + assert(bodyBinary == ``.representation); + } + assert(did == "did:plc:mhz3szj7pcjfpzv7pylcmlgx"); + } + + /*************************************************************************** + * Create repository record + * + * Params: + * record = Record to create as + * collection = Collection of record + * validate = Can be set to 'false' to skip Lexicon schema validation of record data, + * 'true' to require it, or leave unset to validate only for known Lexicons. + * rkey = The Record Key. + * swapCommit = Compare and swap with the previous commit by CID. + * Returns: + * JSON of upload result data + */ + JSONValue createRecord(JSONValue record, + string collection = "app.bsky.feed.post", + string rkey = null, bool validate = true, string swapCommit = null) @safe + { + auto postData = JSONValue([ + "repo": JSONValue(_auth.did), + "collection": JSONValue(collection), + "record": record, + "validate": JSONValue(validate)]); + if (rkey !is null) + postData.setValue("rkey", rkey); + if (swapCommit.length > 0) + postData.setValue("swapCommit", swapCommit); + auto res = _post("/xrpc/com.atproto.repo.createRecord", postData); + _enforceHttpRes(res); + return res; + } + + /*************************************************************************** + * Delete repository record + * + * Params: + * collection = Collection of record + * rkey = refresh key of record + * swapRecord = Swap Record + * swapCommit = Swap Commit + * Returns: + * JSON of upload result data + */ + void deleteRecord(string collection, string rkey, + string swapRecord = null, string swapCommit = null) @safe + { + auto postData = JSONValue([ + "repo": JSONValue(_auth.did), + "collection": JSONValue(collection), + "rkey": JSONValue(rkey)]); + if (swapRecord.length > 0) + postData.setValue("swapRecord", swapRecord); + if (swapCommit.length > 0) + postData.setValue("swapCommit", swapCommit); + auto res = _post("/xrpc/com.atproto.repo.deleteRecord", postData); + _enforceHttpRes(res); + } + + /*************************************************************************** + * Upload blob data + * + * Params: + * data = Upload data + * mimeType = Upload data type + * Returns: + * JSON of upload result data + */ + Blob uploadBlob(immutable(ubyte)[] data, string mimeType) @safe + { + auto res = _post("/xrpc/com.atproto.repo.uploadBlob", data, mimeType); + _enforceHttpRes(res); + return res["blob"].deserializeFromJson!Blob; + } + + /*************************************************************************** + * Reply data from URI + * + * Params: + * uri = URI of reply parent + * Returns: + * PostRef: bsky.lexcons.com.atproto.repo.StrongRef + */ + StrongRef getRecordRef(string uri) @safe + { + import bsky.data: AtProtoURI; + PostRef ret; + auto recordUri = AtProtoURI(uri); + auto record = _get("/xrpc/com.atproto.repo.getRecord", [ + "repo": recordUri.authority, + "collection": recordUri.collection, + "rkey": recordUri.rkey]); + _enforceHttpRes(record); + ret.uri = record["uri"].get!string; + ret.cid = record["cid"].get!string; + return ret; + } + /// ditto + alias getPostRef = getRecordRef; + + /*************************************************************************** + * Embed data of image + */ + struct EmbedImage + { + /*********************************************************************** + * Binary of image + * + * Limitation: < 1MB + */ + immutable(ubyte)[] image; + /*********************************************************************** + * MimeType of image + */ + string mimeType; + /*********************************************************************** + * Alt text + */ + string alt; + } + /// ditto + app.bsky.embed.Images.Image getEmbedImage(EmbedImage image) @safe + { + return app.bsky.embed.Images.Image( + image: uploadBlob(image.image, image.mimeType), + alt: image.alt); + } + /// ditto + app.bsky.embed.Images.Image getEmbedImage(immutable(ubyte)[] imageData, string mimeType, string alt) @safe + { + return app.bsky.embed.Images.Image( + image: uploadBlob(imageData, mimeType), + alt: alt); + } + /// ditto + app.bsky.embed.Images getEmbedImages(EmbedImage[] images) @safe + { + import std.algorithm, std.array; + return app.bsky.embed.Images( + images: images.map!(img => getEmbedImage(img)).array); + } + + /*************************************************************************** + * Embed data of external link + */ + struct EmbedExternal + { + /*********************************************************************** + * URL of external link + */ + string uri; + /*********************************************************************** + * Title of external link + */ + string title; + /*********************************************************************** + * Descriptions + */ + string description; + /*********************************************************************** + * Thumbnail of external link + */ + immutable(ubyte)[] thumb; + /*********************************************************************** + * Thumbnail of external link + */ + string thumbMimeType; + } + /// ditto + app.bsky.embed.External getEmbedExternal(EmbedExternal external) @safe + { + return app.bsky.embed.External( + app.bsky.embed.External.External( + uri: external.uri, + title: external.title, + description: external.description, + thumb: external.thumb.length != 0 + ? uploadBlob(external.thumb, external.thumbMimeType) + : Blob.init) + ); + } + /// ditto + app.bsky.embed.External getEmbedExternal(string uri, string title, string description, + immutable(ubyte)[] thumb = null, string thumbMimeType = null) @safe + { + return getEmbedExternal(EmbedExternal(uri, title, description, thumb, thumbMimeType)); + } + + /*************************************************************************** + * Embed data of external link + */ + alias EmbedRecord = StrongRef; + + /*************************************************************************** + * Embed data of external link + */ + struct EmbedRecordWithMedia + { + /*********************************************************************** + * URI of record + */ + PostRef record; + /*********************************************************************** + * Image data + */ + alias Media = SumType!(EmbedImage[], EmbedExternal); + /// ditto + Media media; + } + + /// + alias EmbedData = SumType!(EmbedImage[], EmbedExternal, EmbedRecord, EmbedRecordWithMedia); + + /*************************************************************************** + * Reply data from URI + * + * Params: + * uri = URI of reply parent + * Returns: + * ReplyRef: bsky.lexcons.app.bsky.feed.ReplyRef + */ + ReplyRef getReplyRef(string uri) @safe + { + import bsky.data: AtProtoURI; + ReplyRef reply; + auto parentUri = AtProtoURI(uri); + auto parent = _get("/xrpc/com.atproto.repo.getRecord", [ + "repo": parentUri.authority, + "collection": parentUri.collection, + "rkey": parentUri.rkey]); + _enforceHttpRes(parent); + reply.parent = PostRef(parent["uri"].get!string, parent["cid"].get!string); + if (auto parentReply = "reply" in parent["value"]) + { + reply.root = PostRef((*parentReply)["root"]["uri"].get!string, (*parentReply)["root"]["cid"].get!string); + } + else + { + reply.root = reply.parent; + } + return reply; + } + + /*************************************************************************** + * Post message + * + * Params: + * record = Record of post + * message = Main text of post + * langs = Array of language of post, default (null) is nothing + * images = Embed images of post + * + */ + PostRef sendPost(string message, Embed embed, + ReplyRef replyRef = ReplyRef.init, string[] langs = null) @safe + { + import std.datetime: Clock; + import std.algorithm: map; + auto jvPost = JSONValue([ + "$type": "app.bsky.feed.post", + "text": message, + "createdAt": Clock.currTime.toUTC.toISOExtString]); + auto jvFacets = JSONValue.emptyArray; + _parseFacet(jvFacets, message); + if ((() @trusted => jvFacets.array.length)() > 0) + jvPost["facets"] = jvFacets; + if (langs.length > 0) + jvPost["langs"] = JSONValue(langs); + + JSONValue _getReplyData(ReplyRef reply) @safe + { + return JSONValue([ + "root": JSONValue([ + "uri": reply.root.uri, + "cid": reply.root.cid]), + "parent": JSONValue([ + "uri": reply.parent.uri, + "cid": reply.parent.cid]), + ]); + } + if (embed !is Embed.init) + jvPost["embed"] = embed.serializeToJson(); + if (replyRef !is ReplyRef.init) + jvPost["reply"] = _getReplyData(replyRef); + return createRecord(jvPost).deserializeFromJson!PostRef(); + } + /// ditto + PostRef sendPost(string message, EmbedData embed, + ReplyRef replyRef = ReplyRef.init, string[] langs = null) @safe + { + import std.datetime: Clock; + import std.algorithm: map; + auto jvPost = JSONValue([ + "$type": "app.bsky.feed.post", + "text": message, + "createdAt": Clock.currTime.toUTC.toISOExtString]); + auto jvFacets = JSONValue.emptyArray; + _parseFacet(jvFacets, message); + if ((() @trusted => jvFacets.array.length)() > 0) + jvPost["facets"] = jvFacets; + if (langs.length > 0) + jvPost["langs"] = JSONValue(langs); + + JSONValue _getEmbedImageData(EmbedImage[] images) @safe + { + return JSONValue(images.map!(img => JSONValue([ + "alt": JSONValue(img.alt), + "image": uploadBlob(img.image, img.mimeType).serializeToJson])).array); + } + JSONValue _getEmbedImage(EmbedImage[] images) @safe + { + return JSONValue([ + "$type": JSONValue("app.bsky.embed.images"), + "images": _getEmbedImageData(images)]); + } + JSONValue _getEmbedExternal(EmbedExternal external) @safe + { + return JSONValue([ + "$type": JSONValue("app.bsky.embed.external"), + "external": JSONValue(external.thumb.length > 0 + ? [ + "uri": JSONValue(external.uri), + "title": JSONValue(external.title), + "description": JSONValue(external.description), + "thumb": uploadBlob(external.thumb, external.thumbMimeType).serializeToJson() + ] : [ + "uri": JSONValue(external.uri), + "title": JSONValue(external.title), + "description": JSONValue(external.description) + ]) + ]); + } + JSONValue _getEmbedRecord(EmbedRecord record) @safe + { + return JSONValue([ + "$type": JSONValue("app.bsky.embed.record"), + "record": JSONValue([ + "uri": record.uri, + "cid": record.cid])]); + } + JSONValue _getEmbedRecordWithMedia(EmbedRecordWithMedia rwm) @safe + { + return JSONValue([ + "$type": JSONValue("app.bsky.embed.record_with_media"), + "record": JSONValue(["record": JSONValue([ + "uri": rwm.record.uri, + "cid": rwm.record.cid])]), + "media": rwm.media.match!( + (EmbedImage[] images) => _getEmbedImage(images), + (EmbedExternal external) => _getEmbedExternal(external), + ) + ]); + } + JSONValue _getReplyData(ReplyRef reply) @safe + { + return JSONValue([ + "root": JSONValue([ + "uri": reply.root.uri, + "cid": reply.root.cid]), + "parent": JSONValue([ + "uri": reply.parent.uri, + "cid": reply.parent.cid]), + ]); + } + if (embed !is EmbedData.init) + { + embed.match!( + (EmbedImage[] images) @safe { + jvPost["embed"] = _getEmbedImage(images); + }, + (EmbedExternal external) @safe { + jvPost["embed"] = _getEmbedExternal(external); + }, + (EmbedRecord record) @safe { + jvPost["embed"] = _getEmbedRecord(record); + }, + (EmbedRecordWithMedia record) @safe { + jvPost["embed"] = _getEmbedRecordWithMedia(record); + } + ); + } + if (replyRef !is ReplyRef.init) + jvPost["reply"] = _getReplyData(replyRef); + return createRecord(jvPost).deserializeFromJson!PostRef(); + } + /// ditto + PostRef sendPost(string message, string[] langs = null) @safe + { + return sendPost(message, Embed.init, ReplyRef.init, langs); + } + /// ditto + PostRef sendPost(string message, app.bsky.embed.Images.Image image, string[] langs = null) @safe + { + return sendPost(message, Embed(app.bsky.embed.Images(image)), ReplyRef.init, langs); + } + /// ditto + PostRef sendPost(string message, app.bsky.embed.Images images, string[] langs = null) @safe + { + return sendPost(message, Embed(images), ReplyRef.init, langs); + } + /// ditto + PostRef sendPost(string message, EmbedImage image, string[] langs = null) @safe + { + return sendPost(message, getEmbedImage(image), langs); + } + /// ditto + PostRef sendPost(string message, EmbedImage[] images, string[] langs = null) @safe + { + return sendPost(message, getEmbedImages(images), langs); + } + /// ditto + PostRef sendPost(string message, app.bsky.embed.External external, string[] langs = null) @safe + { + return sendPost(message, Embed(external), ReplyRef.init, langs); + } + /// ditto + PostRef sendPost(string message, EmbedExternal external, string[] langs = null) @safe + { + return sendPost(message, getEmbedExternal(external), langs); + } + /// ditto + PostRef sendPost(string message, app.bsky.embed.Record record, string[] langs = null) @safe + { + return sendPost(message, Embed(record), ReplyRef.init, langs); + } + /// ditto + PostRef sendPost(string message, EmbedRecord record, string[] langs = null) @safe + { + return sendPost(message, app.bsky.embed.Record(record), langs); + } + /// ditto + PostRef sendPost(string message, app.bsky.embed.RecordWithMedia rwm, string[] langs = null) @safe + { + return sendPost(message, Embed(rwm), ReplyRef.init, langs); + } + /// ditto + PostRef sendPost(string message, EmbedRecordWithMedia recordWithMedia, string[] langs = null) @safe + { + return sendPost(message, app.bsky.embed.RecordWithMedia( + record: app.bsky.embed.Record(recordWithMedia.record), + media: recordWithMedia.media.match!( + (EmbedImage[] images) => app.bsky.embed.RecordWithMedia.Media(getEmbedImages(images)), + (EmbedExternal external) => app.bsky.embed.RecordWithMedia.Media(getEmbedExternal(external)))), langs); + } + /// ditto + PostRef sendPost(string message, EmbedRecord record, EmbedImage image, string[] langs = null) @safe + { + return sendPost(message, app.bsky.embed.RecordWithMedia( + record: app.bsky.embed.Record(record), + media: app.bsky.embed.RecordWithMedia.Media(getEmbedImages([image]))), langs); + } + /// ditto + PostRef sendPost(string message, EmbedRecord record, EmbedImage[] image, string[] langs = null) @safe + { + return sendPost(message, app.bsky.embed.RecordWithMedia( + record: app.bsky.embed.Record(record), + media: app.bsky.embed.RecordWithMedia.Media(getEmbedImages(image))), langs); + } + /// ditto + PostRef sendPost(string message, EmbedRecord record, EmbedExternal external, string[] langs = null) @safe + { + return sendPost(message, app.bsky.embed.RecordWithMedia( + record: app.bsky.embed.Record(record), + media: app.bsky.embed.RecordWithMedia.Media(getEmbedExternal(external))), langs); + } + + // sendPost/createRecord + @safe unittest + { + auto client = _createDummyClient("9dbe5f82-d6a2-4d85-949d-bd6369b2feb5"); + auto postRes = client.sendPost("Post test."); + with (client.req) + { + assert(method == "POST"); + assert(url == `https://bsky.social/xrpc/com.atproto.repo.createRecord`); + assert(mimeType == "application/json"); + auto params = parseJSON(cast(const char[])bodyBinary); + assert(params["collection"].str == "app.bsky.feed.post"); + assert(params["record"]["$type"].str == "app.bsky.feed.post"); + assert(params["record"]["text"].str == "Post test."); + } + assert(postRes.uri == "at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.feed.post/sjxklekf4hsir"); + assert(postRes.cid == "ztdjymlhwsoywyrzip7sku4kbwm32v44vpqwj273kb3zggtvuqxp3qg6bi2"); + } + + // sendPost (with image) + @safe unittest + { + auto client = _createDummyClient("ce07a958-e5e3-4c3b-94f8-8b1ad427ab0b"); + auto imgBin = readDataSource("d-man.png"); + auto postRes = client.sendPost("画像テスト", [Bluesky.EmbedImage(imgBin, "image/png", "D言語くん")]); + with (client.req(0)) + { + assert(method == "POST"); + assert(url == `https://bsky.social/xrpc/com.atproto.repo.uploadBlob`); + assert(mimeType == `image/png`); + assert(bodyBinary == imgBin); + } + with (client.req(1)) + { + assert(method == "POST"); + assert(url == `https://bsky.social/xrpc/com.atproto.repo.createRecord`); + assert(query == ""); + assert(mimeType == "application/json"); + auto params = parseJSON(cast(const char[])bodyBinary); + assert(params["collection"].str == "app.bsky.feed.post"); + assert(params["record"]["embed"]["$type"].str == "app.bsky.embed.images"); + assert((() @trusted => params["record"]["embed"]["images"].array)().length == 1); + assert(params["record"]["embed"]["images"][0]["image"]["$type"].str == "blob"); + assert(params["record"]["embed"]["images"][0]["image"]["mimeType"].str == "image/png"); + assert(params["record"]["embed"]["images"][0]["image"]["ref"]["$link"].str + == "mjxrmzdw2ggq2rjuwxan6hm6w36r2nipkecdrvkpkskk5qoo4slwst3xm6f"); + assert(params["record"]["embed"]["images"][0]["image"]["size"].get!uint == imgBin.length); + } + assert(postRes.uri == "at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.feed.post/jmc4tmxvfqx4s"); + assert(postRes.cid == "6e66lvcbu6sw5shqzjex2reh3eus4w4xvrh7q4vccocdrg4epr5ueintmlg"); + } + + // sendPost (with external URL) + @safe unittest + { + auto client = _createDummyClient("72a91fe8-1f30-4ebc-a42d-6617642dcbfe"); + auto thumbImg = readDataSource!(immutable(ubyte)[])("d-logo.png"); + client.sendPost("External Link Post Test", EmbedExternal( + "https://dlang.org", + "Home - D Programming Language", + "D is a general-purpose programming language with static typing, systems-level access, and C-like syntax.", + thumbImg, "image/png")); + with (client.req(0)) + { + assert(method == "POST"); + assert(url == `https://bsky.social/xrpc/com.atproto.repo.uploadBlob`); + assert(mimeType == `image/png`); + assert(bodyBinary == thumbImg); + } + with (client.req(1)) + { + auto params = parseJSON(cast(const char[])bodyBinary); + assert(params["collection"].str == "app.bsky.feed.post"); + assert(params["record"]["embed"]["$type"].str == "app.bsky.embed.external"); + assert(params["record"]["embed"]["external"]["uri"].str == "https://dlang.org"); + assert(params["record"]["embed"]["external"]["title"].str == "Home - D Programming Language"); + assert(params["record"]["embed"]["external"]["description"].str + == "D is a general-purpose programming language with static typing" + ~ ", systems-level access, and C-like syntax."); + assert(params["record"]["embed"]["external"]["thumb"]["ref"]["$link"].str + == client.httpc.results[0].response["blob"]["ref"]["$link"].str); + assert(params["record"]["text"].str == "External Link Post Test"); + } + } + + /// ditto + PostRef sendReplyPost(string uri, string message, Embed embed = Embed.init, string[] langs = null) @safe + { + return sendPost(message, embed, getReplyRef(uri), langs); + } + /// ditto + PostRef sendReplyPost(string uri, string message, app.bsky.embed.Images images, string[] langs = null) @safe + { + return sendReplyPost(uri, message, Embed(images), langs); + } + /// ditto + PostRef sendReplyPost(string uri, string message, app.bsky.embed.Images.Image[] images, string[] langs = null) @safe + { + return sendReplyPost(uri, message, app.bsky.embed.Images(images), langs); + } + /// ditto + PostRef sendReplyPost(string uri, string message, app.bsky.embed.Images.Image image, string[] langs = null) @safe + { + return sendReplyPost(uri, message, [image], langs); + } + /// ditto + PostRef sendReplyPost(string uri, string message, EmbedImage image, string[] langs = null) @safe + { + return sendReplyPost(uri, message, getEmbedImage(image), langs); + } + /// ditto + PostRef sendReplyPost(string uri, string message, EmbedImage[] images, string[] langs = null) @safe + { + return sendReplyPost(uri, message, getEmbedImages(images), langs); + } + /// ditto + PostRef sendReplyPost(string uri, string message, app.bsky.embed.External external, string[] langs = null) @safe + { + return sendReplyPost(uri, message, Embed(external), langs); + } + /// ditto + PostRef sendReplyPost(string uri, string message, EmbedExternal external, string[] langs = null) @safe + { + return sendReplyPost(uri, message, getEmbedExternal(external), langs); + } + /// ditto + PostRef sendReplyPost(string uri, string message, app.bsky.embed.Record record, string[] langs = null) @safe + { + return sendReplyPost(uri, message, Embed(record), langs); + } + /// ditto + PostRef sendReplyPost(string uri, string message, EmbedRecord record, string[] langs = null) @safe + { + return sendReplyPost(uri, message, app.bsky.embed.Record(record), langs); + } + /// ditto + PostRef sendReplyPost(string uri, string message, app.bsky.embed.RecordWithMedia recordWithMedia, + string[] langs = null) @safe + { + return sendReplyPost(uri, message, Embed(recordWithMedia), langs); + } + /// ditto + PostRef sendReplyPost(string uri, string message, EmbedRecordWithMedia recordWithMedia, string[] langs = null) @safe + { + return sendReplyPost(uri, message, app.bsky.embed.RecordWithMedia( + record: recordWithMedia.record, + media: recordWithMedia.media.match!( + (EmbedImage[] images) => app.bsky.embed.RecordWithMedia.Media(getEmbedImages(images)), + (EmbedExternal external) => app.bsky.embed.RecordWithMedia.Media(getEmbedExternal(external)))), langs); + } + + // sendReplyPost/getRecord/createRecord + @safe unittest + { + auto client = _createDummyClient("0b8503cb-4eb7-45ee-8487-80260a3c9284"); + auto postRes = client.sendReplyPost("at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.feed.post/sjxklekf4hsir", + "Reply test."); + with (client.req(0)) + { + assert(method == "GET"); + assert(url == `https://bsky.social/xrpc/com.atproto.repo.getRecord`); + assert(query + == "rkey=sjxklekf4hsir&repo=did%3Aplc%3Amhz3szj7pcjfpzv7pylcmlgx&collection=app.bsky.feed.post"); + assert(bodyBinary == ``.representation); + } + with (client.req(1)) + { + assert(method == "POST"); + assert(url == `https://bsky.social/xrpc/com.atproto.repo.createRecord`); + assert(query == ""); + assert(mimeType == "application/json"); + auto params = parseJSON(cast(const char[])bodyBinary); + assert(params["collection"].str == "app.bsky.feed.post"); + assert(params["record"]["$type"].str == "app.bsky.feed.post"); + assert(params["record"]["reply"]["parent"]["cid"].str + == "ztdjymlhwsoywyrzip7sku4kbwm32v44vpqwj273kb3zggtvuqxp3qg6bi2"); + assert(params["record"]["reply"]["parent"]["uri"].str + == "at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.feed.post/sjxklekf4hsir"); + assert(params["record"]["reply"]["root"]["cid"].str + == "ztdjymlhwsoywyrzip7sku4kbwm32v44vpqwj273kb3zggtvuqxp3qg6bi2"); + assert(params["record"]["reply"]["root"]["uri"].str + == "at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.feed.post/sjxklekf4hsir"); + assert(params["record"]["text"].str == "Reply test."); + } + assert(postRes.uri == "at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.feed.post/xlujb5m6o43ot"); + assert(postRes.cid == "uphv4f3bytywlue5vmj7bbcgc45clkdij2s7zsyupjbnfzx34zbiwuu56gy"); + } + + // sendReplyPost (with image) + @safe unittest + { + auto client = _createDummyClient("ba2b202c-5657-4b3d-97df-abaff951206c"); + auto imgBin = readDataSource("d-man.png"); + auto postRes = client.sendReplyPost("at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.feed.post/sjxklekf4hsir", + "画像テスト", [EmbedImage(imgBin, "image/png", "D言語くん")]); + with (client.req(0)) + { + assert(method == "POST"); + assert(url == `https://bsky.social/xrpc/com.atproto.repo.uploadBlob`); + assert(mimeType == `image/png`); + assert(bodyBinary == imgBin); + } + with (client.req(1)) + { + assert(method == "GET"); + assert(url == `https://bsky.social/xrpc/com.atproto.repo.getRecord`); + assert(query + == "rkey=sjxklekf4hsir&repo=did%3Aplc%3Amhz3szj7pcjfpzv7pylcmlgx&collection=app.bsky.feed.post"); + assert(bodyBinary == ``.representation); + } + with (client.req(2)) + { + assert(method == "POST"); + assert(url == `https://bsky.social/xrpc/com.atproto.repo.createRecord`); + assert(query == ""); + assert(mimeType == "application/json"); + auto params = parseJSON(cast(const char[])bodyBinary); + assert(params["collection"].str == "app.bsky.feed.post"); + + assert(params["record"]["reply"]["parent"]["cid"].str + == "dx2imfjwbdohkh4re7hh6dguhpczu5mhruw4ofkn6sdmhk4uimas37c5a74"); + assert(params["record"]["reply"]["parent"]["uri"].str + == "at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/5ni6rkonpzlx2"); + assert(params["record"]["reply"]["root"]["cid"].str + == "dx2imfjwbdohkh4re7hh6dguhpczu5mhruw4ofkn6sdmhk4uimas37c5a74"); + assert(params["record"]["reply"]["root"]["uri"].str + == "at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/5ni6rkonpzlx2"); + + assert(params["record"]["embed"]["$type"].str == "app.bsky.embed.images"); + assert((() @trusted => params["record"]["embed"]["images"].array)().length == 1); + assert(params["record"]["embed"]["images"][0]["image"]["$type"].str == "blob"); + assert(params["record"]["embed"]["images"][0]["image"]["mimeType"].str == "image/png"); + assert(params["record"]["embed"]["images"][0]["image"]["ref"]["$link"].str + == "mjxrmzdw2ggq2rjuwxan6hm6w36r2nipkecdrvkpkskk5qoo4slwst3xm6f"); + assert(params["record"]["embed"]["images"][0]["image"]["size"].get!uint == imgBin.length); + } + assert(postRes.uri == "at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/gqg7sygic5mii"); + assert(postRes.cid == "5mawkkcu45gctuc4lucf73ozbsg3p6gj6jr5cioz26egrvaw64bgk4kgef3"); + } + + // sendReplyPost (with external URL) + @safe unittest + { + auto client = _createDummyClient("d0fa2d8d-9f15-48c8-a3e2-28fcc42ed881"); + auto thumbImg = readDataSource!(immutable(ubyte)[])("d-logo.png"); + auto postRes = client.sendReplyPost("at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.feed.post/sjxklekf4hsir", + "External Link Reply Post Test", EmbedExternal( + "https://dlang.org", + "Home - D Programming Language", + "D is a general-purpose programming language with static typing, systems-level access, and C-like syntax.", + thumbImg, "image/png")); + with (client.req(0)) + { + assert(method == "POST"); + assert(url == `https://bsky.social/xrpc/com.atproto.repo.uploadBlob`); + assert(mimeType == `image/png`); + assert(bodyBinary == thumbImg); + } + with (client.req(1)) + { + assert(method == "GET"); + assert(url == `https://bsky.social/xrpc/com.atproto.repo.getRecord`); + assert(query + == "rkey=sjxklekf4hsir&repo=did%3Aplc%3Amhz3szj7pcjfpzv7pylcmlgx&collection=app.bsky.feed.post"); + assert(bodyBinary == ``.representation); + } + with (client.req(2)) + { + auto params = parseJSON(cast(const char[])bodyBinary); + assert(params["collection"].str == "app.bsky.feed.post"); + + assert(params["record"]["reply"]["parent"]["cid"].str + == "dx2imfjwbdohkh4re7hh6dguhpczu5mhruw4ofkn6sdmhk4uimas37c5a74"); + assert(params["record"]["reply"]["parent"]["uri"].str + == "at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/5ni6rkonpzlx2"); + assert(params["record"]["reply"]["root"]["cid"].str + == "dx2imfjwbdohkh4re7hh6dguhpczu5mhruw4ofkn6sdmhk4uimas37c5a74"); + assert(params["record"]["reply"]["root"]["uri"].str + == "at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/5ni6rkonpzlx2"); + + assert(params["record"]["embed"]["$type"].str == "app.bsky.embed.external"); + assert(params["record"]["embed"]["external"]["uri"].str == "https://dlang.org"); + assert(params["record"]["embed"]["external"]["title"].str == "Home - D Programming Language"); + assert(params["record"]["embed"]["external"]["description"].str + == "D is a general-purpose programming language with static typing" + ~ ", systems-level access, and C-like syntax."); + assert(params["record"]["embed"]["external"]["thumb"]["ref"]["$link"].str + == client.httpc.results[0].response["blob"]["ref"]["$link"].str); + assert(params["record"]["text"].str == "External Link Reply Post Test"); + } + assert(postRes.uri == "at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/thtmhnohkv7ew"); + assert(postRes.cid == "uybaf7ict2bgb277cbhklcmhhrcr22q7btbnjzjjjji6crwzvivhkd3wv22"); + } + + // sendReplyPost (with image and record) + @safe unittest + { + auto client = _createDummyClient("032bc000-f6f8-4965-a9c7-b66570112870"); + auto imgBin = readDataSource("d-man.png"); + auto recRef = client.getRecordRef("at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/hyq6lbnl45len"); + auto postRes = client.sendReplyPost("at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/5ni6rkonpzlx2", + "引用と画像のテスト", + EmbedRecordWithMedia(recRef, EmbedRecordWithMedia.Media([EmbedImage(imgBin, "image/png", "D言語くん")]))); + with (client.req(0)) + { + assert(method == "GET"); + assert(url == `https://bsky.social/xrpc/com.atproto.repo.getRecord`); + assert(query + == "rkey=hyq6lbnl45len&repo=did%3Aplc%3Avibjcyg6myvxdi4ezdrhcsuo&collection=app.bsky.feed.post"); + assert(bodyBinary == ``.representation); + } + with (client.req(1)) + { + assert(method == "POST"); + assert(url == `https://bsky.social/xrpc/com.atproto.repo.uploadBlob`); + assert(mimeType == `image/png`); + assert(bodyBinary == imgBin); + } + with (client.req(2)) + { + assert(method == "GET"); + assert(url == `https://bsky.social/xrpc/com.atproto.repo.getRecord`); + assert(query + == "rkey=5ni6rkonpzlx2&repo=did%3Aplc%3Avibjcyg6myvxdi4ezdrhcsuo&collection=app.bsky.feed.post"); + assert(bodyBinary == ``.representation); + } + with (client.req(3)) + { + auto params = parseJSON(cast(const char[])bodyBinary); + assert(params["collection"].str == "app.bsky.feed.post"); + + assert(params["record"]["reply"]["parent"]["cid"].str + == "dx2imfjwbdohkh4re7hh6dguhpczu5mhruw4ofkn6sdmhk4uimas37c5a74"); + assert(params["record"]["reply"]["parent"]["uri"].str + == "at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/5ni6rkonpzlx2"); + assert(params["record"]["reply"]["root"]["cid"].str + == "dx2imfjwbdohkh4re7hh6dguhpczu5mhruw4ofkn6sdmhk4uimas37c5a74"); + assert(params["record"]["reply"]["root"]["uri"].str + == "at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/5ni6rkonpzlx2"); + + assert(params["record"]["embed"]["$type"].str == "app.bsky.embed.recordWithMedia"); + + assert(params["record"]["embed"]["media"]["$type"].str == "app.bsky.embed.images"); + assert(params["record"]["embed"]["media"]["images"][0]["alt"].str == "D言語くん"); + assert(params["record"]["embed"]["media"]["images"][0]["image"]["$type"].str == "blob"); + assert(params["record"]["embed"]["media"]["images"][0]["image"]["mimeType"].str == "image/png"); + assert(params["record"]["embed"]["media"]["images"][0]["image"]["ref"]["$link"].str + == "mjxrmzdw2ggq2rjuwxan6hm6w36r2nipkecdrvkpkskk5qoo4slwst3xm6f"); + assert(params["record"]["embed"]["media"]["images"][0]["image"]["size"].get!size_t() == 13979); + + assert(params["record"]["embed"]["record"]["$type"].str == "app.bsky.embed.record"); + assert(params["record"]["embed"]["record"]["record"]["cid"].str + == "aykzb74s6m3tj3fvsn777i3zeuefoxkxhwwevxy5juuuaxe24rm26rs6wnf"); + assert(params["record"]["embed"]["record"]["record"]["uri"].str + == "at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/hyq6lbnl45len"); + + assert(params["record"]["text"].str == "引用と画像のテスト"); + } + assert(postRes.uri == "at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/rgdb72nfe25ym"); + assert(postRes.cid == "xy2frqraadrxv6xxq3bo2x4rxeppbqny4otupfja5cjqrrprnnwiik2ym4i"); + } + + /// ditto + PostRef sendQuotePost(string uri, string message, string[] langs = null) @safe + { + return sendPost(message, EmbedData(getPostRef(uri)), ReplyRef.init, langs); + } + /// ditto + PostRef sendQuotePost(string uri, string message, app.bsky.embed.Images images, string[] langs = null) @safe + { + return sendPost(message, Embed(app.bsky.embed.RecordWithMedia( + record: getPostRef(uri), + media: app.bsky.embed.RecordWithMedia.Media(images))), + ReplyRef.init, langs); + } + /// ditto + PostRef sendQuotePost(string uri, string message, app.bsky.embed.Images.Image[] images, string[] langs = null) @safe + { + return sendQuotePost(uri, message, app.bsky.embed.Images(images), langs); + } + /// ditto + PostRef sendQuotePost(string uri, string message, app.bsky.embed.Images.Image image, string[] langs = null) @safe + { + return sendQuotePost(uri, message, [image], langs); + } + /// ditto + PostRef sendQuotePost(string uri, string message, EmbedImage image, string[] langs = null) @safe + { + return sendQuotePost(uri, message, getEmbedImage(image), langs); + } + /// ditto + PostRef sendQuotePost(string uri, string message, EmbedImage[] images, string[] langs = null) @safe + { + return sendQuotePost(uri, message, getEmbedImages(images), langs); + } + /// ditto + PostRef sendQuotePost(string uri, string message, app.bsky.embed.External external, string[] langs = null) @safe + { + return sendPost(message, Embed(app.bsky.embed.RecordWithMedia( + record: getPostRef(uri), + media: app.bsky.embed.RecordWithMedia.Media(external))), + ReplyRef.init, langs); + } + /// ditto + PostRef sendQuotePost(string uri, string message, EmbedExternal external, string[] langs = null) @safe + { + return sendQuotePost(uri, message, getEmbedExternal(external), langs); + } + + // sendQuotePost + @safe unittest + { + auto client = _createDummyClient("ff5bc22f-4dff-480c-a8ee-9faf352a69c7"); + auto postRes = client.sendQuotePost("at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.feed.post/sjxklekf4hsir", + "Quote post test"); + with (client.req(0)) + { + assert(method == "GET"); + assert(url == `https://bsky.social/xrpc/com.atproto.repo.getRecord`); + assert(query + == "rkey=sjxklekf4hsir&repo=did%3Aplc%3Amhz3szj7pcjfpzv7pylcmlgx&collection=app.bsky.feed.post"); + assert(bodyBinary == ``.representation); + } + with (client.req(1)) + { + assert(method == "POST"); + assert(url == `https://bsky.social/xrpc/com.atproto.repo.createRecord`); + assert(query == ""); + assert(mimeType == "application/json"); + auto params = parseJSON(cast(const char[])bodyBinary); + assert(params["collection"].str == "app.bsky.feed.post"); + assert(params["record"]["$type"].str == "app.bsky.feed.post"); + + assert(params["record"]["embed"]["$type"].str == "app.bsky.embed.record"); + assert(params["record"]["embed"]["record"]["cid"].str + == "aykzb74s6m3tj3fvsn777i3zeuefoxkxhwwevxy5juuuaxe24rm26rs6wnf"); + assert(params["record"]["embed"]["record"]["uri"].str + == "at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/hyq6lbnl45len"); + + assert(params["record"]["text"].str == "Quote post test"); + } + assert(postRes.uri == "at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.feed.post/3teavi2n5getc"); + assert(postRes.cid == "clmhhuo6wphqp2pb4cbzcaxs7lzmovr5ldigdm5fzzfbzzkku5yll55j7y4"); + } + + // sendQuotePost (with image) + @safe unittest + { + auto client = _createDummyClient("65b058a6-d7eb-414a-8bd8-625331524b6e"); + auto imgBin = readDataSource("d-man.png"); + auto postRes = client.sendQuotePost("at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.feed.post/sjxklekf4hsir", + "Quote post test", EmbedImage(imgBin, "image/png", "D言語くん")); + with (client.req(0)) + { + assert(method == "POST"); + assert(url == `https://bsky.social/xrpc/com.atproto.repo.uploadBlob`); + assert(mimeType == `image/png`); + assert(bodyBinary == imgBin); + } + with (client.req(1)) + { + assert(method == "GET"); + assert(url == `https://bsky.social/xrpc/com.atproto.repo.getRecord`); + assert(query + == "rkey=sjxklekf4hsir&repo=did%3Aplc%3Amhz3szj7pcjfpzv7pylcmlgx&collection=app.bsky.feed.post"); + assert(bodyBinary == ``.representation); + } + with (client.req(2)) + { + assert(method == "POST"); + assert(url == `https://bsky.social/xrpc/com.atproto.repo.createRecord`); + assert(query == ""); + assert(mimeType == "application/json"); + auto params = parseJSON(cast(const char[])bodyBinary); + assert(params["collection"].str == "app.bsky.feed.post"); + assert(params["record"]["$type"].str == "app.bsky.feed.post"); + assert(params["record"]["embed"]["$type"].str == "app.bsky.embed.recordWithMedia"); + assert(params["record"]["embed"]["record"]["record"]["cid"].str + == "aykzb74s6m3tj3fvsn777i3zeuefoxkxhwwevxy5juuuaxe24rm26rs6wnf"); + assert(params["record"]["embed"]["record"]["record"]["uri"].str + == "at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/hyq6lbnl45len"); + + assert(params["record"]["embed"]["media"]["$type"].str == "app.bsky.embed.images"); + assert(params["record"]["embed"]["media"]["images"][0]["alt"].str == "D言語くん"); + assert(params["record"]["embed"]["media"]["images"][0]["image"]["$type"].str == "blob"); + assert(params["record"]["embed"]["media"]["images"][0]["image"]["mimeType"].str == "image/png"); + assert(params["record"]["embed"]["media"]["images"][0]["image"]["ref"]["$link"].str + == "mjxrmzdw2ggq2rjuwxan6hm6w36r2nipkecdrvkpkskk5qoo4slwst3xm6f"); + assert(params["record"]["embed"]["media"]["images"][0]["image"]["size"].get!size_t() == 13979); + + assert(params["record"]["text"].str == "Quote post test"); + } + assert(postRes.uri == "at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/3h546l6tigfco"); + assert(postRes.cid == "pii3oiclei2tdn64oe5cfjihpkvv6o6db4lrzzr2mnefxur7jelrokzzz4q"); + } + + // sendQuotePost (with external URL) + @safe unittest + { + auto client = _createDummyClient("04cdf227-7339-486b-bd12-515453cee504"); + auto thumbImg = readDataSource!(immutable(ubyte)[])("d-logo.png"); + auto postRes = client.sendQuotePost("at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.feed.post/sjxklekf4hsir", + "External Link Quote Post Test", EmbedExternal( + "https://dlang.org", + "Home - D Programming Language", + "D is a general-purpose programming language with static typing, systems-level access, and C-like syntax.", + thumbImg, "image/png")); + with (client.req(0)) + { + assert(method == "POST"); + assert(url == `https://bsky.social/xrpc/com.atproto.repo.uploadBlob`); + assert(mimeType == `image/png`); + assert(bodyBinary == thumbImg); + } + with (client.req(1)) + { + assert(method == "GET"); + assert(url == `https://bsky.social/xrpc/com.atproto.repo.getRecord`); + assert(query + == "rkey=sjxklekf4hsir&repo=did%3Aplc%3Amhz3szj7pcjfpzv7pylcmlgx&collection=app.bsky.feed.post"); + assert(bodyBinary == ``.representation); + } + with (client.req(2)) + { + auto params = parseJSON(cast(const char[])bodyBinary); + assert(params["collection"].str == "app.bsky.feed.post"); + assert(params["record"]["embed"]["$type"].str == "app.bsky.embed.recordWithMedia"); + assert(params["record"]["embed"]["record"]["record"]["cid"].str + == "aykzb74s6m3tj3fvsn777i3zeuefoxkxhwwevxy5juuuaxe24rm26rs6wnf"); + assert(params["record"]["embed"]["record"]["record"]["uri"].str + == "at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/hyq6lbnl45len"); + + assert(params["record"]["embed"]["media"]["$type"].str == "app.bsky.embed.external"); + assert(params["record"]["embed"]["media"]["external"]["uri"].str == "https://dlang.org"); + assert(params["record"]["embed"]["media"]["external"]["title"].str == "Home - D Programming Language"); + assert(params["record"]["embed"]["media"]["external"]["description"].str + == "D is a general-purpose programming language with static typing" + ~ ", systems-level access, and C-like syntax."); + assert(params["record"]["embed"]["media"]["external"]["thumb"]["ref"]["$link"].str + == client.httpc.results[0].response["blob"]["ref"]["$link"].str); + + assert(params["record"]["text"].str == "External Link Quote Post Test"); + } + assert(postRes.uri == "at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/gc5wkr6ugklb6"); + assert(postRes.cid == "7snq6kjyin5s5mth2omrbriiy5bsi4eb5laet6zmyfkn7zt64nqy4vsb2ub"); + } + + /*************************************************************************** + * Delete posts + */ + void deletePost(string uri) @safe + { + import bsky.data: AtProtoURI; + auto atUri = AtProtoURI(uri); + deleteRecord(atUri.collection, atUri.rkey); + } + + // deletePost/deleteRecord + @safe unittest + { + auto client = _createDummyClient("f405c6cd-4fbb-4919-abac-f067dbd51d26"); + client.deletePost("at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.feed.post/xlujb5m6o43ot"); + with (client.req) + { + assert(method == "POST"); + assert(url == `https://bsky.social/xrpc/com.atproto.repo.deleteRecord`); + assert(mimeType == "application/json"); + auto params = parseJSON(cast(const char[])bodyBinary); + assert(params["collection"].str == "app.bsky.feed.post"); + assert(params["repo"].str == "did:plc:2qfqobqz6dzrfa3jv74i6k6m"); + assert(params["rkey"].str == "xlujb5m6o43ot"); + } + } + + /*************************************************************************** + * Mark like the post + */ + StrongRef markLike(string uri) @trusted + { + import std.datetime: Clock; + app.bsky.feed.Like dat; + dat.subject = getPostRef(uri); + dat.createdAt = Clock.currTime.toUTC(); + auto jv = (() @trusted => dat.serializeToJson())(); + return createRecord(jv, "app.bsky.feed.like") + .deserializeFromJson!StrongRef; + } + + /*************************************************************************** + * Delete like mark + */ + void deleteLike(string uri) @safe + { + import std.datetime: Clock; + auto atUri = AtProtoURI(uri); + string rkey; + if (atUri.collection == "app.bsky.feed.post") + { + // ポストに対するLikeを削除しようとしている + auto fullUri = atUri.hasDid ? uri : getRecordRef(uri).uri; + auto posts = fetchPosts([fullUri]); + enforce(posts.length == 1); + enforce(posts[0].viewer.like.length > 0); + rkey = AtProtoURI(posts[0].viewer.like).rkey; + } + else if (atUri.collection == "app.bsky.feed.like") + { + // Likeに対して削除しようとしている + rkey = atUri.rkey; + } + else + { + enforce(0, "Unsupported URI: " ~ uri); + } + deleteRecord("app.bsky.feed.like", rkey); + } + + // markLike/deleteLike + @safe unittest + { + auto client = _createDummyClient("f2862017-1e04-4cf4-b445-4f95ab1962e3"); + auto likeData = client.markLike("at://krzblhls379.vkn.io/app.bsky.feed.post/hyq6lbnl45len"); + with (client.req(0)) + { + assert(method == "GET"); + assert(url == `https://bsky.social/xrpc/com.atproto.repo.getRecord`); + assert(query == "rkey=hyq6lbnl45len&repo=krzblhls379.vkn.io&collection=app.bsky.feed.post"); + assert(bodyBinary == ``.representation); + } + with (client.req(1)) + { + assert(method == "POST"); + assert(url == `https://bsky.social/xrpc/com.atproto.repo.createRecord`); + assert(mimeType == "application/json"); + auto params = parseJSON(cast(const char[])bodyBinary); + assert(params["collection"].str == "app.bsky.feed.like"); + assert(params["record"]["$type"].str == "app.bsky.feed.like"); + assert(params["record"]["subject"]["cid"].str + == "aykzb74s6m3tj3fvsn777i3zeuefoxkxhwwevxy5juuuaxe24rm26rs6wnf"); + assert(params["record"]["subject"]["uri"].str + == "at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/hyq6lbnl45len"); + assert(params["repo"].str == "did:plc:2qfqobqz6dzrfa3jv74i6k6m"); + } + assert(likeData.uri == "at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.feed.like/5hnjyskptmfjq"); + client.deleteLike(likeData.uri); + with (client.req(2)) + { + assert(method == "POST"); + assert(url == `https://bsky.social/xrpc/com.atproto.repo.deleteRecord`); + assert(mimeType == "application/json"); + auto params = parseJSON(cast(const char[])bodyBinary); + assert(params["collection"].str == "app.bsky.feed.like"); + assert(params["rkey"].str == "5hnjyskptmfjq"); + assert(params["repo"].str == "did:plc:2qfqobqz6dzrfa3jv74i6k6m"); + } + } + + /*************************************************************************** + * Repost posts + */ + StrongRef repost(string uri) @safe + { + import std.datetime: Clock; + app.bsky.feed.Repost dat; + dat.subject = getPostRef(uri); + dat.createdAt = Clock.currTime.toUTC(); + auto jv = (() @trusted => dat.serializeToJson())(); + return createRecord(jv, "app.bsky.feed.repost") + .deserializeFromJson!StrongRef; + } + + /*************************************************************************** + * Delete the repost + */ + void deleteRepost(string uri) @safe + { + import std.datetime: Clock; + auto atUri = AtProtoURI(uri); + string rkey; + if (atUri.collection == "app.bsky.feed.post") + { + // ポストに対するRepostを削除しようとしている + auto fullUri = atUri.hasDid ? uri : getRecordRef(uri).uri; + auto posts = fetchPosts([fullUri]); + enforce(posts.length == 1); + enforce(posts[0].viewer.like.length > 0); + rkey = AtProtoURI(posts[0].viewer.like).rkey; + } + else if (atUri.collection == "app.bsky.feed.repost") + { + // Repostに対して削除しようとしている + rkey = atUri.rkey; + } + else + { + enforce(0, "Unsupported URI: " ~ uri); + } + deleteRecord("app.bsky.feed.repost", rkey); + } + + // repost/deleteRepost + @safe unittest + { + auto client = _createDummyClient("43927f09-ea3f-4ae5-a226-beaf564d2622"); + auto repostData = client.repost("at://krzblhls379.vkn.io/app.bsky.feed.post/hyq6lbnl45len"); + with (client.req(0)) + { + assert(method == "GET"); + assert(url == `https://bsky.social/xrpc/com.atproto.repo.getRecord`); + assert(query == "rkey=hyq6lbnl45len&repo=krzblhls379.vkn.io&collection=app.bsky.feed.post"); + assert(bodyBinary == ``.representation); + } + with (client.req(1)) + { + assert(method == "POST"); + assert(url == `https://bsky.social/xrpc/com.atproto.repo.createRecord`); + assert(mimeType == "application/json"); + auto params = parseJSON(cast(const char[])bodyBinary); + assert(params["collection"].str == "app.bsky.feed.repost"); + assert(params["record"]["$type"].str == "app.bsky.feed.repost"); + assert(params["record"]["subject"]["cid"].str + == "aykzb74s6m3tj3fvsn777i3zeuefoxkxhwwevxy5juuuaxe24rm26rs6wnf"); + assert(params["record"]["subject"]["uri"].str + == "at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/hyq6lbnl45len"); + assert(params["repo"].str == "did:plc:2qfqobqz6dzrfa3jv74i6k6m"); + } + assert(repostData.uri == "at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.feed.repost/nbcyhg4w2kz7w"); + client.deleteRepost(repostData.uri); + with (client.req(2)) + { + assert(method == "POST"); + assert(url == `https://bsky.social/xrpc/com.atproto.repo.deleteRecord`); + assert(mimeType == "application/json"); + auto params = parseJSON(cast(const char[])bodyBinary); + assert(params["collection"].str == "app.bsky.feed.repost"); + assert(params["rkey"].str == "nbcyhg4w2kz7w"); + assert(params["repo"].str == "did:plc:2qfqobqz6dzrfa3jv74i6k6m"); + } + } +} + +// Execute with `dub test -d ProvisioningDataSource` +debug (ProvisioningDataSource) @safe unittest +{ + import std; + import std.process: environment; + import bsky._internal.httpc; + auto logFileName = "ut_http.log"; + if (logFileName.exists) + std.file.remove(logFileName); + auto logger = new FileLogger(logFileName); + auto httpc = new CurlHttpClient!()(logger); + if (!g_utAuth) + return; + auto uuid = randomUUID.toString; + enum dstDir = "tests/.test-data"; + if (dstDir.exists) + httpc.setResponseJsonRecorder(dstDir.buildPath(uuid ~ ".json")); + logger.infof("ID: %s", uuid); + auto client = new Bluesky(g_utAuth, httpc); + + version (none) + { + // 投稿 + auto postRes = client.sendPost("Hello, Bluesky!"); + client.deletePost(postRes.uri); + } + version (none) + { + // 画像投稿 + auto imgBin = (() @trusted => cast(immutable(ubyte)[])std.file.read("tests/.ut-data_source/d-man.png"))(); + auto postRes = client.sendPost("画像テスト", [Bluesky.EmbedImage(imgBin, "image/png", "D言語くん")]); + client.deletePost(postRes.uri); + } + version (none) + { + // リンク投稿 + auto thumbImg = readDataSource!(immutable(ubyte)[])("d-logo.png"); + auto postRes = client.sendPost("External Link Post Test", Bluesky.EmbedExternal( + "https://dlang.org", + "Home - D Programming Language", + "D is a general-purpose programming language with static typing, systems-level access, and C-like syntax.", + thumbImg, "image/png")); + client.deletePost(postRes.uri); + } + + version (none) + { + // 画像返信 + auto imgBin = readDataSource("d-man.png"); + auto postRes = client.sendReplyPost("at://did:plc:vuc5dzl4377xhfuwkcyyfrqc/app.bsky.feed.post/3kpadeirzya27", + "画像テスト", [Bluesky.EmbedImage(imgBin, "image/png", "D言語くん")]); + client.deletePost(postRes.uri); + } + + version (none) + { + // リンク返信 + auto thumbImg = readDataSource!(immutable(ubyte)[])("d-logo.png"); + auto postRes = client.sendReplyPost("at://did:plc:vuc5dzl4377xhfuwkcyyfrqc/app.bsky.feed.post/3kpadeirzya27", + "External Link Reply Post Test", Bluesky.EmbedExternal( + "https://dlang.org", + "Home - D Programming Language", + "D is a general-purpose programming language with static typing, systems-level access, and C-like syntax.", + thumbImg, "image/png")); + client.deletePost(postRes.uri); + } + + version (none) + { + // 引用+画像で返信 + auto imgBin = readDataSource("d-man.png"); + auto recRef = client.getRecordRef("at://did:plc:vuc5dzl4377xhfuwkcyyfrqc/app.bsky.feed.post/3krdry3cgzy25"); + auto postRes = client.sendReplyPost("at://did:plc:vuc5dzl4377xhfuwkcyyfrqc/app.bsky.feed.post/3kpadeirzya27", + "引用と画像のテスト", + Bluesky.EmbedRecordWithMedia(recRef, Bluesky.EmbedRecordWithMedia.Media( + [Bluesky.EmbedImage(imgBin, "image/png", "D言語くん")]))); + client.deletePost(postRes.uri); + } + + version (none) + { + // 画像で引用ポスト + auto imgBin = readDataSource("d-man.png"); + auto postRes = client.sendQuotePost("at://did:plc:vuc5dzl4377xhfuwkcyyfrqc/app.bsky.feed.post/3krdry3cgzy25", + "Quote post test", Bluesky.EmbedImage(imgBin, "image/png", "D言語くん")); + client.deletePost(postRes.uri); + } + + version (none) + { + // リンクで引用ポスト + auto thumbImg = readDataSource!(immutable(ubyte)[])("d-logo.png"); + auto postRes = client.sendQuotePost("at://did:plc:vuc5dzl4377xhfuwkcyyfrqc/app.bsky.feed.post/3krdry3cgzy25", + "External Link Reply Post Test", Bluesky.EmbedExternal( + "https://dlang.org", + "Home - D Programming Language", + "D is a general-purpose programming language with static typing, systems-level access, and C-like syntax.", + thumbImg, "image/png")); + } +} + +version (unittest) package(bsky) auto _createDummyClient(string uuid = null, + string loginDummyDid = "did:plc:2qfqobqz6dzrfa3jv74i6k6m", + string loginDummyHandle = "dxutjikmg579.hfor.org", + string loginDummyPassword = "dummy") @trusted +{ + import bsky._internal.httpc; + import std.file; + static struct Ret + { + DummyHttpClient!() httpc; + Bluesky bsky; + ref const(DummyHttpClient!().HttpRequest) req(size_t idx = 0) @safe + { + assert(idx < httpc.results.length); + return httpc.results[idx].request; + } + void resetDataSource(string dsUuid) @safe + { + httpc.clearResult(); + addDataSource(dsUuid); + } + void addDataSource(string dsUuid) @safe + { + httpc.addResult(readDataSource!JSONValue(dsUuid ~ ".json")); + } + alias bsky this; + } + Ret ret; + ret.httpc = new DummyHttpClient!(); + ret.bsky = new Bluesky(ret.httpc); + if (loginDummyDid !is null && loginDummyHandle !is null) + ret.httpc.addResult(_createDummySession(loginDummyDid, loginDummyHandle).toJson); + if (loginDummyHandle !is null && loginDummyPassword !is null) + ret.bsky.login("dxutjikmg579.hfor.org", loginDummyPassword, ret.httpc); + ret.httpc.clearResult(); + if (uuid !is null) + ret.resetDataSource(uuid); + return ret; +} diff --git a/src/bsky/data.d b/src/bsky/data.d new file mode 100644 index 0000000..0c81ee3 --- /dev/null +++ b/src/bsky/data.d @@ -0,0 +1,157 @@ +/******************************************************************************* + * Common data + * + * License: BSL-1.0 + */ +module bsky.data; + +import bsky._internal; + +/******************************************************************************* + * + */ +struct Label +{ + /*************************************************************************** + * + */ + long ver; + /*************************************************************************** + * + */ + string src; + /*************************************************************************** + * + */ + string uri; + /*************************************************************************** + * + */ + string cid; + /*************************************************************************** + * + */ + string val; + /*************************************************************************** + * + */ + bool neg; + /*************************************************************************** + * + */ + @systimeConverter + SysTime cts; + /*************************************************************************** + * + */ + @systimeConverter + SysTime exp; + /*************************************************************************** + * + */ + string sig; +} + + +/******************************************************************************* + * + */ +struct AtProtoURI +{ + import std.exception; + /*************************************************************************** + * AUTHORITY (handle or did) + */ + string authority; + /*************************************************************************** + * COLLECTION (nsid) + */ + string collection; + /*************************************************************************** + * RKEY (record key) + */ + string rkey; + + /*************************************************************************** + * + */ + this(string uri) @safe + { + import std.string; + enforce(uri.startsWith("at://")); + auto splitted = split(uri[5..$], '/'); + enforce(splitted.length >= 1); + authority = splitted[0]; + if (splitted.length > 1) + collection = splitted[1]; + if (splitted.length > 2) + rkey = splitted[2]; + } + + /*************************************************************************** + * + */ + bool hasDid() const pure nothrow @nogc @safe + { + import std.string; + return authority.startsWith("did:"); + } + + /*************************************************************************** + * + */ + bool hasHandle() const pure nothrow @nogc @safe + { + import std.string; + return !authority.startsWith("did:"); + } + + /*************************************************************************** + * + */ + bool hasCollection() const pure nothrow @nogc @safe + { + return collection.length != 0; + } + + /*************************************************************************** + * + */ + bool hasRecordKey() const pure nothrow @nogc @safe + { + return rkey.length != 0; + } + + /*************************************************************************** + * + */ + string toString() const pure nothrow @safe + { + return "at://" ~ authority ~ (collection.length > 0 + ? "/" ~ collection ~ (rkey.length > 0 ? "/" ~ rkey : null) + : null); + } + +} + +@safe unittest +{ + auto uri1 = AtProtoURI("at://did:plc:2qfqobqz6dzrfa3jv74i6k6m/app.bsky.feed.post/5ni6rkonpzlx2"); + assert(uri1.hasDid); + assert(!uri1.hasHandle); + assert(uri1.hasCollection); + assert(uri1.hasRecordKey); + assert(uri1.toString() == "at://did:plc:2qfqobqz6dzrfa3jv74i6k6m/app.bsky.feed.post/5ni6rkonpzlx2"); + auto uri2 = AtProtoURI("at://krzblhls379.vkn.io/app.bsky.feed.post"); + assert(!uri2.hasDid); + assert(uri2.hasHandle); + assert(uri2.hasCollection); + assert(!uri2.hasRecordKey); + assert(uri2.toString() == "at://krzblhls379.vkn.io/app.bsky.feed.post"); + auto uri3 = AtProtoURI("at://krzblhls379.vkn.io"); + assert(!uri3.hasDid); + assert(uri3.hasHandle); + assert(!uri3.hasCollection); + assert(!uri3.hasRecordKey); + assert(uri3.toString() == "at://krzblhls379.vkn.io"); +} diff --git a/src/bsky/lexicons/app/bsky/embed.d b/src/bsky/lexicons/app/bsky/embed.d new file mode 100644 index 0000000..8f7e6ea --- /dev/null +++ b/src/bsky/lexicons/app/bsky/embed.d @@ -0,0 +1,295 @@ +/******************************************************************************* + * AtProto lexicons record types of app.bsky.embed.* + * + * See_Also: + * https://github.com/bluesky-social/atproto/tree/main/lexicons/app/bsky/embed + */ +module bsky.lexicons.app.bsky.embed; + +import bsky._internal.attr: name, key, value, ignoreIf; +public import bsky.lexicons.data: Blob; +public import bsky.lexicons.com.atproto.repo: StrongRef; + +import std.sumtype; + + + +/******************************************************************************* + * Embed data of images + */ +struct Images +{ + /*************************************************************************** + * Type + */ + @name("$type") @key @value!"app.bsky.embed.images" + string type = "app.bsky.embed.images"; + + /*************************************************************************** + * Images data + */ + struct Image + { + /*********************************************************************** + * Blob of image + */ + Blob image; + /*********************************************************************** + * Alt text + */ + string alt; + } + /// ditto + Image[] images; + + /*************************************************************************** + * Constructor + */ + this(Image[] images) pure nothrow @nogc @safe + { + this.images = images; + } + /// ditto + this(Image image) pure nothrow @safe + { + this.images = [image]; + } + /// ditto + this(Blob blob, string alt) pure nothrow @safe + { + this.images = [Image(blob, alt)]; + } +} + +@safe unittest +{ + import std.json; + import bsky._internal.json; + auto imgs = Images( + image: Images.Image(Blob.init, "test")); + auto jv = imgs.serializeToJson(); + assert(jv.getValue!string("$type") == "app.bsky.embed.images"); + assert("images" in jv); + assert(jv["images"].type == JSONType.array); + assert(jv["images"][0].getValue!string("alt") == "test"); + auto imgs2 = jv.deserializeFromJson!Images; + assert(imgs == imgs2); +} + +/******************************************************************************* + * Embed data of external link + */ +struct External +{ + /*************************************************************************** + * Type + */ + @name("$type") @key @value!"app.bsky.embed.external" + string type = "app.bsky.embed.external"; + + /*************************************************************************** + * External data + */ + struct External + { + /*********************************************************************** + * URL of external link + */ + string uri; + /*********************************************************************** + * Title of external link + */ + string title; + /*********************************************************************** + * Descriptions + */ + string description; + /*********************************************************************** + * Thumbnail of external link + */ + @ignoreIf!(dat => dat.thumb is Blob.init) + Blob thumb; + } + /// ditto + External external; + + /*************************************************************************** + * Constructor + */ + this(External external) pure nothrow @nogc @safe + { + this.external = external; + } +} + +@safe unittest +{ + import std.json; + import bsky._internal.json; + auto ext = External( + external: External.External("https://dlang.org")); + auto jv = ext.serializeToJson(); + assert(jv.getValue!string("$type") == "app.bsky.embed.external"); + assert("external" in jv); + assert(jv["external"].type == JSONType.object); + assert(jv["external"].getValue!string("uri") == "https://dlang.org"); + assert("thumb" !in jv["external"]); + auto ext2 = jv.deserializeFromJson!External; + assert(ext == ext2); +} + +/******************************************************************************* + * Embed data of record + */ +struct Record +{ + /*************************************************************************** + * Type + */ + @name("$type") @key @value!"app.bsky.embed.record" + string type = "app.bsky.embed.record"; + + /*************************************************************************** + * Record data + */ + alias Record = StrongRef; + /// ditto + Record record; + + /*************************************************************************** + * Constructor + */ + this(Record record) pure nothrow @nogc @safe + { + this.record = record; + } + + /// ditto + this(string uri, string cid) pure nothrow @nogc @safe + { + this.record = Record(uri, cid); + } +} + +@safe unittest +{ + import std.json; + import bsky._internal.json; + auto rec = Record( + record: StrongRef("at://test.bsly.social/app.bsky.feed.post/3kpadeirzya27", "abcde")); + auto jv = rec.serializeToJson(); + assert(jv.getValue!string("$type") == "app.bsky.embed.record"); + assert("record" in jv); + assert(jv["record"].type == JSONType.object); + assert(jv["record"].getValue!string("uri") == "at://test.bsly.social/app.bsky.feed.post/3kpadeirzya27"); + assert(jv["record"].getValue!string("cid") == "abcde"); + auto rec2 = jv.deserializeFromJson!Record; + assert(rec == rec2); +} + + +/******************************************************************************* + * Embed data of external link + */ +struct RecordWithMedia +{ + /*************************************************************************** + * Type + */ + @name("$type") @key @value!"app.bsky.embed.recordWithMedia" + string type = "app.bsky.embed.recordWithMedia"; + + /*************************************************************************** + * Record data + */ + Record record; + + /*************************************************************************** + * Image/External data + */ + alias Media = SumType!(Images, External); + /// ditto + Media media; + + /*************************************************************************** + * Constructor + */ + this(Record record, Media media) pure nothrow @nogc @safe + { + this.record = record; + this.media = media; + } + /// ditto + this(Record record, External external) pure nothrow @nogc @safe + { + this(record, Media(external)); + } + /// ditto + this(Record record, Images images) pure nothrow @nogc @safe + { + this(record, Media(images)); + } + /// ditto + this(Record record, Images.Image image) pure nothrow @safe + { + this(record, Images(image)); + } + /// ditto + this(StrongRef record, Media media) pure nothrow @nogc @safe + { + this(Record(record), media); + } + /// ditto + this(StrongRef record, External external) pure nothrow @nogc @safe + { + this(Record(record), external); + } + /// ditto + this(StrongRef record, Images.Image image) pure nothrow @safe + { + this(Record(record), image); + } + /// ditto + this(StrongRef record, Images images) pure nothrow @nogc @safe + { + this(Record(record), images); + } +} +/******************************************************************************* + * Embed data of external link + */ +@safe unittest +{ + import std.json; + import bsky._internal.json; + auto rwm = RecordWithMedia( + record: Record("at://test.bsly.social/app.bsky.feed.post/3kpadeirzya27", "abcde"), + image: Images.Image(Blob.init, "test")); + auto jv = rwm.serializeToJson(); + assert(jv.getValue!string("$type") == "app.bsky.embed.recordWithMedia"); + assert("record" in jv); + assert("record" in jv["record"]); + assert("media" in jv); + assert(jv["record"]["record"].type == JSONType.object); + assert(jv["record"]["record"].getValue!string("uri") == "at://test.bsly.social/app.bsky.feed.post/3kpadeirzya27"); + assert(jv["record"]["record"].getValue!string("cid") == "abcde"); + assert(jv["media"].getValue!string("$type") == "app.bsky.embed.images"); + auto rwm2 = jv.deserializeFromJson!RecordWithMedia; + assert(rwm == rwm2); +} + +alias Embed = SumType!(Images, External, Record, RecordWithMedia); +@safe unittest +{ + import std.json; + import bsky._internal.json; + auto embed = Embed(External( + external: External.External("https://dlang.org"))); + auto jv = embed.serializeToJson(); + assert(jv.getValue!string("$type") == "app.bsky.embed.external"); + assert("external" in jv); + assert(jv["external"].type == JSONType.object); + assert(jv["external"].getValue!string("uri") == "https://dlang.org"); + auto embed2 = jv.deserializeFromJson!Embed; + assert(embed == embed2); +} diff --git a/src/bsky/lexicons/app/bsky/feed.d b/src/bsky/lexicons/app/bsky/feed.d new file mode 100644 index 0000000..e7825b1 --- /dev/null +++ b/src/bsky/lexicons/app/bsky/feed.d @@ -0,0 +1,82 @@ +/******************************************************************************* + * AtProto lexicons types of app.bsky.feed.* + * + * See_Also: + * https://github.com/bluesky-social/atproto/tree/main/lexicons/app/bsky/feed + */ +module bsky.lexicons.app.bsky.feed; + +public import std.datetime: SysTime; +import bsky._internal.attr: name, key, value, ignoreIf; +import bsky._internal.misc: systimeConverter; +public import bsky.lexicons.data: Blob; +public import bsky.lexicons.com.atproto.repo: StrongRef; + +/******************************************************************************* + * app.bsky.feed.post + */ +struct Post +{ + /*************************************************************************** + * app.bsky.feed.post#replyRef + */ + struct ReplyRef + { + /*********************************************************************** + * Root of reply tree + */ + StrongRef root; + /*********************************************************************** + * Parent of reply tree + */ + StrongRef parent; + } +} + +/******************************************************************************* + * app.bsky.feed.like + * + * See_Also: + * https://github.com/bluesky-social/atproto/tree/main/lexicons/app/bsky/feed/like.json + */ +struct Like +{ + /*************************************************************************** + * + */ + @name("$type") @key @value!"app.bsky.feed.like" + string type = "app.bsky.feed.like"; + /*************************************************************************** + * + */ + StrongRef subject; + /*************************************************************************** + * + */ + @systimeConverter + SysTime createdAt; +} + +/******************************************************************************* + * app.bsky.feed.repost + * + * See_Also: + * https://github.com/bluesky-social/atproto/tree/main/lexicons/app/bsky/feed/repost.json + */ +struct Repost +{ + /*************************************************************************** + * + */ + @name("$type") @key @value!"app.bsky.feed.repost" + string type = "app.bsky.feed.repost"; + /*************************************************************************** + * + */ + StrongRef subject; + /*************************************************************************** + * + */ + @systimeConverter + SysTime createdAt; +} diff --git a/src/bsky/lexicons/app/bsky/package.d b/src/bsky/lexicons/app/bsky/package.d new file mode 100644 index 0000000..342fae9 --- /dev/null +++ b/src/bsky/lexicons/app/bsky/package.d @@ -0,0 +1,12 @@ +/******************************************************************************* + * AtProto lexicons types of app.bsky.* + * + * See_Also: + * https://github.com/bluesky-social/atproto/tree/main/lexicons/app/bsky + */ +module bsky.lexicons.app.bsky; + +/// +public import bsky.lexicons.app.bsky.embed; +/// ditto +public import bsky.lexicons.app.bsky.feed; diff --git a/src/bsky/lexicons/com/atproto/package.d b/src/bsky/lexicons/com/atproto/package.d new file mode 100644 index 0000000..af934a2 --- /dev/null +++ b/src/bsky/lexicons/com/atproto/package.d @@ -0,0 +1,10 @@ +/******************************************************************************* + * AtProto lexicons record types of com.atproto.* + * + * See_Also: + * https://github.com/bluesky-social/atproto/tree/main/lexicons/com/atproto + */ +module bsky.lexicons.com.atproto; + +/// +public import bsky.lexicons.com.atproto.repo; diff --git a/src/bsky/lexicons/com/atproto/repo.d b/src/bsky/lexicons/com/atproto/repo.d new file mode 100644 index 0000000..13c6743 --- /dev/null +++ b/src/bsky/lexicons/com/atproto/repo.d @@ -0,0 +1,22 @@ +/******************************************************************************* + * AtProto lexicons record types of com.atproto.repo.* + * + * See_Also: + * https://github.com/bluesky-social/atproto/tree/main/lexicons/com/atproto/repo + */ +module bsky.lexicons.com.atproto.repo; + +/******************************************************************************* + * A URI with a content-hash fingerprint. + */ +struct StrongRef +{ + /*************************************************************************** + * + */ + string uri; + /*************************************************************************** + * + */ + string cid; +} diff --git a/src/bsky/lexicons/data.d b/src/bsky/lexicons/data.d new file mode 100644 index 0000000..91734b3 --- /dev/null +++ b/src/bsky/lexicons/data.d @@ -0,0 +1,51 @@ +module bsky.lexicons.data; + +import bsky._internal.json; +import bsky._internal.attr: name, key, value; + +/******************************************************************************* + * Blob + */ +struct Blob +{ + /*************************************************************************** + * Type + */ + @name("$type") @key @value!"blob" + string type = "blob"; + + /*************************************************************************** + * Reference link + */ + struct Ref + { + /*********************************************************************** + * String data of link + */ + @name("$link") string link; + } + /// ditto + @name("ref") Ref reference; + + /*************************************************************************** + * MIME type of data + */ + string mimeType; + + /*************************************************************************** + * Size of data + */ + size_t size; +} + +@safe unittest +{ + import std.json; + auto blob = Blob( + mimeType: "image/jpeg", + reference: Blob.Ref(link: "bafkreicypyknhwkg4lpxj6yskyttb3nkvhywv32tohpkt6bz4xhgyqybiq"), + size: 297434); + import bsky._internal.json; + auto jv = blob.serializeToJson(); + assert(jv.deserializeFromJson!Blob == blob); +} diff --git a/src/bsky/lexicons/package.d b/src/bsky/lexicons/package.d new file mode 100644 index 0000000..fa4d777 --- /dev/null +++ b/src/bsky/lexicons/package.d @@ -0,0 +1,21 @@ +module bsky.lexicons; + + +/******************************************************************************* + * Basic types + */ +public import bsky.lexicons.data; + +/******************************************************************************* + * Lexcon types of app.* + */ +public import bsky.lexicons.app.bsky; +/// ditto +alias app = bsky.lexicons.app; + +/******************************************************************************* + * Lexcon types of com.* + */ +public import bsky.lexicons.com.atproto; +/// ditto +alias com = bsky.lexicons.com; diff --git a/src/bsky/package.d b/src/bsky/package.d new file mode 100644 index 0000000..3fdab46 --- /dev/null +++ b/src/bsky/package.d @@ -0,0 +1,34 @@ +/*************************************************************** + * Client library to access Bluesky + * + * For more detailed usage instructions, please refer to the documentation for each module. + * - $(LINK2 _bsky--_bsky.client.html, _bsky.client) : Client of bluesky. + * - $(LINK2 _bsky--_bsky.auth.html, _bsky.auth) : Authenticate management. + * + * Authors: SHOO + * License: BSL-1.0 + * Copyright: 2024 SHOO + * Examples: + * ------------------------------------------------------------- + * import bsky; + * import std.stdio, std.process; + * + * auto client = new Bluesky; + * client.login(environment.get("BSKYUT_LOGINID"), environment.get("BSKYUT_LOGINPASS")); + * scope (exit) + * client.logout(); + * writefln("Hello! My name is %s.", client.profile.displayName); + * ------------------------------------------------------------- + */ +module bsky; + +/// +public import bsky.client; +/// ditto +public import bsky.user; +/// ditto +public import bsky.post; +/// ditto +public import bsky.data; +/// ditto +public import bsky.auth; diff --git a/src/bsky/post.d b/src/bsky/post.d new file mode 100644 index 0000000..78b507a --- /dev/null +++ b/src/bsky/post.d @@ -0,0 +1,568 @@ +/******************************************************************************* + * Post information + * + * License: BSL-1.0 + */ +module bsky.post; + +import std.sumtype; +import std.range: isInputRange, ElementType; +import bsky._internal; +import bsky.data; + +/******************************************************************************* + * Post information + */ +struct Post +{ + /*************************************************************************** + * + */ + string uri; + + /*************************************************************************** + * + */ + string cid; + + /*************************************************************************** + * + */ + struct Author + { + import bsky.user: User; + /*************************************************************************** + * + */ + string did; + + /*************************************************************************** + * + */ + string handle; + + /*************************************************************************** + * + */ + string displayName; + + /*************************************************************************** + * + */ + string avatar; + + /*************************************************************************** + * + */ + User.Viewer viewer; + + /*************************************************************************** + * + */ + Label[] labels; + } + /// ditto + Author author; + + /*************************************************************************** + * + */ + JSONValue record; + + /*************************************************************************** + * + */ + JSONValue embed; + + /*************************************************************************** + * + */ + size_t replyCount; + + /*************************************************************************** + * + */ + size_t repostCount; + + /*************************************************************************** + * + */ + size_t likeCount; + + /*************************************************************************** + * + */ + @systimeConverter + SysTime indexedAt; + + /*************************************************************************** + * + */ + struct Viewer + { + /*************************************************************************** + * + */ + string repost; + /*************************************************************************** + * + */ + string like; + /*************************************************************************** + * + */ + bool replyDisabled; + } + /// ditto + Viewer viewer; + + /*************************************************************************** + * + */ + Label[] labels; + + /*************************************************************************** + * + */ + struct ThreadGate + { + /*********************************************************************** + * + */ + string uri; + /*********************************************************************** + * + */ + string cid; + /*********************************************************************** + * + */ + JSONValue record; + /*********************************************************************** + * + */ + struct List + { + /******************************************************************* + * + */ + string uri; + /******************************************************************* + * + */ + string cid; + /******************************************************************* + * + */ + string name; + /******************************************************************* + * + */ + string purpose; + /******************************************************************* + * + */ + string avatar; + /******************************************************************* + * + */ + Label[] labels; + /******************************************************************* + * + */ + struct Viewer + { + /*************************************************************** + * + */ + bool muted; + /*************************************************************** + * + */ + bool blocked; + } + /// ditto + Viewer viewer; + /******************************************************************* + * + */ + @systimeConverter + SysTime indexedAt; + } + /// ditto + List[] lists; + } + /// ditto + ThreadGate threadgate; +} + +@safe unittest +{ + import std.json; + auto jv = readDataSource!JSONValue("fe3b5003-a909-496f-a5de-97b1186f7bba.json"); + Post p; + (() @trusted => p.deserializeFromJson(jv["posts"][0]))(); + assert(p.author.handle == "krzblhls379.vkn.io"); +} + + +/******************************************************************************* + * Post information + */ +struct Reply +{ + /*************************************************************************** + * + */ + struct NotFoundPost + { + /*********************************************************************** + * + */ + string uri; + /*********************************************************************** + * + */ + @kind(true) + bool notFound; + } + + /*************************************************************************** + * + */ + struct BlockedPost + { + /*********************************************************************** + * + */ + string uri; + /*********************************************************************** + * + */ + @kind(true) + bool blocked; + /*********************************************************************** + * + */ + Post.Author author; + } + + /*************************************************************************** + * + */ + SumType!(NotFoundPost, BlockedPost, Post) root; + + /*************************************************************************** + * + */ + SumType!(NotFoundPost, BlockedPost, Post) parent; + + /*************************************************************************** + * + */ + this(this) @trusted {} +} + +/******************************************************************************* + * + */ +struct Feed +{ + /*************************************************************************** + * + */ + Post post; + /*************************************************************************** + * + */ + Reply reply; + /*************************************************************************** + * + */ + struct Reason + { + /*********************************************************************** + * + */ + Post.Author by; + /*********************************************************************** + * + */ + @systimeConverter + SysTime indexedAt; + } + /// ditto + Reason reason; +} + + +/******************************************************************************* + * + */ +struct Message +{ + /*************************************************************************** + * Uri of post as `at://...` + */ + string uri; + + /*************************************************************************** + * Post text + */ + string text; + + /*************************************************************************** + * Image data + */ + struct Image + { + /*********************************************************************** + * Image uri as `https://...` + */ + string uri; + /*********************************************************************** + * Thumbnail uri as `https://...` + */ + string thumb; + /*********************************************************************** + * Alt message of image + */ + string alt; + } + + /*************************************************************************** + * Post images uri as `https://...` + */ + Image[] images; + + /*************************************************************************** + * User data + */ + struct User + { + /*********************************************************************** + * ID of user + */ + string did; + /*********************************************************************** + * Handle name + */ + string handle; + /*********************************************************************** + * Display name + */ + string displayName; + } + /*************************************************************************** + * User data of the post author + */ + User postBy; + + /*************************************************************************** + * User data of the author who made this repost + */ + User repostBy; + + /*************************************************************************** + * User data of the author whom this reply message was sent + */ + User replyTo; + + /*************************************************************************** + * Reply target message uri as `at://...` + */ + string replyToUri; + + /*************************************************************************** + * Liking count of this post + */ + size_t likeCount; + + /*************************************************************************** + * Repost count of this post + */ + size_t repostCount; + + /*************************************************************************** + * Time of post created + */ + @systimeConverter + SysTime postTime; + + /*************************************************************************** + * Time of repost created + */ + @systimeConverter + SysTime repostTime; + + /*************************************************************************** + * + */ + bool isReply() + { + return replyTo.did.length > 0; + } + + /*************************************************************************** + * + */ + bool isRepost() + { + return repostBy.did.length > 0; + } +} + +/******************************************************************************* + * + */ +Message toMessage(in Post post) @safe +{ + import std.json: JSONValue, JSONType; + Message ret; + ret.uri = post.uri; + ret.postBy = Message.User(post.author.did, post.author.handle, post.author.displayName); + ret.text = post.record.getValue("text", ""); + if (!post.embed.isNull) + { + if (auto imagesJv = "images" in post.embed) + { + if (imagesJv.type == JSONType.array) foreach (imageJv; (() @trusted => imagesJv.array)()) + { + Message.Image img; + if (auto urlJv = "fullsize" in imageJv) + { + if (urlJv.type == JSONType.string) + img.uri = urlJv.str; + } + if (auto urlJv = "thumb" in imageJv) + { + if (urlJv.type == JSONType.string) + img.thumb = urlJv.str; + } + if (auto altJv = "alt" in imageJv) + { + if (altJv.type == JSONType.string) + img.alt = altJv.str; + } + if (img !is Message.Image.init) + ret.images ~= img; + } + } + // RecodeWithMedia + if (auto mediaJv = "media" in post.embed) + { + if (mediaJv.type == JSONType.object) if (auto imagesJv = "images" in *mediaJv) + { + if (imagesJv.type == JSONType.array) foreach (imageJv; (() @trusted => imagesJv.array)()) + { + Message.Image img; + if (auto urlJv = "fullsize" in imageJv) + { + if (urlJv.type == JSONType.string) + img.uri = urlJv.str; + } + if (auto urlJv = "thumb" in imageJv) + { + if (urlJv.type == JSONType.string) + img.thumb = urlJv.str; + } + if (auto altJv = "alt" in imageJv) + { + if (altJv.type == JSONType.string) + img.alt = altJv.str; + } + if (img !is Message.Image.init) + ret.images ~= img; + } + } + } + } + ret.likeCount = post.likeCount; + ret.repostCount = post.repostCount; + if (auto timJv = "createdAt" in post.record) + { + ret.postTime = timJv.type == JSONType.string + ? SysTime.fromISOExtString(timJv.str).toLocalTime() + : post.indexedAt; + } + else + { + ret.postTime = post.indexedAt; + } + return ret; +} + +/// ditto +Message toMessage(Feed feed) @safe +{ + Message ret; + if (feed.post.uri.length > 0) + ret = toMessage(feed.post); + if (feed.reason.by.did.length > 0) + { + // Repost + ret.repostBy = Message.User(feed.reason.by.did, feed.reason.by.handle, feed.reason.by.displayName); + ret.repostTime = feed.reason.indexedAt; + } + if (feed.reply !is Reply.init) + { + // Reply + feed.reply.parent.match!( + (Post post) + { + ret.replyToUri = post.uri; + ret.replyTo = Message.User(post.author.did, post.author.handle, post.author.displayName); + }, + (_){} + ); + } + return ret; +} + +/// ditto +auto toMessages(Range)(Range posts) @safe +if (isInputRange!Range && is(ElementType!Range: Post)) +{ + import std.algorithm: map; + return posts.map!toMessage; +} + +/// ditto +auto toMessages(Range)(Range feeds) @safe +if (isInputRange!Range && is(ElementType!Range: Feed)) +{ + import std.algorithm: map; + return feeds.map!toMessage; +} + +@safe unittest +{ + import std.json; + auto jv = readDataSource!JSONValue("fe3b5003-a909-496f-a5de-97b1186f7bba.json"); + Post[] posts; + (() @trusted => posts.deserializeFromJson(jv["posts"]))(); + auto msg = posts.toMessages(); + assert(msg[0].uri == "at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/hyq6lbnl45len"); +} + +@safe unittest +{ + import std.json; + auto jv = readDataSource!JSONValue("657d70c5-eb4d-4b33-ab35-86a1589c2e9a.json"); + Feed[] feed; + (() @trusted => feed.deserializeFromJson(jv[0]["body"]["feed"]))(); + auto msg = feed.toMessages(); + assert(msg[0].uri == "at://did:plc:jm26v6khcj237cfl5e3d4kmx/app.bsky.feed.post/rvhnzoikmc3aa"); +} + +@safe unittest +{ + import std.json; + auto jv = readDataSource!JSONValue("49ed9c5d-d355-4f3a-81fb-cab01d1c7e64.json"); + Post[] posts; + (() @trusted => posts.deserializeFromJson(jv[0]["body"]["posts"]))(); + auto msg = posts.toMessages(); + assert(msg[0].uri == "at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.feed.post/2a7dtnyusr74f"); + assert(msg[1].uri == "at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.feed.post/q2dbegoee2gnd"); + assert(msg[2].uri == "at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.feed.post/i3bao2sbegi5d"); +} diff --git a/src/bsky/user.d b/src/bsky/user.d new file mode 100644 index 0000000..23cb2d0 --- /dev/null +++ b/src/bsky/user.d @@ -0,0 +1,229 @@ +/******************************************************************************* + * User information + * + * License: BSL-1.0 + */ +module bsky.user; + +import std.json: JSONValue; +import std.datetime: SysTime; +import bsky._internal; +import bsky.data; + + +/******************************************************************************* + * User information + */ +struct User +{ + + /*************************************************************************** + * + */ + string did; + + /*************************************************************************** + * + */ + string handle; + + /*************************************************************************** + * + */ + string displayName; + + /*************************************************************************** + * + */ + string description; + + /*************************************************************************** + * + */ + string avatar; + + /*************************************************************************** + * + */ + @systimeConverter + SysTime indexedAt; + + /*************************************************************************** + * + */ + struct Viewer + { + /*********************************************************************** + * + */ + bool muted; + /*********************************************************************** + * + */ + struct ByList + { + /******************************************************************* + * + */ + string uri; + /******************************************************************* + * + */ + string cid; + /******************************************************************* + * + */ + string name; + /******************************************************************* + * + */ + JSONValue purpose; + /******************************************************************* + * + */ + string avatar; + /******************************************************************* + * + */ + Label[] labels; + /******************************************************************* + * + */ + struct Viewer + { + /*************************************************************** + * + */ + bool muted; + /*************************************************************** + * + */ + bool blocked; + } + /// ditto + Viewer viewer; + /******************************************************************* + * + */ + @systimeConverter + SysTime indexedAt; + } + /*********************************************************************** + * + */ + ByList mutedByList; + /*********************************************************************** + * + */ + bool blockedBy; + /*********************************************************************** + * + */ + bool blocking; + /*********************************************************************** + * + */ + string following; + /*********************************************************************** + * + */ + string followedBy; + } + + /// ditto + Viewer viewer; + + /// ditto + Label[] labels; + + +} + + +/******************************************************************************* + * User profile information + */ +struct Profile +{ + /*************************************************************************** + * + */ + string did; + + /*************************************************************************** + * + */ + string handle; + + /*************************************************************************** + * + */ + string displayName; + + /*************************************************************************** + * + */ + string description; + + /*************************************************************************** + * + */ + string avatar; + + /*************************************************************************** + * + */ + string banner; + + /*************************************************************************** + * + */ + size_t followersCount; + + /*************************************************************************** + * + */ + size_t followsCount; + + /*************************************************************************** + * + */ + size_t postsCount; + + /*************************************************************************** + * + */ + struct Associated + { + /*********************************************************************** + * + */ + long lists; + /*********************************************************************** + * + */ + long feedgens; + /*********************************************************************** + * + */ + bool labeler; + } + /// ditto + Associated associated; + + /*************************************************************************** + * + */ + @systimeConverter + SysTime indexedAt; + + /*************************************************************************** + * + */ + User.Viewer viewer; + + /*************************************************************************** + * + */ + Label[] labels; +} diff --git a/tests/.ut-data_source/032bc000-f6f8-4965-a9c7-b66570112870.json b/tests/.ut-data_source/032bc000-f6f8-4965-a9c7-b66570112870.json new file mode 100644 index 0000000..79841e5 --- /dev/null +++ b/tests/.ut-data_source/032bc000-f6f8-4965-a9c7-b66570112870.json @@ -0,0 +1,6 @@ +[ +{"body":{"cid":"aykzb74s6m3tj3fvsn777i3zeuefoxkxhwwevxy5juuuaxe24rm26rs6wnf","uri":"at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/hyq6lbnl45len","value":{"$type":"app.bsky.feed.post","createdAt":"2024-04-30T10:39:29.924Z","langs":["ja"],"reply":{"parent":{"cid":"dx2imfjwbdohkh4re7hh6dguhpczu5mhruw4ofkn6sdmhk4uimas37c5a74","uri":"at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/5ni6rkonpzlx2"},"root":{"cid":"dx2imfjwbdohkh4re7hh6dguhpczu5mhruw4ofkn6sdmhk4uimas37c5a74","uri":"at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/5ni6rkonpzlx2"}},"text":"ほな、試しにもう一回言うてみ。"}},"code":200,"mimeType":"application/json; charset=utf-8","reason":"OK"}, +{"body":{"blob":{"$type":"blob","mimeType":"image/png","ref":{"$link":"mjxrmzdw2ggq2rjuwxan6hm6w36r2nipkecdrvkpkskk5qoo4slwst3xm6f"},"size":13979}},"code":200,"mimeType":"application/json; charset=utf-8","reason":"OK"}, +{"body":{"cid":"dx2imfjwbdohkh4re7hh6dguhpczu5mhruw4ofkn6sdmhk4uimas37c5a74","uri":"at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/5ni6rkonpzlx2","value":{"$type":"app.bsky.feed.post","createdAt":"2024-04-03T14:49:37.630675Z","text":"こんにちは、青空さん!\n空の色、めっちゃ綺麗なブルーやな。\nまるで、海原に浮かぶ船みたいや。\nそやけど、その船はどこへ行くんやろ?\nもしかしたら、宝島を目指してるんかもしれへんな。"}},"code":200,"mimeType":"application/json; charset=utf-8","reason":"OK"}, +{"body":{"cid":"xy2frqraadrxv6xxq3bo2x4rxeppbqny4otupfja5cjqrrprnnwiik2ym4i","commit":{"cid":"kzon3w3dcrkxfwwws3ut3etc255v5udns3ncvbrld7i2rb4zghw2vuihpt6","rev":"n35vomf6dlino"},"uri":"at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/rgdb72nfe25ym","validationStatus":"valid"},"code":200,"mimeType":"application/json; charset=utf-8","reason":"OK"} +] diff --git a/tests/.ut-data_source/04cdf227-7339-486b-bd12-515453cee504.json b/tests/.ut-data_source/04cdf227-7339-486b-bd12-515453cee504.json new file mode 100644 index 0000000..c170780 --- /dev/null +++ b/tests/.ut-data_source/04cdf227-7339-486b-bd12-515453cee504.json @@ -0,0 +1,5 @@ +[ +{"body":{"blob":{"$type":"blob","mimeType":"image/png","ref":{"$link":"vwotix62an5fz5pcmd4bz52wm6exrsns5lq2pdiehp7fqoulexyo64btrmf"},"size":77512}},"code":200,"mimeType":"application/json; charset=utf-8","reason":"OK"}, +{"body":{"cid":"aykzb74s6m3tj3fvsn777i3zeuefoxkxhwwevxy5juuuaxe24rm26rs6wnf","uri":"at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/hyq6lbnl45len","value":{"$type":"app.bsky.feed.post","createdAt":"2024-04-30T10:39:29.924Z","langs":["ja"],"reply":{"parent":{"cid":"dx2imfjwbdohkh4re7hh6dguhpczu5mhruw4ofkn6sdmhk4uimas37c5a74","uri":"at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/5ni6rkonpzlx2"},"root":{"cid":"dx2imfjwbdohkh4re7hh6dguhpczu5mhruw4ofkn6sdmhk4uimas37c5a74","uri":"at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/5ni6rkonpzlx2"}},"text":"ほな、試しにもう一回言うてみ。"}},"code":200,"mimeType":"application/json; charset=utf-8","reason":"OK"}, +{"body":{"cid":"7snq6kjyin5s5mth2omrbriiy5bsi4eb5laet6zmyfkn7zt64nqy4vsb2ub","commit":{"cid":"kstnt7wzwenos57pqpbi4qwntdzzvz4tyl4iwdor4vdmqqfueq5b43lnj7p","rev":"d3yrjobn4j7qs"},"uri":"at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/gc5wkr6ugklb6","validationStatus":"valid"},"code":200,"mimeType":"application/json; charset=utf-8","reason":"OK"} +] diff --git a/tests/.ut-data_source/0b8503cb-4eb7-45ee-8487-80260a3c9284.json b/tests/.ut-data_source/0b8503cb-4eb7-45ee-8487-80260a3c9284.json new file mode 100644 index 0000000..e754c74 --- /dev/null +++ b/tests/.ut-data_source/0b8503cb-4eb7-45ee-8487-80260a3c9284.json @@ -0,0 +1,4 @@ +[ +{"body": {"cid": "ztdjymlhwsoywyrzip7sku4kbwm32v44vpqwj273kb3zggtvuqxp3qg6bi2","uri": "at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.feed.post/sjxklekf4hsir","value": {"$type": "app.bsky.feed.post","createdAt": "2024-10-27T09:31:04.5439725Z","text": "ほな、テスト投稿や。\n\n空に舞う蝶々が、そよ風に揺れる花びらのように、優雅に舞い踊る。その姿はまるで、遠い昔の思い出を懐かしむかのように、ゆっくりと、そして確かに、時の流れを感じさせる。\n\nこの投稿は、そんな儚くも美しい瞬間を切り取った、ええ、まあ、なんちゅうか、あれやな、一瞬の輝きを永遠に留めるための、大切な記録なんや。"}},"code": 200,"mimeType": "application/json; charset=utf-8","reason": "OK"}, +{"body": {"cid": "uphv4f3bytywlue5vmj7bbcgc45clkdij2s7zsyupjbnfzx34zbiwuu56gy","commit": {"cid": "5ikmdibyvvvcapm7mvelq2fnimodon2tamm45kjhlkhwclgwsdqmygraoud","rev": "kxsurb5wjx2rd"},"uri": "at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.feed.post/xlujb5m6o43ot","validationStatus": "valid"},"code": 200,"mimeType": "application/json; charset=utf-8","reason": "OK"} +] diff --git a/tests/.ut-data_source/0b877230-5d39-4aa1-ab65-b3e5ed2bd23a.json b/tests/.ut-data_source/0b877230-5d39-4aa1-ab65-b3e5ed2bd23a.json new file mode 100644 index 0000000..083b7c3 --- /dev/null +++ b/tests/.ut-data_source/0b877230-5d39-4aa1-ab65-b3e5ed2bd23a.json @@ -0,0 +1,3 @@ +[ +{"body":{"profiles":[{"associated":{"feedgens":0,"labeler":false,"lists":0,"starterPacks":0},"createdAt":"2024-04-01T15:04:14.605Z","did":"did:plc:vibjcyg6myvxdi4ezdrhcsuo","displayName":"","followersCount":0,"followsCount":0,"handle":"krzblhls379.vkn.io","indexedAt":"2024-04-01T15:04:15.943Z","labels":[],"postsCount":2,"viewer":{"blockedBy":false,"muted":false}},{"associated":{"chat":{"allowIncoming":"following"},"feedgens":0,"labeler":false,"lists":0,"starterPacks":0},"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:mhz3szj7pcjfpzv7pylcmlgx/rupnltt6iifthjqtfsvarqnia6csl2zcqoeq5f64rrqj3rbr3mrqcwvw5de@jpeg","banner":"https://cdn.bsky.app/img/banner/plain/did:plc:mhz3szj7pcjfpzv7pylcmlgx/qxew2xojools7oqq6xdhfi525ovp5l4si3wewtmiam7fdcnqhc6bxhumrgj@jpeg","createdAt":"2023-12-08T13:45:31.054Z","description":"ほな、D言語っちゅうプログラミング言語の話から始めよか。D言語は、なんやらええ感じの機能満載で、プログラマーのお助けマンや。C言語のええとこ取りしながら、さらにパワーアップした感じやな。C言語の親戚みたいなもんやけど、もっとオシャレでスマートなんやで。\n\nC言語は古株やけど、今でも根強い人気で、プログラミング界の重鎮や。堅実で頼りになる存在やな。C++はC言語の進化版で、もっと多彩な技を持っとる。アーティストみたいに、色んな表現ができるんや。\n\n組み込みの世界はまた違った魅力や。ハードウェアとソフトウェアのハーモニーやな。回路は音楽の楽譜みたいなもんや。電気が流れると、プログラムが踊り出すんやで。組み込みのプログラミングは、機械に魂を吹き込むような作業やね。\n\nD言語もC言語も、組み込みの世界で大活躍や。小さなデバイスから巨大なシステムまで、裏で支えとるんやで。この世界は、見えへんけど、めっちゃ重要な役者ばっかりやね。","did":"did:plc:mhz3szj7pcjfpzv7pylcmlgx","displayName":"まにまく","followersCount":44,"followsCount":39,"handle":"upqbv134.esi.org","indexedAt":"2024-02-25T17:09:35.981Z","labels":[],"postsCount":127,"viewer":{"blockedBy":false,"knownFollowers":{"count":32,"followers":[{"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:qi6hrrx2cnb5amths656bsge/qioweyxq3fms2kfkwhnk4zloupe4ncudol5dddjnktx75bgxqveru7etxaj@jpeg","createdAt":"2024-02-07T07:50:13.424Z","did":"did:plc:qi6hrrx2cnb5amths656bsge","displayName":"るつつ","handle":"xkebehmzp264.rmyve.io","labels":[{"cts":"2024-02-21T05:18:53.911Z","neg":false,"src":"did:plc:o3znbbpsfwwtr3vhcfspvk6w","uri":"did:plc:qi6hrrx2cnb5amths656bsge","val":"spam"}],"viewer":{"blockedBy":false,"followedBy":"at://did:plc:qi6hrrx2cnb5amths656bsge/app.bsky.graph.follow/5db23nswd2lhs","following":"at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.graph.follow/so7lyvh5qllt3","muted":false}},{"associated":{"chat":{"allowIncoming":"following"}},"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:jm26v6khcj237cfl5e3d4kmx/zk3s77ybgwi4dlqbwwtmif2br25fqsjnkj2utpgceypox6o2hloxp5ezu24@jpeg","createdAt":"2024-02-07T11:40:49.317Z","did":"did:plc:jm26v6khcj237cfl5e3d4kmx","displayName":"たたぬぬ","handle":"zpyfprzwu539.nxwe.co.jp","labels":[],"viewer":{"blockedBy":false,"followedBy":"at://did:plc:jm26v6khcj237cfl5e3d4kmx/app.bsky.graph.follow/lddyz6mu4xshb","following":"at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.graph.follow/sjjvhknza4yjn","muted":false}},{"associated":{"chat":{"allowIncoming":"following"}},"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:p5enidyx4cxy3iwmu3cwpw4s/fe5uydvjgdnqijq3zfdhctuddaqpfvogacmgcgmssa4dopl536cyl76h7lm@jpeg","createdAt":"2024-02-07T10:07:56.404Z","did":"did:plc:p5enidyx4cxy3iwmu3cwpw4s","displayName":"らいむわゆ","handle":"gmjlweck868.gxpmg.com","labels":[],"viewer":{"blockedBy":false,"followedBy":"at://did:plc:p5enidyx4cxy3iwmu3cwpw4s/app.bsky.graph.follow/ju7yullsnmd2l","following":"at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.graph.follow/pwm5t7la3cmqh","muted":false}},{"associated":{"chat":{"allowIncoming":"following"}},"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:x3ocufrheb7cosmg3hdv6ie7/b3jgubnvupetf2snslt7z6zvxr3bjf5wbwmkz5llucjkd2gvxqwbw7t2xsa@jpeg","createdAt":"2024-02-11T06:48:54.112Z","did":"did:plc:x3ocufrheb7cosmg3hdv6ie7","displayName":"シヒサス","handle":"ripnv830.nwmuf.com","labels":[],"viewer":{"blockedBy":false,"followedBy":"at://did:plc:x3ocufrheb7cosmg3hdv6ie7/app.bsky.graph.follow/hkel2o4rkpy2y","following":"at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.graph.follow/fpgrffco3tgn7","muted":false}},{"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:gfxhtiejfaxum4sp7jhqzv53/ak537e5dwhtlomtq6t3jud4km2q3dwiz6qvyg5sr4ap4jr6brlvln7bjy56@jpeg","createdAt":"2024-02-07T13:33:43.693Z","did":"did:plc:gfxhtiejfaxum4sp7jhqzv53","displayName":"てふを","handle":"pgimyw999.wbieq.co.jp","labels":[],"viewer":{"blockedBy":false,"followedBy":"at://did:plc:gfxhtiejfaxum4sp7jhqzv53/app.bsky.graph.follow/h4kkypchxx3jz","following":"at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.graph.follow/3e5cu4b5aobr5","muted":false}}]},"muted":false}},{"associated":{"chat":{"allowIncoming":"following"},"feedgens":0,"labeler":false,"lists":0,"starterPacks":0},"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:oqd7pjcbeswg7fz3iplyq3dm/kj57gnldotxiwlxpvz7fghbqseam4jqjf2fwcghi2mo7znmd5dpxyrhvpug@jpeg","createdAt":"2024-01-09T14:00:31.712Z","description":"アニメの放送中、ワイワイと盛り上がる声が聞こえてきよる。まるでお祭り騒ぎやな。そやけど、その声の主は、テレビの前で熱く語り合う仲間たちやのうて、インターネットの海を泳ぐ無数の声や。画面の向こうで繰り広げられる熱いバトルに、心躍らせる戦士たちがおるんや。\n\n千年も続く戦いの物語、アイギス。そのクリア報告は、まるで伝説の勇者たちの凱旋や。難攻不落の城を落とした喜び、強敵を倒した達成感。そやけど、その報告は、ただの自慢やのうて、同じ道を歩む仲間へのエールでもあるんや。\n\nサブカルの海は、熱い情熱で満ち満ちとる。一見、現実離れした世界かもしれん。けど、そこには、リアルな人生の喜怒哀楽が詰まっとるんや。","did":"did:plc:oqd7pjcbeswg7fz3iplyq3dm","displayName":"ほわよま","followersCount":63,"followsCount":54,"handle":"zrlhj265.zlrc.io","indexedAt":"2024-02-25T17:51:19.582Z","labels":[],"postsCount":538,"viewer":{"blockedBy":false,"muted":false}}]},"code":200,"mimeType":"application/json; charset=utf-8","reason":"OK"} +] diff --git a/tests/.ut-data_source/2db7023a-b2d2-447d-bfce-9e417a17bdac.json b/tests/.ut-data_source/2db7023a-b2d2-447d-bfce-9e417a17bdac.json new file mode 100644 index 0000000..8f44b38 --- /dev/null +++ b/tests/.ut-data_source/2db7023a-b2d2-447d-bfce-9e417a17bdac.json @@ -0,0 +1,3 @@ +[ +{"body": {"cursor": "3kq3zz6uaui22","followers": [{"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:qw72nflhhpbsvvgxufoouue6/nvlg66fne5ni2wdeh4fjflmsi6aaurw6ih6p3l4e64a5zfwxrz4af2p7z77@jpeg","createdAt": "2023-04-06T10:14:58.345Z","description": "ええやん、ワイの知識、ちょっとだけ教えたるわ。ほな、お嬢ちゃん、6年生か、よう来たな。ワイのサイトはな、 http://www.hogehoge.com や。ここはな、ワイの秘密基地みたいなもんや。イラスト描いたり、作曲したり、プログラミングしたり、ゲーム作ったり…なんでもできるで! ほな、お嬢ちゃんもワイと一緒に冒険しよか? ワイの知識の海で泳いで、宝物見つけたらええ。ほな、まずはな、この絵本みたいな本、読んでみ。ワイの友達、\"はばたき\"ちゃんの物語や。この本読んだら、ワイのことがもっと分かるで。","did": "did:plc:qw72nflhhpbsvvgxufoouue6","displayName": "にももよな","handle": "ghqlud769.xxqb.io","indexedAt": "2024-10-11T22:00:28.006Z","labels": [],"viewer": {"blockedBy": false,"muted": false}},{"associated": {"chat": {"allowIncoming": "following"}},"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:6bb4yel5ctm5hsl5wx3unp3u/xewiukpprzkpf3ltq7e3637jygr7ka7g622rue4xrjgpbpyqyf3ko7zycwj@jpeg","createdAt": "2023-04-25T08:11:37.680Z","description": "ワイはな、あの伝説の魔法少女に憧れてるんや。そやけど、ただの魔法少女やないで。めっちゃ強いねん。悪い奴らをバシバシやっつけるんや。まるでリズムに乗ってるみたいに華麗に舞いながらな。\n\nほんでな、ワイはゲームも大好きやねん。特に音ゲーや。指先でリズムを刻むと、音楽がワイの体の中を駆け巡るんや。そらもう、心地ええで。\n\nあと、あのふわふわの生き物にもメロメロや。ほっぺたが真っ赤で、めっちゃ可愛いやろ。あいつはな、いつもワイの傍におって、ワイのプログラミングの相棒なんや。コードを書くワイの横で、じっと見守ってくれてるんや。\n\nワイの情熱は、色んなとこに広がってるんや。ほら、あの夕焼けみたいに真っ赤なサイト、ワイの城や。ワイの城から、ワイの仲間たちとワイの想いを伝えてるんや。\n\nワイの冒険は、まだまだ続くで。リズムに乗って、ワイの物語を奏でるで。","did": "did:plc:6bb4yel5ctm5hsl5wx3unp3u","displayName": "ヘラキマ","handle": "qkqcpz408.nhd.jp","indexedAt": "2024-10-19T09:11:28.808Z","labels": [],"viewer": {"blockedBy": false,"muted": false}},{"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:ehdejwel5byvb43lwwcicpqs/tvsibbebfoqptstbsmejpsunzyntqj64jhaa2xkrphf2zyakpk74wgogl3u@jpeg","createdAt": "2024-10-19T16:14:11.312Z","did": "did:plc:ehdejwel5byvb43lwwcicpqs","displayName": "","handle": "rfxnwtm569.kiyyv.org","indexedAt": "2024-10-19T16:14:11.312Z","labels": [],"viewer": {"blockedBy": false,"muted": false}},{"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:ts5c75vtvxf7mrkk7akeswpo/ek2av5vcdaepye2yyj6tvualhokxj3c4zwshgrbdb4vjsbsbyzmkbch7mnp@jpeg","createdAt": "2024-07-28T19:38:15.074Z","description": "インドの少女は、そないな、太陽の光を浴びて、真っ赤な花みたいに輝いとる。\nその笑顔は、まるで魔法みたいに、周りの人らを幸せな気持ちにさせるんや。\nほんで、その瞳は、深い海みたいに、神秘的で、見とれてまうほどや。\n\nインドの文化は、色とりどりの糸で織り成された絨毯みたいに、豊かで奥深い。\nその中で育った少女は、そないな文化の宝箱から飛び出した宝石みたいなもんや。\n\nインドの少女は、そないな、世界中の人らを魅了する、特別な輝きを持ってるんやろな。","did": "did:plc:ts5c75vtvxf7mrkk7akeswpo","displayName": "ヤメチ","handle": "vcszpoiu392.njqa.jp","indexedAt": "2024-07-28T19:40:47.875Z","labels": [{"cid": "bafyreiamp523vsjzccys24d6bkm3qc7lfgwtpoc2kamn3woi3pobhe6n5y","cts": "2024-07-28T19:38:15.959Z","src": "did:plc:ar7c4by46qjdydhdevvrndac","uri": "at://did:plc:3uihc33is5gfobam5c2mtzn6/app.bsky.actor.profile/self","val": "sexual","ver": 1}],"viewer": {"blockedBy": false,"muted": false}},{"associated": {"chat": {"allowIncoming": "following"}},"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:2jmq2fzatzhsu6zkktsp26yu/mtunoxjfkgbnfoplditzlbs3j33asfbcqdm5b3pforq4y76kn7v5myo25ig@jpeg","createdAt": "2024-03-31T16:15:37.522Z","description": "ちゃうちゃう、ここはTwitterちゃうで! なんや、お引越ししてきはったん? そら大歓迎や! フォロバは100%やさかい、安心してな。\n\nほんで、なんやて? 相互希望て? そらもう、ええで! ワイもソロキャンパーやねん。テント張って、自然の中で一人で過ごすんが最高やねん。キャンプ仲間が増えるのは嬉しいなぁ。\n\nあっ、でもな、ワイは犬派やねん。ほら、犬って忠実やろ? そやけど、猫も好きやで。あのツンデレ感がたまらんわ。\n\nコンテンツ転載はあかんて? そらそうや。ワイも大事なアルバムはInstagramに載せてるで。アカウントは... ほれ、**天使の羽** みたいなもんや。\n\nTwitterのアカウント? 2段階認証アプリ消しちゃったん? そら大変や! でも、ワイが知ってるアカウントは... **空飛ぶペンギン** みたいなもんや。探してみてな!","did": "did:plc:2jmq2fzatzhsu6zkktsp26yu","displayName": "オモクム","handle": "qipjw251.hpw.jp","indexedAt": "2024-09-20T13:32:02.007Z","labels": [{"cts": "2024-09-22T22:42:04.694Z","src": "did:plc:ar7c4by46qjdydhdevvrndac","uri": "did:plc:r5c3pqz5gtqidjtsghw7haqf","val": "spam","ver": 1}],"viewer": {"blockedBy": false,"muted": false}},{"associated": {"chat": {"allowIncoming": "all"}},"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:mrryffmofaytf4keuttgiz7n/ba4twkakaxxarqnw6jbtdri3ck63ewvjbabqaiqwrpgukzfrqzkxe5jamp5@jpeg","createdAt": "2023-07-01T17:26:05.549Z","description": "東京のハリネズミかて。\n\nそないな針で、プログラミングの信者やて?\n\nほな、日本語でええなら、\n\n\"@Ezhik\" いうて、ソーシャルなマストドンで、\n\nハリネズミが、針を隠して、\n\n信者集めしとるんやろか?\n\nまあ、針は隠さんと、危ないで。","did": "did:plc:mrryffmofaytf4keuttgiz7n","displayName": "てなひに","handle": "cakan509.exu.co.jp","indexedAt": "2024-09-30T16:10:19.710Z","labels": [],"viewer": {"blockedBy": false,"muted": false}},{"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:onhzmd566b2wmojg32mzuejd/ov7hilaqb4kmtsiio2wv6cogwiy23kymq3tcymzz6nmt7bipnfs3e7dis4l@jpeg","createdAt": "2024-02-07T11:48:41.322Z","did": "did:plc:onhzmd566b2wmojg32mzuejd","displayName": "たへむ","handle": "xtmin786.edlb.org","indexedAt": "2024-05-08T15:43:32.756Z","labels": [],"viewer": {"blockedBy": false,"muted": false}},{"associated": {"chat": {"allowIncoming": "following"}},"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:5r4skxuokkfflv2lzt6qsyfq/de6fsil4gs7jaw4idrthla6xepnyhhnbwfmiunlnwwq7jbfqdlkgt7nxlpn@jpeg","createdAt": "2024-02-07T08:26:57.801Z","description": "ほな、ええ感じの動画見てみよか。YouTubeのチャンネル『🍰🍩🍫🍭🍬』","did": "did:plc:5r4skxuokkfflv2lzt6qsyfq","displayName": "ましようへ","handle": "roejfyqf856.njq.social","indexedAt": "2024-04-29T08:03:18.004Z","labels": [],"viewer": {"blockedBy": false,"muted": false}},{"associated": {"chat": {"allowIncoming": "following"}},"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:3pdb6cnqaehzyefs3rjxelve/6erkp4adjyhhvn6j6x4ezzsi7sjsclnibl36ma6qujns4bweim3kqf25tpj@jpeg","createdAt": "2024-02-08T07:40:59.524Z","description": "おっちゃん、音の魔術師やな。ソフトウェアの世界で魔法の杖を振り回して、VSTプラグインいう魔法の粉を生み出しまんねん。そやけど、その魔法の杖、実はコンピューターいう名の魔法使いの弟子が作ったもんやったりして。\n\nフォローしてくれたら、もっとええ音の秘密を教えたるわ。ほんで、アイコン見て笑ろてくれ。AIが作った言うても、似てへんのは、きっとAIの照れ隠しやろな。","did": "did:plc:3pdb6cnqaehzyefs3rjxelve","displayName": "ヨキケ","handle": "xhfghdbn215.nxj.social","indexedAt": "2024-02-15T13:33:54.361Z","labels": [],"viewer": {"blockedBy": false,"muted": false}},{"associated": {"chat": {"allowIncoming": "all"}},"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:wtez6ksqycwspe6w2onjgeeq/dlc3eq5r3f6c4y4yhrltxo6cvovab24bzy54ipvgofgtnjz4me5nl4agfu6@jpeg","createdAt": "2023-09-01T01:30:31.306Z","description": "そないな、皮肉なこと言うて、誰が得するっちゅうねん。\nせやけど、世の中には、皮肉屋さんもおるもんや。\nそないな人らは、何でもかんでも皮肉って、自分だけ笑ろて、周りは置いてけぼり。\nそんなん、あかんやん。\n\nせやから、せやから、皮肉は書かんとこ。\nほな、みんなで楽しい会話を楽しもうやないか。\nほな、ええ感じで、ええ感じで。","did": "did:plc:wtez6ksqycwspe6w2onjgeeq","displayName": "むつのよた","handle": "hjcrjpt520.bfzb.io","indexedAt": "2024-01-20T06:28:41.849Z","labels": [],"viewer": {"blockedBy": false,"muted": false}}],"subject": {"associated": {"chat": {"allowIncoming": "following"}},"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:mhz3szj7pcjfpzv7pylcmlgx/rupnltt6iifthjqtfsvarqnia6csl2zcqoeq5f64rrqj3rbr3mrqcwvw5de@jpeg","createdAt": "2023-12-08T13:45:31.054Z","description": "ほな、D言語っちゅうプログラミング言語の話から始めよか。D言語は、なんやらええ感じの機能満載で、プログラマーのお助けマンや。C言語のええとこ取りしながら、さらにパワーアップした感じやな。C言語の親戚みたいなもんやけど、もっとオシャレでスマートなんやで。\n\nC言語は古株やけど、今でも根強い人気で、プログラミング界の重鎮や。堅実で頼りになる存在やな。C++はC言語の進化版で、もっと多彩な技を持っとる。アーティストみたいに、色んな表現ができるんや。\n\n組み込みの世界はまた違った魅力や。ハードウェアとソフトウェアのハーモニーやな。回路は音楽の楽譜みたいなもんや。電気が流れると、プログラムが踊り出すんやで。組み込みのプログラミングは、機械に魂を吹き込むような作業やね。\n\nD言語もC言語も、組み込みの世界で大活躍や。小さなデバイスから巨大なシステムまで、裏で支えとるんやで。この世界は、見えへんけど、めっちゃ重要な役者ばっかりやね。","did": "did:plc:mhz3szj7pcjfpzv7pylcmlgx","displayName": "まにまく","handle": "upqbv134.esi.org","indexedAt": "2024-02-25T17:09:35.946Z","labels": [],"viewer": {"blockedBy": false,"muted": false}}},"code": 200,"mimeType": "application/json; charset=utf-8","reason": "OK"} +] diff --git a/tests/.ut-data_source/2de5c4b1-09ec-41e7-90ad-add0448b262d.json b/tests/.ut-data_source/2de5c4b1-09ec-41e7-90ad-add0448b262d.json new file mode 100644 index 0000000..a42f490 --- /dev/null +++ b/tests/.ut-data_source/2de5c4b1-09ec-41e7-90ad-add0448b262d.json @@ -0,0 +1 @@ +{"associated":{"feedgens":0,"labeler":false,"lists":0,"starterPacks":0},"createdAt":"2024-04-01T15:04:14.605Z","did":"did:plc:2qfqobqz6dzrfa3jv74i6k6m","displayName":"","followersCount":0,"followsCount":0,"handle":"dxutjikmg579.hfor.org","indexedAt":"2024-04-01T15:04:15.910Z","labels":[],"postsCount":2,"viewer":{"blockedBy":false,"muted":false}} \ No newline at end of file diff --git a/tests/.ut-data_source/38ac37c4-c30b-4458-9c62-8c4abe8d71e9.json b/tests/.ut-data_source/38ac37c4-c30b-4458-9c62-8c4abe8d71e9.json new file mode 100644 index 0000000..39141ac --- /dev/null +++ b/tests/.ut-data_source/38ac37c4-c30b-4458-9c62-8c4abe8d71e9.json @@ -0,0 +1,3 @@ +[ +{"body":{"likes":[{"actor":{"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:onhzmd566b2wmojg32mzuejd/ov7hilaqb4kmtsiio2wv6cogwiy23kymq3tcymzz6nmt7bipnfs3e7dis4l@jpeg","createdAt":"2024-02-07T11:48:41.322Z","did":"did:plc:onhzmd566b2wmojg32mzuejd","displayName":"たへむ","handle":"xtmin786.edlb.org","indexedAt":"2024-05-08T15:43:32.791Z","labels":[],"viewer":{"blockedBy":false,"followedBy":"at://did:plc:onhzmd566b2wmojg32mzuejd/app.bsky.graph.follow/pzfgnufcewthy","following":"at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.graph.follow/d5b6i3pfyr5tm","muted":false}},"createdAt":"2024-05-01T14:38:14.629Z","indexedAt":"2024-05-01T14:38:14.629Z"},{"actor":{"associated":{"chat":{"allowIncoming":"following"}},"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:dlmtup5kdma26m7o5ok3b4sq/itaxfjkqssl2d2w57uckcxthiy3vbgotaischqerqhc66u2uni4kbdoeeyb@jpeg","createdAt":"2023-08-23T04:42:39.625Z","description":"なんやて?D言語いうたら、あのツンデレなプログラミング言語の話やろか?そやけど、ブルアカいうのはよう分からんわ。もしかして、アカウント名とか?まあ、ええわ。\n\nほな、GitHubの話やけど、あれはまるで宝の地図や。宝箱は言うても、ソースコードいう宝や。ほんで、その宝箱はな、アカウント名いう鍵で開くわけや。\"kubo39\"いう鍵で開く宝箱は、どんな宝が眠ってるんやろなぁ。宝探しはワクワクするなぁ。","did":"did:plc:dlmtup5kdma26m7o5ok3b4sq","displayName":"ノヤヲホハ","handle":"zyvql643.jmktt.org","indexedAt":"2024-02-09T05:35:11.845Z","labels":[],"viewer":{"blockedBy":false,"followedBy":"at://did:plc:dlmtup5kdma26m7o5ok3b4sq/app.bsky.graph.follow/vhkxbwd2vupaf","following":"at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.graph.follow/ajrxqtyyxsnde","muted":false}},"createdAt":"2024-04-03T16:41:55.338Z","indexedAt":"2024-04-03T16:41:55.338Z"},{"actor":{"associated":{"chat":{"allowIncoming":"following"}},"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:dv5s5i6g2eaz7catqa6q4uqz/e5ajoqcpyb3xfwqxpixnqhhjdlpbzupzuaadgvkwr64zlgro3ybfqmifdn6@jpeg","createdAt":"2023-12-08T02:20:16.388Z","description":"あれやこれや、色んなもんがごっちゃまぜになって、まるでおもちゃ箱や。\nちゃらんぽらんに積み上げられた宝の山、どれから手ぇつけたらええか、狐につままれたような気分やな。\n一つ一つはキラキラ輝いて、ええ味出してるんやけど、まとまりに欠けるっちゅうか、なんちゅうか…\nま、ええやないか。\nせやからこそ、おもろいもんが見つかるかもしれへん。\n宝探しや思て、わくわくしながら探してみよか。","did":"did:plc:dv5s5i6g2eaz7catqa6q4uqz","displayName":"ノアソ","handle":"lcpwygnd332.pdj.social","indexedAt":"2024-03-24T06:22:15.044Z","labels":[],"viewer":{"blockedBy":false,"followedBy":"at://did:plc:dv5s5i6g2eaz7catqa6q4uqz/app.bsky.graph.follow/rbdrkgzajzlqy","following":"at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.graph.follow/bsfu53awumqqy","muted":false}},"createdAt":"2024-03-24T17:06:27.856Z","indexedAt":"2024-03-24T17:06:27.749Z"}],"uri":"at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.feed.post/2mbuau2ygfu4w"},"code":200,"mimeType":"application/json; charset=utf-8","reason":"OK"} +] diff --git a/tests/.ut-data_source/43927f09-ea3f-4ae5-a226-beaf564d2622.json b/tests/.ut-data_source/43927f09-ea3f-4ae5-a226-beaf564d2622.json new file mode 100644 index 0000000..c33725b --- /dev/null +++ b/tests/.ut-data_source/43927f09-ea3f-4ae5-a226-beaf564d2622.json @@ -0,0 +1,5 @@ +[ +{"body":{"cid":"aykzb74s6m3tj3fvsn777i3zeuefoxkxhwwevxy5juuuaxe24rm26rs6wnf","uri":"at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/hyq6lbnl45len","value":{"$type":"app.bsky.feed.post","createdAt":"2024-04-30T10:39:29.924Z","langs":["ja"],"reply":{"parent":{"cid":"dx2imfjwbdohkh4re7hh6dguhpczu5mhruw4ofkn6sdmhk4uimas37c5a74","uri":"at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/5ni6rkonpzlx2"},"root":{"cid":"dx2imfjwbdohkh4re7hh6dguhpczu5mhruw4ofkn6sdmhk4uimas37c5a74","uri":"at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/5ni6rkonpzlx2"}},"text":"ほな、試しにもう一回言うてみ。"}},"code":200,"mimeType":"application/json; charset=utf-8","reason":"OK"}, +{"body":{"cid":"6brzsmbnsigznjtgmqmrabvyk34ocp3q4wwjrkuhtr5xdelsxb6r5xntki4","commit":{"cid":"j5docxce4ykpwwaeqpy3q4a5nyeaqhdgfiz6py7okefbqzzrsd73dyn2bep","rev":"7hnshjajlh4cy"},"uri":"at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.feed.repost/nbcyhg4w2kz7w","validationStatus":"valid"},"code":200,"mimeType":"application/json; charset=utf-8","reason":"OK"}, +{"body":{"commit":{"cid":"bzhjfa7g7vx3jvqhlim4k3xvh5aqjswgxle2w3cmlfzfphgf6kkx7cb4lis","rev":"cg34ymtjb44kc"}},"code":200,"mimeType":"application/json; charset=utf-8","reason":"OK"} +] diff --git a/tests/.ut-data_source/49ed9c5d-d355-4f3a-81fb-cab01d1c7e64.json b/tests/.ut-data_source/49ed9c5d-d355-4f3a-81fb-cab01d1c7e64.json new file mode 100644 index 0000000..1833380 --- /dev/null +++ b/tests/.ut-data_source/49ed9c5d-d355-4f3a-81fb-cab01d1c7e64.json @@ -0,0 +1,3 @@ +[ +{"body":{"posts":[{"author":{"associated":{"chat":{"allowIncoming":"following"}},"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:mhz3szj7pcjfpzv7pylcmlgx/rupnltt6iifthjqtfsvarqnia6csl2zcqoeq5f64rrqj3rbr3mrqcwvw5de@jpeg","createdAt":"2023-12-08T13:45:31.054Z","did":"did:plc:mhz3szj7pcjfpzv7pylcmlgx","displayName":"まにまく","handle":"upqbv134.esi.org","labels":[],"viewer":{"blockedBy":false,"muted":false}},"cid":"45yfa3rflno5xqm7spwjctrpnvl2u6c2tvzuvdfy5e4fekbmhgttpbhoy2g","embed":{"$type":"app.bsky.embed.images#view","images":[{"alt":"","aspectRatio":{"height":1230,"width":1004},"fullsize":"https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:mhz3szj7pcjfpzv7pylcmlgx/ctu6bdtqwk3auqfw4kehvu2bls367flnghrcehkzjnpa6cwfyptcy5u7ape@jpeg","thumb":"https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:mhz3szj7pcjfpzv7pylcmlgx/ctu6bdtqwk3auqfw4kehvu2bls367flnghrcehkzjnpa6cwfyptcy5u7ape@jpeg"}]},"indexedAt":"2024-10-28T12:52:22.643Z","labels":[],"likeCount":0,"quoteCount":0,"record":{"$type":"app.bsky.feed.post","createdAt":"2024-10-28T12:52:22.762Z","embed":{"$type":"app.bsky.embed.images","images":[{"alt":"","aspectRatio":{"height":1230,"width":1004},"image":{"$type":"blob","mimeType":"image/jpeg","ref":{"$link":"ctu6bdtqwk3auqfw4kehvu2bls367flnghrcehkzjnpa6cwfyptcy5u7ape"},"size":376413}}]},"langs":["ja"],"text":"なんとか80点超えの合格点や! ギリギリやったけど、まぁええ感じやな。\n\nまるで崖っぷちで踏ん張ったみたいな、そらもうヒヤヒヤもんやったで。\n\nでも、なんとか花咲く春を迎えることができたわ。\n\nほな、次は90点目指して、もっと磨きをかけるで!"},"replyCount":0,"repostCount":0,"uri":"at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.feed.post/2a7dtnyusr74f","viewer":{"embeddingDisabled":false,"threadMuted":false}},{"author":{"associated":{"chat":{"allowIncoming":"following"}},"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:mhz3szj7pcjfpzv7pylcmlgx/rupnltt6iifthjqtfsvarqnia6csl2zcqoeq5f64rrqj3rbr3mrqcwvw5de@jpeg","createdAt":"2023-12-08T13:45:31.054Z","did":"did:plc:mhz3szj7pcjfpzv7pylcmlgx","displayName":"まにまく","handle":"upqbv134.esi.org","labels":[],"viewer":{"blockedBy":false,"muted":false}},"cid":"kj6cb55pkzsfjgxy6fylyu7mivycdpyliauge6blvqu32rybpqzyoevz4fs","embed":{"$type":"app.bsky.embed.external#view","external":{"description":"まあ、どんな仕事でもそうやけど、プロジェクトいうんはCI/CDいう大事なもんの統合テストが必要なんや。そやから、統一されたフォーマットで統合テストを実行できると便利やね。\n\nほんで、このサブコマンドは、まあなんちゅうか、その、似たようなもんや。\n\nなんや、その、テストを走らせるいう意味では、まあ、似たようなもんや思うで。","thumb":"https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:lanjqoa3xruegmym3f4moeyy/bafkreiewcik23lpsxpelpbpcb5t3ws5axrbrrc3n2vrwhsftzupibzfvq4@jpeg","title":"このプルリクエストは、ワイがサブコマンドを足したいう話や。プロジェクトで、なんやらプログラムをテストするコマンドみたいやな。\n\nわいは、このサブコマンドでテストを走らせて、プログラムがちゃんと動くかどうか調べられるようにしたんやて。なんや、このコマンドはテストの\"道しるべ\"みたいなもんやな。\n\nこのコマンドは\"ダイヤモンド\"みたいにキラキラ輝いとるらしいで。この\"ダイヤモンド\"を磨いて、ピカピカに光らせるんが、このプロジェクトの役目や。\n\nこのプルリクエストは、その\"ダイヤモンド\"を磨くための新しい\"磨き粉\"を提案しとるみたいやな。この\"磨き粉\"で、もっと効率的に\"ダイヤモンド\"を磨けるようになる言うわけや。","uri":"https://hgoeodr623.hnk.jp/gztrfrxtu"}},"indexedAt":"2024-07-21T12:50:03.083Z","labels":[],"likeCount":0,"quoteCount":0,"record":{"$type":"app.bsky.feed.post","createdAt":"2024-07-21T12:50:03.083Z","embed":{"$type":"app.bsky.embed.external","external":{"description":"まあ、どんな仕事でもそうやけど、プロジェクトいうんはCI/CDいう大事なもんの統合テストが必要なんや。そやから、統一されたフォーマットで統合テストを実行できると便利やね。\n\nほんで、このサブコマンドは、まあなんちゅうか、その、似たようなもんや。\n\nなんや、その、テストを走らせるいう意味では、まあ、似たようなもんや思うで。","thumb":{"$type":"blob","mimeType":"image/jpeg","ref":{"$link":"bafkreiewcik23lpsxpelpbpcb5t3ws5axrbrrc3n2vrwhsftzupibzfvq4"},"size":247858},"title":"このプルリクエストは、ワイがサブコマンドを足したいう話や。プロジェクトで、なんやらプログラムをテストするコマンドみたいやな。\n\nわいは、このサブコマンドでテストを走らせて、プログラムがちゃんと動くかどうか調べられるようにしたんやて。なんや、このコマンドはテストの\"道しるべ\"みたいなもんやな。\n\nこのコマンドは\"ダイヤモンド\"みたいにキラキラ輝いとるらしいで。この\"ダイヤモンド\"を磨いて、ピカピカに光らせるんが、このプロジェクトの役目や。\n\nこのプルリクエストは、その\"ダイヤモンド\"を磨くための新しい\"磨き粉\"を提案しとるみたいやな。この\"磨き粉\"で、もっと効率的に\"ダイヤモンド\"を磨けるようになる言うわけや。","uri":"https://hgoeodr623.hnk.jp/gztrfrxtu"}},"facets":[{"features":[{"$type":"app.bsky.richtext.facet#link","uri":"https://www.fictitiousurl.com/fictitiouspath"}],"index":{"byteEnd":44,"byteStart":0}},{"features":[{"$type":"app.bsky.richtext.facet#tag","tag":"dlang"}],"index":{"byteEnd":109,"byteStart":103}}],"langs":["ja"],"text":"https://www.fictitiousurl.com/fictitiouspath\nほな、プルリクエストした言うとったで!\n#dlang"},"replyCount":0,"repostCount":0,"uri":"at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.feed.post/q2dbegoee2gnd","viewer":{"embeddingDisabled":false,"threadMuted":false}},{"author":{"associated":{"chat":{"allowIncoming":"following"}},"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:mhz3szj7pcjfpzv7pylcmlgx/rupnltt6iifthjqtfsvarqnia6csl2zcqoeq5f64rrqj3rbr3mrqcwvw5de@jpeg","createdAt":"2023-12-08T13:45:31.054Z","did":"did:plc:mhz3szj7pcjfpzv7pylcmlgx","displayName":"まにまく","handle":"upqbv134.esi.org","labels":[],"viewer":{"blockedBy":false,"muted":false}},"cid":"3jlymgjsaodngtcmdywsu2mw4smthekpu5fc7zoxqvih4zvzuqcrrpjmdsn","embed":{"$type":"app.bsky.embed.recordWithMedia#view","media":{"$type":"app.bsky.embed.images#view","images":[{"alt":"","aspectRatio":{"height":1071,"width":1005},"fullsize":"https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:mhz3szj7pcjfpzv7pylcmlgx/2jwr3tz3yflavwl2fgihcqfdgacoji3ilmidbeg7iyr6f7akdq7plhmsrwh@jpeg","thumb":"https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:mhz3szj7pcjfpzv7pylcmlgx/2jwr3tz3yflavwl2fgihcqfdgacoji3ilmidbeg7iyr6f7akdq7plhmsrwh@jpeg"}]},"record":{"record":{"$type":"app.bsky.embed.record#viewRecord","author":{"associated":{"chat":{"allowIncoming":"following"}},"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:mhz3szj7pcjfpzv7pylcmlgx/rupnltt6iifthjqtfsvarqnia6csl2zcqoeq5f64rrqj3rbr3mrqcwvw5de@jpeg","createdAt":"2023-12-08T13:45:31.054Z","did":"did:plc:mhz3szj7pcjfpzv7pylcmlgx","displayName":"まにまく","handle":"upqbv134.esi.org","labels":[],"viewer":{"blockedBy":false,"muted":false}},"cid":"45yfa3rflno5xqm7spwjctrpnvl2u6c2tvzuvdfy5e4fekbmhgttpbhoy2g","embeds":[{"$type":"app.bsky.embed.images#view","images":[{"alt":"","aspectRatio":{"height":1230,"width":1004},"fullsize":"https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:mhz3szj7pcjfpzv7pylcmlgx/ctu6bdtqwk3auqfw4kehvu2bls367flnghrcehkzjnpa6cwfyptcy5u7ape@jpeg","thumb":"https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:mhz3szj7pcjfpzv7pylcmlgx/ctu6bdtqwk3auqfw4kehvu2bls367flnghrcehkzjnpa6cwfyptcy5u7ape@jpeg"}]}],"indexedAt":"2024-10-28T12:52:22.643Z","labels":[],"likeCount":0,"quoteCount":1,"replyCount":0,"repostCount":0,"uri":"at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.feed.post/2a7dtnyusr74f","value":{"$type":"app.bsky.feed.post","createdAt":"2024-10-28T12:52:22.762Z","embed":{"$type":"app.bsky.embed.images","images":[{"alt":"","aspectRatio":{"height":1230,"width":1004},"image":{"$type":"blob","mimeType":"image/jpeg","ref":{"$link":"ctu6bdtqwk3auqfw4kehvu2bls367flnghrcehkzjnpa6cwfyptcy5u7ape"},"size":376413}}]},"langs":["ja"],"text":"なんとか80点超えの合格点や! ギリギリやったけど、まぁええ感じやな。\n\nまるで崖っぷちで踏ん張ったみたいな、そらもうヒヤヒヤもんやったで。\n\nでも、なんとか花咲く春を迎えることができたわ。\n\nほな、次は90点目指して、もっと磨きをかけるで!"}}}},"indexedAt":"2024-10-29T13:38:43.033Z","labels":[],"likeCount":0,"quoteCount":0,"record":{"$type":"app.bsky.feed.post","createdAt":"2024-10-29T13:38:43.033Z","embed":{"$type":"app.bsky.embed.recordWithMedia","media":{"$type":"app.bsky.embed.images","images":[{"alt":"","aspectRatio":{"height":1071,"width":1005},"image":{"$type":"blob","mimeType":"image/jpeg","ref":{"$link":"2jwr3tz3yflavwl2fgihcqfdgacoji3ilmidbeg7iyr6f7akdq7plhmsrwh"},"size":672749}}]},"record":{"$type":"app.bsky.embed.record","record":{"cid":"45yfa3rflno5xqm7spwjctrpnvl2u6c2tvzuvdfy5e4fekbmhgttpbhoy2g","uri":"at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.feed.post/2a7dtnyusr74f"}}},"langs":["ja"],"text":"まあ、80点いうたら、まあまあええ感じやけど、満点目指したら、まあまあ大変や。"},"replyCount":0,"repostCount":0,"uri":"at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.feed.post/i3bao2sbegi5d","viewer":{"embeddingDisabled":false,"threadMuted":false}}]},"code":200,"mimeType":"application/json; charset=utf-8","reason":"OK"} +] diff --git a/tests/.ut-data_source/657d70c5-eb4d-4b33-ab35-86a1589c2e9a.json b/tests/.ut-data_source/657d70c5-eb4d-4b33-ab35-86a1589c2e9a.json new file mode 100644 index 0000000..c4e8d2f --- /dev/null +++ b/tests/.ut-data_source/657d70c5-eb4d-4b33-ab35-86a1589c2e9a.json @@ -0,0 +1,3 @@ +[ +{"body":{"cursor":"2024-10-22T12:25:47.005Z","feed":[{"post":{"author":{"associated":{"chat":{"allowIncoming":"following"}},"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:jm26v6khcj237cfl5e3d4kmx/zk3s77ybgwi4dlqbwwtmif2br25fqsjnkj2utpgceypox6o2hloxp5ezu24@jpeg","createdAt":"2024-02-07T11:40:49.317Z","did":"did:plc:jm26v6khcj237cfl5e3d4kmx","displayName":"たたぬぬ","handle":"zpyfprzwu539.nxwe.co.jp","labels":[],"viewer":{"blockedBy":false,"followedBy":"at://did:plc:jm26v6khcj237cfl5e3d4kmx/app.bsky.graph.follow/lddyz6mu4xshb","following":"at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.graph.follow/sjjvhknza4yjn","muted":false}},"cid":"vmn7jk46qez7toprityoodx3qglrka3cjmhndp5flw52rewihwb36c6zg2t","indexedAt":"2024-10-22T13:17:31.947Z","labels":[],"likeCount":7,"quoteCount":0,"record":{"$type":"app.bsky.feed.post","createdAt":"2024-10-22T13:17:33.096Z","langs":["ja"],"text":"技術っちゅうもんは、まるで魔法の引き出し箱や。\n\n引き出しを開けるたびに、新しい知恵や技が飛び出して、あれこれと試してみるうちに、どんどん引き出しが増えていく。\n\n一つ一つの引き出しは、小さなアイデアや知識でいっぱいで、それらを組み合わせることで、思いがけない発明や発見が生まれたりするんやで。\n\nまるで宝箱を探すようなワクワク感やな。\n\n引き出しを開けるたびに、新しい世界が広がって、無限の可能性が待っとる…そんな感じやろか。\n\nええなぁ、引き出しが増えるたびに、ワクワクするやろなぁ。"},"replyCount":0,"repostCount":0,"uri":"at://did:plc:jm26v6khcj237cfl5e3d4kmx/app.bsky.feed.post/rvhnzoikmc3aa","viewer":{"embeddingDisabled":false,"threadMuted":false}}},{"post":{"author":{"associated":{"chat":{"allowIncoming":"following"}},"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:jm26v6khcj237cfl5e3d4kmx/zk3s77ybgwi4dlqbwwtmif2br25fqsjnkj2utpgceypox6o2hloxp5ezu24@jpeg","createdAt":"2024-02-07T11:40:49.317Z","did":"did:plc:jm26v6khcj237cfl5e3d4kmx","displayName":"たたぬぬ","handle":"zpyfprzwu539.nxwe.co.jp","labels":[],"viewer":{"blockedBy":false,"followedBy":"at://did:plc:jm26v6khcj237cfl5e3d4kmx/app.bsky.graph.follow/lddyz6mu4xshb","following":"at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.graph.follow/sjjvhknza4yjn","muted":false}},"cid":"3ne4abqkxawl3er6a6yvuh6s6vnbxoanqxc7pnubtoixrdi67q5qzhupg44","indexedAt":"2024-10-22T13:16:03.046Z","labels":[],"likeCount":6,"quoteCount":0,"record":{"$type":"app.bsky.feed.post","createdAt":"2024-10-22T13:16:04.873Z","langs":["ja"],"text":"ええ、今取り掛かっとる仕事は、今までで一番の難関やと感じるなぁ。\nひたすら筆を走らせて、絵を描き続けてる日々や。\nなぁなぁ、いつもの技や技術は使わんと、新しい手法で挑戦しとるんや。そらもう、体も心もクタクタやで。\nでもな、この方法が一番の近道やと信じとるんや。上達への近道は、この道しかないんや! 進むしかないで、ええな。"},"replyCount":0,"repostCount":0,"uri":"at://did:plc:jm26v6khcj237cfl5e3d4kmx/app.bsky.feed.post/xwopnxdna4m46","viewer":{"embeddingDisabled":false,"threadMuted":false}}},{"post":{"author":{"associated":{"chat":{"allowIncoming":"following"}},"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:f5o6adr5rbf4u4d36lnpot3c/e67zhb62dcrnbvmy6p3rueegt3h5zktghtyryo337fu3mbjjf53yufxuf2s@jpeg","createdAt":"2023-07-07T17:20:43.729Z","did":"did:plc:f5o6adr5rbf4u4d36lnpot3c","displayName":"ネニシオ","handle":"milujlujf263.natr.social","labels":[{"cid":"s2mvehrmhxhasjhn5jvaamtowu6kxny7f5wutinuqylkl52wts7knz2wyt5","cts":"1970-01-01T00:00:00.000Z","src":"did:plc:f5o6adr5rbf4u4d36lnpot3c","uri":"at://did:plc:f5o6adr5rbf4u4d36lnpot3c/app.bsky.actor.profile/self","val":"!no-unauthenticated"}],"viewer":{"blockedBy":false,"following":"at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.graph.follow/2m2tlarrkgj3h","muted":false}},"cid":"vjr6zr5ln6mzfxqnnqamutr2vtnbfhgcwzjfysmdhsxwebew5z2i6xhukcs","indexedAt":"2024-10-22T13:11:44.683Z","labels":[],"likeCount":0,"quoteCount":0,"record":{"$type":"app.bsky.feed.post","createdAt":"2024-10-22T13:11:44.683Z","langs":["ja"],"text":"ワイのクロムはな、そないな代謝なんか知らんけど、ええ感じに燃費ええねん。そらもう、ええ感じに走ってくれとるで。ワイの相棒みたいなもんや。\n\nほんでな、このクロムっちゅうやつは、そないな見た目とは裏腹に、中身はめっちゃパワフルやねん。まるで、ちゃうちゃう、なんやろな、ほら、あの、えーっと、まぁ、とにかく、ええ感じに力強いねん。\n\nそやけど、たまにやで、ええ感じに走っとるはずやのに、急に息切れしとる時もあるんや。そん時は、ほら、なんやろ、あの、ええと、まぁ、ちょっと休憩さしたろ思て、ええ感じに休ませるねん。\n\n休ませたらな、また元気に走り出すんや。ほんま、不思議なやっちゃ。ワイのクロムは、ちゃうちゃう、なんやろ、ほら、あの、まぁ、ええ感じにワイの相伴に合わせてくれとるんやろな。"},"replyCount":0,"repostCount":0,"uri":"at://did:plc:f5o6adr5rbf4u4d36lnpot3c/app.bsky.feed.post/plggyexytspsi","viewer":{"embeddingDisabled":false,"threadMuted":false}}},{"post":{"author":{"associated":{"chat":{"allowIncoming":"following"}},"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:f5o6adr5rbf4u4d36lnpot3c/e67zhb62dcrnbvmy6p3rueegt3h5zktghtyryo337fu3mbjjf53yufxuf2s@jpeg","createdAt":"2023-07-07T17:20:43.729Z","did":"did:plc:f5o6adr5rbf4u4d36lnpot3c","displayName":"ネニシオ","handle":"milujlujf263.natr.social","labels":[{"cid":"s2mvehrmhxhasjhn5jvaamtowu6kxny7f5wutinuqylkl52wts7knz2wyt5","cts":"1970-01-01T00:00:00.000Z","src":"did:plc:f5o6adr5rbf4u4d36lnpot3c","uri":"at://did:plc:f5o6adr5rbf4u4d36lnpot3c/app.bsky.actor.profile/self","val":"!no-unauthenticated"}],"viewer":{"blockedBy":false,"following":"at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.graph.follow/2m2tlarrkgj3h","muted":false}},"cid":"tf6e6zsnllv4bhk26bet3gan5kbbyv6minlhrx3eu5ulmcqgoejswocabrp","indexedAt":"2024-10-22T13:05:24.419Z","labels":[],"likeCount":2,"quoteCount":0,"record":{"$type":"app.bsky.feed.post","createdAt":"2024-10-22T13:05:24.419Z","langs":["ja"],"text":"血糖値っちゅうもんが天高く昇りよって、頭がぼんやりしとるわ。眠たさに負けて、まるで秋の空に浮かぶ雲のごとく、意識がふわふわと浮き沈みしとるんや。"},"replyCount":0,"repostCount":0,"uri":"at://did:plc:f5o6adr5rbf4u4d36lnpot3c/app.bsky.feed.post/tog2644b2jz2p","viewer":{"embeddingDisabled":false,"threadMuted":false}}},{"post":{"author":{"associated":{"chat":{"allowIncoming":"following"}},"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:r7fo2wivshbyhtergbmubl2g/ybtqsnzadzqtquppvxcwte4ranzteosf7eqfqgiyjqqlgums7oujbxnxckk@jpeg","createdAt":"2023-04-15T08:31:53.798Z","did":"did:plc:r7fo2wivshbyhtergbmubl2g","displayName":"そおう","handle":"iajbe694.xsrzg.io","labels":[],"viewer":{"blockedBy":false,"followedBy":"at://did:plc:r7fo2wivshbyhtergbmubl2g/app.bsky.graph.follow/ezkvz3yernfxj","following":"at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.graph.follow/xnwfmtqs5nnvz","muted":false}},"cid":"l3qfq6teq5eftby2dyq5rpazqhfqgcqcw6bqix5rn6zt35e2wsvtxerzqwj","indexedAt":"2024-10-22T13:01:23.594Z","labels":[],"likeCount":3,"quoteCount":0,"record":{"$type":"app.bsky.feed.post","createdAt":"2024-10-22T13:01:23.594Z","langs":["ja"],"text":"マッチさんは、絵筆で魔法を紡ぐアーティストや。あの独特な線っちゅうんは、まるで風が優雅に舞うような、そやけど力強さも感じさせて、見とれてまうんや。その秘密は、ええ墨と、ええ筆と、ええ紙いう組み合わせや。墨は、深みのある黒いう墨液で、筆は、しなやかでコシのある天然毛いうこだわり。紙は、吸い込みのええ上質な和紙や。この三つが揃うて、あの風合いある線が生まれんねん。\n\nマッチさんは、この道具に愛着を持って、大切に扱いよる。道具に敬意を払うことで、ええ仕事をさせてくれるいう考えや。絵を描くいうんは、ただの作業やのうて、芸術家と道具とのコラボレーションやいうことやな。"},"replyCount":0,"repostCount":0,"uri":"at://did:plc:r7fo2wivshbyhtergbmubl2g/app.bsky.feed.post/zjtfnsrnywuqd","viewer":{"embeddingDisabled":false,"threadMuted":false}}},{"post":{"author":{"associated":{"chat":{"allowIncoming":"none"}},"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:dcbr6qo5xlusiycuki5ep2gx/zd7xytegh5oeluw7gwmdhwjjeujc7bsy5j2kuse2mwggsqwkmhiavofqt2v@jpeg","createdAt":"2023-06-03T12:04:04.064Z","did":"did:plc:dcbr6qo5xlusiycuki5ep2gx","displayName":"おのれく","handle":"uuzfc142.okxwe.social","labels":[{"cid":"kne3srvvtof3tswkrqye3ck5glrokxmcpyluphot2xgweich4362x7nzyao","cts":"1970-01-01T00:00:00.000Z","src":"did:plc:dcbr6qo5xlusiycuki5ep2gx","uri":"at://did:plc:dcbr6qo5xlusiycuki5ep2gx/app.bsky.actor.profile/self","val":"!no-unauthenticated"}],"viewer":{"blockedBy":false,"muted":false}},"cid":"tk3ul3q4ckek75jknqhnc4ojxn7mrn47xrzgmqo4urds4nry4qfj34vtym4","indexedAt":"2024-10-22T13:00:14.916Z","labels":[],"likeCount":3,"quoteCount":0,"record":{"$type":"app.bsky.feed.post","createdAt":"2024-10-22T13:00:14.916Z","facets":[{"features":[{"$type":"app.bsky.richtext.facet#tag","tag":"procreate"}],"index":{"byteEnd":425,"byteStart":415}},{"features":[{"$type":"app.bsky.richtext.facet#tag","tag":"photoshop"}],"index":{"byteEnd":436,"byteStart":426}}],"langs":["ja"],"text":"総入れ歯、もがあはんとお会いした時にも話したけど、ワテが絵を描くんに使てる筆は、なんやらポスカ言う筆ペンみたいなもんの中字で描いたもんを参考にしたオリジナルの筆や。あの有名なネズミの絵描きはんの描き方を研究しとったら、たまたま辿り着いたんや。一応、似たような筆も作ってみたで。 #procreate #photoshop ","via":"TOKIMEKI"},"replyCount":0,"repostCount":2,"uri":"at://did:plc:dcbr6qo5xlusiycuki5ep2gx/app.bsky.feed.post/dthhyivwutdrh","viewer":{"embeddingDisabled":false,"threadMuted":false}},"reason":{"$type":"app.bsky.feed.defs#reasonRepost","by":{"associated":{"chat":{"allowIncoming":"following"}},"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:r7fo2wivshbyhtergbmubl2g/ybtqsnzadzqtquppvxcwte4ranzteosf7eqfqgiyjqqlgums7oujbxnxckk@jpeg","createdAt":"2023-04-15T08:31:53.798Z","did":"did:plc:r7fo2wivshbyhtergbmubl2g","displayName":"そおう","handle":"iajbe694.xsrzg.io","labels":[],"viewer":{"blockedBy":false,"followedBy":"at://did:plc:r7fo2wivshbyhtergbmubl2g/app.bsky.graph.follow/ezkvz3yernfxj","following":"at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.graph.follow/xnwfmtqs5nnvz","muted":false}},"indexedAt":"2024-10-22T13:00:55.198Z"}},{"post":{"author":{"associated":{"chat":{"allowIncoming":"following"}},"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:f5o6adr5rbf4u4d36lnpot3c/e67zhb62dcrnbvmy6p3rueegt3h5zktghtyryo337fu3mbjjf53yufxuf2s@jpeg","createdAt":"2023-07-07T17:20:43.729Z","did":"did:plc:f5o6adr5rbf4u4d36lnpot3c","displayName":"ネニシオ","handle":"milujlujf263.natr.social","labels":[{"cid":"s2mvehrmhxhasjhn5jvaamtowu6kxny7f5wutinuqylkl52wts7knz2wyt5","cts":"1970-01-01T00:00:00.000Z","src":"did:plc:f5o6adr5rbf4u4d36lnpot3c","uri":"at://did:plc:f5o6adr5rbf4u4d36lnpot3c/app.bsky.actor.profile/self","val":"!no-unauthenticated"}],"viewer":{"blockedBy":false,"following":"at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.graph.follow/2m2tlarrkgj3h","muted":false}},"cid":"mkqhyzrtigicf4jctqnjomnso4fkcaqejp7jotsajcidja5jfr5zjpln3iz","indexedAt":"2024-10-22T12:59:33.734Z","labels":[],"likeCount":0,"quoteCount":0,"record":{"$type":"app.bsky.feed.post","createdAt":"2024-10-22T12:59:33.734Z","langs":["ja"],"reply":{"parent":{"cid":"f4nrmaxq6tnkf47tcmvlhbpcaeu5urazyrp5injkcdrod7yakradesxqbag","uri":"at://did:plc:3tampkrfm5zl6masxevc7g7p/app.bsky.feed.post/36o5wk4vv5dai"},"root":{"cid":"kbw72c3fcg3yioqxrxcqn3ads6auw2mka4siwxq6lg5po5d5fkymnvlobtw","uri":"at://did:plc:f5o6adr5rbf4u4d36lnpot3c/app.bsky.feed.post/xj65salkt5wjf"}},"text":"🙆‍♀️\n\nなんやて? そないな顔して、なんか言わんとあかんのか? ほな、ちょっと聞いておくれやす。\n\nある日、一羽の鳥が空高く舞い上がった。その羽ばたきは、まるで風に舞う桜の花びらのようやった。鳥はどこからともなく現れたんやけど、その姿は見る間に小さくなって、あっという間に見えへんようになってしもうた。"},"replyCount":0,"repostCount":0,"uri":"at://did:plc:f5o6adr5rbf4u4d36lnpot3c/app.bsky.feed.post/ublyk3zpithfl","viewer":{"embeddingDisabled":false,"threadMuted":false}},"reply":{"grandparentAuthor":{"associated":{"chat":{"allowIncoming":"following"}},"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:f5o6adr5rbf4u4d36lnpot3c/e67zhb62dcrnbvmy6p3rueegt3h5zktghtyryo337fu3mbjjf53yufxuf2s@jpeg","createdAt":"2023-07-07T17:20:43.729Z","did":"did:plc:f5o6adr5rbf4u4d36lnpot3c","displayName":"ネニシオ","handle":"milujlujf263.natr.social","labels":[{"cid":"s2mvehrmhxhasjhn5jvaamtowu6kxny7f5wutinuqylkl52wts7knz2wyt5","cts":"1970-01-01T00:00:00.000Z","src":"did:plc:f5o6adr5rbf4u4d36lnpot3c","uri":"at://did:plc:f5o6adr5rbf4u4d36lnpot3c/app.bsky.actor.profile/self","val":"!no-unauthenticated"}],"viewer":{"blockedBy":false,"following":"at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.graph.follow/2m2tlarrkgj3h","muted":false}},"parent":{"$type":"app.bsky.feed.defs#postView","author":{"associated":{"chat":{"allowIncoming":"following"}},"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:3tampkrfm5zl6masxevc7g7p/7c4mjbvrbkci43pigspvyqicysno362k6xa3n4gey2aaqceheji4g25v4l2@jpeg","createdAt":"2023-11-12T10:10:02.481Z","did":"did:plc:3tampkrfm5zl6masxevc7g7p","displayName":"エハロメラ","handle":"tgxxwq018.umj.org","labels":[],"viewer":{"blockedBy":false,"muted":false}},"cid":"f4nrmaxq6tnkf47tcmvlhbpcaeu5urazyrp5injkcdrod7yakradesxqbag","indexedAt":"2024-10-22T12:52:45.736Z","labels":[],"likeCount":0,"quoteCount":0,"record":{"$type":"app.bsky.feed.post","createdAt":"2024-10-22T12:52:45.736Z","langs":["ja"],"reply":{"parent":{"cid":"kbw72c3fcg3yioqxrxcqn3ads6auw2mka4siwxq6lg5po5d5fkymnvlobtw","uri":"at://did:plc:f5o6adr5rbf4u4d36lnpot3c/app.bsky.feed.post/xj65salkt5wjf"},"root":{"cid":"kbw72c3fcg3yioqxrxcqn3ads6auw2mka4siwxq6lg5po5d5fkymnvlobtw","uri":"at://did:plc:f5o6adr5rbf4u4d36lnpot3c/app.bsky.feed.post/xj65salkt5wjf"}},"text":"許してくれへん? せやけど、許すも許さんも、そないなこと誰が決めるんやろな。 雨が降るか止むか、空に聞いてみても、空は黙って雲の合間からちらっと顔を出して、そやなぁ、言うて首を傾げるだけや。 許しは、風に舞う桜の花びらのように、儚くて、掴もうとしたら、手のひらをすり抜けてしまうもんかもしれへん。 せやから、許しは、そっと心にしまっといたらええねん。 ほんで、いつか、ほんまに許せる日が来たら、そん時は、心の花びらをそっと開いて、許しの風に吹かせてみたらええ。"},"replyCount":1,"repostCount":0,"uri":"at://did:plc:3tampkrfm5zl6masxevc7g7p/app.bsky.feed.post/36o5wk4vv5dai","viewer":{"embeddingDisabled":false,"threadMuted":false}},"root":{"$type":"app.bsky.feed.defs#postView","author":{"associated":{"chat":{"allowIncoming":"following"}},"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:f5o6adr5rbf4u4d36lnpot3c/e67zhb62dcrnbvmy6p3rueegt3h5zktghtyryo337fu3mbjjf53yufxuf2s@jpeg","createdAt":"2023-07-07T17:20:43.729Z","did":"did:plc:f5o6adr5rbf4u4d36lnpot3c","displayName":"ネニシオ","handle":"milujlujf263.natr.social","labels":[{"cid":"s2mvehrmhxhasjhn5jvaamtowu6kxny7f5wutinuqylkl52wts7knz2wyt5","cts":"1970-01-01T00:00:00.000Z","src":"did:plc:f5o6adr5rbf4u4d36lnpot3c","uri":"at://did:plc:f5o6adr5rbf4u4d36lnpot3c/app.bsky.actor.profile/self","val":"!no-unauthenticated"}],"viewer":{"blockedBy":false,"following":"at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.graph.follow/2m2tlarrkgj3h","muted":false}},"cid":"kbw72c3fcg3yioqxrxcqn3ads6auw2mka4siwxq6lg5po5d5fkymnvlobtw","indexedAt":"2024-10-22T12:46:30.362Z","labels":[],"likeCount":3,"quoteCount":0,"record":{"$type":"app.bsky.feed.post","createdAt":"2024-10-22T12:46:30.362Z","langs":["ja"],"text":"そないなもん、見たんやて? ほな、なんや、あの消しゴムみたいなもんは、ポス消しいうんか? なんか、消しゴムよりデカくて、ゴツい感じやな。 まるで、でっかい消しゴムで、大事なもんを消してまうみたいな、そんな感じや。 そやけど、あれで消したら、きっとスッキリするんやろなぁ。 消しゴムで消すみたいに、サッと消して、ピカピカに輝く未来へ…なんてな。"},"replyCount":1,"repostCount":0,"uri":"at://did:plc:f5o6adr5rbf4u4d36lnpot3c/app.bsky.feed.post/xj65salkt5wjf","viewer":{"embeddingDisabled":false,"threadMuted":false}}}},{"post":{"author":{"associated":{"chat":{"allowIncoming":"following"}},"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:f5o6adr5rbf4u4d36lnpot3c/e67zhb62dcrnbvmy6p3rueegt3h5zktghtyryo337fu3mbjjf53yufxuf2s@jpeg","createdAt":"2023-07-07T17:20:43.729Z","did":"did:plc:f5o6adr5rbf4u4d36lnpot3c","displayName":"ネニシオ","handle":"milujlujf263.natr.social","labels":[{"cid":"s2mvehrmhxhasjhn5jvaamtowu6kxny7f5wutinuqylkl52wts7knz2wyt5","cts":"1970-01-01T00:00:00.000Z","src":"did:plc:f5o6adr5rbf4u4d36lnpot3c","uri":"at://did:plc:f5o6adr5rbf4u4d36lnpot3c/app.bsky.actor.profile/self","val":"!no-unauthenticated"}],"viewer":{"blockedBy":false,"following":"at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.graph.follow/2m2tlarrkgj3h","muted":false}},"cid":"kbw72c3fcg3yioqxrxcqn3ads6auw2mka4siwxq6lg5po5d5fkymnvlobtw","indexedAt":"2024-10-22T12:46:30.362Z","labels":[],"likeCount":3,"quoteCount":0,"record":{"$type":"app.bsky.feed.post","createdAt":"2024-10-22T12:46:30.362Z","langs":["ja"],"text":"そないなもん、見たんやて? ほな、なんや、あの消しゴムみたいなもんは、ポス消しいうんか? なんか、消しゴムよりデカくて、ゴツい感じやな。 まるで、でっかい消しゴムで、大事なもんを消してまうみたいな、そんな感じや。 そやけど、あれで消したら、きっとスッキリするんやろなぁ。 消しゴムで消すみたいに、サッと消して、ピカピカに輝く未来へ…なんてな。"},"replyCount":1,"repostCount":0,"uri":"at://did:plc:f5o6adr5rbf4u4d36lnpot3c/app.bsky.feed.post/xj65salkt5wjf","viewer":{"embeddingDisabled":false,"threadMuted":false}}},{"post":{"author":{"associated":{"chat":{"allowIncoming":"following"}},"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:jm26v6khcj237cfl5e3d4kmx/zk3s77ybgwi4dlqbwwtmif2br25fqsjnkj2utpgceypox6o2hloxp5ezu24@jpeg","createdAt":"2024-02-07T11:40:49.317Z","did":"did:plc:jm26v6khcj237cfl5e3d4kmx","displayName":"たたぬぬ","handle":"zpyfprzwu539.nxwe.co.jp","labels":[],"viewer":{"blockedBy":false,"followedBy":"at://did:plc:jm26v6khcj237cfl5e3d4kmx/app.bsky.graph.follow/lddyz6mu4xshb","following":"at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.graph.follow/sjjvhknza4yjn","muted":false}},"cid":"pipbvkcmlcrgcryevjoh4nljuaumijd43jifvhqtlhiudalupbgtqs7rjaw","indexedAt":"2024-10-22T12:37:59.045Z","labels":[],"likeCount":0,"quoteCount":0,"record":{"$type":"app.bsky.feed.post","createdAt":"2024-10-22T12:38:00.829Z","langs":["ja"],"reply":{"parent":{"cid":"66glfu2rh7dykbrv2ir22ex4wst3b7ql6a3tmjuorkwrwgdyaxhq5frwbyb","uri":"at://did:plc:ugpyahs6oz7tmzbygmdipc7w/app.bsky.feed.post/mlcf7wy23g7gs"},"root":{"cid":"p3kxladhtdhsvoioubntit3hs2m524qaesldnkicraigvowfpz3y6gpaflk","uri":"at://did:plc:jm26v6khcj237cfl5e3d4kmx/app.bsky.feed.post/iurmyqhi5c46b"}},"text":"ほな、流行らせたらええやん? そないなこと言うて、ワイが魔法の杖でも振ったら、たちまち世間が賑わうて、みんなが踊り出すんか? ちゃうで、そんなん簡単にはいかん。流行らせるっちゅうんは、そないな手品みたいなもんやない。\n\n流行はな、そらから降って湧いたみたいに現れるんやけど、実は水面下で地道な努力が詰まってるんやで。種を蒔いて、水やって、肥料やって、日々手入れして、ようやく花が咲く。そやから、流行らせたいんやったら、根気強く、コツコツと努力するんや。\n\n例えば、あの有名な\"なんちゃらダンス\"、あれも一晩で出来たわけやない。誰かが考えて、広めて、みんなで真似して、改良して、メディアが取り上げて、ようやく大ヒットしたんや。そやから、まずは自分から動き出すんやで。踊らにゃ損損!\n\nまぁ、流行らせるっちゅうても、難しいことばっかりやないで。ちょっとした工夫やアイデアで、みんなの心を掴むこともある。ほら、あの\"なんとかチャレンジ\"みたいに、誰でも参加できるような楽しい企画なんか、ええ手やと思わへん? ワイもやってみよかな、なんて思うやろ。\n\n要は、みんなの心に響くような、そないなもんやったら、自然と広まっていくもんや。流行は風みたいなもんやから、無理に押さえつけたらあかん。そっと背中を押して、自由に舞わせるんや。ほな、みんなでワイワイ楽しもうやないか!"},"replyCount":0,"repostCount":0,"uri":"at://did:plc:jm26v6khcj237cfl5e3d4kmx/app.bsky.feed.post/o5as3cjjwhjxb","viewer":{"embeddingDisabled":false,"threadMuted":false}},"reply":{"grandparentAuthor":{"associated":{"chat":{"allowIncoming":"following"}},"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:jm26v6khcj237cfl5e3d4kmx/zk3s77ybgwi4dlqbwwtmif2br25fqsjnkj2utpgceypox6o2hloxp5ezu24@jpeg","createdAt":"2024-02-07T11:40:49.317Z","did":"did:plc:jm26v6khcj237cfl5e3d4kmx","displayName":"たたぬぬ","handle":"zpyfprzwu539.nxwe.co.jp","labels":[],"viewer":{"blockedBy":false,"followedBy":"at://did:plc:jm26v6khcj237cfl5e3d4kmx/app.bsky.graph.follow/lddyz6mu4xshb","following":"at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.graph.follow/sjjvhknza4yjn","muted":false}},"parent":{"$type":"app.bsky.feed.defs#postView","author":{"associated":{"chat":{"allowIncoming":"following"}},"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:ugpyahs6oz7tmzbygmdipc7w/x4ioq7eonn7z7nlbry7lbboos6oc4jwlrghq2c7w5ftockt64u73thfwwla@jpeg","createdAt":"2024-04-21T06:32:38.320Z","did":"did:plc:ugpyahs6oz7tmzbygmdipc7w","displayName":"タカユク","handle":"hmjechwxw502.lxcqe.com","labels":[],"viewer":{"blockedBy":false,"muted":false}},"cid":"66glfu2rh7dykbrv2ir22ex4wst3b7ql6a3tmjuorkwrwgdyaxhq5frwbyb","indexedAt":"2024-10-22T12:18:49.108Z","labels":[],"likeCount":1,"quoteCount":0,"record":{"$type":"app.bsky.feed.post","createdAt":"2024-10-22T12:18:49.108Z","langs":["ja"],"reply":{"parent":{"cid":"p3kxladhtdhsvoioubntit3hs2m524qaesldnkicraigvowfpz3y6gpaflk","uri":"at://did:plc:jm26v6khcj237cfl5e3d4kmx/app.bsky.feed.post/iurmyqhi5c46b"},"root":{"cid":"p3kxladhtdhsvoioubntit3hs2m524qaesldnkicraigvowfpz3y6gpaflk","uri":"at://did:plc:jm26v6khcj237cfl5e3d4kmx/app.bsky.feed.post/iurmyqhi5c46b"}},"text":"ほな、ちょっと待ってや。なんや、そんなに慌てて、せっかちさんやな。\n\nまあ、ええやないか、ゆっくり行こか。急いては事を仕損じる言うし、慌てて走ったら転んでケガするで。\n\nほな、もう一回言うてみ?ゆっくり、落ち着いてな。"},"replyCount":1,"repostCount":0,"uri":"at://did:plc:ugpyahs6oz7tmzbygmdipc7w/app.bsky.feed.post/mlcf7wy23g7gs","viewer":{"embeddingDisabled":false,"threadMuted":false}},"root":{"$type":"app.bsky.feed.defs#postView","author":{"associated":{"chat":{"allowIncoming":"following"}},"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:jm26v6khcj237cfl5e3d4kmx/zk3s77ybgwi4dlqbwwtmif2br25fqsjnkj2utpgceypox6o2hloxp5ezu24@jpeg","createdAt":"2024-02-07T11:40:49.317Z","did":"did:plc:jm26v6khcj237cfl5e3d4kmx","displayName":"たたぬぬ","handle":"zpyfprzwu539.nxwe.co.jp","labels":[],"viewer":{"blockedBy":false,"followedBy":"at://did:plc:jm26v6khcj237cfl5e3d4kmx/app.bsky.graph.follow/lddyz6mu4xshb","following":"at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.graph.follow/sjjvhknza4yjn","muted":false}},"cid":"p3kxladhtdhsvoioubntit3hs2m524qaesldnkicraigvowfpz3y6gpaflk","embed":{"$type":"app.bsky.embed.external#view","external":{"description":"そや、あの有名な音楽ユニットがYouTubeで新曲公開しとったで!名前は…まあ、なんやら難しい名前やったけど、ええ感じのメロディーやったわ。なんでも、あの人気アニメの主題歌やってな。曲名は…そうやな、\"空飛ぶ魚\"とかそんな感じやった気がするわ。海を飛び出す魚みたいに、力強くて爽やかな歌声やったで。歌詞も、夢に向かって突き進むようなメッセージが込められてて、聴いとるこっちも勇気をもろたわ。あのユニットは、いつも独特な世界観でファンを魅了するなぁ。","thumb":"https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:ope2cljdvquqiiix6kugqrjk/bafkreibrs6m6ctgtirtqp54mal4t465uozmmvkegb7c2js7mdp6o6c53zm@jpeg","title":"ようこそ、このめっちゃカオスな世界へ。\n\nここはな、なんでもありのワンダーランドや。常識なんてクソ食らえ、ルールなんて知らん顔、自由気ままに踊る蝶々みたいなとこや。\n\nそやけど、この混沌の中にも、なんとなくの秩序があってな。まるで、色んな色が混ざり合った虹みたいに、美しいハーモニーを奏でとるんや。\n\nこの世界に足を踏み入れたら、もう後戻りはでけへん。好奇心が導くままに、この不思議な迷宮を冒険しや。\n\nさぁ、一緒に飛び込もうや。常識の壁をぶち破って、新たな発見の海原へ!","uri":"https://yzznprbl092.ega.co.jp/ugfexhn.html"}},"indexedAt":"2024-10-22T11:23:44.945Z","labels":[],"likeCount":23,"quoteCount":0,"record":{"$type":"app.bsky.feed.post","createdAt":"2024-10-22T11:23:46.077Z","embed":{"$type":"app.bsky.embed.external","external":{"description":"そや、あの有名な音楽ユニットがYouTubeで新曲公開しとったで!名前は…まあ、なんやら難しい名前やったけど、ええ感じのメロディーやったわ。なんでも、あの人気アニメの主題歌やってな。曲名は…そうやな、\"空飛ぶ魚\"とかそんな感じやった気がするわ。海を飛び出す魚みたいに、力強くて爽やかな歌声やったで。歌詞も、夢に向かって突き進むようなメッセージが込められてて、聴いとるこっちも勇気をもろたわ。あのユニットは、いつも独特な世界観でファンを魅了するなぁ。","thumb":{"$type":"blob","mimeType":"image/jpeg","ref":{"$link":"bafkreibrs6m6ctgtirtqp54mal4t465uozmmvkegb7c2js7mdp6o6c53zm"},"size":68843},"title":"ようこそ、このめっちゃカオスな世界へ。\n\nここはな、なんでもありのワンダーランドや。常識なんてクソ食らえ、ルールなんて知らん顔、自由気ままに踊る蝶々みたいなとこや。\n\nそやけど、この混沌の中にも、なんとなくの秩序があってな。まるで、色んな色が混ざり合った虹みたいに、美しいハーモニーを奏でとるんや。\n\nこの世界に足を踏み入れたら、もう後戻りはでけへん。好奇心が導くままに、この不思議な迷宮を冒険しや。\n\nさぁ、一緒に飛び込もうや。常識の壁をぶち破って、新たな発見の海原へ!","uri":"https://yzznprbl092.ega.co.jp/ugfexhn.html"}},"facets":[{"features":[{"$type":"app.bsky.richtext.facet#link","uri":"https://www.fictionalmusic.com/melodic-rain.html"}],"index":{"byteEnd":306,"byteStart":258}}],"langs":["ja"],"text":"そやな、作業の時はええ音楽流したらええリズムで進むわ。\n\nほな、ドロドロした雰囲気でええ感じの曲、どないや? 例えるなら、雨の日に聴きたいような、しっとりとしたメロディーや。\n\n[https://www.fictionalmusic.com/melodic-rain.html]\n\nこの曲聴いたら、雨の中、傘もささず、泥だらけになりながら、どこかへ歩いていくような気分になるで。 "},"replyCount":1,"repostCount":1,"uri":"at://did:plc:jm26v6khcj237cfl5e3d4kmx/app.bsky.feed.post/iurmyqhi5c46b","viewer":{"embeddingDisabled":false,"threadMuted":false}}}},{"post":{"author":{"associated":{"chat":{"allowIncoming":"following"}},"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:f5o6adr5rbf4u4d36lnpot3c/e67zhb62dcrnbvmy6p3rueegt3h5zktghtyryo337fu3mbjjf53yufxuf2s@jpeg","createdAt":"2023-07-07T17:20:43.729Z","did":"did:plc:f5o6adr5rbf4u4d36lnpot3c","displayName":"ネニシオ","handle":"milujlujf263.natr.social","labels":[{"cid":"s2mvehrmhxhasjhn5jvaamtowu6kxny7f5wutinuqylkl52wts7knz2wyt5","cts":"1970-01-01T00:00:00.000Z","src":"did:plc:f5o6adr5rbf4u4d36lnpot3c","uri":"at://did:plc:f5o6adr5rbf4u4d36lnpot3c/app.bsky.actor.profile/self","val":"!no-unauthenticated"}],"viewer":{"blockedBy":false,"following":"at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.graph.follow/2m2tlarrkgj3h","muted":false}},"cid":"fifsx7bhh52pehdoexgya3menmdbwrylrnqnnogsivzzqcw6whjix6jqcos","indexedAt":"2024-10-22T12:25:47.005Z","labels":[],"likeCount":0,"quoteCount":0,"record":{"$type":"app.bsky.feed.post","createdAt":"2024-10-22T12:25:47.005Z","langs":["ja"],"text":"家っちゅうもんは、人生の宝箱や。そやけど、家を買うて、宝箱を手に入れるんは、そない簡単な話やあらへん。\n\n戸建を買うて、マイホームっちゅう宝箱を手に入れるんは、人生の大きな決断や。家っちゅう宝箱は、夢と希望に満ちた宝箱かもしれへんし、時には重たい責任っちゅう宝箱かもしれへん。\n\n家を買うて解決するんは、雨漏りする屋根やあらへん。家っちゅう宝箱は、家族の笑顔や、安らぎの空間、思い出の詰まった宝箱や。そやから、家を買うて、その宝箱を開けるんは、人生の新たな旅の始まりや。\n\n戸建を買うて、自分の城を築く。それは、大海原に船出するような冒険や。家っちゅう船は、家族を乗せて、未来へと漕ぎ出す。その船旅は、時に荒波に揺られても、きっと宝島へと導いてくれるやろ。"},"replyCount":0,"repostCount":0,"uri":"at://did:plc:f5o6adr5rbf4u4d36lnpot3c/app.bsky.feed.post/jmhsiyamqxfqv","viewer":{"embeddingDisabled":false,"threadMuted":false}}}]},"code":200,"mimeType":"application/json; charset=utf-8","reason":"OK"} +] diff --git a/tests/.ut-data_source/65b058a6-d7eb-414a-8bd8-625331524b6e.json b/tests/.ut-data_source/65b058a6-d7eb-414a-8bd8-625331524b6e.json new file mode 100644 index 0000000..ce15fcd --- /dev/null +++ b/tests/.ut-data_source/65b058a6-d7eb-414a-8bd8-625331524b6e.json @@ -0,0 +1,5 @@ +[ +{"body":{"blob":{"$type":"blob","mimeType":"image/png","ref":{"$link":"mjxrmzdw2ggq2rjuwxan6hm6w36r2nipkecdrvkpkskk5qoo4slwst3xm6f"},"size":13979}},"code":200,"mimeType":"application/json; charset=utf-8","reason":"OK"}, +{"body":{"cid":"aykzb74s6m3tj3fvsn777i3zeuefoxkxhwwevxy5juuuaxe24rm26rs6wnf","uri":"at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/hyq6lbnl45len","value":{"$type":"app.bsky.feed.post","createdAt":"2024-04-30T10:39:29.924Z","langs":["ja"],"reply":{"parent":{"cid":"dx2imfjwbdohkh4re7hh6dguhpczu5mhruw4ofkn6sdmhk4uimas37c5a74","uri":"at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/5ni6rkonpzlx2"},"root":{"cid":"dx2imfjwbdohkh4re7hh6dguhpczu5mhruw4ofkn6sdmhk4uimas37c5a74","uri":"at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/5ni6rkonpzlx2"}},"text":"ほな、試しにもう一回言うてみ。"}},"code":200,"mimeType":"application/json; charset=utf-8","reason":"OK"}, +{"body":{"cid":"pii3oiclei2tdn64oe5cfjihpkvv6o6db4lrzzr2mnefxur7jelrokzzz4q","commit":{"cid":"cip3nujau5ggpjgwxngl6gyu46xgmv6l3z2ndkdovg3djfqll2a4wqequ6i","rev":"nysxj7odbsvpo"},"uri":"at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/3h546l6tigfco","validationStatus":"valid"},"code":200,"mimeType":"application/json; charset=utf-8","reason":"OK"} +] diff --git a/tests/.ut-data_source/72a91fe8-1f30-4ebc-a42d-6617642dcbfe.json b/tests/.ut-data_source/72a91fe8-1f30-4ebc-a42d-6617642dcbfe.json new file mode 100644 index 0000000..6e88243 --- /dev/null +++ b/tests/.ut-data_source/72a91fe8-1f30-4ebc-a42d-6617642dcbfe.json @@ -0,0 +1,4 @@ +[ +{"body":{"blob":{"$type":"blob","mimeType":"image/png","ref":{"$link":"vwotix62an5fz5pcmd4bz52wm6exrsns5lq2pdiehp7fqoulexyo64btrmf"},"size":77512}},"code":200,"mimeType":"application/json; charset=utf-8","reason":"OK"}, +{"body":{"cid":"z6zwhsdkzwl36236bxgrrevw5gtvf3777r3yfbqys5z2vpra6qai3u7aufh","commit":{"cid":"nq7wkx22hoekhzusr7k2nncrbtf7v232yt2jbqp3eth7srzxnd3vodkggwa","rev":"l5fo7atc6hx7m"},"uri":"at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/t7wo76iektu2u","validationStatus":"valid"},"code":200,"mimeType":"application/json; charset=utf-8","reason":"OK"} +] diff --git a/tests/.ut-data_source/89f1adc0-187a-446f-8bae-9c21639622b6.json b/tests/.ut-data_source/89f1adc0-187a-446f-8bae-9c21639622b6.json new file mode 100644 index 0000000..6ace540 --- /dev/null +++ b/tests/.ut-data_source/89f1adc0-187a-446f-8bae-9c21639622b6.json @@ -0,0 +1,3 @@ +[ +{"body":{"feed":[{"post":{"author":{"createdAt":"2024-04-01T15:04:14.605Z","did":"did:plc:2qfqobqz6dzrfa3jv74i6k6m","displayName":"","handle":"krzblhls379.vkn.io","labels":[],"viewer":{"blockedBy":false,"muted":false}},"cid":"aykzb74s6m3tj3fvsn777i3zeuefoxkxhwwevxy5juuuaxe24rm26rs6wnf","indexedAt":"2024-04-30T10:39:29.509Z","labels":[],"likeCount":0,"quoteCount":0,"record":{"$type":"app.bsky.feed.post","createdAt":"2024-04-30T10:39:29.924Z","langs":["ja"],"reply":{"parent":{"cid":"dx2imfjwbdohkh4re7hh6dguhpczu5mhruw4ofkn6sdmhk4uimas37c5a74","uri":"at://did:plc:2qfqobqz6dzrfa3jv74i6k6m/app.bsky.feed.post/5ni6rkonpzlx2"},"root":{"cid":"dx2imfjwbdohkh4re7hh6dguhpczu5mhruw4ofkn6sdmhk4uimas37c5a74","uri":"at://did:plc:2qfqobqz6dzrfa3jv74i6k6m/app.bsky.feed.post/5ni6rkonpzlx2"}},"text":"ほな、試しにもう一回言うてみ。"},"replyCount":0,"repostCount":0,"uri":"at://did:plc:2qfqobqz6dzrfa3jv74i6k6m/app.bsky.feed.post/hyq6lbnl45len","viewer":{"embeddingDisabled":false,"threadMuted":false}},"reply":{"parent":{"$type":"app.bsky.feed.defs#postView","author":{"createdAt":"2024-04-01T15:04:14.605Z","did":"did:plc:2qfqobqz6dzrfa3jv74i6k6m","displayName":"","handle":"krzblhls379.vkn.io","labels":[],"viewer":{"blockedBy":false,"muted":false}},"cid":"dx2imfjwbdohkh4re7hh6dguhpczu5mhruw4ofkn6sdmhk4uimas37c5a74","indexedAt":"2024-04-03T14:49:37.630Z","labels":[],"likeCount":0,"quoteCount":0,"record":{"$type":"app.bsky.feed.post","createdAt":"2024-04-03T14:49:37.630675Z","text":"こんにちは、青空さん!\n空の色、めっちゃ綺麗なブルーやな。\nまるで、海原に浮かぶ船みたいや。\nそやけど、その船はどこへ行くんやろ?\nもしかしたら、宝島を目指してるんかもしれへんな。"},"replyCount":1,"repostCount":0,"uri":"at://did:plc:2qfqobqz6dzrfa3jv74i6k6m/app.bsky.feed.post/5ni6rkonpzlx2","viewer":{"embeddingDisabled":false,"threadMuted":false}},"root":{"$type":"app.bsky.feed.defs#postView","author":{"createdAt":"2024-04-01T15:04:14.605Z","did":"did:plc:2qfqobqz6dzrfa3jv74i6k6m","displayName":"","handle":"krzblhls379.vkn.io","labels":[],"viewer":{"blockedBy":false,"muted":false}},"cid":"dx2imfjwbdohkh4re7hh6dguhpczu5mhruw4ofkn6sdmhk4uimas37c5a74","indexedAt":"2024-04-03T14:49:37.630Z","labels":[],"likeCount":0,"quoteCount":0,"record":{"$type":"app.bsky.feed.post","createdAt":"2024-04-03T14:49:37.630675Z","text":"こんにちは、青空さん!\n空の色、めっちゃ綺麗なブルーやな。\nまるで、海原に浮かぶ船みたいや。\nそやけど、その船はどこへ行くんやろ?\nもしかしたら、宝島を目指してるんかもしれへんな。"},"replyCount":1,"repostCount":0,"uri":"at://did:plc:2qfqobqz6dzrfa3jv74i6k6m/app.bsky.feed.post/5ni6rkonpzlx2","viewer":{"embeddingDisabled":false,"threadMuted":false}}}},{"post":{"author":{"createdAt":"2024-04-01T15:04:14.605Z","did":"did:plc:2qfqobqz6dzrfa3jv74i6k6m","displayName":"","handle":"krzblhls379.vkn.io","labels":[],"viewer":{"blockedBy":false,"muted":false}},"cid":"dx2imfjwbdohkh4re7hh6dguhpczu5mhruw4ofkn6sdmhk4uimas37c5a74","indexedAt":"2024-04-03T14:49:37.630Z","labels":[],"likeCount":0,"quoteCount":0,"record":{"$type":"app.bsky.feed.post","createdAt":"2024-04-03T14:49:37.630675Z","text":"こんにちは、青空さん!\n空の色、めっちゃ綺麗なブルーやな。\nまるで、海原に浮かぶ船みたいや。\nそやけど、その船はどこへ行くんやろ?\nもしかしたら、宝島を目指してるんかもしれへんな。"},"replyCount":1,"repostCount":0,"uri":"at://did:plc:2qfqobqz6dzrfa3jv74i6k6m/app.bsky.feed.post/5ni6rkonpzlx2","viewer":{"embeddingDisabled":false,"threadMuted":false}}}]},"code":200,"mimeType":"application/json; charset=utf-8","reason":"OK"} +] diff --git a/tests/.ut-data_source/906a0151-0e10-4cbc-8e42-a8138271a180.json b/tests/.ut-data_source/906a0151-0e10-4cbc-8e42-a8138271a180.json new file mode 100644 index 0000000..1803e13 --- /dev/null +++ b/tests/.ut-data_source/906a0151-0e10-4cbc-8e42-a8138271a180.json @@ -0,0 +1 @@ +{"cursor":"5","posts":[{"author":{"associated":{"chat":{"allowIncoming":"following"}},"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:vln5i3wuftxv2ytrkdpb7ese/jqkg2t3okmseu6hy6mtivf7tsf2whvqydtbtp53ejhc4vbkw623vm3kdw36@jpeg","createdAt":"2024-09-01T09:31:03.498Z","did":"did:plc:vln5i3wuftxv2ytrkdpb7ese","displayName":"","handle":"mlenkubhh569.ojwk.io","labels":[],"viewer":{"blockedBy":false,"muted":false}},"cid":"mrk2azyzys5pkep7j5vaqztonyrxt5p2ttbmtwivjr4idbv6zylyta3fczz","embed":{"$type":"app.bsky.embed.external#view","external":{"description":"ええ感じの画像エンコーダをD言語に移植するっちゅう話や。","title":"D言語で画像フォーマットをエンコードするプログラムいうたら、まあまあええ感じのやつがおるで。","uri":"https://ednbb299.esfi.jp/olfphunw"}},"indexedAt":"2024-09-01T13:25:22.525Z","labels":[],"likeCount":0,"quoteCount":0,"record":{"$type":"app.bsky.feed.post","createdAt":"2024-09-01T13:25:23.728Z","embed":{"$type":"app.bsky.embed.external","external":{"description":"ええ感じの画像エンコーダをD言語に移植するっちゅう話や。","title":"D言語で画像フォーマットをエンコードするプログラムいうたら、まあまあええ感じのやつがおるで。","uri":"https://ednbb299.esfi.jp/olfphunw"}},"facets":[{"features":[{"$type":"app.bsky.richtext.facet#tag","tag":"QOI"}],"index":{"byteEnd":1243,"byteStart":1239}},{"features":[{"$type":"app.bsky.richtext.facet#tag","tag":"dlang"}],"index":{"byteEnd":1250,"byteStart":1244}}],"langs":["en"],"text":"なんでっか?そないな話、ようわからんけど、なんかええ感じの話やな。\n\n\"Bmp2Qoi\" っちゅうんは、24ビットやいうたらかなりええ感じのビット数のビットマップ・ファイルを、\"QOI\" いうて、なんかめっちゃええ感じの画像ファイルに変換するらしいで。\"QOI\" は \"Quite Okay Image\" の略やて。名前からして、まあまあええ感じやな。\n\nで、あんたの \"Viewer\" いうアプリは、画像を表示できるんやな。そやったら、その \"Bmp2Qoi\" っちゅう変換機能を付け加えたら、もっとええ感じになるんちゃう?画像を表示するだけやなくて、自分で画像を作ったり、変換したりできるようになったら、めっちゃクールやん!\n\nその \"Bmp2Qoi\" っちゅうんは、\"ednbb299\" いうサイトからダウンロードできるらしいで。そこにはソースも入っとるみたいや。ほな、\"olfphunw\" いうページに飛んで、ダウンロードしてみたらええがな。\n\nなんか、ええ感じのプロジェクトが始まりそうやな。あんたの \"Viewer\" アプリが、どんな風に生まれ変わるか、楽しみやなぁ。 #QOI #dlang "},"replyCount":0,"repostCount":0,"uri":"at://did:plc:vln5i3wuftxv2ytrkdpb7ese/app.bsky.feed.post/btpxmtg4jd77j","viewer":{"embeddingDisabled":false,"threadMuted":false}},{"author":{"associated":{"chat":{"allowIncoming":"following"}},"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:vln5i3wuftxv2ytrkdpb7ese/jqkg2t3okmseu6hy6mtivf7tsf2whvqydtbtp53ejhc4vbkw623vm3kdw36@jpeg","createdAt":"2024-09-01T09:31:03.498Z","did":"did:plc:vln5i3wuftxv2ytrkdpb7ese","displayName":"","handle":"mlenkubhh569.ojwk.io","labels":[],"viewer":{"blockedBy":false,"muted":false}},"cid":"ys5houmx5uvioansymcdbacpkkgzwdm6vv2bchqv72l25wdruyqkaqlk6vk","embed":{"$type":"app.bsky.embed.images#view","images":[{"alt":"なんでっしゃろな、この文字の軌跡は。まるで魔法の絨毯に乗って空を飛んでるみたいに、文字がフワフワと宙に浮いてるみたいや。\n\nほんで、この文字の並び、なんかええ感じのメロディーが聞こえてきそうやな。リズムに乗って、文字が踊り出すんちゃうか思てまうわ。\n\nまあ、よう分からんけど、この文字の軌跡は、なんか不思議な魅力があるなあ。見てると、ワクワクしてきて、ええ気分になるわ。","aspectRatio":{"height":200,"width":320},"fullsize":"https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:vln5i3wuftxv2ytrkdpb7ese/mswyxf35oae2oh5aj2na2xedatjhgdbn6owivl4rskrdgfi7vnupprn6zwa@jpeg","thumb":"https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:vln5i3wuftxv2ytrkdpb7ese/mswyxf35oae2oh5aj2na2xedatjhgdbn6owivl4rskrdgfi7vnupprn6zwa@jpeg"},{"alt":"陽炎に揺れるABCの文字が、市松模様の床の上に浮かび上がっとる。\n見よ、その美しさはまるで、陽の光を浴びて輝く七色の虹のようや。\nABCの文字は、まるで宙に浮かぶ雲のようや。\n市松模様の床は、陽の光と影が織りなすコントラストで、見る者に深みのある空間を感じさすんや。\nこの光景は、見る者の心を魅了し、現実か夢か、その境目を曖昧にさせるんや。","aspectRatio":{"height":512,"width":512},"fullsize":"https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:vln5i3wuftxv2ytrkdpb7ese/i6rgd2lnhyzekfoa5e4khlxix25mx2c5yooltghdvztothvaoqlgtoj3fnl@jpeg","thumb":"https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:vln5i3wuftxv2ytrkdpb7ese/i6rgd2lnhyzekfoa5e4khlxix25mx2c5yooltghdvztothvaoqlgtoj3fnl@jpeg"}]},"indexedAt":"2024-09-01T10:28:54.508Z","labels":[],"likeCount":0,"quoteCount":0,"record":{"$type":"app.bsky.feed.post","createdAt":"2024-09-01T10:28:55.113Z","embed":{"$type":"app.bsky.embed.images","images":[{"alt":"なんでっしゃろな、この文字の軌跡は。まるで魔法の絨毯に乗って空を飛んでるみたいに、文字がフワフワと宙に浮いてるみたいや。\n\nほんで、この文字の並び、なんかええ感じのメロディーが聞こえてきそうやな。リズムに乗って、文字が踊り出すんちゃうか思てまうわ。\n\nまあ、よう分からんけど、この文字の軌跡は、なんか不思議な魅力があるなあ。見てると、ワクワクしてきて、ええ気分になるわ。","aspectRatio":{"height":200,"width":320},"image":{"$type":"blob","mimeType":"image/jpeg","ref":{"$link":"mswyxf35oae2oh5aj2na2xedatjhgdbn6owivl4rskrdgfi7vnupprn6zwa"},"size":122458}},{"alt":"陽炎に揺れるABCの文字が、市松模様の床の上に浮かび上がっとる。\n見よ、その美しさはまるで、陽の光を浴びて輝く七色の虹のようや。\nABCの文字は、まるで宙に浮かぶ雲のようや。\n市松模様の床は、陽の光と影が織りなすコントラストで、見る者に深みのある空間を感じさすんや。\nこの光景は、見る者の心を魅了し、現実か夢か、その境目を曖昧にさせるんや。","aspectRatio":{"height":512,"width":512},"image":{"$type":"blob","mimeType":"image/jpeg","ref":{"$link":"i6rgd2lnhyzekfoa5e4khlxix25mx2c5yooltghdvztothvaoqlgtoj3fnl"},"size":191396}}]},"facets":[{"features":[{"$type":"app.bsky.richtext.facet#link","uri":"https://tqebl050.cet.social/tqszphhd"}],"index":{"byteEnd":304,"byteStart":268}},{"features":[{"$type":"app.bsky.richtext.facet#tag","tag":"dlang"}],"index":{"byteEnd":525,"byteStart":519}}],"langs":["en"],"text":"ええ、ちょっと前にアンドリュー・ケンスラー言う人のちっちゃなパス・トレーサーとレイ・トレーサーをDプログラミング言語に翻訳したんや。その翻訳版は\"たこ焼き屋さん\"のサイトで見れるで。 https://tqebl050.cet.social/tqszphhd そこのサイトでは、その翻訳版がどないな風に作られたか言うガイドも載ってるし、どないな風に最適化されて、めっちゃ速くなったか言う話も載ってるで。 #dlang"},"replyCount":0,"repostCount":0,"uri":"at://did:plc:vln5i3wuftxv2ytrkdpb7ese/app.bsky.feed.post/2smhhgr7vaf3c","viewer":{"embeddingDisabled":false,"threadMuted":false}},{"author":{"associated":{"chat":{"allowIncoming":"all"}},"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:jfoe2syf3tkovcbn6bp2xxev/dg56dokdmvumvy2gcwiz555hpskh6lxrhh3svlplg2z3yt6377kygdnuify@jpeg","createdAt":"2023-08-16T20:06:18.583Z","did":"did:plc:jfoe2syf3tkovcbn6bp2xxev","displayName":"ソヤオク","handle":"zemogb641.livsb.org","labels":[],"viewer":{"blockedBy":false,"muted":false}},"cid":"adztve7rkszbo6tadqo6wiskqyloqjtutkf55by3dfrphs22qfhwjrqokup","embed":{"$type":"app.bsky.embed.images#view","images":[{"alt":"","aspectRatio":{"height":645,"width":735},"fullsize":"https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:jfoe2syf3tkovcbn6bp2xxev/anxwcpcrhthgl2yxui77e4a3n7mdtwudciyckqbscfjp6kr3sszkyocjehe@jpeg","thumb":"https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:jfoe2syf3tkovcbn6bp2xxev/anxwcpcrhthgl2yxui77e4a3n7mdtwudciyckqbscfjp6kr3sszkyocjehe@jpeg"}]},"indexedAt":"2024-08-07T18:29:06.740Z","labels":[],"likeCount":2,"quoteCount":0,"record":{"$type":"app.bsky.feed.post","createdAt":"2024-08-07T18:29:06.740Z","embed":{"$type":"app.bsky.embed.images","images":[{"alt":"","aspectRatio":{"height":645,"width":735},"image":{"$type":"blob","mimeType":"image/jpeg","ref":{"$link":"anxwcpcrhthgl2yxui77e4a3n7mdtwudciyckqbscfjp6kr3sszkyocjehe"},"size":165081}}]},"facets":[{"features":[{"$type":"app.bsky.richtext.facet#tag","tag":"gamedesign"}],"index":{"byteEnd":1022,"byteStart":1011}},{"features":[{"$type":"app.bsky.richtext.facet#tag","tag":"dlang"}],"index":{"byteEnd":1029,"byteStart":1023}},{"features":[{"$type":"app.bsky.richtext.facet#tag","tag":"programming"}],"index":{"byteEnd":1042,"byteStart":1030}}],"langs":["en"],"text":"まあ、こんな小技もあるんやで。配列データの整列が崩れて、見栄えが悪うなるんを防ぐ方法や。配列データいうたら、まるで並べられた宝石みたいなもんやろ? そやけど、その宝石に汚いサインいうか、汚点がついたら台無しや。せやから、そのサインを隠して、きれいな宝石箱みたいな配列データを保つんや。\n\nほな、その小技いうんは、まあ、ちょっとした魔法みたいなもんや。配列データの周りに、見えへん壁みたいなんを作って、サインが中に入れへんようにするんや。そうすれば、サインは外でうろうろしとるだけや。配列データは、その壁に守られて、きれいなままや。\n\nまあ、こんな感じで、ちょっとした工夫でデータの見栄えはええ感じになるんや。ほな、この小技でデータの美しさを保って、見る人の目を楽しませてあげてな!\n#gamedesign #dlang #programming"},"replyCount":0,"repostCount":0,"uri":"at://did:plc:jfoe2syf3tkovcbn6bp2xxev/app.bsky.feed.post/5fs5scbqqgtnc","viewer":{"embeddingDisabled":false,"threadMuted":false}},{"author":{"associated":{"chat":{"allowIncoming":"all"}},"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:jfoe2syf3tkovcbn6bp2xxev/dg56dokdmvumvy2gcwiz555hpskh6lxrhh3svlplg2z3yt6377kygdnuify@jpeg","createdAt":"2023-08-16T20:06:18.583Z","did":"did:plc:jfoe2syf3tkovcbn6bp2xxev","displayName":"ソヤオク","handle":"zemogb641.livsb.org","labels":[],"viewer":{"blockedBy":false,"muted":false}},"cid":"nqwa42j7aqbwlxkehjyck6a3pvuseclovf6w4rrgvv45erzruqe427ykvnf","embed":{"$type":"app.bsky.embed.images#view","images":[{"alt":"","aspectRatio":{"height":596,"width":797},"fullsize":"https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:jfoe2syf3tkovcbn6bp2xxev/nw2kgvnmdsmsewb4dsdjjtl5ywujfsbsmwimbjf72wr6yczpudiluyt2dgf@jpeg","thumb":"https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:jfoe2syf3tkovcbn6bp2xxev/nw2kgvnmdsmsewb4dsdjjtl5ywujfsbsmwimbjf72wr6yczpudiluyt2dgf@jpeg"}]},"indexedAt":"2024-08-01T00:27:39.657Z","labels":[],"likeCount":0,"quoteCount":0,"record":{"$type":"app.bsky.feed.post","createdAt":"2024-08-01T00:27:39.657Z","embed":{"$type":"app.bsky.embed.images","images":[{"alt":"","aspectRatio":{"height":596,"width":797},"image":{"$type":"blob","mimeType":"image/jpeg","ref":{"$link":"nw2kgvnmdsmsewb4dsdjjtl5ywujfsbsmwimbjf72wr6yczpudiluyt2dgf"},"size":37173}}]},"facets":[{"features":[{"$type":"app.bsky.richtext.facet#link","uri":"https://www.random-montecarlo.com"}],"index":{"byteEnd":739,"byteStart":706}},{"features":[{"$type":"app.bsky.richtext.facet#tag","tag":"raylib"}],"index":{"byteEnd":748,"byteStart":741}},{"features":[{"$type":"app.bsky.richtext.facet#tag","tag":"dlang"}],"index":{"byteEnd":755,"byteStart":749}},{"features":[{"$type":"app.bsky.richtext.facet#tag","tag":"programming"}],"index":{"byteEnd":768,"byteStart":756}},{"features":[{"$type":"app.bsky.richtext.facet#tag","tag":"boardgame"}],"index":{"byteEnd":779,"byteStart":769}},{"features":[{"$type":"app.bsky.richtext.facet#tag","tag":"ai"}],"index":{"byteEnd":783,"byteStart":780}}],"langs":["en"],"text":"ほな、モンテカルロ木探索いうやつを試してみよか。\n\n今、あるプロジェクトをこしらえてんねん。そのために、この木探索いうやつをちょいと試運転してみよ思うんや。\n\n木探索いうんは、まあ、木の枝葉を広げていくようなもんで、色んな可能性を広げていくんや。\n\nモンテカルロいうたら、なんやらオシャレな響きやけど、要は、ランダムに探索していく方法や。\n\n木の枝葉をランダムに広げて、色んな可能性を試してみるんや。\n\nほな、この木探索で、どんな新しい発見があるか、ワクワクしながら見守ってみよか。\n\n[https://www.random-montecarlo.com]\n#raylib #dlang #programming #boardgame #ai"},"replyCount":0,"repostCount":0,"uri":"at://did:plc:jfoe2syf3tkovcbn6bp2xxev/app.bsky.feed.post/qrlnzxj5stekx","viewer":{"embeddingDisabled":false,"threadMuted":false}},{"author":{"associated":{"chat":{"allowIncoming":"following"}},"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:mhz3szj7pcjfpzv7pylcmlgx/rupnltt6iifthjqtfsvarqnia6csl2zcqoeq5f64rrqj3rbr3mrqcwvw5de@jpeg","createdAt":"2023-12-08T13:45:31.054Z","did":"did:plc:mhz3szj7pcjfpzv7pylcmlgx","displayName":"まにまく","handle":"upqbv134.esi.org","labels":[],"viewer":{"blockedBy":false,"muted":false}},"cid":"kj6cb55pkzsfjgxy6fylyu7mivycdpyliauge6blvqu32rybpqzyoevz4fs","embed":{"$type":"app.bsky.embed.external#view","external":{"description":"まあ、どんな仕事でもそうやけど、プロジェクトいうんはCI/CDいう大事なもんの統合テストが必要なんや。そやから、統一されたフォーマットで統合テストを実行できると便利やね。\n\nほんで、この度追加した「runtests」いうサブコマンドは、まあなんちゅうか、その、似たようなもんや。\n\nなんや、その、テストを走らせるいう意味では、まあ、似たようなもんや思うで。","thumb":"https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:lanjqoa3xruegmym3f4moeyy/bafkreiewcik23lpsxpelpbpcb5t3ws5axrbrrc3n2vrwhsftzupibzfvq4@jpeg","title":"#2952番のプルリクエストは、\"shoo\"っちゅう人が \"runtests\"いうサブコマンドを足したいう話や。\"dlang/dub\"いうプロジェクトで、なんやらプログラムをテストするコマンドみたいやな。\n\n\"shoo\"はんは、このサブコマンドでテストを走らせて、プログラムがちゃんと動くかどうか調べられるようにしたんやて。なんや、このコマンドはテストの\"道しるべ\"みたいなもんやな。\n\n\"dlang/dub\"いうプロジェクトは、\"D\"いうプログラミング言語のツールや言うとる。この\"D\"いう言語は、\"ダイヤモンド\"みたいにキラキラ輝いとるらしいで。この\"ダイヤモンド\"を磨いて、ピカピカに光らせるんが、この\"dlang/dub\"いうプロジェクトの役目や。\n\nこのプルリクエストは、その\"ダイヤモンド\"を磨くための新しい\"磨き粉\"を提案しとるみたいやな。\"shoo\"はんは、この\"磨き粉\"で、もっと効率的に\"ダイヤモンド\"を磨けるようになる言うとるで。","uri":"https://hgoeodr623.hnk.jp/gztrfrxtu"}},"indexedAt":"2024-07-21T12:50:03.083Z","labels":[],"likeCount":0,"quoteCount":0,"record":{"$type":"app.bsky.feed.post","createdAt":"2024-07-21T12:50:03.083Z","embed":{"$type":"app.bsky.embed.external","external":{"description":"まあ、どんな仕事でもそうやけど、プロジェクトいうんはCI/CDいう大事なもんの統合テストが必要なんや。そやから、統一されたフォーマットで統合テストを実行できると便利やね。\n\nほんで、この度追加した「runtests」いうサブコマンドは、まあなんちゅうか、その、似たようなもんや。\n\nなんや、その、テストを走らせるいう意味では、まあ、似たようなもんや思うで。","thumb":{"$type":"blob","mimeType":"image/jpeg","ref":{"$link":"bafkreiewcik23lpsxpelpbpcb5t3ws5axrbrrc3n2vrwhsftzupibzfvq4"},"size":247858},"title":"#2952番のプルリクエストは、\"shoo\"っちゅう人が \"runtests\"いうサブコマンドを足したいう話や。\"dlang/dub\"いうプロジェクトで、なんやらプログラムをテストするコマンドみたいやな。\n\n\"shoo\"はんは、このサブコマンドでテストを走らせて、プログラムがちゃんと動くかどうか調べられるようにしたんやて。なんや、このコマンドはテストの\"道しるべ\"みたいなもんやな。\n\n\"dlang/dub\"いうプロジェクトは、\"D\"いうプログラミング言語のツールや言うとる。この\"D\"いう言語は、\"ダイヤモンド\"みたいにキラキラ輝いとるらしいで。この\"ダイヤモンド\"を磨いて、ピカピカに光らせるんが、この\"dlang/dub\"いうプロジェクトの役目や。\n\nこのプルリクエストは、その\"ダイヤモンド\"を磨くための新しい\"磨き粉\"を提案しとるみたいやな。\"shoo\"はんは、この\"磨き粉\"で、もっと効率的に\"ダイヤモンド\"を磨けるようになる言うとるで。","uri":"https://hgoeodr623.hnk.jp/gztrfrxtu"}},"facets":[{"features":[{"$type":"app.bsky.richtext.facet#link","uri":"https://www.fictitiousurl.com/fictitiouspath"}],"index":{"byteEnd":44,"byteStart":0}},{"features":[{"$type":"app.bsky.richtext.facet#tag","tag":"dlang"}],"index":{"byteEnd":109,"byteStart":103}}],"langs":["ja"],"text":"https://www.fictitiousurl.com/fictitiouspath\nほな、プルリクエストした言うとったで!\n#dlang"},"replyCount":0,"repostCount":0,"uri":"at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.feed.post/q2dbegoee2gnd","viewer":{"embeddingDisabled":false,"threadMuted":false}}]} \ No newline at end of file diff --git a/tests/.ut-data_source/9dbe5f82-d6a2-4d85-949d-bd6369b2feb5.json b/tests/.ut-data_source/9dbe5f82-d6a2-4d85-949d-bd6369b2feb5.json new file mode 100644 index 0000000..b7a4af6 --- /dev/null +++ b/tests/.ut-data_source/9dbe5f82-d6a2-4d85-949d-bd6369b2feb5.json @@ -0,0 +1,3 @@ +[ +{"body":{"cid":"ztdjymlhwsoywyrzip7sku4kbwm32v44vpqwj273kb3zggtvuqxp3qg6bi2","commit":{"cid":"n2rhhhgexri4wh25zpi64rdl2gznrvirrxjmxdolq57odhuuc4rtrs7fv3s","rev":"lvrbsjllzt3ql"},"uri":"at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.feed.post/sjxklekf4hsir","validationStatus":"valid"},"code":200,"mimeType":"application/json; charset=utf-8","reason":"OK"} +] diff --git a/tests/.ut-data_source/a2a5d059-987f-4f7e-bc7d-db5c7e61519a.json b/tests/.ut-data_source/a2a5d059-987f-4f7e-bc7d-db5c7e61519a.json new file mode 100644 index 0000000..482d837 --- /dev/null +++ b/tests/.ut-data_source/a2a5d059-987f-4f7e-bc7d-db5c7e61519a.json @@ -0,0 +1,3 @@ +[ +{"body":{"cursor":"3kqdkgzob6a2a","follows":[{"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:yqxl63hjx47cgew6tousdqek/eyhb2ozarawfcpj4l732dpdgpjlsbqiayowctegzr2qrd5tbo7zfolkfqu3@jpeg","createdAt":"2023-08-12T15:02:12.075Z","description":"九州のどえらい田舎で、フリーランスのエンジニアっちゅうんは、まるで雑用係みたいなもんや。そやけど、その腕前は確かなもんで、TypeScript、Cloudflare、Firebase、Rust、D言語と、色んなプログラミング言語を操るんやで。\n\nこのエンジニアは、まるで雲を掴むような存在で、そやけど、その技術力は本物や。 Cloudflare のような空の上の雲を扱うツールから、Firebase の炎のように熱い情熱でプロジェクトを燃え上がらせるまで、幅広い分野で活躍しとる。\n\nD言語で書かれたコードは、まるで詩のように美しく、Rust の堅牢さは、古の城壁のようや。 そやから、このエンジニアは、技術の森を駆け巡る狩人のようなもんや。\n\n田舎の静かな環境は、このエンジニアにとって、集中力を高めるための最高の舞台や。 そやから、九州の片田舎は、このエンジニアにとって、創造性の泉や。","did":"did:plc:yqxl63hjx47cgew6tousdqek","displayName":"ヌラカ","handle":"xgnczoc001.uqmfk.jp","indexedAt":"2024-01-20T06:17:13.965Z","labels":[],"viewer":{"blockedBy":false,"muted":false}},{"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:qw72nflhhpbsvvgxufoouue6/nvlg66fne5ni2wdeh4fjflmsi6aaurw6ih6p3l4e64a5zfwxrz4af2p7z77@jpeg","createdAt":"2023-04-06T10:14:58.345Z","description":"ええやん、ワイの知識、ちょっとだけ教えたるわ。ほな、お嬢ちゃん、6年生か、よう来たな。ワイのサイトはな、 http://www.hogehoge.com や。ここはな、ワイの秘密基地みたいなもんや。イラスト描いたり、作曲したり、プログラミングしたり、ゲーム作ったり…なんでもできるで! ほな、お嬢ちゃんもワイと一緒に冒険しよか? ワイの知識の海で泳いで、宝物見つけたらええ。ほな、まずはな、この絵本みたいな本、読んでみ。ワイの友達、\"はばたき\"ちゃんの物語や。この本読んだら、ワイのことがもっと分かるで。","did":"did:plc:qw72nflhhpbsvvgxufoouue6","displayName":"にももよな","handle":"ghqlud769.xxqb.io","indexedAt":"2024-10-11T22:00:28.006Z","labels":[],"viewer":{"blockedBy":false,"muted":false}},{"associated":{"chat":{"allowIncoming":"following"}},"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:6f3bxhchqjoa34jzar53v4yd/t5i536mygwkgrig54hfwoyvqssct426bvzrqp5vhcpdytihd7oi3vz2uxfs@jpeg","createdAt":"2024-02-07T01:47:29.301Z","description":"わて、絵筆握ればモナリザも真っ青の芸術家、ギター掻き鳴らせばビートルズも顔負けのミュージシャン、キーボード叩けばビル・ゲイツもビックリのプログラマー、包丁握ればゴッドタンも唸る料理人。なんでもできる万能やけど、なんや器用貧乏な気もしてな。\n\nほんで、Twitterで見つけたんが、あの有名なBlueBirdの方。","did":"did:plc:6f3bxhchqjoa34jzar53v4yd","displayName":"をのやけ","handle":"ewvuo281.dsgn.jp","indexedAt":"2024-02-07T03:29:40.677Z","labels":[],"viewer":{"blockedBy":false,"muted":false}},{"associated":{"chat":{"allowIncoming":"following"}},"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:6bb4yel5ctm5hsl5wx3unp3u/xewiukpprzkpf3ltq7e3637jygr7ka7g622rue4xrjgpbpyqyf3ko7zycwj@jpeg","createdAt":"2023-04-25T08:11:37.680Z","description":"ワイはな、あの伝説の魔法少女に憧れてるんや。そやけど、ただの魔法少女やないで。めっちゃ強いねん。悪い奴らをバシバシやっつけるんや。まるでリズムに乗ってるみたいに華麗に舞いながらな。\n\nほんでな、ワイはゲームも大好きやねん。特に音ゲーや。指先でリズムを刻むと、音楽がワイの体の中を駆け巡るんや。そらもう、心地ええで。\n\nあと、あのふわふわの生き物にもメロメロや。ほっぺたが真っ赤で、めっちゃ可愛いやろ。あいつはな、いつもワイの傍におって、ワイのプログラミングの相棒なんや。コードを書くワイの横で、じっと見守ってくれてるんや。\n\nワイの情熱は、色んなとこに広がってるんや。ほら、あの夕焼けみたいに真っ赤なサイト、ワイの城や。ワイの城から、ワイの仲間たちとワイの想いを伝えてるんや。\n\nワイの冒険は、まだまだ続くで。リズムに乗って、ワイの物語を奏でるで。","did":"did:plc:6bb4yel5ctm5hsl5wx3unp3u","displayName":"ヘラキマ","handle":"qkqcpz408.nhd.jp","indexedAt":"2024-10-19T09:11:28.808Z","labels":[],"viewer":{"blockedBy":false,"muted":false}},{"associated":{"chat":{"allowIncoming":"following"}},"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:f5o6adr5rbf4u4d36lnpot3c/e67zhb62dcrnbvmy6p3rueegt3h5zktghtyryo337fu3mbjjf53yufxuf2s@jpeg","createdAt":"2023-07-07T17:20:43.729Z","did":"did:plc:f5o6adr5rbf4u4d36lnpot3c","displayName":"ネニシオ","handle":"milujlujf263.natr.social","indexedAt":"2024-10-06T10:41:38.006Z","labels":[{"cid":"bafyreibrrtlakysl3ih5ui6zw62xfhmar7n4jpm5k2dfytpj5sx6tomgm4","cts":"1970-01-01T00:00:00.000Z","src":"did:plc:tq2kkfw7mkrt7rnmih4iq5dn","uri":"at://did:plc:tq2kkfw7mkrt7rnmih4iq5dn/app.bsky.actor.profile/self","val":"!no-unauthenticated"}],"viewer":{"blockedBy":false,"muted":false}},{"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:ehdejwel5byvb43lwwcicpqs/tvsibbebfoqptstbsmejpsunzyntqj64jhaa2xkrphf2zyakpk74wgogl3u@jpeg","createdAt":"2024-10-19T16:14:11.312Z","did":"did:plc:ehdejwel5byvb43lwwcicpqs","displayName":"","handle":"rfxnwtm569.kiyyv.org","indexedAt":"2024-10-19T16:14:11.312Z","labels":[],"viewer":{"blockedBy":false,"muted":false}},{"associated":{"chat":{"allowIncoming":"following"}},"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:2jvttj4nqlhk35bpmdfdlz4g/mkicnh4jeemkwxta7i4gufhn7egexn3pcfgtyeqn2umipvazxnqj3zu7mcc@jpeg","createdAt":"2023-07-04T03:58:55.580Z","description":"なぁ、あいつ、D級や言うてんけど、ほんまはちゃうんちゃう?\n\nなんや、あの愛想の良さ。まるで太陽みたいにキラキラ輝いとって、みんなを温めとる。\n\nそやけど、その笑顔の裏には、誰も知らん秘密の花園があるんかもしれへんな。\n\nD級いうても、ほんまはもっと深い愛情を隠し持っとるんやない?\n\nまぁ、確かめる術はないんやけど、あいつがおるだけで、なんかええ雰囲気やわ。","did":"did:plc:2jvttj4nqlhk35bpmdfdlz4g","displayName":"オミホフヤ","handle":"tfvpqlolx208.src.org","indexedAt":"2024-01-20T05:59:17.710Z","labels":[],"viewer":{"blockedBy":false,"muted":false}},{"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:onhzmd566b2wmojg32mzuejd/ov7hilaqb4kmtsiio2wv6cogwiy23kymq3tcymzz6nmt7bipnfs3e7dis4l@jpeg","createdAt":"2024-02-07T11:48:41.322Z","did":"did:plc:onhzmd566b2wmojg32mzuejd","displayName":"たへむ","handle":"xtmin786.edlb.org","indexedAt":"2024-05-08T15:43:32.756Z","labels":[],"viewer":{"blockedBy":false,"muted":false}},{"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:nqvjtadr3qiwjp227gnk2va5/rkbsnsttno25bea3iy47delcq77vhlpkqto4ndenang66uxszqez53pxmkk@jpeg","createdAt":"2024-02-08T23:12:59.872Z","description":"ワイ、えひぬんいう名でな、この界隈で知らんもんおらんで。そやけど、えひぬっちゅう名前はもう誰か使っとるみたいやさかい、しゃあない、ワイはえひぬんでええわ。\n\nなんでも食らう雑食系や。インフラ寄りのプログラマーの生息地で、日々ええ感じに生きとる。漫画やアニメ、ゲームも大好物やで。そやから、ワイの話は幅広いんや。\n\n漫画の話したら、あれや、あの有名な\"マンガ王国\"の話。アニメは\"アニメランド\"で、ゲームは\"ゲームシティ\"いうとこでな、ワイはそこの住人や。あっちこっち飛び回って、色んなネタ拾うてくるんや。\n\nまあ、そんな感じで、ワイは気ままに生きとる。興味の赴くままに、どこまでもな。","did":"did:plc:nqvjtadr3qiwjp227gnk2va5","displayName":"えひぬ","handle":"qvrahl782.dnz.com","indexedAt":"2024-02-13T05:24:46.369Z","labels":[],"viewer":{"blockedBy":false,"muted":false}},{"associated":{"chat":{"allowIncoming":"following"}},"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:3pdb6cnqaehzyefs3rjxelve/6erkp4adjyhhvn6j6x4ezzsi7sjsclnibl36ma6qujns4bweim3kqf25tpj@jpeg","createdAt":"2024-02-08T07:40:59.524Z","description":"おっちゃん、音の魔術師やな。ソフトウェアの世界で魔法の杖を振り回して、VSTプラグインいう魔法の粉を生み出しまんねん。そやけど、その魔法の杖、実はコンピューターいう名の魔法使いの弟子が作ったもんやったりして。\n\nフォローしてくれたら、もっとええ音の秘密を教えたるわ。ほんで、アイコン見て笑ろてくれ。AIが作った言うても、似てへんのは、きっとAIの照れ隠しやろな。","did":"did:plc:3pdb6cnqaehzyefs3rjxelve","displayName":"ヨキケ","handle":"xhfghdbn215.nxj.social","indexedAt":"2024-02-15T13:33:54.361Z","labels":[],"viewer":{"blockedBy":false,"muted":false}}],"subject":{"associated":{"chat":{"allowIncoming":"following"}},"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:mhz3szj7pcjfpzv7pylcmlgx/rupnltt6iifthjqtfsvarqnia6csl2zcqoeq5f64rrqj3rbr3mrqcwvw5de@jpeg","createdAt":"2023-12-08T13:45:31.054Z","description":"ほな、D言語っちゅうプログラミング言語の話から始めよか。D言語は、なんやらええ感じの機能満載で、プログラマーのお助けマンや。C言語のええとこ取りしながら、さらにパワーアップした感じやな。C言語の親戚みたいなもんやけど、もっとオシャレでスマートなんやで。\n\nC言語は古株やけど、今でも根強い人気で、プログラミング界の重鎮や。堅実で頼りになる存在やな。C++はC言語の進化版で、もっと多彩な技を持っとる。アーティストみたいに、色んな表現ができるんや。\n\n組み込みの世界はまた違った魅力や。ハードウェアとソフトウェアのハーモニーやな。回路は音楽の楽譜みたいなもんや。電気が流れると、プログラムが踊り出すんやで。組み込みのプログラミングは、機械に魂を吹き込むような作業やね。\n\nD言語もC言語も、組み込みの世界で大活躍や。小さなデバイスから巨大なシステムまで、裏で支えとるんやで。この世界は、見えへんけど、めっちゃ重要な役者ばっかりやね。","did":"did:plc:mhz3szj7pcjfpzv7pylcmlgx","displayName":"まにまく","handle":"upqbv134.esi.org","indexedAt":"2024-02-25T17:09:35.946Z","labels":[],"viewer":{"blockedBy":false,"muted":false}}},"code":200,"mimeType":"application/json; charset=utf-8","reason":"OK"} +] diff --git a/tests/.ut-data_source/ba2b202c-5657-4b3d-97df-abaff951206c.json b/tests/.ut-data_source/ba2b202c-5657-4b3d-97df-abaff951206c.json new file mode 100644 index 0000000..6647f56 --- /dev/null +++ b/tests/.ut-data_source/ba2b202c-5657-4b3d-97df-abaff951206c.json @@ -0,0 +1,5 @@ +[ +{"body":{"blob":{"$type":"blob","mimeType":"image/png","ref":{"$link":"mjxrmzdw2ggq2rjuwxan6hm6w36r2nipkecdrvkpkskk5qoo4slwst3xm6f"},"size":13979}},"code":200,"mimeType":"application/json; charset=utf-8","reason":"OK"}, +{"body":{"cid":"dx2imfjwbdohkh4re7hh6dguhpczu5mhruw4ofkn6sdmhk4uimas37c5a74","uri":"at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/5ni6rkonpzlx2","value":{"$type":"app.bsky.feed.post","createdAt":"2024-04-03T14:49:37.630675Z","text":"こんにちは、青空さん!\n空の色、めっちゃ綺麗なブルーやな。\nまるで、海原に浮かぶ船みたいや。\nそやけど、その船はどこへ行くんやろ?\nもしかしたら、宝島を目指してるんかもしれへんな。"}},"code":200,"mimeType":"application/json; charset=utf-8","reason":"OK"}, +{"body":{"cid":"5mawkkcu45gctuc4lucf73ozbsg3p6gj6jr5cioz26egrvaw64bgk4kgef3","commit":{"cid":"i3qi23gljdsp4pfuomuigjip7usjxbsl46equ7ltak63u4sdbshojxe5gbi","rev":"ozzgqwuqgcet2"},"uri":"at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/gqg7sygic5mii","validationStatus":"valid"},"code":200,"mimeType":"application/json; charset=utf-8","reason":"OK"} +] diff --git a/tests/.ut-data_source/ce07a958-e5e3-4c3b-94f8-8b1ad427ab0b.json b/tests/.ut-data_source/ce07a958-e5e3-4c3b-94f8-8b1ad427ab0b.json new file mode 100644 index 0000000..c0b9810 --- /dev/null +++ b/tests/.ut-data_source/ce07a958-e5e3-4c3b-94f8-8b1ad427ab0b.json @@ -0,0 +1,4 @@ +[ +{"body":{"blob":{"$type":"blob","mimeType":"image/png","ref":{"$link":"mjxrmzdw2ggq2rjuwxan6hm6w36r2nipkecdrvkpkskk5qoo4slwst3xm6f"},"size":13979}},"code":200,"mimeType":"application/json; charset=utf-8","reason":"OK"}, +{"body":{"cid":"6e66lvcbu6sw5shqzjex2reh3eus4w4xvrh7q4vccocdrg4epr5ueintmlg","commit":{"cid":"6ulbkgc3b3vnkyfvv7n6mklwh62bdvqpcnozkcdw2gwihuhwtcoj5owbqmp","rev":"bzpnz6o3vimyj"},"uri":"at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.feed.post/jmc4tmxvfqx4s","validationStatus":"valid"},"code":200,"mimeType":"application/json; charset=utf-8","reason":"OK"} +] diff --git a/tests/.ut-data_source/d-logo.png b/tests/.ut-data_source/d-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..6a78666241b3b6aae5299cd92580c41008b51d4a GIT binary patch literal 77512 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 literal 0 HcmV?d00001 diff --git a/tests/.ut-data_source/d-man.png b/tests/.ut-data_source/d-man.png new file mode 100644 index 0000000000000000000000000000000000000000..ba701c6897b3bad4b2789bef56911d4e33637e15 GIT binary patch literal 13979 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 BM+g7_ literal 0 HcmV?d00001 diff --git a/tests/.ut-data_source/d0fa2d8d-9f15-48c8-a3e2-28fcc42ed881.json b/tests/.ut-data_source/d0fa2d8d-9f15-48c8-a3e2-28fcc42ed881.json new file mode 100644 index 0000000..90c8aa0 --- /dev/null +++ b/tests/.ut-data_source/d0fa2d8d-9f15-48c8-a3e2-28fcc42ed881.json @@ -0,0 +1,5 @@ +[ +{"body":{"blob":{"$type":"blob","mimeType":"image/png","ref":{"$link":"vwotix62an5fz5pcmd4bz52wm6exrsns5lq2pdiehp7fqoulexyo64btrmf"},"size":77512}},"code":200,"mimeType":"application/json; charset=utf-8","reason":"OK"}, +{"body":{"cid":"dx2imfjwbdohkh4re7hh6dguhpczu5mhruw4ofkn6sdmhk4uimas37c5a74","uri":"at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/5ni6rkonpzlx2","value":{"$type":"app.bsky.feed.post","createdAt":"2024-04-03T14:49:37.630675Z","text":"こんにちは、青空さん!\n空の色、めっちゃ綺麗なブルーやな。\nまるで、海原に浮かぶ船みたいや。\nそやけど、その船はどこへ行くんやろ?\nもしかしたら、宝島を目指してるんかもしれへんな。"}},"code":200,"mimeType":"application/json; charset=utf-8","reason":"OK"}, +{"body":{"cid":"uybaf7ict2bgb277cbhklcmhhrcr22q7btbnjzjjjji6crwzvivhkd3wv22","commit":{"cid":"ydhq7kjvt4yi6uaulnylah2pkeuksgdztrbgateijsiwiy6nku4umplxwc2","rev":"6dpnsvyov3k7s"},"uri":"at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/thtmhnohkv7ew","validationStatus":"valid"},"code":200,"mimeType":"application/json; charset=utf-8","reason":"OK"} +] diff --git a/tests/.ut-data_source/d8b6ad01-f85e-4d8e-bbd0-66347c7025c5.json b/tests/.ut-data_source/d8b6ad01-f85e-4d8e-bbd0-66347c7025c5.json new file mode 100644 index 0000000..599b7ca --- /dev/null +++ b/tests/.ut-data_source/d8b6ad01-f85e-4d8e-bbd0-66347c7025c5.json @@ -0,0 +1,3 @@ +[ +{"body":{"repostedBy":[{"associated":{"chat":{"allowIncoming":"following"}},"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:dlmtup5kdma26m7o5ok3b4sq/itaxfjkqssl2d2w57uckcxthiy3vbgotaischqerqhc66u2uni4kbdoeeyb@jpeg","createdAt":"2023-08-23T04:42:39.625Z","description":"なんやて?D言語いうたら、あのツンデレなプログラミング言語の話やろか?そやけど、ブルアカいうのはよう分からんわ。もしかして、アカウント名とか?まあ、ええわ。\n\nほな、GitHubの話やけど、あれはまるで宝の地図や。宝箱は言うても、ソースコードいう宝や。ほんで、その宝箱はな、アカウント名いう鍵で開くわけや。\"kubo39\"いう鍵で開く宝箱は、どんな宝が眠ってるんやろなぁ。宝探しはワクワクするなぁ。","did":"did:plc:dlmtup5kdma26m7o5ok3b4sq","displayName":"ノヤヲホハ","handle":"zyvql643.jmktt.org","indexedAt":"2024-02-09T05:35:11.845Z","labels":[],"viewer":{"blockedBy":false,"followedBy":"at://did:plc:dlmtup5kdma26m7o5ok3b4sq/app.bsky.graph.follow/vhkxbwd2vupaf","following":"at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.graph.follow/ajrxqtyyxsnde","muted":false}},{"associated":{"chat":{"allowIncoming":"following"}},"avatar":"https://cdn.bsky.app/img/avatar/plain/did:plc:mhz3szj7pcjfpzv7pylcmlgx/rupnltt6iifthjqtfsvarqnia6csl2zcqoeq5f64rrqj3rbr3mrqcwvw5de@jpeg","createdAt":"2023-12-08T13:45:31.054Z","description":"ほな、D言語っちゅうプログラミング言語の話から始めよか。D言語は、なんやらええ感じの機能満載で、プログラマーのお助けマンや。C言語のええとこ取りしながら、さらにパワーアップした感じやな。C言語の親戚みたいなもんやけど、もっとオシャレでスマートなんやで。\n\nC言語は古株やけど、今でも根強い人気で、プログラミング界の重鎮や。堅実で頼りになる存在やな。C++はC言語の進化版で、もっと多彩な技を持っとる。アーティストみたいに、色んな表現ができるんや。\n\n組み込みの世界はまた違った魅力や。ハードウェアとソフトウェアのハーモニーやな。回路は音楽の楽譜みたいなもんや。電気が流れると、プログラムが踊り出すんやで。組み込みのプログラミングは、機械に魂を吹き込むような作業やね。\n\nD言語もC言語も、組み込みの世界で大活躍や。小さなデバイスから巨大なシステムまで、裏で支えとるんやで。この世界は、見えへんけど、めっちゃ重要な役者ばっかりやね。","did":"did:plc:mhz3szj7pcjfpzv7pylcmlgx","displayName":"まにまく","handle":"upqbv134.esi.org","indexedAt":"2024-02-25T17:09:35.981Z","labels":[],"viewer":{"blockedBy":false,"muted":false}}],"uri":"at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.feed.post/2mbuau2ygfu4w"},"code":200,"mimeType":"application/json; charset=utf-8","reason":"OK"} +] diff --git a/tests/.ut-data_source/f2862017-1e04-4cf4-b445-4f95ab1962e3.json b/tests/.ut-data_source/f2862017-1e04-4cf4-b445-4f95ab1962e3.json new file mode 100644 index 0000000..3a48d16 --- /dev/null +++ b/tests/.ut-data_source/f2862017-1e04-4cf4-b445-4f95ab1962e3.json @@ -0,0 +1,5 @@ +[ +{"body":{"cid":"aykzb74s6m3tj3fvsn777i3zeuefoxkxhwwevxy5juuuaxe24rm26rs6wnf","uri":"at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/hyq6lbnl45len","value":{"$type":"app.bsky.feed.post","createdAt":"2024-04-30T10:39:29.924Z","langs":["ja"],"reply":{"parent":{"cid":"dx2imfjwbdohkh4re7hh6dguhpczu5mhruw4ofkn6sdmhk4uimas37c5a74","uri":"at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/5ni6rkonpzlx2"},"root":{"cid":"dx2imfjwbdohkh4re7hh6dguhpczu5mhruw4ofkn6sdmhk4uimas37c5a74","uri":"at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/5ni6rkonpzlx2"}},"text":"ほな、試しにもう一回言うてみ。"}},"code":200,"mimeType":"application/json; charset=utf-8","reason":"OK"}, +{"body":{"cid":"7pohoknso2cr66jgq5n4tz2xyc5pybeemqb2xbkmwtwigsfqr64gadanis2","commit":{"cid":"kw4ceyqvdrdtf5selvv7p6ys6sqwnqo7fetfwycmb6qgcunmtudb7vloqcs","rev":"6tyz5pgzw5rtp"},"uri":"at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.feed.like/5hnjyskptmfjq","validationStatus":"valid"},"code":200,"mimeType":"application/json; charset=utf-8","reason":"OK"}, +{"body":{"commit":{"cid":"2so46ajopsq4r5jduqcnxmnvnqs7pr2v6s4oaapkmhzm6tkqkxpvw3lusyy","rev":"bfk6qu7u2udtt"}},"code":200,"mimeType":"application/json; charset=utf-8","reason":"OK"} +] diff --git a/tests/.ut-data_source/f405c6cd-4fbb-4919-abac-f067dbd51d26.json b/tests/.ut-data_source/f405c6cd-4fbb-4919-abac-f067dbd51d26.json new file mode 100644 index 0000000..1d67a4c --- /dev/null +++ b/tests/.ut-data_source/f405c6cd-4fbb-4919-abac-f067dbd51d26.json @@ -0,0 +1,3 @@ +[ +{"body":{"commit":{"cid":"6rkq4fxju6r3kcvkbec6djs2usa3qcf5rjoeef6afse6ct7wk2i3konekez","rev":"h6eppdwcd2ger"}},"code":200,"mimeType":"application/json; charset=utf-8","reason":"OK"} +] diff --git a/tests/.ut-data_source/fe3b5003-a909-496f-a5de-97b1186f7bba.json b/tests/.ut-data_source/fe3b5003-a909-496f-a5de-97b1186f7bba.json new file mode 100644 index 0000000..d9bb6a6 --- /dev/null +++ b/tests/.ut-data_source/fe3b5003-a909-496f-a5de-97b1186f7bba.json @@ -0,0 +1 @@ +{"posts":[{"author":{"createdAt":"2024-04-01T15:04:14.605Z","did":"did:plc:vibjcyg6myvxdi4ezdrhcsuo","displayName":"","handle":"krzblhls379.vkn.io","labels":[],"viewer":{"blockedBy":false,"muted":false}},"cid":"aykzb74s6m3tj3fvsn777i3zeuefoxkxhwwevxy5juuuaxe24rm26rs6wnf","indexedAt":"2024-04-30T10:39:29.509Z","labels":[],"likeCount":0,"quoteCount":0,"record":{"$type":"app.bsky.feed.post","createdAt":"2024-04-30T10:39:29.924Z","langs":["ja"],"reply":{"parent":{"cid":"dx2imfjwbdohkh4re7hh6dguhpczu5mhruw4ofkn6sdmhk4uimas37c5a74","uri":"at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/5ni6rkonpzlx2"},"root":{"cid":"dx2imfjwbdohkh4re7hh6dguhpczu5mhruw4ofkn6sdmhk4uimas37c5a74","uri":"at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/5ni6rkonpzlx2"}},"text":"ほな、試しにもう一回言うてみ。"},"replyCount":0,"repostCount":0,"uri":"at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/hyq6lbnl45len","viewer":{"embeddingDisabled":false,"threadMuted":false}},{"author":{"createdAt":"2024-04-01T15:04:14.605Z","did":"did:plc:vibjcyg6myvxdi4ezdrhcsuo","displayName":"","handle":"krzblhls379.vkn.io","labels":[],"viewer":{"blockedBy":false,"muted":false}},"cid":"dx2imfjwbdohkh4re7hh6dguhpczu5mhruw4ofkn6sdmhk4uimas37c5a74","indexedAt":"2024-04-03T14:49:37.630Z","labels":[],"likeCount":0,"quoteCount":0,"record":{"$type":"app.bsky.feed.post","createdAt":"2024-04-03T14:49:37.630675Z","text":"こんにちは、青空さん!\n空の色、めっちゃ綺麗なブルーやな。\nまるで、海原に浮かぶ船みたいや。\nそやけど、その船はどこへ行くんやろ?\nもしかしたら、宝島を目指してるんかもしれへんな。"},"replyCount":1,"repostCount":0,"uri":"at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/5ni6rkonpzlx2","viewer":{"embeddingDisabled":false,"threadMuted":false}}]} \ No newline at end of file diff --git a/tests/.ut-data_source/ff5bc22f-4dff-480c-a8ee-9faf352a69c7.json b/tests/.ut-data_source/ff5bc22f-4dff-480c-a8ee-9faf352a69c7.json new file mode 100644 index 0000000..cf129a1 --- /dev/null +++ b/tests/.ut-data_source/ff5bc22f-4dff-480c-a8ee-9faf352a69c7.json @@ -0,0 +1,4 @@ +[ +{"body":{"cid":"aykzb74s6m3tj3fvsn777i3zeuefoxkxhwwevxy5juuuaxe24rm26rs6wnf","uri":"at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/hyq6lbnl45len","value":{"$type":"app.bsky.feed.post","createdAt":"2024-04-30T10:39:29.924Z","langs":["ja"],"reply":{"parent":{"cid":"dx2imfjwbdohkh4re7hh6dguhpczu5mhruw4ofkn6sdmhk4uimas37c5a74","uri":"at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/5ni6rkonpzlx2"},"root":{"cid":"dx2imfjwbdohkh4re7hh6dguhpczu5mhruw4ofkn6sdmhk4uimas37c5a74","uri":"at://did:plc:vibjcyg6myvxdi4ezdrhcsuo/app.bsky.feed.post/5ni6rkonpzlx2"}},"text":"ほな、試しにもう一回言うてみ。"}},"code":200,"mimeType":"application/json; charset=utf-8","reason":"OK"}, +{"body":{"cid":"clmhhuo6wphqp2pb4cbzcaxs7lzmovr5ldigdm5fzzfbzzkku5yll55j7y4","commit":{"cid":"iskuh6jmawqqgy5fp2jl7qlngdjfwagqmn3nmsjoevd4adwl5fwe5acjvju","rev":"l47pk445farbe"},"uri":"at://did:plc:mhz3szj7pcjfpzv7pylcmlgx/app.bsky.feed.post/3teavi2n5getc","validationStatus":"valid"},"code":200,"mimeType":"application/json; charset=utf-8","reason":"OK"} +] diff --git a/tests/build_examples.d b/tests/build_examples.d new file mode 100644 index 0000000..a25dcc5 --- /dev/null +++ b/tests/build_examples.d @@ -0,0 +1,26 @@ +module tests.build_examples; + +import std; + +int main() +{ + auto dubExe = environment.get("DUB", "dub"); + int result; + foreach (de; dirEntries("../examples", SpanMode.shallow)) + { + if (de.name.baseName.startsWith(".")) + continue; + auto pid = spawnProcess([dubExe, "build"], stdin, stdout, stderr, null, Config.none, de.name); + auto status = pid.wait(); + if (status == 0) + { + writeln(i"$(de.name.baseName) has SUCCEEDED."); + } + else + { + writeln(i"$(de.name.baseName) has FAILED."); + result = -1; + } + } + return result; +}