├── .github ├── CODEOWNERS ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml └── workflows │ ├── actionlint.yml │ ├── dprint.yml │ ├── firebase-hosting-merge.yml │ ├── firebase-hosting-pull-request.yml │ ├── hashira-cui--release.yml │ ├── hashira-cui--test.yml │ ├── hashira-web--test.yml │ ├── hashira-web-functions--test.yml │ ├── merge-bot-pr.yml │ └── pages.yml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yaml ├── LICENSE ├── Makefile ├── README.md ├── client └── client.go ├── cmd ├── hashira-cui │ ├── ctrl.go │ ├── cursor.go │ ├── delegater.go │ ├── editor.go │ ├── keybinding.go │ ├── keyedtask.go │ ├── main.go │ ├── model.go │ ├── pane.go │ ├── pubsub.go │ ├── view.go │ └── view_test.go ├── hashira-web-client │ └── main.go ├── hashira │ ├── commands.go │ └── main.go └── hashirad │ └── main.go ├── daemon ├── daemon.go ├── grpc.go └── grpc_test.go ├── database ├── boltdb.go ├── boltdb_test.go └── database.go ├── docs ├── Makefile ├── book.toml ├── book │ ├── .nojekyll │ ├── 404.html │ ├── FontAwesome │ │ ├── css │ │ │ └── font-awesome.css │ │ └── fonts │ │ │ ├── FontAwesome.ttf │ │ │ ├── fontawesome-webfont.eot │ │ │ ├── fontawesome-webfont.svg │ │ │ ├── fontawesome-webfont.ttf │ │ │ ├── fontawesome-webfont.woff │ │ │ └── fontawesome-webfont.woff2 │ ├── assets │ │ └── images │ │ │ └── uml │ │ │ └── 636bfd90b359509ea12ac7f7ca3fc622a6af8f45.svg │ ├── ayu-highlight.css │ ├── book.js │ ├── clipboard.min.js │ ├── communication │ │ ├── daemon2datastore.html │ │ ├── front2daemon.html │ │ └── overview.html │ ├── css │ │ ├── chrome.css │ │ ├── general.css │ │ ├── print.css │ │ └── variables.css │ ├── daemon │ │ ├── entity.html │ │ └── overview.html │ ├── elasticlunr.min.js │ ├── favicon.png │ ├── favicon.svg │ ├── fonts │ │ ├── OPEN-SANS-LICENSE.txt │ │ ├── SOURCE-CODE-PRO-LICENSE.txt │ │ ├── fonts.css │ │ ├── open-sans-v17-all-charsets-300.woff2 │ │ ├── open-sans-v17-all-charsets-300italic.woff2 │ │ ├── open-sans-v17-all-charsets-600.woff2 │ │ ├── open-sans-v17-all-charsets-600italic.woff2 │ │ ├── open-sans-v17-all-charsets-700.woff2 │ │ ├── open-sans-v17-all-charsets-700italic.woff2 │ │ ├── open-sans-v17-all-charsets-800.woff2 │ │ ├── open-sans-v17-all-charsets-800italic.woff2 │ │ ├── open-sans-v17-all-charsets-italic.woff2 │ │ ├── open-sans-v17-all-charsets-regular.woff2 │ │ └── source-code-pro-v11-all-charsets-500.woff2 │ ├── highlight.css │ ├── highlight.js │ ├── index.html │ ├── mark.min.js │ ├── mdbook-plantuml-img │ │ ├── 318b7e10f89ecde5b196f1d82d952bd7629d460b.svg │ │ ├── 35fa4c1648d5063cde4624ce5a91d67b790c002f.svg │ │ ├── b356288d94cf36c2c956a9219d6b5a174a0679ba.svg │ │ ├── b9d8843abdb2735cbc2ef31cdf6cc5be7035e15f.svg │ │ └── f3586f2bf96d39cc9734911942cc03d4b4137c1e.svg │ ├── overview.html │ ├── print.html │ ├── prototype.html │ ├── searcher.js │ ├── searchindex.js │ ├── searchindex.json │ ├── tables.html │ └── tomorrow-night.css └── src │ ├── README.md │ ├── SUMMARY.md │ ├── assets │ └── images │ │ └── uml │ │ └── 636bfd90b359509ea12ac7f7ca3fc622a6af8f45.svg │ ├── communication │ ├── daemon2datastore.md │ ├── front2daemon.md │ └── overview.md │ ├── daemon │ ├── entity.md │ └── overview.md │ ├── mdbook-plantuml-img │ ├── 318b7e10f89ecde5b196f1d82d952bd7629d460b.svg │ ├── 35fa4c1648d5063cde4624ce5a91d67b790c002f.svg │ ├── b356288d94cf36c2c956a9219d6b5a174a0679ba.svg │ ├── b9d8843abdb2735cbc2ef31cdf6cc5be7035e15f.svg │ └── f3586f2bf96d39cc9734911942cc03d4b4137c1e.svg │ ├── overview.md │ ├── prototype.md │ └── tables.md ├── dprint.json ├── go.mod ├── go.sum ├── hashira-web ├── .firebase │ └── hosting.cHVibGlj.cache ├── .firebaserc ├── .gitignore ├── .tool-versions ├── assets │ ├── android-icon-144x144.png │ ├── android-icon-192x192.png │ ├── android-icon-36x36.png │ ├── android-icon-48x48.png │ ├── android-icon-72x72.png │ ├── android-icon-96x96.png │ ├── apple-icon-114x114.png │ ├── apple-icon-120x120.png │ ├── apple-icon-144x144.png │ ├── apple-icon-152x152.png │ ├── apple-icon-180x180.png │ ├── apple-icon-57x57.png │ ├── apple-icon-60x60.png │ ├── apple-icon-72x72.png │ ├── apple-icon-76x76.png │ ├── apple-icon-precomposed.png │ ├── apple-icon.png │ ├── browserconfig.xml │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon-96x96.png │ ├── favicon.ico │ ├── manifest.json │ ├── ms-icon-144x144.png │ ├── ms-icon-150x150.png │ ├── ms-icon-310x310.png │ ├── ms-icon-70x70.png │ └── service-worker.js ├── firebase.json ├── firestore.indexes.json ├── firestore.rules ├── functions │ ├── .gcloudignore │ ├── .gitignore │ ├── Makefile │ ├── func.go │ ├── go.mod │ ├── go.sum │ └── hashira │ │ ├── accesstoken.go │ │ ├── accesstoken_test.go │ │ ├── add.go │ │ ├── download.go │ │ ├── download_test.go │ │ ├── entities.go │ │ ├── hashira.go │ │ ├── hashira_test.go │ │ ├── merge.go │ │ ├── merge_test.go │ │ ├── store.go │ │ ├── store │ │ ├── atstore.go │ │ └── tpstore.go │ │ ├── upload.go │ │ └── upload_test.go ├── index.html ├── package.json ├── src │ ├── AccessToken.tsx │ ├── App.tsx │ ├── Header.tsx │ ├── Router.tsx │ ├── Tags.tsx │ ├── TaskInput.tsx │ ├── TaskList.tsx │ ├── env.d.ts │ ├── firebase.ts │ ├── hooks.ts │ ├── index.tsx │ ├── revision.ts │ ├── styles.ts │ ├── task.ts │ └── types.ts ├── tsconfig.json ├── vite.config.ts └── yarn.lock ├── proto └── hashira.proto ├── service ├── hashira.pb.go └── hashira_grpc.pb.go ├── sync ├── client.go ├── package.go └── syncutil │ ├── client.go │ ├── download.go │ ├── testaccesstoken.go │ └── upload.go ├── tool ├── Makefile ├── protoc_gen_go_install.sh └── protoc_install.sh └── xdg ├── xdg.go └── xdg_test.go /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @pankona 2 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Pull request for issue #{issue number} 2 | 3 | ## Updates 4 | 5 | - 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: "/" 5 | schedule: 6 | interval: monthly 7 | open-pull-requests-limit: 10 8 | - package-ecosystem: npm 9 | directory: "/hashira-web" 10 | schedule: 11 | interval: monthly 12 | open-pull-requests-limit: 10 13 | groups: 14 | react-dependencies: # See #1352 for details 15 | patterns: 16 | - "*react*" 17 | ignore: 18 | - dependency-name: "@types/node" 19 | # https://github.com/vitejs/vite/issues/15714#issuecomment-1949236530 20 | # https://github.com/DefinitelyTyped/DefinitelyTyped/pull/68300 21 | update-types: 22 | ["version-update:semver-major", "version-update:semver-minor"] 23 | - dependency-name: "styled-components" 24 | # https://github.com/pankona/hashira/issues/1162#issuecomment-1950253823 25 | update-types: ["version-update:semver-major"] 26 | - dependency-name: "vite" 27 | # https://github.com/pankona/hashira/issues/1162#issuecomment-1950257529 28 | update-types: ["version-update:semver-major"] 29 | - package-ecosystem: gomod 30 | directory: "/hashira-web/functions" 31 | schedule: 32 | interval: monthly 33 | open-pull-requests-limit: 10 34 | - package-ecosystem: "github-actions" 35 | directory: "/" 36 | schedule: 37 | interval: "monthly" 38 | -------------------------------------------------------------------------------- /.github/workflows/actionlint.yml: -------------------------------------------------------------------------------- 1 | name: Lint GitHub Actions workflows 2 | on: 3 | push: 4 | branches: 5 | - master 6 | paths: 7 | - ".github/workflows/**" 8 | pull_request: 9 | paths: 10 | - ".github/workflows/**" 11 | 12 | jobs: 13 | actionlint: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Download actionlint 18 | id: get_actionlint 19 | run: bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) 20 | shell: bash 21 | - name: Check workflow files 22 | run: ${{ steps.get_actionlint.outputs.executable }} -color 23 | shell: bash 24 | -------------------------------------------------------------------------------- /.github/workflows/dprint.yml: -------------------------------------------------------------------------------- 1 | name: dprint 2 | on: 3 | push: 4 | branches: [master] 5 | pull_request: 6 | 7 | jobs: 8 | check: 9 | timeout-minutes: 15 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: dprint/check@v2.2 14 | with: 15 | dprint-version: "0.48.0" 16 | -------------------------------------------------------------------------------- /.github/workflows/firebase-hosting-merge.yml: -------------------------------------------------------------------------------- 1 | # This file was auto-generated by the Firebase CLI 2 | # https://github.com/firebase/firebase-tools 3 | 4 | name: Deploy to Firebase Hosting on merge 5 | 6 | on: 7 | push: 8 | branches: 9 | - master 10 | workflow_dispatch: 11 | 12 | defaults: 13 | run: 14 | working-directory: hashira-web 15 | 16 | jobs: 17 | build_and_deploy: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v4 21 | - uses: actions/setup-node@v4 22 | with: 23 | node-version-file: "hashira-web/.tool-versions" 24 | cache: "yarn" 25 | cache-dependency-path: "hashira-web/yarn.lock" 26 | - run: yarn install --frozen-lockfile 27 | - run: VITE_REVISION="Rev ${{ github.sha }} Build at $(date)" yarn build 28 | - uses: FirebaseExtended/action-hosting-deploy@v0 29 | with: 30 | entryPoint: hashira-web 31 | repoToken: "${{ secrets.GITHUB_TOKEN }}" 32 | firebaseServiceAccount: "${{ secrets.FIREBASE_SERVICE_ACCOUNT_HASHIRA_WEB }}" 33 | channelId: live 34 | projectId: hashira-web 35 | -------------------------------------------------------------------------------- /.github/workflows/firebase-hosting-pull-request.yml: -------------------------------------------------------------------------------- 1 | # This file was auto-generated by the Firebase CLI 2 | # https://github.com/firebase/firebase-tools 3 | 4 | name: Deploy to Firebase Hosting on PR 5 | 6 | on: 7 | pull_request: 8 | types: [opened, synchronize, reopened, labeled] 9 | paths: 10 | - ".github/workflows/firebase-hosting-pull-request.yml" 11 | - "hashira-web/**" 12 | 13 | defaults: 14 | run: 15 | working-directory: hashira-web 16 | 17 | jobs: 18 | build_and_preview: 19 | if: contains(github.event.pull_request.labels.*.name, 'ok to test') && (github.triggering_actor == 'pankona') 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | - uses: actions/setup-node@v4 24 | with: 25 | node-version-file: "hashira-web/.tool-versions" 26 | cache: "yarn" 27 | cache-dependency-path: "hashira-web/yarn.lock" 28 | - run: yarn install --frozen-lockfile 29 | - run: VITE_REVISION="Rev ${{ github.event.pull_request.head.sha }} Build at $(date)" yarn build 30 | - uses: FirebaseExtended/action-hosting-deploy@v0 31 | with: 32 | entryPoint: hashira-web 33 | repoToken: "${{ secrets.GITHUB_TOKEN }}" 34 | firebaseServiceAccount: "${{ secrets.FIREBASE_SERVICE_ACCOUNT_HASHIRA_WEB }}" 35 | projectId: hashira-web 36 | -------------------------------------------------------------------------------- /.github/workflows/hashira-cui--release.yml: -------------------------------------------------------------------------------- 1 | name: hashira-cui - release 2 | on: 3 | push: 4 | tags: 5 | - "v[0-9]+.[0-9]+.[0-9]+" 6 | jobs: 7 | release: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v4 12 | with: 13 | fetch-depth: 0 14 | - name: Setup Go 15 | uses: actions/setup-go@v5 16 | with: 17 | go-version-file: "go.mod" 18 | cache-dependency-path: "go.sum" 19 | - name: Run GoReleaser 20 | uses: goreleaser/goreleaser-action@v6 21 | with: 22 | distribution: goreleaser 23 | version: latest 24 | args: release --clean 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | -------------------------------------------------------------------------------- /.github/workflows/hashira-cui--test.yml: -------------------------------------------------------------------------------- 1 | name: hashira-cui - CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | paths: 7 | - "**" 8 | - "!hashira-web/**" 9 | - "!.github/workflows/**" 10 | - ".github/workflows/hashira-cui--test.yml" 11 | pull_request: 12 | paths: 13 | - "**" 14 | - "!hashira-web/**" 15 | - "!.github/workflows/**" 16 | - ".github/workflows/hashira-cui--test.yml" 17 | jobs: 18 | build: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v4 22 | - uses: actions/setup-go@v5 23 | with: 24 | go-version-file: "go.mod" 25 | cache-dependency-path: "go.sum" 26 | - run: make 27 | test: 28 | runs-on: ubuntu-latest 29 | steps: 30 | - uses: actions/checkout@v4 31 | - uses: actions/setup-go@v5 32 | with: 33 | go-version-file: "go.mod" 34 | cache-dependency-path: "go.sum" 35 | - run: make test 36 | lint: 37 | runs-on: ubuntu-latest 38 | steps: 39 | - uses: actions/checkout@v4 40 | - uses: actions/setup-go@v5 41 | with: 42 | go-version-file: "go.mod" 43 | cache-dependency-path: "go.sum" 44 | - uses: golangci/golangci-lint-action@1481404843c368bc19ca9406f87d6e0fc97bdcfd # v7.0.0 45 | with: 46 | version: v2.0.2 47 | -------------------------------------------------------------------------------- /.github/workflows/hashira-web--test.yml: -------------------------------------------------------------------------------- 1 | name: hashira-web 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | paths: 7 | - ".github/workflows/hashira-web--test.yml" 8 | - "hashira-web/**" 9 | - "!hashira-web/functions/**" 10 | pull_request: 11 | paths: 12 | - ".github/workflows/hashira-web--test.yml" 13 | - "hashira-web/**" 14 | - "!hashira-web/functions/**" 15 | 16 | defaults: 17 | run: 18 | working-directory: ./hashira-web 19 | 20 | jobs: 21 | build: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v4 25 | - uses: actions/setup-node@v4 26 | with: 27 | node-version-file: "hashira-web/.tool-versions" 28 | cache: "yarn" 29 | cache-dependency-path: "hashira-web/yarn.lock" 30 | - run: yarn install --frozen-lockfile 31 | - run: yarn build 32 | typecheck: 33 | runs-on: ubuntu-latest 34 | steps: 35 | - uses: actions/checkout@v4 36 | - uses: actions/setup-node@v4 37 | with: 38 | node-version-file: "hashira-web/.tool-versions" 39 | cache: "yarn" 40 | cache-dependency-path: "hashira-web/yarn.lock" 41 | - run: yarn install --frozen-lockfile 42 | - run: yarn typecheck 43 | -------------------------------------------------------------------------------- /.github/workflows/hashira-web-functions--test.yml: -------------------------------------------------------------------------------- 1 | name: hashira-web-functions 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | paths: 7 | - ".github/workflows/hashira-web-functions--test.yml" 8 | - "hashira-web/functions/**" 9 | pull_request: 10 | paths: 11 | - ".github/workflows/hashira-web-functions--test.yml" 12 | - "hashira-web/functions/**" 13 | 14 | defaults: 15 | run: 16 | working-directory: ./hashira-web/functions 17 | 18 | jobs: 19 | functions-test: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | - uses: actions/setup-go@v5 24 | with: 25 | go-version-file: "hashira-web/functions/go.mod" 26 | cache-dependency-path: "hashira-web/functions/go.sum" 27 | - run: go test ./... 28 | -------------------------------------------------------------------------------- /.github/workflows/merge-bot-pr.yml: -------------------------------------------------------------------------------- 1 | name: Merge bot PR after CI 2 | on: pull_request 3 | 4 | permissions: 5 | contents: write 6 | pull-requests: write 7 | 8 | jobs: 9 | dependabot: 10 | timeout-minutes: 30 11 | runs-on: ubuntu-latest 12 | if: ${{ github.actor == 'dependabot[bot]' }} 13 | steps: 14 | - name: Dependabot metadata 15 | id: metadata 16 | uses: dependabot/fetch-metadata@v2 17 | - name: Wait other jobs 18 | # if: ${{steps.metadata.outputs.update-type != 'version-update:semver-major'}} 19 | uses: kachick/wait-other-jobs@v3 20 | timeout-minutes: 15 21 | - name: Approve and merge 22 | # if: ${{steps.metadata.outputs.update-type != 'version-update:semver-major'}} 23 | run: gh pr review --approve "$PR_URL" && gh pr merge --auto --squash "$PR_URL" 24 | env: 25 | PR_URL: ${{github.event.pull_request.html_url}} 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | -------------------------------------------------------------------------------- /.github/workflows/pages.yml: -------------------------------------------------------------------------------- 1 | name: Deploy documents 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - "docs/book" 9 | - ".github/workflows/pages.yml" 10 | workflow_dispatch: 11 | 12 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 13 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 14 | concurrency: 15 | group: "pages" 16 | cancel-in-progress: false 17 | 18 | jobs: 19 | # Currently nothing to build. Just uploading 20 | build: 21 | runs-on: ubuntu-latest 22 | env: 23 | HUGO_VERSION: 0.113.0 24 | steps: 25 | - name: Checkout 26 | uses: actions/checkout@v4 27 | - name: Upload artifact 28 | uses: actions/upload-pages-artifact@v3 29 | with: 30 | path: ./docs/book 31 | deploy: 32 | permissions: 33 | pages: write 34 | id-token: write 35 | environment: 36 | name: github-pages 37 | url: ${{ steps.deployment.outputs.page_url }} 38 | runs-on: ubuntu-latest 39 | needs: [build] 40 | steps: 41 | - name: Deploy to GitHub Pages 42 | id: deployment 43 | uses: actions/deploy-pages@v4 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | docsrc/node_modules 2 | docsrc/assets 3 | docsrc/_book 4 | 5 | cmd/hashirad/hashirad 6 | cmd/hashira/hashira 7 | cmd/hashira-cui/hashira-cui 8 | cmd/hashira-web-client/hashira-web-client 9 | cmd/hashira-api/hashira-api 10 | cmd/hashira-cui/github-release.sh 11 | cmd/hashira-cui/pkg 12 | .idea/ 13 | vendor/ 14 | dist/ 15 | 16 | datastore-export-* 17 | 18 | .vscode/ 19 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | exclusions: 4 | generated: lax 5 | presets: 6 | - comments 7 | - common-false-positives 8 | - legacy 9 | - std-error-handling 10 | paths: 11 | - service 12 | - hashira-web/ 13 | - third_party$ 14 | - builtin$ 15 | - examples$ 16 | formatters: 17 | exclusions: 18 | generated: lax 19 | paths: 20 | - third_party$ 21 | - builtin$ 22 | - examples$ 23 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # This is an example .goreleaser.yml file with some sensible defaults. 2 | # Make sure to check the documentation at https://goreleaser.com 3 | before: 4 | hooks: 5 | # You may remove this if you don't use go modules. 6 | - go mod tidy 7 | # you may remove this if you don't need go generate 8 | - go generate ./... 9 | builds: 10 | - id: hashira-cui 11 | main: ./cmd/hashira-cui 12 | binary: hashira-cui 13 | ldflags: 14 | - -s -w 15 | - -X main.Version={{.Version}} 16 | - -X main.Revision={{.ShortCommit}} 17 | env: 18 | - CGO_ENABLED=0 19 | goarch: 20 | - amd64 21 | goos: 22 | - linux 23 | - darwin 24 | - id: hashira-web-client 25 | main: ./cmd/hashira-web-client 26 | binary: hashira-web-client 27 | ldflags: 28 | - -s -w 29 | - -X main.Version={{.Version}} 30 | - -X main.Revision={{.ShortCommit}} 31 | env: 32 | - CGO_ENABLED=0 33 | goarch: 34 | - amd64 35 | goos: 36 | - linux 37 | - darwin 38 | 39 | archives: 40 | - format: tar.gz 41 | # this name template makes the OS and Arch compatible with the results of uname. 42 | name_template: >- 43 | {{ .ProjectName }}_ 44 | {{- title .Os }}_ 45 | {{- if eq .Arch "amd64" }}x86_64 46 | {{- else if eq .Arch "386" }}i386 47 | {{- else }}{{ .Arch }}{{ end }} 48 | {{- if .Arm }}v{{ .Arm }}{{ end }} 49 | # use zip for windows archives 50 | format_overrides: 51 | - goos: windows 52 | format: zip 53 | checksum: 54 | name_template: "checksums.txt" 55 | snapshot: 56 | name_template: "{{ incpatch .Version }}-next" 57 | changelog: 58 | sort: asc 59 | filters: 60 | exclude: 61 | - "^docs:" 62 | - "^test:" 63 | # The lines beneath this are called `modelines`. See `:help modeline` 64 | # Feel free to remove those if you don't want/use them. 65 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 66 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj 67 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Yosuke Akatsuka 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | MAKEFLAGS += --no-builtin-rules 3 | 4 | PROTO_DIR = proto 5 | PB_GO_DIR = service 6 | 7 | PROTOS = $(shell find $(PROTO_DIR) -type f -exec basename {} \; | grep proto$$) 8 | PB_GOS = $(PROTOS:%.proto=$(PB_GO_DIR)/%.pb.go) 9 | 10 | BUILD_CMD ?= go build 11 | UPDATE_DEPENDENCIES_CMD ?= go get -u && go mod tidy 12 | 13 | build: 14 | cd $(CURDIR)/cmd/hashira && $(BUILD_CMD) 15 | cd $(CURDIR)/cmd/hashirad && $(BUILD_CMD) 16 | cd $(CURDIR)/cmd/hashira-cui && $(BUILD_CMD) 17 | cd $(CURDIR)/cmd/hashira-web-client && $(BUILD_CMD) 18 | 19 | install: 20 | @BUILD_CMD="go install" make build 21 | 22 | all: genproto lint test 23 | make build 24 | 25 | genproto: $(PB_GOS) 26 | 27 | $(PB_GO_DIR)/%.pb.go: $(PROTO_DIR)/%.proto 28 | mkdir -p $(dir $@) 29 | protoc \ 30 | -I $(PROTO_DIR) \ 31 | --go_out=. \ 32 | --go_opt=module=github.com/pankona/hashira \ 33 | --go-grpc_out=. \ 34 | --go-grpc_opt=module=github.com/pankona/hashira \ 35 | ./$< 36 | 37 | update-dependencies: 38 | cd $(CURDIR)/cmd/hashira && $(UPDATE_DEPENDENCIES_CMD) 39 | cd $(CURDIR)/cmd/hashirad && $(UPDATE_DEPENDENCIES_CMD) 40 | cd $(CURDIR)/cmd/hashira-cui && $(UPDATE_DEPENDENCIES_CMD) 41 | cd $(CURDIR)/cmd/hashira-web-client && $(UPDATE_DEPENDENCIES_CMD) 42 | 43 | lint: 44 | dprint check 45 | golangci-lint run ./... 46 | 47 | test: 48 | go test -race ./... 49 | 50 | clean: 51 | rm -rf $(PB_GO_DIR) 52 | 53 | release: 54 | goreleaser --clean 55 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hashira 2 | 3 | Application to manage **today's** tasks. Establish a Hashira for the day.\ 4 | design documents are available at [here](https://pankona.github.io/hashira/) 5 | 6 | hashira on web is available here (alpha version) https://hashira-web.web.app 7 | 8 | ## Concepts 9 | 10 | - Makes tasks clear for the day and concentrate to eliminate them. 11 | - Records time consuming for each tasks to reveal differences between estimations and results. 12 | - May help increasing accuracy of our work-load estimation. 13 | - May help us to notice our waste of time. 14 | 15 | ## Features 16 | 17 | ### Manage today's tasks 18 | 19 | - "Backlogs" to add any miscellaneous tasks. 20 | - Move tasks for today to "ToDo". 21 | - Starts a task, then the task moves to "Doing". 22 | - Finishes the task, then the task moves to "Done". 23 | - If the "Doing" task is interrupted by another unexpected task, then the "Doing" task moves back to "ToDo" and new task is placed on "Doing". 24 | - At the end of the day, **(Not implemented yet)** 25 | - "Done" field is archived automatically and new one is created for new day. 26 | - "Doing" task moves back to "ToDo" automatically to intent to start again next day. 27 | 28 | ### Calculate consumed times **(Not implemented yet)** 29 | 30 | - Calculates how many times is consumed for each "Doing" task. 31 | - Consumed time is measured only for one task, which is placed on top of "Doing". 32 | - Show them in graph. 33 | 34 | ## Installation of `hashira-cui` 35 | 36 | - At this moment, only `hashira-cui` is available among hashira family. 37 | - For Android, iOS, Web, Desktop app, will be available someday... 38 | - Executable `hashira-cui` is available on [release page](https://github.com/pankona/hashira/releases) (recommended) 39 | - `hashira-cui` is `go get`able. Try following command to install `hashira-cui` via `go get`. 40 | - Note that `hashira-cui` installed via `go get` may be broken because of unsettled dependencies. 41 | - Using release page is recommended. 42 | 43 | ```bash 44 | $ go get github.com/pankona/hashira/cmd/hashira-cui 45 | ``` 46 | 47 | ## Available Keybindings of hashira-cui 48 | 49 | - Ordinal use 50 | 51 | | Key | Action | Remarks | 52 | | ----- | ----------------------------------------------------------------- | ------------------------------------------------- | 53 | | Enter | Show input window for register a new task | | 54 | | e | Show input window for editing focused task | | 55 | | Space | Select focused task | | 56 | | j / k | Up/Down cursor
(change priority if a task is selected) | | 57 | | h / l | Change focused pane
(change task's pane if a task is selected) | | 58 | | i / I | Move focused task to left/right pane | | 59 | | x | Move focused task to Done | If focused task is already on Done, it is deleted | 60 | 61 | - While input 62 | 63 | | Key | Action | Remarks | 64 | | ----------- | ----------------------------------------- | ---------------------------------- | 65 | | Ctrl- b / f | Move cursor backward/forward | Same as using arrow left/right key | 66 | | Ctrl- a / e | Move cursor at start/end of line | Same as Home/End key | 67 | | Ctrl- h | Remove a character on previous of cursor | Same as Backspace | 68 | | Ctrl- d | Remove a character on cursor | Same as Delete | 69 | | Esc | Discard any change and close input window | | 70 | 71 | ## Notes 72 | 73 | - hashira generates its datastore under `$XDG_DATA_HOME/hashira` 74 | - Remove them for re-initializing hashira or leaving from using hashira... 75 | 76 | ## LICENSE 77 | 78 | MIT 79 | 80 | ## Author 81 | 82 | Yosuke Akatsuka (@pankona) 83 | 84 | - [Twitter](https://twitter.com/pankona) 85 | - [GitHub](https://github.com/pankona) 86 | - [Qiita](https://qiita.com/pankona) 87 | -------------------------------------------------------------------------------- /client/client.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/pankona/hashira/service" 9 | "google.golang.org/grpc" 10 | "google.golang.org/grpc/credentials/insecure" 11 | ) 12 | 13 | // Client is a hashira client structure 14 | type Client struct { 15 | Address string 16 | } 17 | 18 | func (c *Client) withClient(f func(service.HashiraClient) error) error { 19 | conn, err := grpc.NewClient(c.Address, grpc.WithTransportCredentials(insecure.NewCredentials())) 20 | if err != nil { 21 | return errors.New("failed to Dial: " + err.Error()) 22 | } 23 | defer func() { 24 | e := conn.Close() 25 | if e != nil { 26 | fmt.Printf("failed to close connection: %s\n", e.Error()) 27 | } 28 | }() 29 | 30 | return f(service.NewHashiraClient(conn)) 31 | } 32 | 33 | // Create creates new task 34 | func (c *Client) Create(ctx context.Context, task *service.Task) error { 35 | return c.withClient( 36 | func(hc service.HashiraClient) error { 37 | com := &service.CommandCreate{ 38 | Task: task, 39 | } 40 | result, err := hc.Create(ctx, com) 41 | if err != nil { 42 | return errors.New("Create failed: " + err.Error()) 43 | } 44 | result.ProtoMessage() 45 | return nil 46 | }) 47 | } 48 | 49 | // Update updates an existing task 50 | func (c *Client) Update(ctx context.Context, task *service.Task) error { 51 | return c.withClient( 52 | func(hc service.HashiraClient) error { 53 | com := &service.CommandUpdate{ 54 | Task: task, 55 | } 56 | 57 | _, err := hc.Update(context.Background(), com) 58 | if err != nil { 59 | return fmt.Errorf("update a task failed: %s", err.Error()) 60 | } 61 | 62 | return nil 63 | }) 64 | } 65 | 66 | // Delete marks specified task as deleted 67 | func (c *Client) Delete(ctx context.Context, id string) error { 68 | return c.withClient( 69 | func(hc service.HashiraClient) error { 70 | com := &service.CommandDelete{ 71 | Id: id, 72 | } 73 | result, err := hc.Delete(ctx, com) 74 | if err != nil { 75 | return errors.New("Delete failed: " + err.Error()) 76 | } 77 | result.ProtoMessage() 78 | return nil 79 | }) 80 | } 81 | 82 | // Delete marks specified task as deleted 83 | func (c *Client) PhysicalDelete(ctx context.Context, id string) error { 84 | return c.withClient( 85 | func(hc service.HashiraClient) error { 86 | com := &service.CommandPhysicalDelete{ 87 | Id: id, 88 | } 89 | result, err := hc.PhysicalDelete(ctx, com) 90 | if err != nil { 91 | return errors.New("PhysicalDelete failed: " + err.Error()) 92 | } 93 | result.ProtoMessage() 94 | return nil 95 | }) 96 | } 97 | 98 | // Retrieve retrieves all tasks 99 | func (c *Client) Retrieve(ctx context.Context) (map[string]*service.Task, error) { 100 | var tasks map[string]*service.Task 101 | 102 | err := c.withClient( 103 | func(hc service.HashiraClient) error { 104 | com := &service.CommandRetrieve{ExcludeDeleted: true} 105 | result, err := hc.Retrieve(ctx, com) 106 | if err != nil { 107 | return errors.New("Retrieve failed: " + err.Error()) 108 | } 109 | tasks = result.Tasks 110 | return nil 111 | }) 112 | 113 | return tasks, err 114 | } 115 | 116 | // Retrieve retrieves all tasks 117 | func (c *Client) RetrieveAll(ctx context.Context) (map[string]*service.Task, error) { 118 | var tasks map[string]*service.Task 119 | 120 | err := c.withClient( 121 | func(hc service.HashiraClient) error { 122 | com := &service.CommandRetrieve{ExcludeDeleted: false} 123 | result, err := hc.Retrieve(ctx, com) 124 | if err != nil { 125 | return errors.New("Retrieve failed: " + err.Error()) 126 | } 127 | tasks = result.Tasks 128 | return nil 129 | }) 130 | 131 | return tasks, err 132 | } 133 | 134 | // UpdatePriority updates tasks' priorities 135 | func (c *Client) UpdatePriority(ctx context.Context, priorities map[string]*service.Priority) (map[string]*service.Priority, error) { 136 | var ret map[string]*service.Priority 137 | 138 | err := c.withClient(func(hc service.HashiraClient) error { 139 | com := &service.CommandUpdatePriority{ 140 | Priorities: priorities, 141 | } 142 | p, err := hc.UpdatePriority(ctx, com) 143 | if err != nil { 144 | return errors.New("UpdatePriority failed: " + err.Error()) 145 | } 146 | ret = p.Priorities 147 | return nil 148 | }) 149 | 150 | return ret, err 151 | } 152 | 153 | // RetrievePriority retrieves tasks' priorities 154 | func (c *Client) RetrievePriority(ctx context.Context) (map[string]*service.Priority, error) { 155 | var ret map[string]*service.Priority 156 | 157 | err := c.withClient(func(hc service.HashiraClient) error { 158 | com := &service.CommandRetrievePriority{} 159 | p, err := hc.RetrievePriority(ctx, com) 160 | if err != nil { 161 | return errors.New("RetrievePriority failed: " + err.Error()) 162 | } 163 | ret = p.Priorities 164 | return nil 165 | }) 166 | 167 | return ret, err 168 | } 169 | -------------------------------------------------------------------------------- /cmd/hashira-cui/ctrl.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | 8 | "github.com/pankona/hashira/service" 9 | ) 10 | 11 | // Ctrl represents controller of hashira-cui's mvc 12 | type Ctrl struct { 13 | m *Model 14 | pub Publisher 15 | queue chan delegateCommand 16 | errChan chan error 17 | } 18 | 19 | type delegateCommand struct { 20 | event delegateEvent 21 | data []interface{} 22 | } 23 | 24 | // Initialize initializes controller 25 | func (c *Ctrl) Initialize() { 26 | c.queue = make(chan delegateCommand, 128) 27 | 28 | ctx := context.Background() 29 | 30 | go func() { 31 | // TODO: support cancel using context 32 | err := <-c.errChan 33 | log.Printf("[ERROR] %v", err) 34 | }() 35 | 36 | go func() { 37 | c.eventLoop(ctx) 38 | }() 39 | } 40 | 41 | func (c *Ctrl) eventLoop(ctx context.Context) { 42 | for { 43 | // TODO: support cancel using context 44 | com := <-c.queue 45 | 46 | err := c.eventDispatch(ctx, com.event, com.data) 47 | if err != nil { 48 | c.errChan <- err 49 | } 50 | 51 | err = c.Update(context.Background()) 52 | if err != nil { 53 | c.errChan <- err 54 | } 55 | } 56 | } 57 | 58 | func (c *Ctrl) eventDispatch(ctx context.Context, event delegateEvent, data []interface{}) error { 59 | var err error 60 | 61 | switch event { 62 | case AddTask: 63 | task := (*service.Task)(data[0].(*KeyedTask)) 64 | task.IsDirty = true 65 | 66 | err = c.m.Create(ctx, task) 67 | case UpdateTask: 68 | task := (*service.Task)(data[0].(*KeyedTask)) 69 | task.IsDirty = true 70 | 71 | err = c.m.Update(ctx, task) 72 | case DeleteTask: 73 | task := (*service.Task)(data[0].(*KeyedTask)) 74 | task.IsDirty = true 75 | 76 | err = c.m.Delete(ctx, task.Id) 77 | case UpdatePriority: 78 | priority := data[0].(map[string]*service.Priority) 79 | for k := range priority { 80 | priority[k].IsDirty = true 81 | } 82 | 83 | _, err = c.m.UpdatePriority(ctx, priority) 84 | case UpdateBulk: 85 | task := (*service.Task)(data[0].(*KeyedTask)) 86 | task.IsDirty = true 87 | priority := data[1].(map[string]*service.Priority) 88 | for k := range priority { 89 | priority[k].IsDirty = true 90 | } 91 | 92 | err = c.m.Update(ctx, task) 93 | if err != nil { 94 | c.errChan <- err 95 | } 96 | _, err = c.m.UpdatePriority(ctx, priority) 97 | default: 98 | panic(fmt.Sprintf("unknown delegateCommand: %v", event)) 99 | } 100 | 101 | return err 102 | } 103 | 104 | // SetPublisher sets controller's Publisher 105 | func (c *Ctrl) SetPublisher(p Publisher) { 106 | c.pub = p 107 | } 108 | 109 | // Delegate is called from view to delegate functionality that are not 110 | // covered by view. 111 | func (c *Ctrl) Delegate(event delegateEvent, data ...interface{}) (err error) { 112 | c.queue <- delegateCommand{event: event, data: data} 113 | return nil 114 | } 115 | 116 | // Update retrieve latest information from model and 117 | // reflect them to view via PubSub. 118 | func (c *Ctrl) Update(ctx context.Context) error { 119 | // TODO: 120 | // List and RetrievePriority should archive in 121 | // one communication for better performance 122 | tasks, err := c.m.List(ctx) 123 | if err != nil { 124 | return err 125 | } 126 | 127 | priorities, err := c.m.RetrievePriority(ctx) 128 | if err != nil { 129 | return err 130 | } 131 | 132 | ktasks := make(map[string]*KeyedTask) 133 | for k, v := range tasks { 134 | ktasks[k] = (*KeyedTask)(v) 135 | } 136 | 137 | c.pub.Publish("update", ktasks, priorities) 138 | return nil 139 | } 140 | -------------------------------------------------------------------------------- /cmd/hashira-cui/cursor.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | type cursor struct { 4 | index int 5 | focusedPane *Pane 6 | } 7 | 8 | func (c *cursor) sanitize(maxLen int) *cursor { 9 | ret := c 10 | 11 | if c.index < 0 { 12 | ret.index = 0 13 | } else if c.index > maxLen { 14 | ret.index = maxLen 15 | } 16 | 17 | if c.index > c.focusedPane.tasks.Len()-1 { 18 | ret.index = c.focusedPane.tasks.Len() - 1 19 | } 20 | 21 | return ret 22 | } 23 | -------------------------------------------------------------------------------- /cmd/hashira-cui/delegater.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // Delegater in an interface to call delegate function, 4 | // to cover functionality that is not covered by view 5 | type Delegater interface { 6 | Delegate(event delegateEvent, data ...interface{}) error 7 | } 8 | 9 | type delegateEvent int 10 | 11 | const ( 12 | // AddTask is a event for adding a new task 13 | AddTask delegateEvent = iota 14 | // UpdateTask is a event for updating a task 15 | UpdateTask 16 | // DeleteTask is a event for deleting a task 17 | DeleteTask 18 | // UpdatePriority is a event for updating task priority 19 | UpdatePriority 20 | // UpdateBulk updates specified task and priority 21 | UpdateBulk 22 | ) 23 | -------------------------------------------------------------------------------- /cmd/hashira-cui/editor.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | 6 | runewidth "github.com/mattn/go-runewidth" 7 | "github.com/pankona/gocui" 8 | ) 9 | 10 | type hashiraEditor struct{} 11 | 12 | func (e *hashiraEditor) Edit(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) { 13 | switch key { 14 | case gocui.KeyCtrlF: 15 | v.MoveCursor(+1, 0, false) 16 | case gocui.KeyCtrlB: 17 | v.MoveCursor(-1, 0, false) 18 | case gocui.KeyHome, gocui.KeyCtrlA: 19 | // move to start of line 20 | _, oy := v.Origin() 21 | err := v.SetOrigin(0, oy) 22 | if err != nil { 23 | log.Printf("failed to set origin: %v", err) 24 | return 25 | } 26 | maxX, _ := v.Size() 27 | v.MoveCursor(-maxX, 0, false) 28 | case gocui.KeyEnd, gocui.KeyCtrlE: 29 | // move to end of line 30 | _, oy := v.Origin() 31 | err := v.SetOrigin(0, oy) 32 | if err != nil { 33 | log.Printf("failed to set origin: %v", err) 34 | return 35 | } 36 | bufLen := runewidth.StringWidth(v.Buffer()) 37 | cx, _ := v.Cursor() 38 | v.MoveCursor(+bufLen-cx, 0, false) 39 | case gocui.KeyCtrlD: 40 | v.EditDelete(false) 41 | default: 42 | gocui.DefaultEditor.Edit(v, key, ch, mod) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /cmd/hashira-cui/keybinding.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/pankona/gocui" 5 | "github.com/pkg/errors" 6 | ) 7 | 8 | const inputViewName = "input" 9 | 10 | type errContainer struct { 11 | err error 12 | } 13 | 14 | func (e *errContainer) setError(err error) { 15 | if err != nil { 16 | e.err = errors.Wrapf(e.err, "failed to set keybinding: %v", err.Error()) 17 | } 18 | } 19 | 20 | // ConfigureKeyBindings configures keybindings for ordinal use 21 | func (v *View) ConfigureKeyBindings(g *gocui.Gui) error { 22 | ec := &errContainer{} 23 | for _, p := range v.panes { 24 | ec.setError(g.SetKeybinding(p.name, 'h', gocui.ModNone, v.KeyLeft)) 25 | ec.setError(g.SetKeybinding(p.name, gocui.KeyArrowLeft, gocui.ModNone, v.KeyLeft)) 26 | ec.setError(g.SetKeybinding(p.name, 'l', gocui.ModNone, v.KeyRight)) 27 | ec.setError(g.SetKeybinding(p.name, gocui.KeyArrowRight, gocui.ModNone, v.KeyRight)) 28 | 29 | ec.setError(g.SetKeybinding(p.name, 'k', gocui.ModNone, v.Up)) 30 | ec.setError(g.SetKeybinding(p.name, gocui.KeyArrowUp, gocui.ModNone, v.Up)) 31 | ec.setError(g.SetKeybinding(p.name, 'j', gocui.ModNone, v.Down)) 32 | ec.setError(g.SetKeybinding(p.name, gocui.KeyArrowDown, gocui.ModNone, v.Down)) 33 | 34 | ec.setError(g.SetKeybinding(p.name, 'x', gocui.ModNone, v.KeyDelete)) 35 | ec.setError(g.SetKeybinding(p.name, gocui.KeyDelete, gocui.ModNone, v.KeyDelete)) 36 | ec.setError(g.SetKeybinding(p.name, 'i', gocui.ModNone, v.KeyI)) 37 | ec.setError(g.SetKeybinding(p.name, 'I', gocui.ModNone, v.KeyShiftI)) 38 | ec.setError(g.SetKeybinding(p.name, 'e', gocui.ModNone, v.KeyE)) 39 | ec.setError(g.SetKeybinding(p.name, gocui.KeySpace, gocui.ModNone, v.KeySpace)) 40 | } 41 | ec.setError(g.SetKeybinding("", gocui.KeyEnter, gocui.ModNone, v.KeyEnter)) 42 | ec.setError(g.SetKeybinding("", gocui.KeyEsc, gocui.ModNone, v.KeyEsc)) 43 | ec.setError(g.SetKeybinding("", gocui.KeyCtrlC, gocui.ModNone, v.Quit)) 44 | return ec.err 45 | } 46 | 47 | // KeyLeft reacts for "h" and left arrow 48 | func (v *View) KeyLeft(g *gocui.Gui, _ *gocui.View) error { 49 | return v.Left() 50 | } 51 | 52 | // KeyRight reacts for "l" and right arrow 53 | func (v *View) KeyRight(g *gocui.Gui, _ *gocui.View) error { 54 | return v.Right() 55 | } 56 | 57 | // KeyDelete reacts for "delete" and "x" 58 | func (v *View) KeyDelete(*gocui.Gui, *gocui.View) error { 59 | t := v.FocusedTask() 60 | return v.markTaskAsDone(t) 61 | } 62 | 63 | // KeyI reacts for "i" 64 | func (v *View) KeyI(g *gocui.Gui, gv *gocui.View) error { 65 | t := v.FocusedTask() 66 | if t == nil { 67 | return nil 68 | } 69 | 70 | if v.selectedTask != nil && 71 | t.Id == v.selectedTask.Id { 72 | v.selectedTask = nil 73 | } 74 | 75 | return v.moveTaskTo(t, dirRight) 76 | } 77 | 78 | // KeyShiftI reacts for "I" 79 | func (v *View) KeyShiftI(g *gocui.Gui, gv *gocui.View) error { 80 | if gv.Name() == inputViewName { 81 | return v.input(g, gv) 82 | } 83 | 84 | t := v.FocusedTask() 85 | if t == nil { 86 | return nil 87 | } 88 | 89 | if v.selectedTask != nil && 90 | t.Id == v.selectedTask.Id { 91 | v.selectedTask = nil 92 | } 93 | 94 | return v.moveTaskTo(t, dirLeft) 95 | } 96 | 97 | // KeyE reacts for "e" 98 | func (v *View) KeyE(g *gocui.Gui, gv *gocui.View) error { 99 | t := v.FocusedTask() 100 | if t == nil { 101 | return nil 102 | } 103 | 104 | v.editingTask = t 105 | 106 | return v.input(g, gv) 107 | } 108 | 109 | // KeySpace reacts for "Space" 110 | func (v *View) KeySpace(g *gocui.Gui, gv *gocui.View) error { 111 | return v.selectFocusedTask() 112 | } 113 | 114 | // KeyEnter reacts for "Enter" 115 | func (v *View) KeyEnter(g *gocui.Gui, gv *gocui.View) error { 116 | return v.input(g, gv) 117 | } 118 | 119 | // KeyEsc reacts for "Esc" 120 | func (v *View) KeyEsc(g *gocui.Gui, gv *gocui.View) error { 121 | if gv.Name() == inputViewName { 122 | return v.hideInput(g, gv) 123 | } 124 | return nil 125 | } 126 | -------------------------------------------------------------------------------- /cmd/hashira-cui/keyedtask.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/pankona/hashira/service" 4 | 5 | // KeyedTask is service.Task with Key() function. 6 | type KeyedTask service.Task 7 | 8 | // Key returns task's ID. 9 | // This function is for satisfying orderedmap.Keyer interface. 10 | func (kt *KeyedTask) Key() string { 11 | return kt.Id 12 | } 13 | -------------------------------------------------------------------------------- /cmd/hashira-cui/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "flag" 7 | "fmt" 8 | "io" 9 | "log" 10 | "log/syslog" 11 | "os" 12 | "os/user" 13 | "path/filepath" 14 | "time" 15 | 16 | "github.com/pankona/gocui" 17 | hashirac "github.com/pankona/hashira/client" 18 | "github.com/pankona/hashira/daemon" 19 | "github.com/pankona/hashira/database" 20 | "github.com/pankona/hashira/sync/syncutil" 21 | "github.com/pankona/hashira/xdg" 22 | ) 23 | 24 | var ( 25 | Version = "unset" 26 | Revision = "unset" 27 | ) 28 | 29 | func main() { 30 | var ( 31 | flagVersion bool 32 | ) 33 | flag.BoolVar(&flagVersion, "version", false, "show version") 34 | flag.Parse() 35 | 36 | if flagVersion { 37 | fmt.Printf("hashira-cui version: %s, Revision: %s\n", Version, Revision) 38 | return 39 | } 40 | 41 | logger, err := syslog.New(syslog.LOG_INFO|syslog.LOG_LOCAL0, "hashira-cui") 42 | if err != nil { 43 | log.Printf("failed to connect to logger but continue to work: %v", err) 44 | log.SetOutput(io.Discard) 45 | } else { 46 | log.SetOutput(logger) 47 | } 48 | 49 | db, err := initializeDB() 50 | if err != nil { 51 | os.Exit(1) 52 | } 53 | 54 | const daemonPort = 50056 55 | 56 | d := &daemon.Daemon{ 57 | Port: daemonPort, 58 | DB: db, 59 | } 60 | 61 | go func() { 62 | if err = d.Run(); err != nil { 63 | fmt.Printf("failed to start hashira daemon: %s\n", err.Error()) 64 | os.Exit(1) 65 | } 66 | }() 67 | defer func() { 68 | d.Stop() 69 | }() 70 | 71 | // initialize gocui 72 | // specify false means: supportOverlaps = false 73 | g, err := gocui.NewGui(gocui.OutputNormal, false) 74 | if err != nil { 75 | log.Panicln(err) 76 | } 77 | defer g.Close() 78 | 79 | // prepare model 80 | hashirac := &hashirac.Client{Address: fmt.Sprintf("localhost:%d", daemonPort)} 81 | syncclient := &syncutil.Client{DaemonPort: daemonPort} 82 | m := NewModel(hashirac, syncclient) 83 | 84 | // Start synchronization with cloud if HASHIRA_ACCESS_TOKEN is set 85 | accesstoken, ok := os.LookupEnv("HASHIRA_ACCESS_TOKEN") 86 | 87 | if ok { 88 | go func() { 89 | sc := syncutil.Client{DaemonPort: daemonPort} 90 | err := sc.TestAccessToken(accesstoken) 91 | if err != nil { 92 | log.Printf("HASHIRA_ACCESSTOKEN is invalid. Synchronization is not started: %v", err) 93 | } 94 | m.SetAccessToken(accesstoken) 95 | 96 | go func() { 97 | // sync on launch immediately 98 | if err := m.SyncNow(context.Background()); err != nil { 99 | log.Printf("failed to sync: %v", err) 100 | } 101 | 102 | // start polling 103 | for { 104 | m.NotifySync() 105 | <-time.After(2 * time.Minute) 106 | } 107 | }() 108 | 109 | if err := m.SyncOnNotify(context.Background()); err != nil { 110 | log.Printf("sync on notify finished: %v", err) 111 | } 112 | }() 113 | } 114 | 115 | // prepare controller 116 | ps := &PubSub{} 117 | m.SetPublisher(ps) 118 | 119 | c := &Ctrl{ 120 | m: m, 121 | pub: ps, 122 | } 123 | 124 | c.Initialize() 125 | c.SetPublisher(ps) 126 | // prepare view 127 | v := &View{} 128 | v.Initialize(g, c) 129 | g.SetManager(v) 130 | 131 | err = v.ConfigureKeyBindings(g) 132 | if err != nil { 133 | panic(fmt.Sprintf("failed to configure keybindings: %s", err.Error())) 134 | } 135 | 136 | ps.Subscribe("view", v) 137 | 138 | // retrieve tasks first for initial screen 139 | err = c.Update(context.Background()) 140 | if err != nil { 141 | panic(fmt.Sprintf("failed to retrieve initial tasks: %s", err.Error())) 142 | } 143 | 144 | err = g.MainLoop() 145 | if err != nil && err != gocui.ErrQuit { 146 | log.Panicln(err) 147 | } 148 | } 149 | 150 | func initializeDB() (database.Databaser, error) { 151 | db := &database.BoltDB{} 152 | usr, err := user.Current() 153 | if err != nil { 154 | return nil, errors.New("failed to current user: " + err.Error()) 155 | } 156 | 157 | legacyDataDir := filepath.Join(usr.HomeDir, ".config", "hashira") 158 | isLegacyDataDirExist := func() bool { 159 | legacyInfo, err := os.Stat(legacyDataDir) 160 | if err != nil && os.IsNotExist(err) { 161 | return false 162 | } 163 | return legacyInfo.IsDir() 164 | } 165 | dataDir := legacyDataDir 166 | if !isLegacyDataDirExist() { 167 | x := &xdg.Xdg{User: *usr} 168 | dataDir = filepath.Join(x.DataHome(), "hashira") 169 | err = os.MkdirAll(dataDir, 0700) 170 | if err != nil { 171 | return nil, errors.New("failed to create data directory: " + err.Error()) 172 | } 173 | } 174 | 175 | err = db.Initialize(filepath.Join(dataDir, "db")) 176 | if err != nil { 177 | return nil, errors.New("failed to initialize db: " + err.Error()) 178 | } 179 | return db, nil 180 | } 181 | -------------------------------------------------------------------------------- /cmd/hashira-cui/model.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "time" 9 | 10 | hashirac "github.com/pankona/hashira/client" 11 | "github.com/pankona/hashira/service" 12 | "github.com/pankona/hashira/sync/syncutil" 13 | ) 14 | 15 | // Model represents model of hashira's mvc 16 | type Model struct { 17 | hashirac *hashirac.Client 18 | syncclient *syncutil.Client 19 | 20 | // TODO: remove if accesstoken is held by syncclient 21 | accesstoken string 22 | syncChan chan struct{} 23 | 24 | pub Publisher 25 | } 26 | 27 | func NewModel(hc *hashirac.Client, sc *syncutil.Client) *Model { 28 | return &Model{ 29 | hashirac: hc, 30 | syncclient: sc, 31 | syncChan: make(chan struct{}), 32 | } 33 | } 34 | 35 | // List retrieves task list using hashira client 36 | func (m *Model) List(ctx context.Context) (map[string]*service.Task, error) { 37 | return m.hashirac.Retrieve(ctx) 38 | } 39 | 40 | // RetrievePriority retrieves priorities using hashira client 41 | func (m *Model) RetrievePriority(ctx context.Context) (map[string]*service.Priority, error) { 42 | return m.hashirac.RetrievePriority(ctx) 43 | } 44 | 45 | // UpdatePriority updates priorities using hashira client 46 | func (m *Model) UpdatePriority(ctx context.Context, p map[string]*service.Priority) (map[string]*service.Priority, error) { 47 | p, err := m.hashirac.UpdatePriority(ctx, p) 48 | if err != nil { 49 | return nil, fmt.Errorf("failed to update priority: %w", err) 50 | } 51 | m.NotifySync() 52 | 53 | return p, nil 54 | } 55 | 56 | func (m *Model) Create(ctx context.Context, task *service.Task) error { 57 | if err := m.hashirac.Create(ctx, task); err != nil { 58 | return fmt.Errorf("failed to create a new task: %w", err) 59 | } 60 | m.NotifySync() 61 | 62 | return nil 63 | } 64 | 65 | func (m *Model) Update(ctx context.Context, task *service.Task) error { 66 | if err := m.hashirac.Update(ctx, task); err != nil { 67 | return fmt.Errorf("failed to update a task: %w", err) 68 | } 69 | m.NotifySync() 70 | 71 | return nil 72 | } 73 | 74 | func (m *Model) Delete(ctx context.Context, id string) error { 75 | if err := m.hashirac.Delete(ctx, id); err != nil { 76 | return fmt.Errorf("failed to delete a task: %w", err) 77 | } 78 | m.NotifySync() 79 | 80 | return nil 81 | } 82 | 83 | func (m *Model) SetAccessToken(accesstoken string) { 84 | m.accesstoken = accesstoken 85 | } 86 | 87 | func (m *Model) SetPublisher(p Publisher) { 88 | m.pub = p 89 | } 90 | 91 | func (m *Model) NotifySync() { 92 | select { 93 | case m.syncChan <- struct{}{}: 94 | default: 95 | } 96 | } 97 | 98 | var errSyncOnNotifyCanceled = errors.New("sync on notify has been canceled") 99 | 100 | func (m *Model) SyncOnNotify(ctx context.Context) error { 101 | if m.accesstoken == "" { 102 | return nil 103 | } 104 | 105 | var cancelFunc context.CancelFunc 106 | 107 | for { 108 | select { 109 | case <-ctx.Done(): 110 | return errSyncOnNotifyCanceled 111 | case <-m.syncChan: 112 | if cancelFunc != nil { 113 | cancelFunc() 114 | } 115 | 116 | ctx, cancel := context.WithCancel(ctx) 117 | cancelFunc = cancel 118 | 119 | go func(ctx context.Context) { 120 | select { 121 | case <-ctx.Done(): 122 | // do nothing 123 | case <-time.After(1 * time.Minute): 124 | if err := m.sync(ctx); err != nil { 125 | log.Printf("failed to sync: %v", err) 126 | } 127 | } 128 | }(ctx) 129 | } 130 | } 131 | } 132 | 133 | func (m *Model) SyncNow(ctx context.Context) error { 134 | return m.sync(ctx) 135 | } 136 | 137 | func (m *Model) sync(ctx context.Context) error { 138 | if m.accesstoken == "" { 139 | return nil 140 | } 141 | 142 | if err := m.syncclient.Upload(m.accesstoken, syncutil.UploadDirtyOnly); err != nil { 143 | return fmt.Errorf("failed to upload tasks: %w", err) 144 | } 145 | if err := m.syncclient.Download(m.accesstoken); err != nil { 146 | return fmt.Errorf("failed to download tasks: %w", err) 147 | } 148 | 149 | // publish to notify update of tasks and priorities 150 | tasks, err := m.List(ctx) 151 | if err != nil { 152 | return fmt.Errorf("failed to retrieve tasks: %w", err) 153 | } 154 | 155 | priorities, err := m.RetrievePriority(ctx) 156 | if err != nil { 157 | return fmt.Errorf("failed to retrieve priorities: %w", err) 158 | } 159 | 160 | ktasks := make(map[string]*KeyedTask) 161 | for k, v := range tasks { 162 | ktasks[k] = (*KeyedTask)(v) 163 | } 164 | 165 | m.pub.Publish("update", ktasks, priorities) 166 | 167 | return nil 168 | } 169 | -------------------------------------------------------------------------------- /cmd/hashira-cui/pane.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/pankona/gocui" 8 | "github.com/pankona/hashira/service" 9 | "github.com/pankona/orderedmap" 10 | ) 11 | 12 | // Pane represents a pane, 13 | // like one of Backlog, ToDo, Doing, Done 14 | type Pane struct { 15 | name string 16 | index int // place of this pane 17 | left *Pane 18 | right *Pane 19 | place service.Place 20 | tasks *orderedmap.OrderedMap 21 | renderFrom int 22 | } 23 | 24 | type rectangle struct { 25 | x0, y0, x1, y1 int 26 | } 27 | 28 | // Layout writes tasks in pane 29 | func (p *Pane) Layout(g *gocui.Gui, c *cursor, focusedIndex int, selectedTask *KeyedTask) error { 30 | maxX, maxY := g.Size() 31 | rect := rectangle{maxX / 4 * p.index, 1, maxX/4*p.index + maxX/4 - 1, maxY - 1} 32 | 33 | v, err := g.SetView(p.name, rect.x0, rect.y0, rect.x1, rect.y1, 0) 34 | if err != nil { 35 | if err != gocui.ErrUnknownView { 36 | return err 37 | } 38 | v.Title = p.name 39 | } 40 | 41 | v.Clear() 42 | 43 | return p.render(v, rect, c, focusedIndex, selectedTask) 44 | } 45 | 46 | func (p *Pane) render(w io.Writer, rect rectangle, cursor *cursor, 47 | focusedIndex int, selectedTask *KeyedTask) error { 48 | 49 | // -1 is adjustment for considering width of frame 50 | maxLen := rect.y1 - rect.y0 - 2 51 | if maxLen < 0 { 52 | return fmt.Errorf("invalid pane height. height must be positive") 53 | } 54 | 55 | // cursor must point within pane max length 56 | c := cursor.sanitize(maxLen) 57 | 58 | // calculate index from where to render for scrolling 59 | p.renderFrom = p.calcRenderFrom(focusedIndex, maxLen) 60 | 61 | return p.renderTasks(w, c, selectedTask) 62 | } 63 | 64 | func (p *Pane) calcRenderFrom(focusedIndex, maxLen int) int { 65 | renderFrom := p.renderFrom 66 | 67 | if focusedIndex == -1 { 68 | // this pane is not focused. nop 69 | return renderFrom 70 | } 71 | 72 | // calculate renderFrom for scrolling 73 | to := renderFrom + maxLen 74 | 75 | if focusedIndex > to { 76 | renderFrom += focusedIndex - to 77 | } else if focusedIndex < p.renderFrom { 78 | renderFrom -= renderFrom - focusedIndex 79 | } 80 | 81 | return renderFrom 82 | } 83 | 84 | func (p *Pane) renderTasks(w io.Writer, cursor *cursor, selected *KeyedTask) error { 85 | var taskNum int 86 | 87 | return p.tasks.ForEach(func(i int, v orderedmap.Keyer) error { 88 | if i < p.renderFrom { 89 | // skip rendering to scroll 90 | return nil 91 | } 92 | 93 | task := v.(*KeyedTask) 94 | 95 | prefix := "" 96 | if selected != nil && task.Id == selected.Id { 97 | prefix = "*" 98 | } 99 | 100 | var err error 101 | 102 | if p == cursor.focusedPane && taskNum == cursor.index { 103 | _, err = fmt.Fprintf(w, "%s \033[3%d;%dm%s\033[0m\n", prefix, 7, 4, task.Name) 104 | } else { 105 | _, err = fmt.Fprintf(w, "%s %s\n", prefix, task.Name) 106 | } 107 | 108 | if err != nil { 109 | return fmt.Errorf("failed to render task: %v", err) 110 | } 111 | 112 | taskNum++ 113 | return nil 114 | }) 115 | } 116 | -------------------------------------------------------------------------------- /cmd/hashira-cui/pubsub.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "sync" 4 | 5 | // Subscriber is an interface of PubSub subscriber 6 | type Subscriber interface { 7 | OnEvent(event string, data ...interface{}) 8 | } 9 | 10 | // Publisher is an interface of PubSub publisher 11 | type Publisher interface { 12 | Publish(event string, data ...interface{}) 13 | } 14 | 15 | // PubSub is a struct for pubsub message passing 16 | type PubSub struct { 17 | ss sync.Map 18 | } 19 | 20 | // Publish publishes specified event and data to subscribers 21 | func (ps *PubSub) Publish(event string, data ...interface{}) { 22 | ps.ss.Range(func(k, v interface{}) bool { 23 | s := v.(Subscriber) 24 | s.OnEvent(event, data...) 25 | return true 26 | }) 27 | } 28 | 29 | // Subscribe registers subscriber to PubSub 30 | func (ps *PubSub) Subscribe(id string, s Subscriber) { 31 | ps.ss.Store(id, s) 32 | } 33 | -------------------------------------------------------------------------------- /cmd/hashira-cui/view_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | -------------------------------------------------------------------------------- /cmd/hashira-web-client/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "fmt" 7 | "log" 8 | "os" 9 | "os/user" 10 | "path/filepath" 11 | "time" 12 | 13 | "github.com/pankona/hashira/daemon" 14 | "github.com/pankona/hashira/database" 15 | "github.com/pankona/hashira/sync/syncutil" 16 | ) 17 | 18 | var ( 19 | Version = "unset" 20 | Revision = "unset" 21 | ) 22 | 23 | const daemonPort = 50058 24 | 25 | func main() { 26 | var ( 27 | flagSync bool 28 | flagUpload bool 29 | flagDownload bool 30 | flagTest bool 31 | flagVersion bool 32 | ) 33 | 34 | flag.BoolVar(&flagVersion, "version", false, "show version") 35 | flag.BoolVar(&flagSync, "sync", false, "sync (download and upload) tasks and priorities with hashira-web") 36 | flag.BoolVar(&flagUpload, "upload", false, "upload tasks and priorities to hashira-web") 37 | flag.BoolVar(&flagDownload, "download", false, "download tasks and priorities from hashira-web") 38 | flag.BoolVar(&flagTest, "test", false, "test the hashira-web works") 39 | flag.Parse() 40 | 41 | if flagVersion { 42 | fmt.Printf("hashira-web-client version: %s, Revision: %s\n", Version, Revision) 43 | return 44 | } 45 | 46 | accesstoken := os.Getenv("HASHIRA_ACCESS_TOKEN") 47 | if accesstoken == "" { 48 | log.Printf("Please specify environment variable HASHIRA_ACCESS_TOKEN. Abort.") 49 | os.Exit(1) 50 | } 51 | 52 | done := make(chan struct{}) 53 | go func() { 54 | launchHashirad(done) 55 | }() 56 | 57 | time.Sleep(1 * time.Second) 58 | 59 | sc := syncutil.Client{DaemonPort: daemonPort} 60 | 61 | switch { 62 | case flagSync: 63 | if err := sc.Download(accesstoken); err != nil { 64 | log.Printf("failed to download: %v", err) 65 | } 66 | log.Printf("download completed") 67 | if err := sc.Upload(accesstoken, syncutil.UploadAll); err != nil { 68 | log.Printf("failed to upload: %v", err) 69 | } 70 | log.Printf("upload completed") 71 | case flagUpload: 72 | if err := sc.Upload(accesstoken, syncutil.UploadDirtyOnly); err != nil { 73 | log.Printf("failed to upload: %v", err) 74 | } 75 | log.Printf("upload completed") 76 | case flagDownload: 77 | if err := sc.Download(accesstoken); err != nil { 78 | log.Printf("failed to download: %v", err) 79 | } 80 | log.Printf("download completed") 81 | case flagTest: 82 | fallthrough 83 | default: 84 | if err := sc.TestAccessToken(accesstoken); err != nil { 85 | log.Printf("test access token failed: %v", err) 86 | } else { 87 | log.Println("The accesstoken is valid. hashira-web will work!") 88 | } 89 | } 90 | 91 | done <- struct{}{} 92 | } 93 | 94 | func initializeDB() (database.Databaser, error) { 95 | db := &database.BoltDB{} 96 | usr, err := user.Current() 97 | if err != nil { 98 | return nil, errors.New("failed to current user: " + err.Error()) 99 | } 100 | 101 | configDir := filepath.Join(usr.HomeDir, ".config", "hashira") 102 | err = os.MkdirAll(configDir, 0700) 103 | if err != nil { 104 | return nil, errors.New("failed to create config directory: " + err.Error()) 105 | } 106 | 107 | err = db.Initialize(filepath.Join(configDir, "db")) 108 | if err != nil { 109 | return nil, errors.New("failed to initialize db: " + err.Error()) 110 | } 111 | return db, nil 112 | } 113 | 114 | func launchHashirad(done <-chan struct{}) { 115 | db, err := initializeDB() 116 | if err != nil { 117 | fmt.Printf("failed to initialize DB: %s\n", err.Error()) 118 | os.Exit(1) 119 | } 120 | 121 | d := &daemon.Daemon{ 122 | Port: daemonPort, 123 | DB: db, 124 | } 125 | 126 | go func() { 127 | <-done 128 | d.Stop() 129 | }() 130 | 131 | if err = d.Run(); err != nil { 132 | fmt.Printf("failed to start hashira daemon: %s\n", err.Error()) 133 | os.Exit(1) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /cmd/hashira/commands.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strconv" 8 | 9 | "github.com/alecthomas/kingpin/v2" 10 | "github.com/pankona/hashira/client" 11 | "github.com/pankona/hashira/service" 12 | ) 13 | 14 | func addNewCmd(ctx context.Context, c *client.Client) { 15 | newCmd := kingpin.Command( 16 | "new", 17 | "add new task with specified task name") 18 | name := newCmd.Arg( 19 | "name", 20 | "name of task which is newly added"). 21 | Required().String() 22 | _ = newCmd.Action(func(pc *kingpin.ParseContext) error { 23 | return create(ctx, c, *name) 24 | }) 25 | } 26 | 27 | func addListCmd(ctx context.Context, c *client.Client) { 28 | listCmd := kingpin.Command( 29 | "list", 30 | "show list of tasks") 31 | _ = listCmd.Action(func(pc *kingpin.ParseContext) error { 32 | return list(ctx, c) 33 | }) 34 | } 35 | 36 | func create(ctx context.Context, c *client.Client, name string) error { 37 | t := &service.Task{ 38 | Name: name, 39 | Place: service.Place_BACKLOG, 40 | } 41 | err := c.Create(ctx, t) 42 | if err != nil { 43 | return errors.New("failed to create a new task: " + err.Error()) 44 | } 45 | return nil 46 | } 47 | 48 | func list(ctx context.Context, c *client.Client) error { 49 | tasks, err := c.Retrieve(ctx) 50 | if err != nil { 51 | return errors.New("failed to create a new task: " + err.Error()) 52 | } 53 | for _, v := range tasks { 54 | id, err := strconv.Atoi(v.Id) 55 | if err != nil { 56 | continue 57 | } 58 | fmt.Printf("[%04d]\t%s\t%v\t%v\n", id, v.Name, v.Place, v.IsDeleted) 59 | } 60 | return nil 61 | } 62 | -------------------------------------------------------------------------------- /cmd/hashira/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/alecthomas/kingpin/v2" 7 | "github.com/pankona/hashira/client" 8 | ) 9 | 10 | const ( 11 | program = "hashira" 12 | version = "v0.0.1" 13 | ) 14 | 15 | func main() { 16 | c := &client.Client{ 17 | Address: "localhost:50056", 18 | } 19 | ctx := context.Background() 20 | 21 | addNewCmd(ctx, c) 22 | addListCmd(ctx, c) 23 | 24 | kingpin.Version(program + " " + version) 25 | _ = kingpin.Parse() 26 | } 27 | -------------------------------------------------------------------------------- /cmd/hashirad/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "os" 9 | "os/user" 10 | "path/filepath" 11 | "time" 12 | 13 | "github.com/pankona/hashira/daemon" 14 | "github.com/pankona/hashira/database" 15 | "github.com/pankona/hashira/sync/syncutil" 16 | ) 17 | 18 | func initializeDB() (database.Databaser, error) { 19 | db := &database.BoltDB{} 20 | usr, err := user.Current() 21 | if err != nil { 22 | return nil, errors.New("failed to current user: " + err.Error()) 23 | } 24 | 25 | configDir := filepath.Join(usr.HomeDir, ".config", "hashira") 26 | err = os.MkdirAll(configDir, 0700) 27 | if err != nil { 28 | return nil, errors.New("failed to create config directory: " + err.Error()) 29 | } 30 | 31 | err = db.Initialize(filepath.Join(configDir, "db")) 32 | if err != nil { 33 | return nil, errors.New("failed to initialize db: " + err.Error()) 34 | } 35 | 36 | return db, nil 37 | } 38 | 39 | func main() { 40 | db, err := initializeDB() 41 | if err != nil { 42 | fmt.Printf("failed to initialize DB: %s\n", err.Error()) 43 | os.Exit(1) 44 | } 45 | 46 | port := 50057 47 | d := &daemon.Daemon{ 48 | Port: port, 49 | DB: db, 50 | } 51 | 52 | accesstoken, ok := os.LookupEnv("HASHIRA_ACCESS_TOKEN") 53 | if ok { 54 | if err := startSync(context.Background(), port, accesstoken); err != nil { 55 | log.Printf("failed to start synchronization: %v", err) 56 | } 57 | } 58 | 59 | if err = d.Run(); err != nil { 60 | fmt.Printf("failed to start hashira daemon: %s\n", err.Error()) 61 | os.Exit(1) 62 | } 63 | } 64 | 65 | func startSync(ctx context.Context, daemonPort int, accesstoken string) error { 66 | fmt.Printf("start synchronization...\n") 67 | 68 | sc := syncutil.Client{DaemonPort: daemonPort} 69 | err := sc.TestAccessToken(accesstoken) 70 | if err != nil { 71 | return fmt.Errorf("HASHIRA_ACCESSTOKEN is invalid. Synchronization is not started: %w", err) 72 | } 73 | fmt.Printf("HASHIRA_ACCESSTOKEN is valid. hashira-web will work!\n") 74 | 75 | const syncInterval = 10 * time.Minute 76 | 77 | go func() { 78 | for { 79 | select { 80 | case <-ctx.Done(): 81 | break 82 | default: 83 | if err := sc.Upload(accesstoken, syncutil.UploadDirtyOnly); err != nil { 84 | log.Printf("failed to upload: %v", err) 85 | } 86 | if err := sc.Download(accesstoken); err != nil { 87 | log.Printf("failed to download: %v", err) 88 | } 89 | <-time.After(syncInterval) 90 | } 91 | } 92 | }() 93 | 94 | return nil 95 | } 96 | -------------------------------------------------------------------------------- /daemon/daemon.go: -------------------------------------------------------------------------------- 1 | package daemon 2 | 3 | import ( 4 | "errors" 5 | "net" 6 | "strconv" 7 | 8 | "github.com/pankona/hashira/database" 9 | "github.com/pankona/hashira/service" 10 | "google.golang.org/grpc" 11 | "google.golang.org/grpc/reflection" 12 | ) 13 | 14 | // Daemon is a structure that implement hashira service 15 | type Daemon struct { 16 | Port int 17 | DB database.Databaser 18 | server *grpc.Server 19 | 20 | service.UnimplementedHashiraServer 21 | } 22 | 23 | // Run starts hashira daemon (as gRPC server) 24 | func (d *Daemon) Run() error { 25 | p := ":" + strconv.Itoa(d.Port) 26 | listen, err := net.Listen("tcp", p) 27 | if err != nil { 28 | return errors.New("gRPC server failed to listen [" + p + "]: " + err.Error()) 29 | } 30 | s := grpc.NewServer() 31 | reflection.Register(s) 32 | service.RegisterHashiraServer(s, d) 33 | 34 | d.server = s 35 | 36 | return d.server.Serve(listen) 37 | } 38 | 39 | // Stop stops hashira daemon 40 | func (d *Daemon) Stop() { 41 | d.server.Stop() 42 | } 43 | -------------------------------------------------------------------------------- /daemon/grpc_test.go: -------------------------------------------------------------------------------- 1 | package daemon 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/pankona/hashira/database" 8 | "github.com/pankona/hashira/service" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | type mockDB struct { 13 | database.Databaser 14 | data map[string]map[string][]byte 15 | } 16 | 17 | func (m *mockDB) Save(bucket, id string, value []byte) (string, error) { 18 | if m.data == nil { 19 | m.data = make(map[string]map[string][]byte) 20 | } 21 | 22 | if m.data[bucket] == nil { 23 | m.data[bucket] = make(map[string][]byte) 24 | } 25 | 26 | m.data[bucket][id] = value 27 | return id, nil 28 | } 29 | 30 | func (m *mockDB) Load(bucket, id string) ([]byte, error) { 31 | if m.data == nil { 32 | return nil, nil 33 | } 34 | 35 | if m.data[bucket] == nil { 36 | return nil, nil 37 | } 38 | 39 | return m.data[bucket][id], nil 40 | } 41 | 42 | func (m *mockDB) ForEach(bucket string, f func(k, v []byte) error) error { 43 | for k, v := range m.data[bucket] { 44 | err := f([]byte(k), v) 45 | if err != nil { 46 | return err 47 | } 48 | } 49 | return nil 50 | } 51 | 52 | type testcase struct { 53 | inName string 54 | wantName string 55 | inPlace service.Place 56 | wantPlace service.Place 57 | } 58 | 59 | func testcases() []testcase { 60 | tcs := []testcase{ 61 | {inName: "test", inPlace: service.Place_BACKLOG, wantName: "test", wantPlace: service.Place_BACKLOG}, 62 | } 63 | return tcs 64 | } 65 | 66 | func testCreate(d *Daemon, t *testing.T) { 67 | tcs := testcases() 68 | for _, tc := range tcs { 69 | cc := &service.CommandCreate{ 70 | Task: &service.Task{ 71 | Name: tc.inName, 72 | Place: tc.inPlace, 73 | }, 74 | } 75 | result, err := d.Create(context.Background(), cc) 76 | if err != nil { 77 | t.Fatalf("Create returned unexpected error: %s", err.Error()) 78 | } 79 | 80 | if result.GetTask().GetName() != tc.wantName { 81 | t.Errorf("unexpected result. [got] %v [want] %v", result.GetTask().GetName(), tc.wantName) 82 | } 83 | 84 | if result.GetTask().GetPlace() != tc.wantPlace { 85 | t.Errorf("unexpected result. [got] %v [want] %v", result.GetTask().GetPlace(), tc.wantPlace) 86 | } 87 | } 88 | } 89 | 90 | func TestUpdatePriority(t *testing.T) { 91 | p := map[string]*service.Priority{ 92 | service.Place_BACKLOG.String(): { 93 | Ids: []string{"1", "2", "3", "4", "5"}, 94 | }, 95 | service.Place_TODO.String(): { 96 | Ids: []string{"6", "7", "8", "9", "10"}, 97 | }, 98 | service.Place_DOING.String(): { 99 | Ids: []string{"11", "12", "13", "14", "15"}, 100 | }, 101 | service.Place_DONE.String(): { 102 | Ids: []string{"16", "17", "18", "19", "20"}, 103 | }, 104 | } 105 | 106 | d := &Daemon{ 107 | DB: &mockDB{}, 108 | } 109 | m, err := d.updatePriority(p) 110 | require.Nil(t, err) 111 | 112 | require.Equal(t, m[service.Place_BACKLOG.String()].Ids, []string{"1", "2", "3", "4", "5"}) 113 | require.Equal(t, m[service.Place_TODO.String()].Ids, []string{"6", "7", "8", "9", "10"}) 114 | require.Equal(t, m[service.Place_DOING.String()].Ids, []string{"11", "12", "13", "14", "15"}) 115 | require.Equal(t, m[service.Place_DONE.String()].Ids, []string{"16", "17", "18", "19", "20"}) 116 | } 117 | 118 | func TestEndPointCreate(t *testing.T) { 119 | d := &Daemon{ 120 | DB: &mockDB{}, 121 | } 122 | testCreate(d, t) 123 | } 124 | 125 | func TestEndPointRetrieve(t *testing.T) { 126 | d := &Daemon{ 127 | DB: &mockDB{}, 128 | } 129 | testCreate(d, t) 130 | 131 | rc := &service.CommandRetrieve{} 132 | result, err := d.Retrieve(context.Background(), rc) 133 | if err != nil { 134 | t.Fatalf("Retrieve returned unexpected error: %s", err.Error()) 135 | } 136 | 137 | tcs := testcases() 138 | tasks := result.GetTasks() 139 | if len(tasks) != len(tcs) { 140 | t.Errorf("unexpected result. [got] %d [want] %d", len(tasks), len(tcs)) 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /database/boltdb.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/gofrs/uuid" 7 | bolt "go.etcd.io/bbolt" 8 | ) 9 | 10 | // BoltDB provides API for using BoltDB 11 | // This function implements Databaser interface. 12 | type BoltDB struct { 13 | dbpath string 14 | db *bolt.DB 15 | } 16 | 17 | func (b *BoltDB) open() error { 18 | var err error 19 | b.db, err = bolt.Open(b.dbpath, 0600, nil) 20 | if err != nil { 21 | return fmt.Errorf("failed to open database: %v", err) 22 | } 23 | return nil 24 | } 25 | 26 | func (b *BoltDB) close() error { 27 | return b.db.Close() 28 | } 29 | 30 | // Initialize initializes BoltDB instance 31 | func (b *BoltDB) Initialize(dbpath string) error { 32 | b.dbpath = dbpath 33 | err := b.open() 34 | if err != nil { 35 | return fmt.Errorf("failed to open database: %v", err) 36 | } 37 | return b.close() 38 | } 39 | 40 | // Finalize finalizes BoltDB instance 41 | func (b *BoltDB) Finalize() error { 42 | // nop 43 | return nil 44 | } 45 | 46 | func (b *BoltDB) withDBOpenClose(f func() error) error { 47 | err := b.open() 48 | if err != nil { 49 | return fmt.Errorf("open returned error: %v", err) 50 | } 51 | 52 | err = f() 53 | if err != nil { 54 | return fmt.Errorf("failed to save/load: %v", err) 55 | } 56 | 57 | return b.close() 58 | } 59 | 60 | // Save stores specified key/value to database 61 | func (b *BoltDB) Save(bucket, id string, value []byte) (string, error) { 62 | var ret string 63 | return ret, b.withDBOpenClose( 64 | func() error { 65 | return b.db.Update( 66 | func(tx *bolt.Tx) error { 67 | b, err := tx.CreateBucketIfNotExists([]byte(bucket)) 68 | if err != nil { 69 | return fmt.Errorf("failed to create bucket: %v", err) 70 | } 71 | if id == "" { 72 | u, e := uuid.NewV4() 73 | if e != nil { 74 | return fmt.Errorf("failed generate uuid: %v", err) 75 | } 76 | id = u.String() 77 | } 78 | 79 | err = b.Put([]byte(id), value) 80 | if err != nil { 81 | return err 82 | } 83 | ret = id 84 | return nil 85 | }) 86 | }) 87 | } 88 | 89 | // Load loads data by id 90 | func (b *BoltDB) Load(bucket, id string) ([]byte, error) { 91 | var ret []byte 92 | err := b.withDBOpenClose( 93 | func() error { 94 | err := b.db.View( 95 | func(tx *bolt.Tx) error { 96 | if tx == nil { 97 | return nil 98 | } 99 | b := tx.Bucket([]byte(bucket)) 100 | if b == nil { 101 | return nil 102 | } 103 | v := b.Get([]byte(id)) 104 | if v == nil { 105 | return nil 106 | } 107 | ret = make([]byte, len(v)) 108 | copy(ret, v) 109 | return nil 110 | }) 111 | if err != nil { 112 | return fmt.Errorf("failed load: %v", err) 113 | } 114 | return nil 115 | }) 116 | return ret, err 117 | } 118 | 119 | func (b *BoltDB) PhysicalDelete(bucket, id string) error { 120 | return b.withDBOpenClose( 121 | func() error { 122 | err := b.db.Update( 123 | func(tx *bolt.Tx) error { 124 | if tx == nil { 125 | return nil 126 | } 127 | b := tx.Bucket([]byte(bucket)) 128 | if b == nil { 129 | return nil 130 | } 131 | if err := b.Delete([]byte(id)); err != nil { 132 | return fmt.Errorf("failed to physical delete from db: %w", err) 133 | } 134 | return nil 135 | }) 136 | if err != nil { 137 | return fmt.Errorf("failed to physical delete: %v", err) 138 | } 139 | return nil 140 | }) 141 | } 142 | 143 | // ForEach loops for all items already saved and invoke specified function 144 | func (b *BoltDB) ForEach(bucket string, f func(k, v []byte) error) error { 145 | err := b.withDBOpenClose( 146 | func() error { 147 | err := b.db.View( 148 | func(tx *bolt.Tx) error { 149 | if tx == nil { 150 | return nil 151 | } 152 | b := tx.Bucket([]byte(bucket)) 153 | if b == nil { 154 | return nil 155 | } 156 | err := b.ForEach(f) 157 | if err != nil { 158 | return fmt.Errorf("for each stopped: %v", err) 159 | } 160 | return nil 161 | }) 162 | if err != nil { 163 | return fmt.Errorf("failed load: %v", err) 164 | } 165 | return nil 166 | }) 167 | return err 168 | } 169 | -------------------------------------------------------------------------------- /database/boltdb_test.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "testing" 8 | ) 9 | 10 | func setup(filename *string) (func(), error) { 11 | f, err := os.CreateTemp("", "") 12 | if err != nil { 13 | return nil, errors.New("fatal. failed to create tempfile") 14 | } 15 | *filename = f.Name() 16 | err = f.Close() 17 | if err != nil { 18 | return nil, errors.New("fatal. failed to close file") 19 | } 20 | return func() { 21 | err = os.RemoveAll(*filename) 22 | if err != nil { 23 | fmt.Printf("os.RemoveAll returned error: %v", err) 24 | } 25 | }, nil 26 | } 27 | 28 | func TestBoltDBInterface(t *testing.T) { 29 | var _ Databaser = &BoltDB{} 30 | } 31 | 32 | func TestBoltDBInitialize(t *testing.T) { 33 | db := &BoltDB{} 34 | var filename string 35 | teardown, err := setup(&filename) 36 | if err != nil { 37 | t.Fatalf("setup failed: %v", err) 38 | } 39 | defer teardown() 40 | 41 | err = db.Initialize(filename) 42 | if err != nil { 43 | t.Fatalf("Initialized returned unexpected error: %s", err.Error()) 44 | } 45 | } 46 | 47 | func TestBoltDBFinalize(t *testing.T) { 48 | db := &BoltDB{} 49 | err := db.Finalize() 50 | if err != nil { 51 | t.Fatalf("Finalize returned unexpected error: %s", err.Error()) 52 | } 53 | } 54 | 55 | func TestBoltDBSaveLoad(t *testing.T) { 56 | db := &BoltDB{} 57 | var filename string 58 | teardown, err := setup(&filename) 59 | if err != nil { 60 | t.Fatalf("setup failed: %v", err) 61 | } 62 | defer teardown() 63 | 64 | err = db.Initialize(filename) 65 | if err != nil { 66 | t.Fatalf("Initialized returned unexpected error: %s", err.Error()) 67 | } 68 | 69 | _, err = db.Save("testbucket", "testid", []byte("testdata")) 70 | if err != nil { 71 | t.Fatalf("save returned unexpected error: %s", err.Error()) 72 | } 73 | 74 | v, err := db.Load("testbucket", "testid") 75 | if err != nil { 76 | t.Fatalf("load returned unexpected error: %s", err.Error()) 77 | } 78 | 79 | if string(v) != "testdata" { 80 | t.Fatalf("load returned unexpected result. [got] %s [want] %s", string(v), "testdata") 81 | } 82 | } 83 | 84 | func TestBoltDBSaveLoadWithoutID(t *testing.T) { 85 | db := &BoltDB{} 86 | var filename string 87 | teardown, err := setup(&filename) 88 | if err != nil { 89 | t.Fatalf("setup failed: %v", err) 90 | } 91 | defer teardown() 92 | 93 | err = db.Initialize(filename) 94 | if err != nil { 95 | t.Fatalf("Initialized returned unexpected error: %s", err.Error()) 96 | } 97 | 98 | for i := 0; i < 10; i++ { 99 | _, err = db.Save("testbucket", "", []byte("testdata")) 100 | if err != nil { 101 | t.Fatalf("save returned unexpected error: %s", err.Error()) 102 | } 103 | } 104 | 105 | err = db.ForEach("testbucket", func(k, v []byte) error { 106 | t.Logf("[%s] %s", string(k), string(v)) 107 | return nil 108 | }) 109 | if err != nil { 110 | t.Fatalf("ForEach returned unexpected error: %s", err.Error()) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /database/database.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | // Databaser is an interface to treat databases 4 | type Databaser interface { 5 | Initialize(dbpath string) error 6 | Finalize() error 7 | Save(bucket, id string, value []byte) (string, error) 8 | Load(bucket, id string) ([]byte, error) 9 | PhysicalDelete(bucket, id string) error 10 | ForEach(bucket string, f func(k, v []byte) error) error 11 | } 12 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | 2 | all: 3 | mdbook build 4 | -------------------------------------------------------------------------------- /docs/book.toml: -------------------------------------------------------------------------------- 1 | [book] 2 | authors = ["Yosuke Akatsuka"] 3 | language = "en" 4 | multilingual = false 5 | src = "src" 6 | title = "hashira's design document" 7 | 8 | [preprocessor.plantuml] 9 | plantuml-cmd="plantuml" 10 | -------------------------------------------------------------------------------- /docs/book/.nojekyll: -------------------------------------------------------------------------------- 1 | This file makes sure that Github Pages doesn't process mdBook's output. 2 | -------------------------------------------------------------------------------- /docs/book/FontAwesome/fonts/FontAwesome.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pankona/hashira/8faa229755b2a029082f5e495e307acf9188872b/docs/book/FontAwesome/fonts/FontAwesome.ttf -------------------------------------------------------------------------------- /docs/book/FontAwesome/fonts/fontawesome-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pankona/hashira/8faa229755b2a029082f5e495e307acf9188872b/docs/book/FontAwesome/fonts/fontawesome-webfont.eot -------------------------------------------------------------------------------- /docs/book/FontAwesome/fonts/fontawesome-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pankona/hashira/8faa229755b2a029082f5e495e307acf9188872b/docs/book/FontAwesome/fonts/fontawesome-webfont.ttf -------------------------------------------------------------------------------- /docs/book/FontAwesome/fonts/fontawesome-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pankona/hashira/8faa229755b2a029082f5e495e307acf9188872b/docs/book/FontAwesome/fonts/fontawesome-webfont.woff -------------------------------------------------------------------------------- /docs/book/FontAwesome/fonts/fontawesome-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pankona/hashira/8faa229755b2a029082f5e495e307acf9188872b/docs/book/FontAwesome/fonts/fontawesome-webfont.woff2 -------------------------------------------------------------------------------- /docs/book/assets/images/uml/636bfd90b359509ea12ac7f7ca3fc622a6af8f45.svg: -------------------------------------------------------------------------------- 1 | alicealicebobbobhogehoge -------------------------------------------------------------------------------- /docs/book/ayu-highlight.css: -------------------------------------------------------------------------------- 1 | /* 2 | Based off of the Ayu theme 3 | Original by Dempfi (https://github.com/dempfi/ayu) 4 | */ 5 | 6 | .hljs { 7 | display: block; 8 | overflow-x: auto; 9 | background: #191f26; 10 | color: #e6e1cf; 11 | } 12 | 13 | .hljs-comment, 14 | .hljs-quote { 15 | color: #5c6773; 16 | font-style: italic; 17 | } 18 | 19 | .hljs-variable, 20 | .hljs-template-variable, 21 | .hljs-attribute, 22 | .hljs-attr, 23 | .hljs-regexp, 24 | .hljs-link, 25 | .hljs-selector-id, 26 | .hljs-selector-class { 27 | color: #ff7733; 28 | } 29 | 30 | .hljs-number, 31 | .hljs-meta, 32 | .hljs-builtin-name, 33 | .hljs-literal, 34 | .hljs-type, 35 | .hljs-params { 36 | color: #ffee99; 37 | } 38 | 39 | .hljs-string, 40 | .hljs-bullet { 41 | color: #b8cc52; 42 | } 43 | 44 | .hljs-title, 45 | .hljs-built_in, 46 | .hljs-section { 47 | color: #ffb454; 48 | } 49 | 50 | .hljs-keyword, 51 | .hljs-selector-tag, 52 | .hljs-symbol { 53 | color: #ff7733; 54 | } 55 | 56 | .hljs-name { 57 | color: #36a3d9; 58 | } 59 | 60 | .hljs-tag { 61 | color: #00568d; 62 | } 63 | 64 | .hljs-emphasis { 65 | font-style: italic; 66 | } 67 | 68 | .hljs-strong { 69 | font-weight: bold; 70 | } 71 | 72 | .hljs-addition { 73 | color: #91b362; 74 | } 75 | 76 | .hljs-deletion { 77 | color: #d96c75; 78 | } 79 | -------------------------------------------------------------------------------- /docs/book/css/general.css: -------------------------------------------------------------------------------- 1 | /* Base styles and content styles */ 2 | 3 | @import 'variables.css'; 4 | 5 | :root { 6 | /* Browser default font-size is 16px, this way 1 rem = 10px */ 7 | font-size: 62.5%; 8 | color-scheme: var(--color-scheme); 9 | } 10 | 11 | html { 12 | font-family: "Open Sans", sans-serif; 13 | color: var(--fg); 14 | background-color: var(--bg); 15 | text-size-adjust: none; 16 | -webkit-text-size-adjust: none; 17 | } 18 | 19 | body { 20 | margin: 0; 21 | font-size: 1.6rem; 22 | overflow-x: hidden; 23 | } 24 | 25 | code { 26 | font-family: var(--mono-font) !important; 27 | font-size: var(--code-font-size); 28 | direction: ltr !important; 29 | } 30 | 31 | /* make long words/inline code not x overflow */ 32 | main { 33 | overflow-wrap: break-word; 34 | } 35 | 36 | /* make wide tables scroll if they overflow */ 37 | .table-wrapper { 38 | overflow-x: auto; 39 | } 40 | 41 | /* Don't change font size in headers. */ 42 | h1 code, h2 code, h3 code, h4 code, h5 code, h6 code { 43 | font-size: unset; 44 | } 45 | 46 | .left { float: left; } 47 | .right { float: right; } 48 | .boring { opacity: 0.6; } 49 | .hide-boring .boring { display: none; } 50 | .hidden { display: none !important; } 51 | 52 | h2, h3 { margin-block-start: 2.5em; } 53 | h4, h5 { margin-block-start: 2em; } 54 | 55 | .header + .header h3, 56 | .header + .header h4, 57 | .header + .header h5 { 58 | margin-block-start: 1em; 59 | } 60 | 61 | h1:target::before, 62 | h2:target::before, 63 | h3:target::before, 64 | h4:target::before, 65 | h5:target::before, 66 | h6:target::before { 67 | display: inline-block; 68 | content: "»"; 69 | margin-inline-start: -30px; 70 | width: 30px; 71 | } 72 | 73 | /* This is broken on Safari as of version 14, but is fixed 74 | in Safari Technology Preview 117 which I think will be Safari 14.2. 75 | https://bugs.webkit.org/show_bug.cgi?id=218076 76 | */ 77 | :target { 78 | /* Safari does not support logical properties */ 79 | scroll-margin-top: calc(var(--menu-bar-height) + 0.5em); 80 | } 81 | 82 | .page { 83 | outline: 0; 84 | padding: 0 var(--page-padding); 85 | margin-block-start: calc(0px - var(--menu-bar-height)); /* Compensate for the #menu-bar-hover-placeholder */ 86 | } 87 | .page-wrapper { 88 | box-sizing: border-box; 89 | background-color: var(--bg); 90 | } 91 | .no-js .page-wrapper, 92 | .js:not(.sidebar-resizing) .page-wrapper { 93 | transition: margin-left 0.3s ease, transform 0.3s ease; /* Animation: slide away */ 94 | } 95 | [dir=rtl] .js:not(.sidebar-resizing) .page-wrapper { 96 | transition: margin-right 0.3s ease, transform 0.3s ease; /* Animation: slide away */ 97 | } 98 | 99 | .content { 100 | overflow-y: auto; 101 | padding: 0 5px 50px 5px; 102 | } 103 | .content main { 104 | margin-inline-start: auto; 105 | margin-inline-end: auto; 106 | max-width: var(--content-max-width); 107 | } 108 | .content p { line-height: 1.45em; } 109 | .content ol { line-height: 1.45em; } 110 | .content ul { line-height: 1.45em; } 111 | .content a { text-decoration: none; } 112 | .content a:hover { text-decoration: underline; } 113 | .content img, .content video { max-width: 100%; } 114 | .content .header:link, 115 | .content .header:visited { 116 | color: var(--fg); 117 | } 118 | .content .header:link, 119 | .content .header:visited:hover { 120 | text-decoration: none; 121 | } 122 | 123 | table { 124 | margin: 0 auto; 125 | border-collapse: collapse; 126 | } 127 | table td { 128 | padding: 3px 20px; 129 | border: 1px var(--table-border-color) solid; 130 | } 131 | table thead { 132 | background: var(--table-header-bg); 133 | } 134 | table thead td { 135 | font-weight: 700; 136 | border: none; 137 | } 138 | table thead th { 139 | padding: 3px 20px; 140 | } 141 | table thead tr { 142 | border: 1px var(--table-header-bg) solid; 143 | } 144 | /* Alternate background colors for rows */ 145 | table tbody tr:nth-child(2n) { 146 | background: var(--table-alternate-bg); 147 | } 148 | 149 | 150 | blockquote { 151 | margin: 20px 0; 152 | padding: 0 20px; 153 | color: var(--fg); 154 | background-color: var(--quote-bg); 155 | border-block-start: .1em solid var(--quote-border); 156 | border-block-end: .1em solid var(--quote-border); 157 | } 158 | 159 | .warning { 160 | margin: 20px; 161 | padding: 0 20px; 162 | border-inline-start: 2px solid var(--warning-border); 163 | } 164 | 165 | .warning:before { 166 | position: absolute; 167 | width: 3rem; 168 | height: 3rem; 169 | margin-inline-start: calc(-1.5rem - 21px); 170 | content: "ⓘ"; 171 | text-align: center; 172 | background-color: var(--bg); 173 | color: var(--warning-border); 174 | font-weight: bold; 175 | font-size: 2rem; 176 | } 177 | 178 | blockquote .warning:before { 179 | background-color: var(--quote-bg); 180 | } 181 | 182 | kbd { 183 | background-color: var(--table-border-color); 184 | border-radius: 4px; 185 | border: solid 1px var(--theme-popup-border); 186 | box-shadow: inset 0 -1px 0 var(--theme-hover); 187 | display: inline-block; 188 | font-size: var(--code-font-size); 189 | font-family: var(--mono-font); 190 | line-height: 10px; 191 | padding: 4px 5px; 192 | vertical-align: middle; 193 | } 194 | 195 | :not(.footnote-definition) + .footnote-definition, 196 | .footnote-definition + :not(.footnote-definition) { 197 | margin-block-start: 2em; 198 | } 199 | .footnote-definition { 200 | font-size: 0.9em; 201 | margin: 0.5em 0; 202 | } 203 | .footnote-definition p { 204 | display: inline; 205 | } 206 | 207 | .tooltiptext { 208 | position: absolute; 209 | visibility: hidden; 210 | color: #fff; 211 | background-color: #333; 212 | transform: translateX(-50%); /* Center by moving tooltip 50% of its width left */ 213 | left: -8px; /* Half of the width of the icon */ 214 | top: -35px; 215 | font-size: 0.8em; 216 | text-align: center; 217 | border-radius: 6px; 218 | padding: 5px 8px; 219 | margin: 5px; 220 | z-index: 1000; 221 | } 222 | .tooltipped .tooltiptext { 223 | visibility: visible; 224 | } 225 | 226 | .chapter li.part-title { 227 | color: var(--sidebar-fg); 228 | margin: 5px 0px; 229 | font-weight: bold; 230 | } 231 | 232 | .result-no-output { 233 | font-style: italic; 234 | } 235 | -------------------------------------------------------------------------------- /docs/book/css/print.css: -------------------------------------------------------------------------------- 1 | 2 | #sidebar, 3 | #menu-bar, 4 | .nav-chapters, 5 | .mobile-nav-chapters { 6 | display: none; 7 | } 8 | 9 | #page-wrapper.page-wrapper { 10 | transform: none !important; 11 | margin-inline-start: 0px; 12 | overflow-y: initial; 13 | } 14 | 15 | #content { 16 | max-width: none; 17 | margin: 0; 18 | padding: 0; 19 | } 20 | 21 | .page { 22 | overflow-y: initial; 23 | } 24 | 25 | code { 26 | direction: ltr !important; 27 | } 28 | 29 | pre > .buttons { 30 | z-index: 2; 31 | } 32 | 33 | a, a:visited, a:active, a:hover { 34 | color: #4183c4; 35 | text-decoration: none; 36 | } 37 | 38 | h1, h2, h3, h4, h5, h6 { 39 | page-break-inside: avoid; 40 | page-break-after: avoid; 41 | } 42 | 43 | pre, code { 44 | page-break-inside: avoid; 45 | white-space: pre-wrap; 46 | } 47 | 48 | .fa { 49 | display: none !important; 50 | } 51 | -------------------------------------------------------------------------------- /docs/book/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pankona/hashira/8faa229755b2a029082f5e495e307acf9188872b/docs/book/favicon.png -------------------------------------------------------------------------------- /docs/book/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 7 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /docs/book/fonts/SOURCE-CODE-PRO-LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2010, 2012 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe Systems Incorporated in the United States and/or other countries. 2 | 3 | This Font Software is licensed under the SIL Open Font License, Version 1.1. 4 | This license is copied below, and is also available with a FAQ at: 5 | http://scripts.sil.org/OFL 6 | 7 | 8 | ----------------------------------------------------------- 9 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 10 | ----------------------------------------------------------- 11 | 12 | PREAMBLE 13 | The goals of the Open Font License (OFL) are to stimulate worldwide 14 | development of collaborative font projects, to support the font creation 15 | efforts of academic and linguistic communities, and to provide a free and 16 | open framework in which fonts may be shared and improved in partnership 17 | with others. 18 | 19 | The OFL allows the licensed fonts to be used, studied, modified and 20 | redistributed freely as long as they are not sold by themselves. The 21 | fonts, including any derivative works, can be bundled, embedded, 22 | redistributed and/or sold with any software provided that any reserved 23 | names are not used by derivative works. The fonts and derivatives, 24 | however, cannot be released under any other type of license. The 25 | requirement for fonts to remain under this license does not apply 26 | to any document created using the fonts or their derivatives. 27 | 28 | DEFINITIONS 29 | "Font Software" refers to the set of files released by the Copyright 30 | Holder(s) under this license and clearly marked as such. This may 31 | include source files, build scripts and documentation. 32 | 33 | "Reserved Font Name" refers to any names specified as such after the 34 | copyright statement(s). 35 | 36 | "Original Version" refers to the collection of Font Software components as 37 | distributed by the Copyright Holder(s). 38 | 39 | "Modified Version" refers to any derivative made by adding to, deleting, 40 | or substituting -- in part or in whole -- any of the components of the 41 | Original Version, by changing formats or by porting the Font Software to a 42 | new environment. 43 | 44 | "Author" refers to any designer, engineer, programmer, technical 45 | writer or other person who contributed to the Font Software. 46 | 47 | PERMISSION & CONDITIONS 48 | Permission is hereby granted, free of charge, to any person obtaining 49 | a copy of the Font Software, to use, study, copy, merge, embed, modify, 50 | redistribute, and sell modified and unmodified copies of the Font 51 | Software, subject to the following conditions: 52 | 53 | 1) Neither the Font Software nor any of its individual components, 54 | in Original or Modified Versions, may be sold by itself. 55 | 56 | 2) Original or Modified Versions of the Font Software may be bundled, 57 | redistributed and/or sold with any software, provided that each copy 58 | contains the above copyright notice and this license. These can be 59 | included either as stand-alone text files, human-readable headers or 60 | in the appropriate machine-readable metadata fields within text or 61 | binary files as long as those fields can be easily viewed by the user. 62 | 63 | 3) No Modified Version of the Font Software may use the Reserved Font 64 | Name(s) unless explicit written permission is granted by the corresponding 65 | Copyright Holder. This restriction only applies to the primary font name as 66 | presented to the users. 67 | 68 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 69 | Software shall not be used to promote, endorse or advertise any 70 | Modified Version, except to acknowledge the contribution(s) of the 71 | Copyright Holder(s) and the Author(s) or with their explicit written 72 | permission. 73 | 74 | 5) The Font Software, modified or unmodified, in part or in whole, 75 | must be distributed entirely under this license, and must not be 76 | distributed under any other license. The requirement for fonts to 77 | remain under this license does not apply to any document created 78 | using the Font Software. 79 | 80 | TERMINATION 81 | This license becomes null and void if any of the above conditions are 82 | not met. 83 | 84 | DISCLAIMER 85 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 86 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 87 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 88 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 89 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 90 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 91 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 92 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 93 | OTHER DEALINGS IN THE FONT SOFTWARE. 94 | -------------------------------------------------------------------------------- /docs/book/fonts/fonts.css: -------------------------------------------------------------------------------- 1 | /* Open Sans is licensed under the Apache License, Version 2.0. See http://www.apache.org/licenses/LICENSE-2.0 */ 2 | /* Source Code Pro is under the Open Font License. See https://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=OFL */ 3 | 4 | /* open-sans-300 - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */ 5 | @font-face { 6 | font-family: 'Open Sans'; 7 | font-style: normal; 8 | font-weight: 300; 9 | src: local('Open Sans Light'), local('OpenSans-Light'), 10 | url('open-sans-v17-all-charsets-300.woff2') format('woff2'); 11 | } 12 | 13 | /* open-sans-300italic - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */ 14 | @font-face { 15 | font-family: 'Open Sans'; 16 | font-style: italic; 17 | font-weight: 300; 18 | src: local('Open Sans Light Italic'), local('OpenSans-LightItalic'), 19 | url('open-sans-v17-all-charsets-300italic.woff2') format('woff2'); 20 | } 21 | 22 | /* open-sans-regular - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */ 23 | @font-face { 24 | font-family: 'Open Sans'; 25 | font-style: normal; 26 | font-weight: 400; 27 | src: local('Open Sans Regular'), local('OpenSans-Regular'), 28 | url('open-sans-v17-all-charsets-regular.woff2') format('woff2'); 29 | } 30 | 31 | /* open-sans-italic - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */ 32 | @font-face { 33 | font-family: 'Open Sans'; 34 | font-style: italic; 35 | font-weight: 400; 36 | src: local('Open Sans Italic'), local('OpenSans-Italic'), 37 | url('open-sans-v17-all-charsets-italic.woff2') format('woff2'); 38 | } 39 | 40 | /* open-sans-600 - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */ 41 | @font-face { 42 | font-family: 'Open Sans'; 43 | font-style: normal; 44 | font-weight: 600; 45 | src: local('Open Sans SemiBold'), local('OpenSans-SemiBold'), 46 | url('open-sans-v17-all-charsets-600.woff2') format('woff2'); 47 | } 48 | 49 | /* open-sans-600italic - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */ 50 | @font-face { 51 | font-family: 'Open Sans'; 52 | font-style: italic; 53 | font-weight: 600; 54 | src: local('Open Sans SemiBold Italic'), local('OpenSans-SemiBoldItalic'), 55 | url('open-sans-v17-all-charsets-600italic.woff2') format('woff2'); 56 | } 57 | 58 | /* open-sans-700 - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */ 59 | @font-face { 60 | font-family: 'Open Sans'; 61 | font-style: normal; 62 | font-weight: 700; 63 | src: local('Open Sans Bold'), local('OpenSans-Bold'), 64 | url('open-sans-v17-all-charsets-700.woff2') format('woff2'); 65 | } 66 | 67 | /* open-sans-700italic - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */ 68 | @font-face { 69 | font-family: 'Open Sans'; 70 | font-style: italic; 71 | font-weight: 700; 72 | src: local('Open Sans Bold Italic'), local('OpenSans-BoldItalic'), 73 | url('open-sans-v17-all-charsets-700italic.woff2') format('woff2'); 74 | } 75 | 76 | /* open-sans-800 - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */ 77 | @font-face { 78 | font-family: 'Open Sans'; 79 | font-style: normal; 80 | font-weight: 800; 81 | src: local('Open Sans ExtraBold'), local('OpenSans-ExtraBold'), 82 | url('open-sans-v17-all-charsets-800.woff2') format('woff2'); 83 | } 84 | 85 | /* open-sans-800italic - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */ 86 | @font-face { 87 | font-family: 'Open Sans'; 88 | font-style: italic; 89 | font-weight: 800; 90 | src: local('Open Sans ExtraBold Italic'), local('OpenSans-ExtraBoldItalic'), 91 | url('open-sans-v17-all-charsets-800italic.woff2') format('woff2'); 92 | } 93 | 94 | /* source-code-pro-500 - latin_vietnamese_latin-ext_greek_cyrillic-ext_cyrillic */ 95 | @font-face { 96 | font-family: 'Source Code Pro'; 97 | font-style: normal; 98 | font-weight: 500; 99 | src: url('source-code-pro-v11-all-charsets-500.woff2') format('woff2'); 100 | } 101 | -------------------------------------------------------------------------------- /docs/book/fonts/open-sans-v17-all-charsets-300.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pankona/hashira/8faa229755b2a029082f5e495e307acf9188872b/docs/book/fonts/open-sans-v17-all-charsets-300.woff2 -------------------------------------------------------------------------------- /docs/book/fonts/open-sans-v17-all-charsets-300italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pankona/hashira/8faa229755b2a029082f5e495e307acf9188872b/docs/book/fonts/open-sans-v17-all-charsets-300italic.woff2 -------------------------------------------------------------------------------- /docs/book/fonts/open-sans-v17-all-charsets-600.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pankona/hashira/8faa229755b2a029082f5e495e307acf9188872b/docs/book/fonts/open-sans-v17-all-charsets-600.woff2 -------------------------------------------------------------------------------- /docs/book/fonts/open-sans-v17-all-charsets-600italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pankona/hashira/8faa229755b2a029082f5e495e307acf9188872b/docs/book/fonts/open-sans-v17-all-charsets-600italic.woff2 -------------------------------------------------------------------------------- /docs/book/fonts/open-sans-v17-all-charsets-700.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pankona/hashira/8faa229755b2a029082f5e495e307acf9188872b/docs/book/fonts/open-sans-v17-all-charsets-700.woff2 -------------------------------------------------------------------------------- /docs/book/fonts/open-sans-v17-all-charsets-700italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pankona/hashira/8faa229755b2a029082f5e495e307acf9188872b/docs/book/fonts/open-sans-v17-all-charsets-700italic.woff2 -------------------------------------------------------------------------------- /docs/book/fonts/open-sans-v17-all-charsets-800.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pankona/hashira/8faa229755b2a029082f5e495e307acf9188872b/docs/book/fonts/open-sans-v17-all-charsets-800.woff2 -------------------------------------------------------------------------------- /docs/book/fonts/open-sans-v17-all-charsets-800italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pankona/hashira/8faa229755b2a029082f5e495e307acf9188872b/docs/book/fonts/open-sans-v17-all-charsets-800italic.woff2 -------------------------------------------------------------------------------- /docs/book/fonts/open-sans-v17-all-charsets-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pankona/hashira/8faa229755b2a029082f5e495e307acf9188872b/docs/book/fonts/open-sans-v17-all-charsets-italic.woff2 -------------------------------------------------------------------------------- /docs/book/fonts/open-sans-v17-all-charsets-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pankona/hashira/8faa229755b2a029082f5e495e307acf9188872b/docs/book/fonts/open-sans-v17-all-charsets-regular.woff2 -------------------------------------------------------------------------------- /docs/book/fonts/source-code-pro-v11-all-charsets-500.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pankona/hashira/8faa229755b2a029082f5e495e307acf9188872b/docs/book/fonts/source-code-pro-v11-all-charsets-500.woff2 -------------------------------------------------------------------------------- /docs/book/highlight.css: -------------------------------------------------------------------------------- 1 | /* 2 | * An increased contrast highlighting scheme loosely based on the 3 | * "Base16 Atelier Dune Light" theme by Bram de Haan 4 | * (http://atelierbram.github.io/syntax-highlighting/atelier-schemes/dune) 5 | * Original Base16 color scheme by Chris Kempson 6 | * (https://github.com/chriskempson/base16) 7 | */ 8 | 9 | /* Comment */ 10 | .hljs-comment, 11 | .hljs-quote { 12 | color: #575757; 13 | } 14 | 15 | /* Red */ 16 | .hljs-variable, 17 | .hljs-template-variable, 18 | .hljs-attribute, 19 | .hljs-tag, 20 | .hljs-name, 21 | .hljs-regexp, 22 | .hljs-link, 23 | .hljs-name, 24 | .hljs-selector-id, 25 | .hljs-selector-class { 26 | color: #d70025; 27 | } 28 | 29 | /* Orange */ 30 | .hljs-number, 31 | .hljs-meta, 32 | .hljs-built_in, 33 | .hljs-builtin-name, 34 | .hljs-literal, 35 | .hljs-type, 36 | .hljs-params { 37 | color: #b21e00; 38 | } 39 | 40 | /* Green */ 41 | .hljs-string, 42 | .hljs-symbol, 43 | .hljs-bullet { 44 | color: #008200; 45 | } 46 | 47 | /* Blue */ 48 | .hljs-title, 49 | .hljs-section { 50 | color: #0030f2; 51 | } 52 | 53 | /* Purple */ 54 | .hljs-keyword, 55 | .hljs-selector-tag { 56 | color: #9d00ec; 57 | } 58 | 59 | .hljs { 60 | display: block; 61 | overflow-x: auto; 62 | background: #f6f7f6; 63 | color: #000; 64 | } 65 | 66 | .hljs-emphasis { 67 | font-style: italic; 68 | } 69 | 70 | .hljs-strong { 71 | font-weight: bold; 72 | } 73 | 74 | .hljs-addition { 75 | color: #22863a; 76 | background-color: #f0fff4; 77 | } 78 | 79 | .hljs-deletion { 80 | color: #b31d28; 81 | background-color: #ffeef0; 82 | } 83 | -------------------------------------------------------------------------------- /docs/book/tomorrow-night.css: -------------------------------------------------------------------------------- 1 | /* Tomorrow Night Theme */ 2 | /* https://github.com/jmblog/color-themes-for-highlightjs */ 3 | /* Original theme - https://github.com/chriskempson/tomorrow-theme */ 4 | /* https://github.com/jmblog/color-themes-for-highlightjs */ 5 | 6 | /* Tomorrow Comment */ 7 | .hljs-comment { 8 | color: #969896; 9 | } 10 | 11 | /* Tomorrow Red */ 12 | .hljs-variable, 13 | .hljs-attribute, 14 | .hljs-tag, 15 | .hljs-regexp, 16 | .ruby .hljs-constant, 17 | .xml .hljs-tag .hljs-title, 18 | .xml .hljs-pi, 19 | .xml .hljs-doctype, 20 | .html .hljs-doctype, 21 | .css .hljs-id, 22 | .css .hljs-class, 23 | .css .hljs-pseudo { 24 | color: #cc6666; 25 | } 26 | 27 | /* Tomorrow Orange */ 28 | .hljs-number, 29 | .hljs-preprocessor, 30 | .hljs-pragma, 31 | .hljs-built_in, 32 | .hljs-literal, 33 | .hljs-params, 34 | .hljs-constant { 35 | color: #de935f; 36 | } 37 | 38 | /* Tomorrow Yellow */ 39 | .ruby .hljs-class .hljs-title, 40 | .css .hljs-rule .hljs-attribute { 41 | color: #f0c674; 42 | } 43 | 44 | /* Tomorrow Green */ 45 | .hljs-string, 46 | .hljs-value, 47 | .hljs-inheritance, 48 | .hljs-header, 49 | .hljs-name, 50 | .ruby .hljs-symbol, 51 | .xml .hljs-cdata { 52 | color: #b5bd68; 53 | } 54 | 55 | /* Tomorrow Aqua */ 56 | .hljs-title, 57 | .css .hljs-hexcolor { 58 | color: #8abeb7; 59 | } 60 | 61 | /* Tomorrow Blue */ 62 | .hljs-function, 63 | .python .hljs-decorator, 64 | .python .hljs-title, 65 | .ruby .hljs-function .hljs-title, 66 | .ruby .hljs-title .hljs-keyword, 67 | .perl .hljs-sub, 68 | .javascript .hljs-title, 69 | .coffeescript .hljs-title { 70 | color: #81a2be; 71 | } 72 | 73 | /* Tomorrow Purple */ 74 | .hljs-keyword, 75 | .javascript .hljs-function { 76 | color: #b294bb; 77 | } 78 | 79 | .hljs { 80 | display: block; 81 | overflow-x: auto; 82 | background: #1d1f21; 83 | color: #c5c8c6; 84 | } 85 | 86 | .coffeescript .javascript, 87 | .javascript .xml, 88 | .tex .hljs-formula, 89 | .xml .javascript, 90 | .xml .vbscript, 91 | .xml .css, 92 | .xml .hljs-cdata { 93 | opacity: 0.5; 94 | } 95 | 96 | .hljs-addition { 97 | color: #718c00; 98 | } 99 | 100 | .hljs-deletion { 101 | color: #c82829; 102 | } 103 | -------------------------------------------------------------------------------- /docs/src/README.md: -------------------------------------------------------------------------------- 1 | # Table of contents 2 | 3 | {{#include ./SUMMARY.md}} 4 | -------------------------------------------------------------------------------- /docs/src/SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Summary 2 | 3 | - [Introduction](README.md) 4 | - [Overview](./overview.md) 5 | - [Communication](./communication/overview.md) 6 | - [Front to daemon](./communication/front2daemon.md) 7 | - [Daemon to database](./communication/daemon2datastore.md) 8 | - [Daemon](./daemon/overview.md) 9 | - [Entity](./daemon/entity.md) 10 | - [DB tables](./tables.md) 11 | - [Prototype](./prototype.md) 12 | -------------------------------------------------------------------------------- /docs/src/assets/images/uml/636bfd90b359509ea12ac7f7ca3fc622a6af8f45.svg: -------------------------------------------------------------------------------- 1 | alicealicebobbobhogehoge -------------------------------------------------------------------------------- /docs/src/communication/daemon2datastore.md: -------------------------------------------------------------------------------- 1 | # Communication between daemon to datastore 2 | 3 | ## Overview 4 | 5 | - Use GRPC for communication between daemon and datastore 6 | - Daemon send array of command, and datastore will apply them 7 | - Daemon retrieves updates from datastore periodically for syncing 8 | 9 | ## GRPC APIs 10 | 11 | - Use Hashira service. See [daemon overview](../daemon/overview.md). 12 | -------------------------------------------------------------------------------- /docs/src/communication/front2daemon.md: -------------------------------------------------------------------------------- 1 | # Communication between front to daemon 2 | 3 | ## Overview 4 | 5 | - hashira uses GRPC for communication between front and daemon 6 | 7 | ## PC 8 | 9 | ### CLI and Daemon 10 | 11 | - CLI to Daemon 12 | - Add a new task to Backlog 13 | - Change task's status 14 | - Show task list 15 | - Daemon to CLI 16 | - None 17 | 18 | ### GUI and Daemon 19 | 20 | - Assume to use Electron 21 | - GUI to daemon 22 | - Add a new task to Backlog, ToDo, Doing, Done 23 | - Change task's status 24 | - Show task list on each status 25 | - Show consume of each task 26 | - Daemon to GUI 27 | - notify any update of tasks 28 | 29 | - TODO: Show GUI picture 30 | 31 | ## Android 32 | 33 | ### application and daemon 34 | 35 | - GUI to Daemon 36 | - Add a new task to Backlog, ToDo, Doing, Done 37 | - Change task's status 38 | - Show task list on each status 39 | - Show consume of each task 40 | - Daemon to GUI 41 | - Notify any update of tasks 42 | 43 | - TODO: Show GUI picture 44 | 45 | ### Widget and Daemon 46 | 47 | - Widget to Daemon 48 | - Add a new task to Backlog 49 | - Change task's status to Done 50 | - Show task list 51 | - Daemon to widget 52 | - Notify any update of tasks 53 | 54 | - TODO: Show GUI picture 55 | -------------------------------------------------------------------------------- /docs/src/communication/overview.md: -------------------------------------------------------------------------------- 1 | # Communication 2 | 3 | - Use GRPC for communication between front and daemon 4 | - Use GRPC for communication between daemon and datastore 5 | -------------------------------------------------------------------------------- /docs/src/daemon/entity.md: -------------------------------------------------------------------------------- 1 | # Data entities for local document store 2 | 3 | - Two data entities are declared for local document store. 4 | - Tasks 5 | - Priorities 6 | 7 | - Refer [proto file](https://github.com/pankona/hashira/blob/master/proto/hashira.proto) for latest data entity declaration. 8 | 9 | ## Tasks 10 | 11 | - Tasks represents each ToDo items. 12 | 13 | ``` 14 | message Task { 15 | string id = 1; 16 | string name = 2; 17 | Place place = 3; 18 | bool isDeleted = 4; 19 | } 20 | ``` 21 | 22 | ## Priorities 23 | 24 | - Priorities represents priority of each task. 25 | - Priorities are represented as its place and array of task ID. 26 | - The ID placed on lower index means higher priority. 27 | 28 | ``` 29 | message Priority { 30 | Place place = 1; 31 | repeated string ids = 2; 32 | } 33 | ``` 34 | -------------------------------------------------------------------------------- /docs/src/daemon/overview.md: -------------------------------------------------------------------------------- 1 | # Overview of Daemon's functionality 2 | 3 | - Daemon has responsibility for 4 | - Receive requests from front end. 5 | - Caches commands and reflect them to local document store for working without network. 6 | - Send chunk of commands to datastore for syncing. 7 | - Receive chunk of data to sync with datastore. 8 | 9 | ## Caches data entities on local document store 10 | 11 | - Caches commands and reflect them to local document store for working without network. 12 | - [here](./entity.md) is data entities for local document store 13 | 14 | ## Sync with datastore 15 | 16 | - Daemon has a document store to cache commands from front end, and store data from datastore. 17 | - Periodically, daemon tries to perform syncing local data with datastore. 18 | - Send local commands, that are "not synced yet", to datastore. 19 | - Retrieve chunk of data. They are JSON formatted and represent "1 week of data", for example. 20 | - Daemon stores 10 weeks of data in local document store. 21 | 22 | ```plantuml 23 | @startuml 24 | 25 | hide footbox 26 | 27 | participant "client 1" as c1 28 | participant "daemon 1" as d1 29 | participant "datastore" as ds 30 | participant "daemon 2" as d2 31 | participant "client 2" as c2 32 | 33 | note right of d1 : assume daemon 1 is offline 34 | c1 -> d1 : command-1 (cached) 35 | c1 -> d1 : command-2 (cached) 36 | 37 | note right of d1 : assume daemon 1 is online,\n then send all cached commands to datastore 38 | d1 -> ds : command-1, 2 as JSON 39 | 40 | ds -> ds : apply command-1, 2 41 | 42 | d1 -> ds : fetch(10 weeks) 43 | d1 <- ds : chunk of data as JSON\n 10 weeks of data for example 44 | d1 -> d1 : store JSON to local document store 45 | 46 | note right of d2 : assume daemon 2 is offline 47 | d2 <- c2 : command-3 (cached) 48 | d2 <- c2 : command-4 (cached) 49 | 50 | note right of d2 : assume daemon 2 is online,\n then send all cached commands to datastore 51 | ds <- d2 : command-3, 4 as JSON 52 | 53 | ds -> ds : apply command-3, 4 54 | 55 | ds <- d2 : fetch(10 weeks) 56 | ds -> d2 : chunk of data as JSON 57 | d2 -> d2 : store JSON to local document store 58 | 59 | d1 -> ds : fetch(10 weeks) 60 | d1 <- ds : chunk o data as JSON 61 | d1 -> d1 : store JSON to local document store 62 | 63 | @enduml 64 | ``` 65 | 66 | ## GRPC API 67 | 68 | - Hashira Service 69 | - send(array of command) 70 | - sends specified commands. 71 | - retrieve(from, to (number of weeks)) array of task 72 | - returns array of task with specified term. 73 | 74 | - command and related enumeration 75 | 76 | what (enum) 77 | 78 | | enum | remarks | 79 | | ------ | ---------------------- | 80 | | new | create a new task | 81 | | update | update state of a task | 82 | 83 | command (structure) 84 | 85 | | field | type | remarks | 86 | | ------- | ------------ | ---------------------------------------- | 87 | | what | enum of what | new, update, etc. | 88 | | payload | string | JSON formatted string how to treat what. | 89 | 90 | ## Command handling 91 | 92 | - When daemon receives commands, cache them and return ok immediately. 93 | - If daemon is online, send cached commands to datastore. 94 | - When datastore receives commands and succeed to apply them, datastore sends notification to daemon. 95 | - At daemon receiving notification, retrieve chunk of data from datastore for syncing. 96 | - When daemon succeed to apply them, daemon sends notification to front end. 97 | - At front end receiving notification, retrieve chunk of data from daemon. 98 | - When front end succeed to retrieve chunk of data, render them. 99 | 100 | - If daemon is offline, postpone to send commands to datastore. 101 | - Instead, daemon applies the cached commands to local document store, 102 | and send notification to front end as same as written above. 103 | 104 | ```plantuml 105 | @startuml 106 | 107 | hide footbox 108 | 109 | participant client as c 110 | participant daemon as d 111 | participant datastore as ds 112 | 113 | c -> d : send(command-1) 114 | d -> d : cache command-1 115 | c <- d : ok 116 | 117 | alt daemon is online 118 | 119 | note right of d : proceed on goroutine 120 | d -> ds : send(command-1) 121 | ds -> ds : apply(command-1) 122 | d <- ds : ok 123 | 124 | note right of ds: send notification\n because of update 125 | d <- ds : notify 126 | 127 | note right of d : fetch latest data\n from datastore 128 | d -> ds : fetch(10 weeks) 129 | d <- ds : chunk of data 130 | d -> d : apply data to\n local document store 131 | 132 | else daemon is offline 133 | 134 | d -> d : apply data to\n local document store\n (using cache) 135 | 136 | end 137 | 138 | note right of d : send notification\n because of update 139 | c <- d : notify 140 | c -> d : fetch(10 weeks) 141 | c <- d : chunk of data 142 | c -> c : render data 143 | 144 | @enduml 145 | ``` 146 | -------------------------------------------------------------------------------- /docs/src/overview.md: -------------------------------------------------------------------------------- 1 | # hashira Design overview 2 | 3 | ## Functions of hashira on PC 4 | 5 | ### CLI and GUI 6 | 7 | - Add a new task and place it on Backlog 8 | - Change status of tasks to ToDo, Doing, and Done 9 | - Add interrupter task, place it on Doing directory 10 | - Show list of tasks on each status 11 | 12 | ### GUI specific 13 | 14 | - Show chart to indicate how long time spent to current task 15 | 16 | ## Functions of hashira on Android 17 | 18 | ### Application 19 | 20 | - Add a new task and place it on Backlog 21 | - Change status of tasks to ToDo, Doing, and Done 22 | - Add interrupter task, place it on Doing directory 23 | - Show list of tasks on each status 24 | 25 | ### Widget 26 | 27 | - Add a new task and place it on Backlog 28 | - Show list of tasks on Backlog 29 | - Show current task and how long time spent to the task 30 | 31 | ## Functions of Daemon 32 | 33 | - It is for making hashira work even there's no network 34 | - Daemon has database to cache the tasks and "commands" 35 | - "commands" means a unit of modifications to tasks. 36 | e.g) Add new task, move task to ToDo, and so on 37 | - "commands" are used to sync with datastore 38 | - If network is available, then sync registered or changed tasks with cloud 39 | 40 | ## Functions of datastore 41 | 42 | - It is assumed to place on cloud. it is for syncing data between devices 43 | - It accepts "commands" to add or modify tasks 44 | - Daemon can retrieve chunk of commands with from-to query to reflect datastore's update to local database 45 | 46 | ## Modules 47 | 48 | ```plantuml 49 | @startuml 50 | 51 | package local { 52 | package pc { 53 | class pc_cli { 54 | } 55 | class pc_gui { 56 | } 57 | class pc_daemon { 58 | } 59 | } 60 | 61 | package android { 62 | class android_gui { 63 | } 64 | class android_daemon { 65 | } 66 | } 67 | } 68 | 69 | package cloud { 70 | class datastore { 71 | } 72 | } 73 | 74 | pc_cli --> pc_daemon 75 | pc_gui --> pc_daemon 76 | android_gui --> android_daemon 77 | 78 | pc_daemon --> datastore 79 | android_daemon --> datastore 80 | 81 | @enduml 82 | ``` 83 | -------------------------------------------------------------------------------- /docs/src/prototype.md: -------------------------------------------------------------------------------- 1 | # Prototype 2 | 3 | Here's design memorandum of hashira prototyping, for checking usability. 4 | 5 | ## Feature notice of prototype version 6 | 7 | - No performance/resource consideration 8 | - front end is for linux PC only 9 | - CUI and GUI (may use astilectron) 10 | - No cloud syncing 11 | - No "Doing" time calculation 12 | - Daemon uses MySQL as DB 13 | 14 | ## Sequence of tweaking tasks 15 | 16 | ```plantuml 17 | @startuml 18 | 19 | hide footbox 20 | 21 | actor user as user 22 | participant "cui" as cui 23 | participant "daemon" as d 24 | participant "DB" as db 25 | 26 | == initialize daemon == 27 | 28 | user -> d : launch 29 | d -> db : create config and DB if not exist 30 | d <- db : ok 31 | d -> d : start gRPC server 32 | user <- d : ok 33 | 34 | == add a new task == 35 | 36 | cui -> d : command(new)\n via gRPC 37 | note right of d : with task's\n name\n status\n label 38 | d -> db : execute insert query 39 | d <- db : ok 40 | cui <- d : ok 41 | 42 | == retrieve list of tasks (ordinal use) == 43 | 44 | cui -> d : command(list)\n via gRPC 45 | note right of d : with\n Backlog\n ToDo\n Doing\n Today's Done 46 | d -> db : execute select query 47 | d <- db : ok (with task list) 48 | cui <- d : ok (with task list) 49 | cui -> cui : render using\n received tasks 50 | 51 | == retrieve list of tasks (long history) == 52 | 53 | cui -> d : command(list)\n via gRPC 54 | note right of d : with\n Done of this week\n or this month\n or last 2 months 55 | d -> db : execute select query 56 | d <- db : ok (with task list) 57 | cui <- d : ok (with task list) 58 | cui -> cui : render using\n received tasks 59 | 60 | == move a task to Doing == 61 | 62 | cui -> d : command(update)\n via gRPC 63 | note right of d : with\n task id\n new status (Doing) 64 | d -> db : execute update query 65 | note right of db: execute\n move current Doing to ToDo\n move specified task to Doing 66 | d <- db : ok (with updated tasks) 67 | cui <- d : ok (with updated tasks) 68 | cui -> d : retrieve list of tasks 69 | cui <- d : list of tasks 70 | cui -> cui : render using\n received tasks 71 | 72 | == move a task to Done == 73 | 74 | cui -> d : command(update)\n via gRPC 75 | note right of d : with\n task id\n new status (Done) 76 | d -> db : execute update query 77 | note right of db: execute\n move current Doing to Done 78 | d <- db : ok (with updated task) 79 | cui <- d : ok (with updated task) 80 | cui -> d : retrieve list of tasks 81 | cui <- d : list of tasks 82 | cui -> cui : render using\n received tasks 83 | 84 | @enduml 85 | ``` 86 | -------------------------------------------------------------------------------- /docs/src/tables.md: -------------------------------------------------------------------------------- 1 | # Database tables 2 | 3 | ## User 4 | 5 | | column | type | remarks | 6 | | --------- | ------ | ----------------------------- | 7 | | user_id | number | primary key, non-null, unique | 8 | | user_name | string | non-null, unique | 9 | | password | string | non-null | 10 | 11 | ## Task 12 | 13 | | column | type | remarks | 14 | | --------- | ------ | ----------------------------- | 15 | | task_id | number | primary key, non-null, unique | 16 | | task_name | string | non-null | 17 | | user_id | number | non-null | 18 | | status_id | number | non-null | 19 | | label_id | number | non-null | 20 | | done_at | time | non-null | 21 | 22 | - task represents tasks and its status. 23 | 24 | ## Status 25 | 26 | | column | type | remarks | 27 | | ----------- | ------ | ----------------------------- | 28 | | status_id | number | primary key, non-null, unique | 29 | | status_name | string | non-null, unique | 30 | 31 | - status represents task's status. like "Backlog", "ToDo", "Doing" and "Done" will be inserted here. 32 | 33 | ## Consume 34 | 35 | | column | type | remarks | 36 | | ----------- | ------ | ----------------------------- | 37 | | consume_id | number | primary key, non-null, unique | 38 | | task_id | number | non-null | 39 | | started_at | time | non-null | 40 | | finished_at | time | non-null | 41 | | consumed | time | non-null | 42 | 43 | - consume represents how many times are consumed for specified task. 44 | 45 | ## Label 46 | 47 | | column | type | remarks | 48 | | ---------- | ------ | ----------------------------- | 49 | | label_id | number | primary key, non-null, unique | 50 | | label_name | string | non-null | 51 | 52 | - label represents task's label. this is used to task classification. 53 | 54 | ## ER diagram 55 | 56 | ```plantuml 57 | @startuml 58 | 59 | entity task { 60 | + task_id 61 | -- 62 | task_name 63 | status_id 64 | label_id 65 | done_at 66 | } 67 | 68 | entity status { 69 | + status_id 70 | -- 71 | status_name 72 | } 73 | 74 | entity consume { 75 | + consume_id 76 | -- 77 | task_id 78 | started_at 79 | finished_at 80 | consumed 81 | } 82 | 83 | entity label { 84 | + label_id 85 | -- 86 | label_name 87 | } 88 | 89 | task }---- status 90 | task }---- label 91 | task -ri-{ consume 92 | 93 | @enduml 94 | ``` 95 | -------------------------------------------------------------------------------- /dprint.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript": {}, 3 | "json": {}, 4 | "markdown": {}, 5 | "yaml": {}, 6 | "excludes": [ 7 | "**/node_modules", 8 | "**/*-lock.json", 9 | "dist", 10 | "docs/book" 11 | ], 12 | "plugins": [ 13 | "https://plugins.dprint.dev/typescript-0.93.3.wasm", 14 | "https://plugins.dprint.dev/json-0.19.4.wasm", 15 | "https://plugins.dprint.dev/markdown-0.17.8.wasm", 16 | "https://plugins.dprint.dev/g-plane/pretty_yaml-v0.5.0.wasm" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/pankona/hashira 2 | 3 | require ( 4 | github.com/alecthomas/kingpin/v2 v2.4.0 5 | github.com/gofrs/uuid v4.4.0+incompatible 6 | github.com/mattn/go-runewidth v0.0.16 7 | github.com/pankona/gocui v0.3.1-0.20190107094646-a6233010013f 8 | github.com/pankona/orderedmap v0.0.0-20190220081458-0d2a73efbdac 9 | github.com/pkg/errors v0.9.1 10 | github.com/stretchr/testify v1.10.0 11 | go.etcd.io/bbolt v1.4.0 12 | google.golang.org/grpc v1.72.2 13 | google.golang.org/protobuf v1.36.6 14 | ) 15 | 16 | require ( 17 | github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect 18 | github.com/davecgh/go-spew v1.1.1 // indirect 19 | github.com/jesseduffield/termbox-go v0.0.0-20200823212418-a2289ed6aafe // indirect 20 | github.com/kr/pretty v0.1.0 // indirect 21 | github.com/pmezard/go-difflib v1.0.0 // indirect 22 | github.com/rivo/uniseg v0.4.2 // indirect 23 | github.com/xhit/go-str2duration/v2 v2.1.0 // indirect 24 | golang.org/x/net v0.38.0 // indirect 25 | golang.org/x/sys v0.31.0 // indirect 26 | golang.org/x/text v0.23.0 // indirect 27 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect 28 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect 29 | gopkg.in/yaml.v3 v3.0.1 // indirect 30 | ) 31 | 32 | go 1.23.5 33 | -------------------------------------------------------------------------------- /hashira-web/.firebase/hosting.cHVibGlj.cache: -------------------------------------------------------------------------------- 1 | 404.html,1632315303217,b7bab6b83fa074653ff28c8d2a64135d3434575f70a12ab3d3ba8080461b9537 2 | index.html,1632319683437,c768061a6372030d5005e4ee0a6f11812680fdd2c839a70942366bca97545863 3 | assets/bundle.js,1632321960727,aec0ba42de927fb4a95a9eb26093789d3a84883bcbeae541528521324208d8a3 4 | -------------------------------------------------------------------------------- /hashira-web/.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "hashira-web" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /hashira-web/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | public/assets 3 | build 4 | yarn-error.log 5 | -------------------------------------------------------------------------------- /hashira-web/.tool-versions: -------------------------------------------------------------------------------- 1 | yarn 1.22.15 2 | nodejs 22.13.0 3 | -------------------------------------------------------------------------------- /hashira-web/assets/android-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pankona/hashira/8faa229755b2a029082f5e495e307acf9188872b/hashira-web/assets/android-icon-144x144.png -------------------------------------------------------------------------------- /hashira-web/assets/android-icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pankona/hashira/8faa229755b2a029082f5e495e307acf9188872b/hashira-web/assets/android-icon-192x192.png -------------------------------------------------------------------------------- /hashira-web/assets/android-icon-36x36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pankona/hashira/8faa229755b2a029082f5e495e307acf9188872b/hashira-web/assets/android-icon-36x36.png -------------------------------------------------------------------------------- /hashira-web/assets/android-icon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pankona/hashira/8faa229755b2a029082f5e495e307acf9188872b/hashira-web/assets/android-icon-48x48.png -------------------------------------------------------------------------------- /hashira-web/assets/android-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pankona/hashira/8faa229755b2a029082f5e495e307acf9188872b/hashira-web/assets/android-icon-72x72.png -------------------------------------------------------------------------------- /hashira-web/assets/android-icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pankona/hashira/8faa229755b2a029082f5e495e307acf9188872b/hashira-web/assets/android-icon-96x96.png -------------------------------------------------------------------------------- /hashira-web/assets/apple-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pankona/hashira/8faa229755b2a029082f5e495e307acf9188872b/hashira-web/assets/apple-icon-114x114.png -------------------------------------------------------------------------------- /hashira-web/assets/apple-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pankona/hashira/8faa229755b2a029082f5e495e307acf9188872b/hashira-web/assets/apple-icon-120x120.png -------------------------------------------------------------------------------- /hashira-web/assets/apple-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pankona/hashira/8faa229755b2a029082f5e495e307acf9188872b/hashira-web/assets/apple-icon-144x144.png -------------------------------------------------------------------------------- /hashira-web/assets/apple-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pankona/hashira/8faa229755b2a029082f5e495e307acf9188872b/hashira-web/assets/apple-icon-152x152.png -------------------------------------------------------------------------------- /hashira-web/assets/apple-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pankona/hashira/8faa229755b2a029082f5e495e307acf9188872b/hashira-web/assets/apple-icon-180x180.png -------------------------------------------------------------------------------- /hashira-web/assets/apple-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pankona/hashira/8faa229755b2a029082f5e495e307acf9188872b/hashira-web/assets/apple-icon-57x57.png -------------------------------------------------------------------------------- /hashira-web/assets/apple-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pankona/hashira/8faa229755b2a029082f5e495e307acf9188872b/hashira-web/assets/apple-icon-60x60.png -------------------------------------------------------------------------------- /hashira-web/assets/apple-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pankona/hashira/8faa229755b2a029082f5e495e307acf9188872b/hashira-web/assets/apple-icon-72x72.png -------------------------------------------------------------------------------- /hashira-web/assets/apple-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pankona/hashira/8faa229755b2a029082f5e495e307acf9188872b/hashira-web/assets/apple-icon-76x76.png -------------------------------------------------------------------------------- /hashira-web/assets/apple-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pankona/hashira/8faa229755b2a029082f5e495e307acf9188872b/hashira-web/assets/apple-icon-precomposed.png -------------------------------------------------------------------------------- /hashira-web/assets/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pankona/hashira/8faa229755b2a029082f5e495e307acf9188872b/hashira-web/assets/apple-icon.png -------------------------------------------------------------------------------- /hashira-web/assets/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | #ffffff -------------------------------------------------------------------------------- /hashira-web/assets/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pankona/hashira/8faa229755b2a029082f5e495e307acf9188872b/hashira-web/assets/favicon-16x16.png -------------------------------------------------------------------------------- /hashira-web/assets/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pankona/hashira/8faa229755b2a029082f5e495e307acf9188872b/hashira-web/assets/favicon-32x32.png -------------------------------------------------------------------------------- /hashira-web/assets/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pankona/hashira/8faa229755b2a029082f5e495e307acf9188872b/hashira-web/assets/favicon-96x96.png -------------------------------------------------------------------------------- /hashira-web/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pankona/hashira/8faa229755b2a029082f5e495e307acf9188872b/hashira-web/assets/favicon.ico -------------------------------------------------------------------------------- /hashira-web/assets/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hashira web", 3 | "short_name": "hashira-web", 4 | "description": "Web application for hashira", 5 | "start_url": "/", 6 | "display": "standalone", 7 | "background_color": "#ffffff", 8 | "theme_color": "#ffffff", 9 | "icons": [ 10 | { 11 | "src": "/android-icon-36x36.png", 12 | "sizes": "36x36", 13 | "type": "image/png", 14 | "density": "0.75" 15 | }, 16 | { 17 | "src": "/android-icon-48x48.png", 18 | "sizes": "48x48", 19 | "type": "image/png", 20 | "density": "1.0" 21 | }, 22 | { 23 | "src": "/android-icon-72x72.png", 24 | "sizes": "72x72", 25 | "type": "image/png", 26 | "density": "1.5" 27 | }, 28 | { 29 | "src": "/android-icon-96x96.png", 30 | "sizes": "96x96", 31 | "type": "image/png", 32 | "density": "2.0" 33 | }, 34 | { 35 | "src": "/android-icon-144x144.png", 36 | "sizes": "144x144", 37 | "type": "image/png", 38 | "density": "3.0" 39 | }, 40 | { 41 | "src": "/android-icon-192x192.png", 42 | "sizes": "192x192", 43 | "type": "image/png", 44 | "density": "4.0" 45 | } 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /hashira-web/assets/ms-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pankona/hashira/8faa229755b2a029082f5e495e307acf9188872b/hashira-web/assets/ms-icon-144x144.png -------------------------------------------------------------------------------- /hashira-web/assets/ms-icon-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pankona/hashira/8faa229755b2a029082f5e495e307acf9188872b/hashira-web/assets/ms-icon-150x150.png -------------------------------------------------------------------------------- /hashira-web/assets/ms-icon-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pankona/hashira/8faa229755b2a029082f5e495e307acf9188872b/hashira-web/assets/ms-icon-310x310.png -------------------------------------------------------------------------------- /hashira-web/assets/ms-icon-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pankona/hashira/8faa229755b2a029082f5e495e307acf9188872b/hashira-web/assets/ms-icon-70x70.png -------------------------------------------------------------------------------- /hashira-web/assets/service-worker.js: -------------------------------------------------------------------------------- 1 | self.addEventListener("install", (e) => { 2 | console.log("install service worker:", JSON.stringify(e)); 3 | }); 4 | 5 | self.addEventListener("activate", (e) => { 6 | console.log("activate service worker:", JSON.stringify(e)); 7 | }); 8 | 9 | self.addEventListener("fetch", () => {}); 10 | -------------------------------------------------------------------------------- /hashira-web/firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "hosting": { 3 | "public": "build", 4 | "ignore": ["firebase.json", "**/.*", "**/node_modules/**"] 5 | }, 6 | "firestore": { 7 | "rules": "firestore.rules", 8 | "indexes": "firestore.indexes.json" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /hashira-web/firestore.indexes.json: -------------------------------------------------------------------------------- 1 | { 2 | "indexes": [], 3 | "fieldOverrides": [] 4 | } 5 | -------------------------------------------------------------------------------- /hashira-web/firestore.rules: -------------------------------------------------------------------------------- 1 | rules_version = '2'; 2 | service cloud.firestore { 3 | match /databases/{database}/documents { 4 | match /accesstokens/{document=**} { 5 | allow create: if request.auth != null 6 | allow read, delete: if request.auth != null && resource.data.uid == request.auth.uid; 7 | } 8 | match /tasksAndPriorities/{uid} { 9 | allow read: if request.auth != null && request.auth.uid == uid; 10 | } 11 | match /{document=**} { 12 | allow read, write: if false; 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /hashira-web/functions/.gcloudignore: -------------------------------------------------------------------------------- 1 | # This file specifies files that are *not* uploaded to Google Cloud Platform 2 | # using gcloud. It follows the same syntax as .gitignore, with the addition of 3 | # "#!include" directives (which insert the entries of the given .gitignore-style 4 | # file at that point). 5 | # 6 | # For more information, run: 7 | # $ gcloud topic gcloudignore 8 | # 9 | .gcloudignore 10 | # If you would like to upload your .git directory, .gitignore file or files 11 | # from your .gitignore file, remove the corresponding line 12 | # below: 13 | .git 14 | .gitignore 15 | 16 | #!include:.gitignore 17 | -------------------------------------------------------------------------------- /hashira-web/functions/.gitignore: -------------------------------------------------------------------------------- 1 | firebase-debug.log 2 | -------------------------------------------------------------------------------- /hashira-web/functions/Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | go test ./... 3 | 4 | deploy-all: 5 | make deploy-call 6 | 7 | deploy-call: 8 | gcloud functions deploy call --gen2 --entry-point Call --runtime go122 --memory 256Mi --trigger-http --allow-unauthenticated --region asia-northeast1 --max-instances 10 9 | -------------------------------------------------------------------------------- /hashira-web/functions/func.go: -------------------------------------------------------------------------------- 1 | package functions 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "net/url" 7 | 8 | "github.com/pankona/hashira/hashira-web/functions/hashira" 9 | "github.com/pankona/hashira/hashira-web/functions/hashira/store" 10 | ) 11 | 12 | func Call(w http.ResponseWriter, r *http.Request) { 13 | setHeadersForCORS(w) 14 | if r.Method == http.MethodOptions { 15 | w.WriteHeader(http.StatusNoContent) 16 | return 17 | } 18 | 19 | values, err := url.ParseQuery(r.URL.RawQuery) 20 | if err != nil { 21 | log.Printf("failed to parse query: %v", err) 22 | w.WriteHeader(http.StatusBadRequest) 23 | return 24 | } 25 | 26 | method, ok := values["method"] 27 | if !ok { 28 | log.Printf("method is missing in query string") 29 | w.WriteHeader(http.StatusBadRequest) 30 | return 31 | } 32 | 33 | if len(method) != 1 { 34 | log.Printf("only one method is allowed but %d methods are specified", len(method)) 35 | w.WriteHeader(http.StatusBadRequest) 36 | return 37 | } 38 | 39 | h := hashira.New(store.NewAccessTokenStore(), store.NewTaskAndPriorityStore()) 40 | 41 | switch method[0] { 42 | case "ping": 43 | w.WriteHeader(http.StatusOK) 44 | if _, err := w.Write([]byte("pong")); err != nil { 45 | log.Printf("ping failed: %v", err) 46 | } 47 | 48 | case "test-access-token": 49 | h.TestAccessToken(w, r) 50 | 51 | case "upload": 52 | h.Upload(w, r) 53 | 54 | case "download": 55 | h.Download(w, r) 56 | 57 | case "add": 58 | h.Add(w, r) 59 | 60 | default: 61 | log.Printf("%s is not implemented", method[0]) 62 | w.WriteHeader(http.StatusBadRequest) 63 | } 64 | } 65 | 66 | // deprecated. use Call instead. 67 | func Ping(w http.ResponseWriter, r *http.Request) { 68 | setHeadersForCORS(w) 69 | if r.Method == http.MethodOptions { 70 | w.WriteHeader(http.StatusNoContent) 71 | return 72 | } 73 | 74 | w.WriteHeader(http.StatusOK) 75 | if _, err := w.Write([]byte("pong")); err != nil { 76 | log.Printf("ping failed: %v", err) 77 | } 78 | } 79 | 80 | // deprecated. use Call instead. 81 | func TestAccessToken(w http.ResponseWriter, r *http.Request) { 82 | setHeadersForCORS(w) 83 | if r.Method == http.MethodOptions { 84 | w.WriteHeader(http.StatusNoContent) 85 | return 86 | } 87 | h := hashira.New(store.NewAccessTokenStore(), store.NewTaskAndPriorityStore()) 88 | h.TestAccessToken(w, r) 89 | } 90 | 91 | // deprecated. use Call instead. 92 | func Upload(w http.ResponseWriter, r *http.Request) { 93 | setHeadersForCORS(w) 94 | if r.Method == http.MethodOptions { 95 | w.WriteHeader(http.StatusNoContent) 96 | return 97 | } 98 | h := hashira.New(store.NewAccessTokenStore(), store.NewTaskAndPriorityStore()) 99 | h.Upload(w, r) 100 | } 101 | 102 | // deprecated. use Call instead. 103 | func Download(w http.ResponseWriter, r *http.Request) { 104 | setHeadersForCORS(w) 105 | if r.Method == http.MethodOptions { 106 | w.WriteHeader(http.StatusNoContent) 107 | return 108 | } 109 | h := hashira.New(store.NewAccessTokenStore(), store.NewTaskAndPriorityStore()) 110 | h.Download(w, r) 111 | } 112 | 113 | // deprecated. use Call instead. 114 | func Add(w http.ResponseWriter, r *http.Request) { 115 | setHeadersForCORS(w) 116 | if r.Method == http.MethodOptions { 117 | w.WriteHeader(http.StatusNoContent) 118 | return 119 | } 120 | h := hashira.New(store.NewAccessTokenStore(), store.NewTaskAndPriorityStore()) 121 | h.Add(w, r) 122 | } 123 | 124 | func setHeadersForCORS(w http.ResponseWriter) { 125 | w.Header().Set("Access-Control-Allow-Origin", "*") 126 | w.Header().Set("Access-Control-Allow-Methods", "POST") 127 | w.Header().Add("Access-Control-Allow-Headers", "Content-Type") 128 | w.Header().Add("Access-Control-Allow-Headers", "Authorization") 129 | w.Header().Set("Access-Control-Max-Age", "3600") 130 | } 131 | -------------------------------------------------------------------------------- /hashira-web/functions/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/pankona/hashira/hashira-web/functions 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.23.7 6 | 7 | require ( 8 | cloud.google.com/go/firestore v1.18.0 9 | firebase.google.com/go/v4 v4.15.2 10 | github.com/google/go-cmp v0.7.0 11 | google.golang.org/grpc v1.72.2 12 | ) 13 | 14 | require ( 15 | cel.dev/expr v0.20.0 // indirect 16 | cloud.google.com/go v0.117.0 // indirect 17 | cloud.google.com/go/auth v0.13.0 // indirect 18 | cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect 19 | cloud.google.com/go/compute/metadata v0.6.0 // indirect 20 | cloud.google.com/go/iam v1.2.2 // indirect 21 | cloud.google.com/go/longrunning v0.6.2 // indirect 22 | cloud.google.com/go/monitoring v1.21.2 // indirect 23 | cloud.google.com/go/storage v1.49.0 // indirect 24 | github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.26.0 // indirect 25 | github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.48.1 // indirect 26 | github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.48.1 // indirect 27 | github.com/MicahParks/keyfunc v1.9.0 // indirect 28 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 29 | github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42 // indirect 30 | github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect 31 | github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect 32 | github.com/felixge/httpsnoop v1.0.4 // indirect 33 | github.com/go-jose/go-jose/v4 v4.0.5 // indirect 34 | github.com/go-logr/logr v1.4.2 // indirect 35 | github.com/go-logr/stdr v1.2.2 // indirect 36 | github.com/golang-jwt/jwt/v4 v4.5.2 // indirect 37 | github.com/golang/protobuf v1.5.4 // indirect 38 | github.com/google/s2a-go v0.1.8 // indirect 39 | github.com/google/uuid v1.6.0 // indirect 40 | github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect 41 | github.com/googleapis/gax-go/v2 v2.14.1 // indirect 42 | github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect 43 | github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect 44 | github.com/zeebo/errs v1.4.0 // indirect 45 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 46 | go.opentelemetry.io/contrib/detectors/gcp v1.34.0 // indirect 47 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect 48 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect 49 | go.opentelemetry.io/otel v1.34.0 // indirect 50 | go.opentelemetry.io/otel/metric v1.34.0 // indirect 51 | go.opentelemetry.io/otel/sdk v1.34.0 // indirect 52 | go.opentelemetry.io/otel/sdk/metric v1.34.0 // indirect 53 | go.opentelemetry.io/otel/trace v1.34.0 // indirect 54 | golang.org/x/crypto v0.36.0 // indirect 55 | golang.org/x/net v0.38.0 // indirect 56 | golang.org/x/oauth2 v0.26.0 // indirect 57 | golang.org/x/sync v0.12.0 // indirect 58 | golang.org/x/sys v0.31.0 // indirect 59 | golang.org/x/text v0.23.0 // indirect 60 | golang.org/x/time v0.8.0 // indirect 61 | google.golang.org/api v0.215.0 // indirect 62 | google.golang.org/appengine/v2 v2.0.6 // indirect 63 | google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 // indirect 64 | google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect 65 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect 66 | google.golang.org/protobuf v1.36.5 // indirect 67 | ) 68 | -------------------------------------------------------------------------------- /hashira-web/functions/hashira/accesstoken.go: -------------------------------------------------------------------------------- 1 | package hashira 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "regexp" 10 | ) 11 | 12 | var bearerRegex = regexp.MustCompile("(?i)^bearer (.*)$") 13 | 14 | func (h *Hashira) TestAccessToken(w http.ResponseWriter, r *http.Request) { 15 | accesstoken, err := h.retrieveAccessTokenFromHeader(r.Context(), r.Header) 16 | if err != nil { 17 | log.Printf("failed to retrieve accesstoken from header: %v", err) 18 | w.WriteHeader(http.StatusUnauthorized) 19 | return 20 | } 21 | 22 | uid, err := h.AccessTokenStore.FindUidByAccessToken(r.Context(), accesstoken) 23 | if err != nil { 24 | log.Printf("could not find a user who has the accesstoken %v: %v", accesstoken, err) 25 | w.WriteHeader(http.StatusUnauthorized) 26 | return 27 | } 28 | 29 | log.Printf("accesstoken test for %s (uid: %s) is OK", accesstoken, uid) 30 | 31 | w.WriteHeader(http.StatusOK) 32 | w.Write([]byte("OK")) 33 | } 34 | 35 | func (h *Hashira) retrieveAccessTokenFromHeader(ctx context.Context, header http.Header) (string, error) { 36 | if len(header["Authorization"]) != 1 { 37 | return "", errors.New("Authorization header is missing or appears more than once. error") 38 | } 39 | 40 | authHeader := header["Authorization"][0] 41 | matches := bearerRegex.FindStringSubmatch(authHeader) 42 | if len(matches) != 2 { 43 | return "", fmt.Errorf("unexpected Authorization header format: %v", authHeader) 44 | } 45 | 46 | accesstoken := matches[1] 47 | return accesstoken, nil 48 | } 49 | -------------------------------------------------------------------------------- /hashira-web/functions/hashira/accesstoken_test.go: -------------------------------------------------------------------------------- 1 | package hashira 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | ) 10 | 11 | func TestTestAccessToken(t *testing.T) { 12 | t.Parallel() 13 | 14 | defaultMockFindByAccessToken := func(ctx context.Context, accesstoken string) (string, error) { 15 | return "dummy_uid", nil 16 | } 17 | 18 | tests := []struct { 19 | name string 20 | inHeaders []string 21 | wantStatus int 22 | mockFunc func(ctx context.Context, accesstoken string) (string, error) 23 | }{ 24 | { 25 | name: "regular case", 26 | inHeaders: []string{"bearer 123"}, 27 | mockFunc: defaultMockFindByAccessToken, 28 | wantStatus: http.StatusOK, 29 | }, 30 | { 31 | name: "header missing", 32 | inHeaders: nil, 33 | mockFunc: defaultMockFindByAccessToken, 34 | wantStatus: http.StatusUnauthorized, 35 | }, 36 | { 37 | name: "too many headers", 38 | inHeaders: []string{"bearer 123", "bearer 456", "bearer 789"}, 39 | mockFunc: defaultMockFindByAccessToken, 40 | wantStatus: http.StatusUnauthorized, 41 | }, 42 | { 43 | name: "no bearer", 44 | inHeaders: []string{"123"}, 45 | mockFunc: defaultMockFindByAccessToken, 46 | wantStatus: http.StatusUnauthorized, 47 | }, 48 | { 49 | name: "associated user not found ", 50 | inHeaders: []string{"bearer nobody-use-this-access-token"}, 51 | mockFunc: func(ctx context.Context, accesstoken string) (string, error) { 52 | return "", errors.New("user not found who uses this accesstoken") 53 | }, 54 | wantStatus: http.StatusUnauthorized, 55 | }, 56 | } 57 | 58 | for _, tt := range tests { 59 | tt := tt 60 | t.Run(tt.name, func(t *testing.T) { 61 | t.Parallel() 62 | 63 | rec := httptest.NewRecorder() 64 | req := httptest.NewRequest(http.MethodGet, "/", nil) 65 | 66 | for _, header := range tt.inHeaders { 67 | req.Header.Add("Authorization", header) 68 | } 69 | 70 | h := &Hashira{ 71 | AccessTokenStore: &mockAccessTokenStore{ 72 | mockFindUidByAccessToken: tt.mockFunc, 73 | }, 74 | } 75 | 76 | h.TestAccessToken(rec, req) 77 | 78 | result := rec.Result() 79 | if result.StatusCode != tt.wantStatus { 80 | t.Errorf("unexpected result: [got] %d [want] %d", result.StatusCode, tt.wantStatus) 81 | } 82 | }) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /hashira-web/functions/hashira/add.go: -------------------------------------------------------------------------------- 1 | package hashira 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "log" 7 | "net/http" 8 | ) 9 | 10 | func (h *Hashira) Add(w http.ResponseWriter, r *http.Request) { 11 | accesstoken, err := h.retrieveAccessTokenFromHeader(r.Context(), r.Header) 12 | if err != nil { 13 | log.Printf("failed to retrieve accesstoken from header: %v", err) 14 | w.WriteHeader(http.StatusUnauthorized) 15 | return 16 | } 17 | 18 | uid, err := h.AccessTokenStore.FindUidByAccessToken(r.Context(), accesstoken) 19 | if err != nil { 20 | log.Printf("could not find a user who has the accesstoken %v: %v", accesstoken, err) 21 | w.WriteHeader(http.StatusUnauthorized) 22 | return 23 | } 24 | 25 | buf, err := io.ReadAll(r.Body) 26 | if err != nil { 27 | log.Printf("failed to read body: %v", err) 28 | w.WriteHeader(http.StatusInternalServerError) 29 | return 30 | } 31 | 32 | m := map[string]interface{}{} 33 | if err := json.Unmarshal(buf, &m); err != nil { 34 | log.Printf("failed to unmarshal: %v", err) 35 | w.WriteHeader(http.StatusInternalServerError) 36 | return 37 | } 38 | _, ok := m["data"] 39 | if ok { 40 | buf, err = json.Marshal(m["data"]) 41 | if err != nil { 42 | log.Printf("failed to marshal: %v", err) 43 | w.WriteHeader(http.StatusInternalServerError) 44 | return 45 | } 46 | } 47 | 48 | var tp TaskAndPriority 49 | err = json.Unmarshal(buf, &tp) 50 | if err != nil { 51 | log.Printf("failed to read body: %v", err) 52 | w.WriteHeader(http.StatusBadRequest) 53 | return 54 | } 55 | 56 | oldtp, err := h.TaskAndPriorityStore.Load(r.Context(), uid) 57 | if err != nil { 58 | log.Printf("failed to load task and priorities: %v", err) 59 | w.WriteHeader(http.StatusInternalServerError) 60 | return 61 | } 62 | 63 | if tp, err = addTaskAndPriorities(tp, oldtp); err != nil { 64 | log.Printf("failed to merge task and priorities: %v", err) 65 | w.WriteHeader(http.StatusInternalServerError) 66 | return 67 | } 68 | 69 | for k, task := range tp.Tasks { 70 | if task.IsDeleted { 71 | delete(tp.Tasks, k) 72 | } 73 | } 74 | 75 | if err := h.TaskAndPriorityStore.Save(r.Context(), uid, tp); err != nil { 76 | log.Printf("failed to save tasks and priorities: %v", err) 77 | w.WriteHeader(http.StatusInternalServerError) 78 | return 79 | } 80 | } 81 | 82 | func addTaskAndPriorities(newtp, oldtp TaskAndPriority) (TaskAndPriority, error) { 83 | ret := map[string]Task{} 84 | 85 | for k, v := range oldtp.Tasks { 86 | ret[k] = v 87 | } 88 | for k, v := range newtp.Tasks { 89 | ret[k] = v 90 | } 91 | 92 | priorities := appendPriorities(newtp.Priority, oldtp.Priority) 93 | 94 | // Remove priorities if the place is not matched to task's place 95 | for k, p := range priorities { 96 | for i := 0; i < len(p); i++ { 97 | taskID := p[i] 98 | if ret[taskID].Place != k { 99 | p = append(p[:i], p[i+1:]...) 100 | i -= 1 101 | } 102 | } 103 | priorities[k] = p 104 | } 105 | 106 | return TaskAndPriority{ 107 | Tasks: ret, 108 | Priority: priorities, 109 | }, nil 110 | } 111 | 112 | func appendPriorities(newPriorities, oldPriorities map[string][]string) map[string][]string { 113 | ret := map[string][]string{"BACKLOG": {}, "TODO": {}, "DOING": {}, "DONE": {}} 114 | for k := range ret { 115 | ret[k] = append(newPriorities[k], oldPriorities[k]...) 116 | } 117 | return ret 118 | } 119 | -------------------------------------------------------------------------------- /hashira-web/functions/hashira/download.go: -------------------------------------------------------------------------------- 1 | package hashira 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "net/http" 7 | ) 8 | 9 | func (h *Hashira) Download(w http.ResponseWriter, r *http.Request) { 10 | accesstoken, err := h.retrieveAccessTokenFromHeader(r.Context(), r.Header) 11 | if err != nil { 12 | log.Printf("failed to retrieve accesstoken from header: %v", err) 13 | w.WriteHeader(http.StatusUnauthorized) 14 | return 15 | } 16 | 17 | uid, err := h.AccessTokenStore.FindUidByAccessToken(r.Context(), accesstoken) 18 | if err != nil { 19 | log.Printf("could not find a user who has the accesstoken %v: %v", accesstoken, err) 20 | w.WriteHeader(http.StatusUnauthorized) 21 | return 22 | } 23 | 24 | tp, err := h.TaskAndPriorityStore.Load(r.Context(), uid) 25 | if err != nil { 26 | log.Printf("failed to load tasks and priorities: %v", err) 27 | w.WriteHeader(http.StatusInternalServerError) 28 | return 29 | } 30 | 31 | w.WriteHeader(http.StatusOK) 32 | if err := json.NewEncoder(w).Encode(tp); err != nil { 33 | log.Printf("failed to write response body: %v", err) 34 | return 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /hashira-web/functions/hashira/download_test.go: -------------------------------------------------------------------------------- 1 | package hashira 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "net/http" 8 | "net/http/httptest" 9 | "testing" 10 | 11 | "github.com/google/go-cmp/cmp" 12 | ) 13 | 14 | func TestDownload(t *testing.T) { 15 | t.Parallel() 16 | 17 | var ( 18 | defaultBody = TaskAndPriority{ 19 | Tasks: map[string]Task{ 20 | "task1": {ID: "task1", Name: "task", Place: "Backlog", IsDeleted: false}, 21 | }, 22 | Priority: map[string][]string{ 23 | "Backlog": {"task1"}, 24 | }, 25 | } 26 | defaultMockFunc = func(ctx context.Context, uid string) (TaskAndPriority, error) { return defaultBody, nil } 27 | ) 28 | 29 | tests := []struct { 30 | name string 31 | wantStatus int 32 | wantRespBody TaskAndPriority 33 | mockFunc func(ctx context.Context, uid string) (TaskAndPriority, error) 34 | }{ 35 | { 36 | name: "regular case", 37 | wantStatus: http.StatusOK, 38 | wantRespBody: defaultBody, 39 | mockFunc: defaultMockFunc, 40 | }, 41 | { 42 | name: "failed to load", 43 | wantStatus: http.StatusInternalServerError, 44 | wantRespBody: defaultBody, 45 | mockFunc: func(ctx context.Context, uid string) (TaskAndPriority, error) { 46 | return TaskAndPriority{}, errors.New("dummy error") 47 | }, 48 | }, 49 | } 50 | 51 | for _, tt := range tests { 52 | tt := tt 53 | t.Run(tt.name, func(t *testing.T) { 54 | t.Parallel() 55 | 56 | rec := httptest.NewRecorder() 57 | 58 | req := httptest.NewRequest(http.MethodPost, "/", nil) 59 | req.Header.Add("Authorization", "bearer 123") 60 | 61 | h := &Hashira{ 62 | AccessTokenStore: &mockAccessTokenStore{ 63 | mockFindUidByAccessToken: func(ctx context.Context, accesstoken string) (string, error) { 64 | return "dummy_uid", nil 65 | }, 66 | }, 67 | TaskAndPriorityStore: &mockTaskAndPriorityStore{ 68 | mockLoad: tt.mockFunc, 69 | }, 70 | } 71 | 72 | h.Download(rec, req) 73 | 74 | result := rec.Result() 75 | if result.StatusCode != tt.wantStatus { 76 | t.Errorf("unexpected result: [got] %d [want] %d", result.StatusCode, tt.wantStatus) 77 | } 78 | 79 | // don't continue testing since body is nil if response is not ok 80 | if tt.wantStatus != http.StatusOK { 81 | return 82 | } 83 | 84 | var gotRespBody TaskAndPriority 85 | if err := json.NewDecoder(result.Body).Decode(&gotRespBody); err != nil { 86 | t.Errorf("failed to decode response body: %v", err) 87 | } 88 | 89 | if diff := cmp.Diff(tt.wantRespBody, gotRespBody); diff != "" { 90 | t.Errorf("unexpected result: diff: %s", diff) 91 | } 92 | }) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /hashira-web/functions/hashira/entities.go: -------------------------------------------------------------------------------- 1 | package hashira 2 | 3 | type Task struct { 4 | ID string 5 | Name string 6 | Place string 7 | IsDeleted bool 8 | } 9 | 10 | type Priority map[string][]string 11 | 12 | // priority's key should be one of following strings: 13 | // "BACKLOG", "TODO", "DOING", "DONE" 14 | type TaskAndPriority struct { 15 | Tasks map[string]Task `json:"tasks"` 16 | Priority Priority `json:"priority"` 17 | } 18 | -------------------------------------------------------------------------------- /hashira-web/functions/hashira/hashira.go: -------------------------------------------------------------------------------- 1 | package hashira 2 | 3 | type Hashira struct { 4 | AccessTokenStore AccessTokenStore 5 | TaskAndPriorityStore TaskAndPriorityStore 6 | } 7 | 8 | func New(atStore AccessTokenStore, tpStore TaskAndPriorityStore) *Hashira { 9 | return &Hashira{ 10 | AccessTokenStore: atStore, 11 | TaskAndPriorityStore: tpStore, 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /hashira-web/functions/hashira/hashira_test.go: -------------------------------------------------------------------------------- 1 | package hashira 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type mockAccessTokenStore struct { 8 | mockFindUidByAccessToken func(ctx context.Context, accesstoken string) (string, error) 9 | } 10 | 11 | func (m *mockAccessTokenStore) FindUidByAccessToken(ctx context.Context, accesstoken string) (string, error) { 12 | return m.mockFindUidByAccessToken(ctx, accesstoken) 13 | } 14 | 15 | type mockTaskAndPriorityStore struct { 16 | mockSave func(ctx context.Context, uid string, tp TaskAndPriority) error 17 | mockLoad func(ctx context.Context, uid string) (TaskAndPriority, error) 18 | } 19 | 20 | func (m *mockTaskAndPriorityStore) Save(ctx context.Context, uid string, tp TaskAndPriority) error { 21 | return m.mockSave(ctx, uid, tp) 22 | } 23 | 24 | func (m *mockTaskAndPriorityStore) Load(ctx context.Context, uid string) (TaskAndPriority, error) { 25 | return m.mockLoad(ctx, uid) 26 | } 27 | -------------------------------------------------------------------------------- /hashira-web/functions/hashira/merge.go: -------------------------------------------------------------------------------- 1 | package hashira 2 | 3 | import "sort" 4 | 5 | func mergeStringSlice(a, b []string) []string { 6 | ar := stringSliceToRankedDataSlice(a) 7 | br := stringSliceToRankedDataSlice(b) 8 | 9 | ret := mergeRankedData(ar, br) 10 | 11 | return rankedDataSliceToStringSlice(ret) 12 | } 13 | 14 | func stringSliceToRankedDataSlice(a []string) []rankedData { 15 | ret := make([]rankedData, 0, len(a)) 16 | for i := range a { 17 | ret = append(ret, rankedData{a[i], i}) 18 | } 19 | return ret 20 | } 21 | 22 | func rankedDataSliceToStringSlice(a []rankedData) []string { 23 | ret := make([]string, 0, len(a)) 24 | for _, v := range a { 25 | ret = append(ret, v.data.(string)) 26 | } 27 | return ret 28 | } 29 | 30 | type rankedData struct { 31 | data interface{} 32 | rank int 33 | } 34 | 35 | func mergeRankedData(ar, br []rankedData) []rankedData { 36 | br = removeDuplicates(br, ar) 37 | 38 | ret := append(ar, br...) 39 | 40 | sort.SliceStable(ret, func(i, j int) bool { 41 | return ret[i].rank < ret[j].rank 42 | }) 43 | 44 | return ret 45 | } 46 | 47 | func max(a, b int) int { 48 | if a > b { 49 | return a 50 | } 51 | return b 52 | } 53 | 54 | // return a - b 55 | func removeDuplicates(a, b []rankedData) []rankedData { 56 | ret := make([]rankedData, 0, max(len(a), len(b))) 57 | for i, v := range a { 58 | if includes(b, v) { 59 | continue 60 | } 61 | ret = append(ret, a[i]) 62 | } 63 | return ret 64 | } 65 | 66 | func includes(a []rankedData, i rankedData) bool { 67 | for _, v := range a { 68 | if v.data == i.data { 69 | return true 70 | } 71 | } 72 | return false 73 | } 74 | -------------------------------------------------------------------------------- /hashira-web/functions/hashira/merge_test.go: -------------------------------------------------------------------------------- 1 | package hashira 2 | 3 | import ( 4 | "strconv" 5 | "testing" 6 | 7 | "github.com/google/go-cmp/cmp" 8 | ) 9 | 10 | func TestMergeRankedData(t *testing.T) { 11 | t.Parallel() 12 | 13 | tests := []struct { 14 | inA []int 15 | inB []int 16 | want []int 17 | }{ 18 | { 19 | inA: []int{1, 3, 5}, 20 | inB: []int{2, 4, 6}, 21 | want: []int{1, 2, 3, 4, 5, 6}, 22 | }, 23 | { 24 | inA: []int{1, 3, 5, 7, 9, 11, 13}, 25 | inB: []int{2, 4, 6, 8, 10}, 26 | want: []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 13}, 27 | }, 28 | { 29 | inA: []int{1, 3, 5, 7, 9}, 30 | inB: []int{2, 4, 6, 8, 10, 11, 13}, 31 | want: []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 13}, 32 | }, 33 | { 34 | inA: []int{1, 3, 5, 7, 9}, 35 | inB: nil, 36 | want: []int{1, 3, 5, 7, 9}, 37 | }, 38 | { 39 | inA: nil, 40 | inB: []int{1, 3, 5, 7, 9}, 41 | want: []int{1, 3, 5, 7, 9}, 42 | }, 43 | { 44 | inA: []int{1, 3, 5, 7, 9}, 45 | inB: []int{1, 3, 5, 7, 9, 11}, 46 | want: []int{1, 3, 5, 7, 9, 11}, 47 | }, 48 | { 49 | inA: []int{1, 3, 5, 7, 9}, 50 | inB: []int{1, 3, 5, 7, 9, 2, 4, 6, 8, 10, 11}, 51 | want: []int{1, 3, 5, 7, 9, 2, 4, 6, 8, 10, 11}, 52 | }, 53 | { 54 | inA: []int{1, 3, 5, 7, 9}, 55 | inB: []int{9, 7, 5, 3, 1}, 56 | want: []int{1, 3, 5, 7, 9}, 57 | }, 58 | { 59 | inA: []int{1, 3, 5, 7, 9, 2}, 60 | inB: []int{2, 4, 6, 8, 10}, 61 | want: []int{1, 3, 4, 5, 6, 7, 8, 9, 10, 2}, 62 | }, 63 | } 64 | 65 | for i, tt := range tests { 66 | tt := tt 67 | if i != 8 { 68 | continue 69 | } 70 | t.Run(strconv.Itoa(i), func(t *testing.T) { 71 | t.Parallel() 72 | 73 | a := intSliceToRankedDataSlice(tt.inA) 74 | b := intSliceToRankedDataSlice(tt.inB) 75 | 76 | gotRankedDataSlice := mergeRankedData(a, b) 77 | got := rankedDataSliceToIntSlice(gotRankedDataSlice) 78 | if diff := cmp.Diff(tt.want, got); diff != "" { 79 | t.Errorf("unexpected result: diff: %s", diff) 80 | } 81 | }) 82 | } 83 | } 84 | 85 | func intSliceToRankedDataSlice(a []int) []rankedData { 86 | ret := make([]rankedData, 0, len(a)) 87 | for i := range a { 88 | ret = append(ret, rankedData{a[i], i}) 89 | } 90 | return ret 91 | } 92 | 93 | func rankedDataSliceToIntSlice(a []rankedData) []int { 94 | ret := make([]int, 0, len(a)) 95 | for _, v := range a { 96 | ret = append(ret, v.data.(int)) 97 | } 98 | return ret 99 | } 100 | -------------------------------------------------------------------------------- /hashira-web/functions/hashira/store.go: -------------------------------------------------------------------------------- 1 | package hashira 2 | 3 | import "context" 4 | 5 | type AccessTokenStore interface { 6 | FindUidByAccessToken(ctx context.Context, accesstoken string) (string, error) 7 | } 8 | 9 | type TaskAndPriorityStore interface { 10 | Save(ctx context.Context, uid string, tp TaskAndPriority) error 11 | Load(ctx context.Context, uid string) (TaskAndPriority, error) 12 | } 13 | -------------------------------------------------------------------------------- /hashira-web/functions/hashira/store/atstore.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "cloud.google.com/go/firestore" 8 | firebase "firebase.google.com/go/v4" 9 | ) 10 | 11 | type AccessTokenStore struct{} 12 | 13 | func NewAccessTokenStore() *AccessTokenStore { 14 | return &AccessTokenStore{} 15 | } 16 | 17 | func (a *AccessTokenStore) FindUidByAccessToken(ctx context.Context, accesstoken string) (string, error) { 18 | // accesstoken length is assumed to be 36 (uuid). 19 | // If the accesstoken is longer than it, let's assume it is idtoken 20 | if len(accesstoken) > 36 { 21 | app, err := firebase.NewApp(ctx, nil) 22 | if err != nil { 23 | return "", fmt.Errorf("failed to create new firebase app: %w", err) 24 | } 25 | 26 | cli, err := app.Auth(ctx) 27 | if err != nil { 28 | return "", fmt.Errorf("failed to prepare Auth client: %w", err) 29 | } 30 | 31 | token, err := cli.VerifyIDToken(ctx, accesstoken) 32 | if err != nil { 33 | return "", fmt.Errorf("failed to verify idtoken: %w", err) 34 | } 35 | 36 | return token.UID, nil 37 | } 38 | 39 | client, err := firestore.NewClient(ctx, "hashira-web") 40 | if err != nil { 41 | return "", fmt.Errorf("failed to create firebase client: %w", err) 42 | } 43 | 44 | iter := client.Collection("accesstokens").Where("accesstoken", "==", accesstoken).Documents(ctx) 45 | docs, err := iter.GetAll() 46 | if err != nil { 47 | return "", fmt.Errorf("failed to get documents: %w", err) 48 | } 49 | if len(docs) == 0 { 50 | return "", fmt.Errorf("user not found who has the accesstoken [%v]", accesstoken) 51 | } 52 | if len(docs) >= 2 { 53 | return "", fmt.Errorf("at least two users has same accesstoken [%s]. data inconsistency. fatal", accesstoken) 54 | } 55 | doc := docs[0] 56 | uid, ok := doc.Data()["uid"] 57 | if !ok { 58 | return "", fmt.Errorf("fetched accesstoken data doesn't have uid field. fatal") 59 | } 60 | 61 | ret, ok := uid.(string) 62 | if !ok { 63 | return "", fmt.Errorf("fetched uid is not string. fatal") 64 | } 65 | 66 | return ret, nil 67 | } 68 | -------------------------------------------------------------------------------- /hashira-web/functions/hashira/store/tpstore.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | 9 | "cloud.google.com/go/firestore" 10 | "github.com/pankona/hashira/hashira-web/functions/hashira" 11 | "google.golang.org/grpc/codes" 12 | "google.golang.org/grpc/status" 13 | ) 14 | 15 | type TaskAndPriorityStore struct{} 16 | 17 | func NewTaskAndPriorityStore() *TaskAndPriorityStore { 18 | return &TaskAndPriorityStore{} 19 | } 20 | 21 | func (t *TaskAndPriorityStore) Save(ctx context.Context, uid string, tp hashira.TaskAndPriority) error { 22 | client, err := firestore.NewClient(ctx, "hashira-web") 23 | if err != nil { 24 | return fmt.Errorf("failed to create firebase client: %w", err) 25 | } 26 | 27 | buf := &bytes.Buffer{} 28 | if err := json.NewEncoder(buf).Encode(tp); err != nil { 29 | return fmt.Errorf("failed to encode data: %w", err) 30 | } 31 | 32 | if _, err := client.Collection("tasksAndPriorities").Doc(uid).Set(ctx, tp); err != nil { 33 | return fmt.Errorf("failed to write documents: %w", err) 34 | } 35 | 36 | return nil 37 | } 38 | 39 | func (t *TaskAndPriorityStore) Load(ctx context.Context, uid string) (hashira.TaskAndPriority, error) { 40 | client, err := firestore.NewClient(ctx, "hashira-web") 41 | if err != nil { 42 | return hashira.TaskAndPriority{}, fmt.Errorf("failed to create firebase client: %w", err) 43 | } 44 | 45 | ds, err := client.Collection("tasksAndPriorities").Doc(uid).Get(ctx) 46 | if err != nil { 47 | if s, ok := status.FromError(err); ok { 48 | if s.Code() == codes.NotFound { 49 | // update with empty tasks and priorities for initial data 50 | tp := hashira.TaskAndPriority{} 51 | buf := &bytes.Buffer{} 52 | if err := json.NewEncoder(buf).Encode(tp); err != nil { 53 | return hashira.TaskAndPriority{}, fmt.Errorf("failed to encode data: %w", err) 54 | } 55 | if _, err := client.Collection("tasksAndPriorities").Doc(uid).Create(ctx, tp); err != nil { 56 | return hashira.TaskAndPriority{}, fmt.Errorf("failed to create initial documents: %w", err) 57 | } 58 | return hashira.TaskAndPriority{}, nil 59 | } 60 | } 61 | return hashira.TaskAndPriority{}, fmt.Errorf("failed to get documents: %w", err) 62 | } 63 | 64 | buf := &bytes.Buffer{} 65 | if err := json.NewEncoder(buf).Encode(ds.Data()); err != nil { 66 | return hashira.TaskAndPriority{}, fmt.Errorf("failed to encode data: %w", err) 67 | } 68 | 69 | var ret hashira.TaskAndPriority 70 | json.NewDecoder(buf).Decode(&ret) 71 | 72 | return ret, nil 73 | } 74 | -------------------------------------------------------------------------------- /hashira-web/functions/hashira/upload.go: -------------------------------------------------------------------------------- 1 | package hashira 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "log" 7 | "net/http" 8 | ) 9 | 10 | func (h *Hashira) Upload(w http.ResponseWriter, r *http.Request) { 11 | accesstoken, err := h.retrieveAccessTokenFromHeader(r.Context(), r.Header) 12 | if err != nil { 13 | log.Printf("failed to retrieve accesstoken from header: %v", err) 14 | w.WriteHeader(http.StatusUnauthorized) 15 | return 16 | } 17 | 18 | uid, err := h.AccessTokenStore.FindUidByAccessToken(r.Context(), accesstoken) 19 | if err != nil { 20 | log.Printf("could not find a user who has the accesstoken %v: %v", accesstoken, err) 21 | w.WriteHeader(http.StatusUnauthorized) 22 | return 23 | } 24 | 25 | buf, err := io.ReadAll(r.Body) 26 | if err != nil { 27 | log.Printf("failed to read body: %v", err) 28 | w.WriteHeader(http.StatusInternalServerError) 29 | return 30 | } 31 | 32 | m := map[string]interface{}{} 33 | if err := json.Unmarshal(buf, &m); err != nil { 34 | log.Printf("failed to unmarshal: %v", err) 35 | w.WriteHeader(http.StatusInternalServerError) 36 | return 37 | } 38 | _, ok := m["data"] 39 | if ok { 40 | buf, err = json.Marshal(m["data"]) 41 | if err != nil { 42 | log.Printf("failed to marshal: %v", err) 43 | w.WriteHeader(http.StatusInternalServerError) 44 | return 45 | } 46 | } 47 | 48 | var tp TaskAndPriority 49 | err = json.Unmarshal(buf, &tp) 50 | if err != nil { 51 | log.Printf("failed to read body: %v", err) 52 | w.WriteHeader(http.StatusBadRequest) 53 | return 54 | } 55 | 56 | oldtp, err := h.TaskAndPriorityStore.Load(r.Context(), uid) 57 | if err != nil { 58 | log.Printf("failed to load task and priorities: %v", err) 59 | w.WriteHeader(http.StatusInternalServerError) 60 | return 61 | } 62 | 63 | if tp, err = mergeTaskAndPriorities(tp, oldtp); err != nil { 64 | log.Printf("failed to merge task and priorities: %v", err) 65 | w.WriteHeader(http.StatusInternalServerError) 66 | return 67 | } 68 | 69 | for k, task := range tp.Tasks { 70 | if task.IsDeleted { 71 | delete(tp.Tasks, k) 72 | } 73 | } 74 | 75 | if err := h.TaskAndPriorityStore.Save(r.Context(), uid, tp); err != nil { 76 | log.Printf("failed to save tasks and priorities: %v", err) 77 | w.WriteHeader(http.StatusInternalServerError) 78 | return 79 | } 80 | } 81 | 82 | func mergeTaskAndPriorities(newtp, oldtp TaskAndPriority) (TaskAndPriority, error) { 83 | ret := map[string]Task{} 84 | 85 | for k, v := range oldtp.Tasks { 86 | ret[k] = v 87 | } 88 | for k, v := range newtp.Tasks { 89 | ret[k] = v 90 | } 91 | 92 | priorities := mergePriorities(newtp.Priority, oldtp.Priority) 93 | 94 | // Remove priorities if the place is not matched to task's place 95 | for k, p := range priorities { 96 | for i := 0; i < len(p); i++ { 97 | taskID := p[i] 98 | if ret[taskID].Place != k { 99 | p = append(p[:i], p[i+1:]...) 100 | i -= 1 101 | } 102 | } 103 | priorities[k] = p 104 | } 105 | 106 | return TaskAndPriority{ 107 | Tasks: ret, 108 | Priority: priorities, 109 | }, nil 110 | } 111 | 112 | func mergePriorities(newPriorities, oldPriorities map[string][]string) map[string][]string { 113 | ret := map[string][]string{"BACKLOG": {}, "TODO": {}, "DOING": {}, "DONE": {}} 114 | for k := range ret { 115 | ret[k] = mergeStringSlice(newPriorities[k], oldPriorities[k]) 116 | } 117 | return ret 118 | } 119 | -------------------------------------------------------------------------------- /hashira-web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 15 | 20 | 25 | 30 | 35 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | hashira web 49 | 50 | 51 |
52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /hashira-web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hashira-web", 3 | "version": "0.0.1", 4 | "description": "web frontend for hashira", 5 | "main": "index.js", 6 | "repository": "https://github.com/hashira", 7 | "author": "pankona", 8 | "license": "MIT", 9 | "scripts": { 10 | "start": "vite", 11 | "build": "vite build", 12 | "preview": "vite preview", 13 | "typecheck": "tsc --noEmit", 14 | "deploy-firebase-rules": "firebase deploy --only firestore:rules" 15 | }, 16 | "devDependencies": { 17 | "@types/babel__core": "^7.20.3", 18 | "@types/node": "~22.13.17", 19 | "@types/styled-components": "^5.1.34", 20 | "@types/uuid": "^10.0.0", 21 | "@vitejs/plugin-react": "^4.5.0", 22 | "ansi-regex": "6.1.0", 23 | "firebase-tools": "^14.5.1", 24 | "react-router-dom": "^7.6.1", 25 | "reload": "^3.3.0", 26 | "styled-components": "^5.3.11", 27 | "styled-normalize": "^8.1.1", 28 | "ts-node": "^10.9.2", 29 | "typescript": "5.8.3", 30 | "vite": "^4.5.14" 31 | }, 32 | "dependencies": { 33 | "@types/react": "^19.1.6", 34 | "@types/react-dom": "^19.1.5", 35 | "firebase": "^11.8.1", 36 | "react": "^19.1.0", 37 | "react-dom": "^19.1.0", 38 | "uuid": "^11.1.0" 39 | }, 40 | "resolutions": { 41 | "pac-resolver": "^5.0.0" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /hashira-web/src/AccessToken.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import * as firebase from "./firebase"; 3 | import Header from "./Header"; 4 | import { useFetchAccessTokens } from "./hooks"; 5 | 6 | const AccessToken: React.FC<{ user: firebase.User | null | undefined }> = ({ 7 | user, 8 | }) => { 9 | const [fetchAccessTokenState, fetchAccessTokens] = useFetchAccessTokens(); 10 | const accesstokens = fetchAccessTokenState.data; 11 | const [checkedTokens, setCheckedTokens] = React.useState<{ 12 | [key: string]: boolean; 13 | }>({}); 14 | 15 | React.useEffect(() => { 16 | if (user) { 17 | Promise.all([fetchAccessTokens(user.uid)]).catch((e) => { 18 | console.log("fetch error:", JSON.stringify(e)); 19 | }); 20 | } 21 | }, [user]); 22 | 23 | return ( 24 |
25 |
26 | {!user || !accesstokens ?
Loading...
: ( 27 | <> 28 | 36 | 57 | {accesstokens.map((token: string) => { 58 | return ( 59 |
  • 60 | ) => { 64 | setCheckedTokens({ 65 | ...checkedTokens, 66 | [token]: e.target.checked, 67 | }); 68 | }} 69 | name={token} 70 | value={token} 71 | /> 72 | {token} 73 |
  • 74 | ); 75 | })} 76 | 77 | )} 78 |
    79 | ); 80 | }; 81 | 82 | export default AccessToken; 83 | -------------------------------------------------------------------------------- /hashira-web/src/Header.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useNavigate } from "react-router-dom"; 3 | import styled from "styled-components"; 4 | import * as firebase from "./firebase"; 5 | import { revision } from "./revision"; 6 | 7 | const StyledHeader = styled.div` 8 | display: flex; 9 | justify-content: space-between; 10 | padding-left: 8px; 11 | padding-right: 8px; 12 | padding-top: 8px; 13 | padding-bottom: 8px; 14 | `; 15 | 16 | const StyledRevision = styled.div` 17 | width: 100%; 18 | white-space: nowrap; 19 | overflow-x: scroll; 20 | -ms-overflow-style: none; 21 | scrollbar-width: none; 22 | ::-webkit-scrollbar { 23 | display: none; 24 | } 25 | `; 26 | 27 | const StyledLoginLogout = styled.div` 28 | display: flex; 29 | justify-content: end; 30 | gap: 16px; 31 | `; 32 | 33 | const Header: React.FC<{ 34 | user: firebase.User | null | undefined; 35 | }> = ({ user }) => { 36 | const navigate = useNavigate(); 37 | 38 | return ( 39 | 40 |
    41 |
    navigate("/")} 48 | > 49 | hashira web 50 |
    51 | {revision()} 52 |
    53 | 54 | {(() => { 55 | switch (user) { 56 | case undefined: 57 | return
    ; 58 | case null: 59 | return ( 60 |
    61 | Login 62 |
    63 | ); 64 | default: 65 | return ( 66 | <> 67 |
    navigate("/tags")} 69 | style={{ cursor: "pointer" }} 70 | > 71 | Tags 72 |
    73 |
    navigate("/accesstokens")} 75 | style={{ cursor: "pointer" }} 76 | > 77 | Access tokens 78 |
    79 |
    80 | Logout 81 |
    82 | 83 | ); 84 | } 85 | })()} 86 |
    87 |
    88 | ); 89 | }; 90 | 91 | export default Header; 92 | -------------------------------------------------------------------------------- /hashira-web/src/Router.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; 3 | import AccessToken from "./AccessToken"; 4 | import App from "./App"; 5 | import { useUser } from "./hooks"; 6 | import Tags from "./Tags"; 7 | 8 | const Router: React.FC = () => { 9 | const user = useUser(); 10 | 11 | React.useEffect(() => { 12 | if ("serviceWorker" in navigator) { 13 | navigator.serviceWorker 14 | .register("/service-worker.js") 15 | .then(() => { 16 | console.log("service worker is registered"); 17 | }) 18 | .catch((e) => { 19 | console.log("failed to register service worker: ", JSON.stringify(e)); 20 | }); 21 | } 22 | }, []); 23 | 24 | return ( 25 | 26 | 27 | } /> 28 | } /> 29 | } /> 30 | } /> 31 | 32 | 33 | ); 34 | }; 35 | 36 | export default Router; 37 | -------------------------------------------------------------------------------- /hashira-web/src/TaskInput.tsx: -------------------------------------------------------------------------------- 1 | import React, { useId } from "react"; 2 | import styled from "styled-components"; 3 | import { StyledHorizontalSpacer } from "./styles"; 4 | import { normalizeTasks } from "./task"; 5 | 6 | const StyledInputForm = styled.form` 7 | display: flex; 8 | `; 9 | 10 | const TaskInput: React.FC<{ 11 | onSubmitTasks: (tasks: string[]) => Promise; 12 | disabled: boolean; 13 | }> = ({ onSubmitTasks, disabled }) => { 14 | const [tasks, setTasks] = React.useState([]); 15 | 16 | return ( 17 | 18 | 27 | 28 | ) => { 34 | e.preventDefault(); 35 | await onSubmitTasks(tasks); 36 | setTasks([]); 37 | }} 38 | /> 39 | 40 | ); 41 | }; 42 | 43 | export default TaskInput; 44 | -------------------------------------------------------------------------------- /hashira-web/src/TaskList.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | import * as firebase from "./firebase"; 4 | 5 | const StyledList = styled.div` 6 | min-width: 300px; 7 | max-width: 300px; 8 | max-height: 80vh; 9 | overflow-y: auto; 10 | padding-left: 10px; 11 | padding-right: 10px; 12 | border: solid; 13 | `; 14 | 15 | const StyledListItem = styled.div` 16 | display: flex; 17 | align-items: center; 18 | position: relative; 19 | min-height: 24px; 20 | `; 21 | 22 | const StyledCheckbox = styled.input.attrs({ type: "checkbox" })` 23 | position: absolute; 24 | `; 25 | 26 | const StyledListContent = styled.input.attrs({ type: "text" })` 27 | display: flex; 28 | align-items: center; 29 | min-height: 24px; 30 | width: 100%; 31 | white-space: nowrap; 32 | overflow-y: scroll; 33 | border: none; 34 | -ms-overflow-style: none; 35 | scrollbar-width: none; 36 | ::-webkit-scrollbar { 37 | display: none; 38 | } 39 | margin-left: 24px; 40 | z-index: 1000; 41 | `; 42 | 43 | const StyledArrow = styled.div` 44 | position: absolute; 45 | min-width: 24px; 46 | min-height: 24px; 47 | display: flex; 48 | justify-content: space-between; 49 | align-items: center; 50 | width: 100%; 51 | font-size: 12px; 52 | `; 53 | 54 | export const TaskList: React.FC<{ 55 | place: typeof firebase.Places[number]; 56 | tasksAndPriorities: firebase.TasksAndPriorities; 57 | checkedTasks: { [key: string]: boolean }; 58 | setCheckedTasks: (a: { [key: string]: boolean }) => void; 59 | onEditTasks: (tasks: firebase.TasksObject) => Promise; 60 | mode: "move" | "select"; 61 | onMoveTask: (taskId: string, direction: "left" | "right") => Promise; 62 | filterText: string; 63 | }> = ({ 64 | place, 65 | tasksAndPriorities, 66 | checkedTasks, 67 | setCheckedTasks, 68 | onEditTasks, 69 | mode, 70 | onMoveTask, 71 | filterText, 72 | }) => { 73 | const [updatedTasks, setUpdatedTasks] = React.useState<{ 74 | [key: string]: string; 75 | }>({}); 76 | 77 | // onEditCompleted は、どれかひとつの task の変更が終わると呼び出される (onBlur)。 78 | // 79 | // validation の後、string を firebase.TasksObject の形に変換して onEditTasks を呼び出す。 80 | // 81 | // 典型的な使い方をすると一度に更新される task はたかだかひとつであるが、onEditTasks 内で 82 | // 通信エラーが起こるなどすると、変更が DB に反映されていない task が取り残される可能性がありそう。 83 | // そのため、変更候補のものはすべていっぺんに更新するような処理にしている。 84 | const onEditCompleted = async () => { 85 | const tasksToUpdate: firebase.TasksObject = {}; 86 | for (const v in updatedTasks) { 87 | if (updatedTasks[v] === "") { 88 | delete updatedTasks[v]; 89 | setUpdatedTasks({ 90 | ...updatedTasks, 91 | }); 92 | return; 93 | } 94 | 95 | const task = tasksAndPriorities["Tasks"][v]; 96 | tasksToUpdate[v] = { 97 | ID: task.ID, 98 | IsDeleted: false, 99 | Name: updatedTasks[v], 100 | Place: task.Place, 101 | }; 102 | } 103 | 104 | await onEditTasks(tasksToUpdate); 105 | setUpdatedTasks({}); 106 | }; 107 | 108 | const noItem = [ 109 | 110 | No item 111 | , 112 | ]; 113 | 114 | const convertTasksAndPrioritiesToJSXElement = ( 115 | tasksAndPriorities: firebase.TasksAndPriorities, 116 | ): React.ReactElement[] => { 117 | if (!tasksAndPriorities["Priority"][place]) { 118 | return noItem; 119 | } 120 | const filteredItems = tasksAndPriorities["Priority"][place] 121 | .filter((v: string) => tasksAndPriorities["Tasks"][v]) 122 | .filter((v: string) => { 123 | if (!filterText) return true; 124 | const taskName = tasksAndPriorities["Tasks"][v].Name.toLowerCase(); 125 | return filterText.toLowerCase().split(" ").every(word => taskName.includes(word.trim())); 126 | }); 127 | 128 | if (filteredItems.length === 0) { 129 | return noItem; 130 | } 131 | 132 | return filteredItems.map((p: string) => { 133 | const taskId = tasksAndPriorities["Tasks"][p].ID; 134 | const taskName = tasksAndPriorities["Tasks"][p].Name; 135 | return ( 136 | 137 | <> 138 | { 148 | setUpdatedTasks({ 149 | ...updatedTasks, 150 | [taskId]: e.target.value, 151 | }); 152 | }} 153 | onBlur={() => onEditCompleted()} 154 | /> 155 | 156 | {mode === "select" 157 | ? ( 158 | { 162 | setCheckedTasks({ 163 | ...checkedTasks, 164 | [taskId]: e.target.checked, 165 | }); 166 | }} 167 | /> 168 | ) 169 | : ( 170 | 171 |
    onMoveTask(taskId, "left")} 174 | > 175 | 👈 176 |
    177 |
    onMoveTask(taskId, "right")} 180 | > 181 | 👉 182 |
    183 |
    184 | )} 185 | 186 |
    187 | ); 188 | }); 189 | }; 190 | 191 | return ( 192 | 193 |
    197 | {place} 198 |
    199 | {convertTasksAndPrioritiesToJSXElement(tasksAndPriorities)} 200 |
    201 | ); 202 | }; 203 | -------------------------------------------------------------------------------- /hashira-web/src/env.d.ts: -------------------------------------------------------------------------------- 1 | interface ImportMetaEnv { 2 | readonly VITE_REVISION: string; 3 | } 4 | 5 | interface ImportMeta { 6 | readonly env: ImportMetaEnv; 7 | } 8 | -------------------------------------------------------------------------------- /hashira-web/src/firebase.ts: -------------------------------------------------------------------------------- 1 | import { FirebaseOptions, initializeApp } from "firebase/app"; 2 | import * as auth from "firebase/auth"; 3 | import { 4 | addDoc, 5 | collection, 6 | deleteDoc, 7 | doc, 8 | DocumentData, 9 | FieldValue, 10 | getDoc, 11 | getDocs, 12 | getFirestore, 13 | query, 14 | QueryDocumentSnapshot, 15 | serverTimestamp, 16 | where, 17 | } from "firebase/firestore"; 18 | import * as functions from "firebase/functions"; 19 | import { v4 as uuidv4 } from "uuid"; 20 | 21 | const firebaseConfig: FirebaseOptions = { 22 | apiKey: "AIzaSyDMkM3qb_CUokFQDSFemLhPOqXJrR-rVbo", 23 | authDomain: "hashira-web.web.app", 24 | projectId: "hashira-web", 25 | storageBucket: "hashira-web.appspot.com", 26 | messagingSenderId: "150558268935", 27 | appId: "1:150558268935:web:74eef753ffba6bb8bd54a2", 28 | measurementId: "G-EEZ5MJJ6XL", 29 | }; 30 | 31 | const app = initializeApp(firebaseConfig); 32 | 33 | export const login = () => { 34 | const provider = new auth.GoogleAuthProvider(); 35 | auth.signInWithRedirect(auth.getAuth(), provider); 36 | }; 37 | 38 | export const logout = () => { 39 | auth.signOut(auth.getAuth()); 40 | localStorage.removeItem("user"); 41 | }; 42 | 43 | export const onAuthStateChanged = (cb: (user: User | null) => void) => { 44 | auth.onAuthStateChanged(auth.getAuth(), cb); 45 | }; 46 | 47 | export type User = auth.User; 48 | 49 | interface accesstoken { 50 | uid: string; 51 | accesstoken: string; 52 | timestamp: FieldValue; 53 | } 54 | 55 | export const claimNewAccessToken = async (uid: string) => { 56 | const db = getFirestore(app); 57 | const data: accesstoken = { 58 | uid: uid, 59 | accesstoken: uuidv4(), 60 | timestamp: serverTimestamp(), 61 | }; 62 | await addDoc(collection(db, "accesstokens"), data); 63 | }; 64 | 65 | export const fetchAccessTokens = async (uid: string): Promise => { 66 | const db = getFirestore(app); 67 | const querySnapshot = await getDocs( 68 | query(collection(db, "accesstokens"), where("uid", "==", uid)), 69 | ); 70 | const ret: accesstoken[] = []; 71 | querySnapshot.forEach((doc: QueryDocumentSnapshot) => { 72 | const data = doc.data({ serverTimestamps: "estimate" }); 73 | 74 | const token: accesstoken = { 75 | uid: data.uid, 76 | accesstoken: data.accesstoken, 77 | timestamp: data.timestamp, 78 | }; 79 | ret.push(token); 80 | }); 81 | 82 | return ret 83 | .sort((a: any, b: any) => { 84 | return a.timestamp.seconds - b.timestamp.seconds; 85 | }) 86 | .map((a: accesstoken) => { 87 | return a.accesstoken; 88 | }); 89 | }; 90 | 91 | export const revokeAccessTokens = async ( 92 | uid: string, 93 | accesstokens: string[], 94 | ) => { 95 | const db = getFirestore(app); 96 | 97 | for (const accesstoken of accesstokens) { 98 | const querySnapshot = await getDocs( 99 | query( 100 | collection(db, "accesstokens"), 101 | where("uid", "==", uid), 102 | where("accesstoken", "==", accesstoken), 103 | ), 104 | ); 105 | 106 | for (const doc of querySnapshot.docs) { 107 | await deleteDoc(doc.ref); 108 | } 109 | } 110 | }; 111 | 112 | export const Places = ["BACKLOG", "TODO", "DOING", "DONE"] as const; 113 | 114 | // 複数の task を受け取って、全部 BACKLOG の一番上に積む 115 | export const addTasks = async (tasks: string[]) => { 116 | const tasksObject: { 117 | [key: string]: { 118 | ID: string; 119 | IsDeleted: boolean; 120 | Name: string; 121 | Place: typeof Places[number]; 122 | }; 123 | } = {}; 124 | const priorities: string[] = []; 125 | 126 | tasks.forEach((v: string) => { 127 | const taskId = uuidv4(); 128 | tasksObject[taskId] = { 129 | ID: taskId, 130 | IsDeleted: false, 131 | Name: v, 132 | Place: "BACKLOG", 133 | }; 134 | priorities.push(taskId); 135 | }); 136 | 137 | try { 138 | await functions.httpsCallable( 139 | functions.getFunctions(app, "asia-northeast1"), 140 | "call?method=add", 141 | )({ 142 | tasks: tasksObject, 143 | priority: { 144 | BACKLOG: priorities, 145 | }, 146 | }); 147 | } catch (e) { 148 | // FIXME: 149 | // currently cloud functions doesn't return appropriate response 150 | // that fits httpsCallable protocol even if the function succeeded. 151 | console.log("error:", e); 152 | } 153 | }; 154 | 155 | export interface TasksObject { 156 | [key: string]: { 157 | ID: string; 158 | IsDeleted: boolean; 159 | Name: string; 160 | Place: typeof Places[number]; 161 | }; 162 | } 163 | 164 | const newPriorities = (tasksObject: TasksObject) => { 165 | const priorities: { 166 | [key in typeof Places[number]]: string[]; 167 | } = { 168 | BACKLOG: [], 169 | TODO: [], 170 | DOING: [], 171 | DONE: [], 172 | }; 173 | 174 | for (const task of Object.values(tasksObject)) { 175 | priorities[task.Place].push(task.ID); 176 | } 177 | 178 | return priorities; 179 | }; 180 | 181 | // updateTasks 182 | // 183 | // @param updatePosition - Stack the updated tasks on top of each lane if true 184 | export const updateTasks = async (tasksObject: TasksObject, updatePosition: boolean) => { 185 | const appendix = updatePosition 186 | ? { priority: newPriorities(tasksObject) } 187 | : {}; 188 | 189 | try { 190 | await functions.httpsCallable( 191 | functions.getFunctions(app, "asia-northeast1"), 192 | "call?method=add", 193 | )({ 194 | tasks: tasksObject, 195 | ...appendix, 196 | }); 197 | } catch (e) { 198 | // FIXME: 199 | // currently cloud functions doesn't return appropriate response 200 | // that fits httpsCallable protocol even if the function succeeded. 201 | console.log("error:", e); 202 | } 203 | }; 204 | 205 | export const ping = async () => { 206 | try { 207 | await functions.httpsCallable( 208 | functions.getFunctions(app, "asia-northeast1"), 209 | "call?method=ping", 210 | )(); 211 | } catch (e) { 212 | // FIXME: 213 | // currently cloud functions doesn't return appropriate response 214 | // that fits httpsCallable protocol even if the function succeeded. 215 | console.log("Known Bug - #877:", e, "See https://github.com/pankona/hashira/issues/877 for further detail."); 216 | } 217 | }; 218 | 219 | export const fetchTaskAndPriorities = async (uid: string) => { 220 | const db = getFirestore(app); 221 | const docRef = doc(db, "tasksAndPriorities", uid); 222 | const docSnapshot = await getDoc(docRef); 223 | return docSnapshot.data() as TasksAndPriorities; 224 | }; 225 | 226 | export interface TasksAndPriorities { 227 | Priority: { 228 | [key in typeof Places[number]]: string[]; 229 | }; 230 | Tasks: { 231 | [key: string]: { 232 | Place: typeof Places[number]; 233 | Name: string; 234 | ID: string; 235 | IsDeleted: boolean; 236 | }; 237 | }; 238 | } 239 | 240 | export const tasksAndPrioritiesInitialValue: TasksAndPriorities = { 241 | Priority: { 242 | BACKLOG: [], 243 | TODO: [], 244 | DOING: [], 245 | DONE: [], 246 | }, 247 | Tasks: {}, 248 | }; 249 | -------------------------------------------------------------------------------- /hashira-web/src/hooks.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import * as firebase from "./firebase"; 3 | import { tasksAndPrioritiesInitialValue } from "./firebase"; 4 | import { normalizeTasks } from "./task"; 5 | 6 | type APIState = { 7 | isLoading: boolean; 8 | error: string | null; 9 | data: T | null; 10 | }; 11 | 12 | export const useAddTasks = (): [ 13 | APIState, 14 | (tasksToAdd: string[]) => Promise, 15 | ] => { 16 | const [state, setState] = React.useState>({ 17 | isLoading: false, 18 | error: null, 19 | data: null, 20 | }); 21 | 22 | return [ 23 | state, 24 | React.useCallback((tasksToAdd: string[]): Promise => { 25 | return new Promise((resolve, reject) => { 26 | setState({ 27 | isLoading: true, 28 | error: null, 29 | data: null, 30 | }); 31 | 32 | firebase 33 | .addTasks(normalizeTasks(tasksToAdd)) 34 | .then(() => { 35 | setState({ 36 | isLoading: false, 37 | error: null, 38 | data: null, 39 | }); 40 | resolve(); 41 | }) 42 | .catch((e) => reject(e)); 43 | }); 44 | }, []), 45 | ]; 46 | }; 47 | 48 | export const useUpdateTasks = (): [ 49 | APIState, 50 | (tasks: firebase.TasksObject, updatePosition: boolean) => Promise, 51 | ] => { 52 | const [state, setState] = React.useState>({ 53 | isLoading: false, 54 | error: null, 55 | data: null, 56 | }); 57 | 58 | return [ 59 | state, 60 | React.useCallback((tasks: firebase.TasksObject, updatePosition: boolean): Promise => { 61 | return new Promise((resolve, reject) => { 62 | setState({ 63 | isLoading: true, 64 | error: null, 65 | data: null, 66 | }); 67 | 68 | firebase 69 | .updateTasks(tasks, updatePosition) 70 | .then((result) => { 71 | setState({ 72 | isLoading: false, 73 | error: null, 74 | data: result, 75 | }); 76 | resolve(result); 77 | }) 78 | .catch((e) => reject(e)); 79 | }); 80 | }, []), 81 | ]; 82 | }; 83 | 84 | export const useFetchTasksAndPriorities = (): [ 85 | APIState, 86 | (userId: string) => Promise, 87 | ] => { 88 | const [state, setState] = React.useState>({ 89 | isLoading: false, 90 | error: null, 91 | data: null, 92 | }); 93 | 94 | return [ 95 | state, 96 | React.useCallback((userId: string): Promise => { 97 | return new Promise((resolve, reject) => { 98 | setState((prev) => { 99 | return { 100 | ...prev, 101 | isLoading: true, 102 | error: null, 103 | }; 104 | }); 105 | 106 | firebase 107 | .fetchTaskAndPriorities(userId) 108 | .then((result) => { 109 | if (!result) { 110 | // in case for empty result 111 | setState({ 112 | isLoading: false, 113 | error: null, 114 | data: tasksAndPrioritiesInitialValue, 115 | }); 116 | resolve(tasksAndPrioritiesInitialValue); 117 | return; 118 | } 119 | 120 | setState({ 121 | isLoading: false, 122 | error: null, 123 | data: result, 124 | }); 125 | resolve(result); 126 | }) 127 | .catch((e) => { 128 | setState({ 129 | isLoading: false, 130 | error: JSON.stringify(e), 131 | data: null, 132 | }); 133 | reject(e); 134 | }); 135 | }); 136 | }, []), 137 | ]; 138 | }; 139 | 140 | export const useFetchAccessTokens = (): [ 141 | APIState, 142 | (userId: string) => Promise, 143 | ] => { 144 | const [state, setState] = React.useState>({ 145 | isLoading: false, 146 | error: null, 147 | data: null, 148 | }); 149 | 150 | return [ 151 | state, 152 | React.useCallback((userId: string): Promise => { 153 | return new Promise((resolve, reject) => { 154 | setState({ 155 | isLoading: true, 156 | error: null, 157 | data: null, 158 | }); 159 | 160 | firebase 161 | .fetchAccessTokens(userId) 162 | .then((result) => { 163 | setState({ 164 | isLoading: false, 165 | error: null, 166 | data: result, 167 | }); 168 | resolve(result); 169 | }) 170 | .catch((e) => reject(e)); 171 | }); 172 | }, []), 173 | ]; 174 | }; 175 | 176 | export const useUser = () => { 177 | const [state, setState] = React.useState(undefined); 178 | 179 | React.useEffect(() => { 180 | const cachedUser = localStorage.getItem("user"); 181 | if (cachedUser) { 182 | setState(JSON.parse(cachedUser)); 183 | } 184 | 185 | firebase.onAuthStateChanged((user: firebase.User | null) => { 186 | if (!user) { 187 | setState(null); 188 | localStorage.removeItem("user"); 189 | return; 190 | } 191 | setState(user); 192 | localStorage.setItem("user", JSON.stringify(user)); 193 | }); 194 | }, []); 195 | 196 | return state; 197 | }; 198 | -------------------------------------------------------------------------------- /hashira-web/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import { createGlobalStyle } from "styled-components"; 4 | import { normalize } from "styled-normalize"; 5 | import Router from "./Router"; 6 | import { assertIsDefined } from "./types"; 7 | 8 | const GlobalStyle = createGlobalStyle` 9 | ${normalize} 10 | `; 11 | 12 | const rootElement = document.getElementById("app"); 13 | assertIsDefined(rootElement); 14 | createRoot(rootElement).render( 15 | 16 | 17 | 18 | , 19 | ); 20 | -------------------------------------------------------------------------------- /hashira-web/src/revision.ts: -------------------------------------------------------------------------------- 1 | export const revision = (): string => { 2 | return import.meta.env.VITE_REVISION || "unknown revision"; 3 | }; 4 | -------------------------------------------------------------------------------- /hashira-web/src/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const StyledVerticalSpacer = styled.div` 4 | min-height: 8px; 5 | `; 6 | 7 | export const StyledHorizontalSpacer = styled.div` 8 | min-width: 8px; 9 | `; 10 | -------------------------------------------------------------------------------- /hashira-web/src/task.ts: -------------------------------------------------------------------------------- 1 | export const normalizeTasks = (lines: readonly string[]): string[] => { 2 | return lines.flatMap((line) => { 3 | const task = line.trim(); 4 | return task ? [task] : []; 5 | }); 6 | }; 7 | -------------------------------------------------------------------------------- /hashira-web/src/types.ts: -------------------------------------------------------------------------------- 1 | // https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-7.html#assertion-functions 2 | export function assertIsDefined(val: T): asserts val is NonNullable { 3 | if (val === undefined || val === null) { 4 | throw new Error(`Expected 'val' to be defined, but received ${val}`); 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /hashira-web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "ESNext", 5 | "moduleResolution": "Bundler", 6 | "esModuleInterop": true, 7 | "lib": [ 8 | "DOM", 9 | "DOM.Iterable", 10 | "ES2022" 11 | ], 12 | "strict": true, 13 | "jsx": "react-jsx", // ここを元に戻す 14 | "skipLibCheck": true, 15 | "allowJs": true, 16 | "isolatedModules": true, 17 | "noImplicitAny": true, 18 | "strictNullChecks": true, 19 | "types": [ 20 | "react", 21 | "react-dom", 22 | "styled-components" 23 | ] 24 | }, 25 | "ts-node": { 26 | "compilerOptions": { 27 | "target": "ES2020", 28 | "module": "CommonJS" 29 | } 30 | }, 31 | "include": [ 32 | "src/**/*" 33 | ], 34 | "exclude": [ 35 | "node_modules" 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /hashira-web/vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from "@vitejs/plugin-react"; 2 | import { defineConfig } from "vite"; 3 | 4 | export default defineConfig({ 5 | build: { 6 | outDir: "build", 7 | }, 8 | publicDir: "assets", 9 | plugins: [react()], 10 | }); 11 | -------------------------------------------------------------------------------- /proto/hashira.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | option go_package = "github.com/pankona/hashira/service"; 4 | 5 | package service; 6 | 7 | service Hashira { 8 | rpc Create(CommandCreate) returns (ResultCreate) {} 9 | rpc Update(CommandUpdate) returns (ResultUpdate) {} 10 | rpc Delete(CommandDelete) returns (ResultDelete) {} 11 | rpc PhysicalDelete(CommandPhysicalDelete) returns (ResultPhysicalDelete) {} 12 | rpc Retrieve(CommandRetrieve) returns (ResultRetrieve) {} 13 | rpc UpdatePriority(CommandUpdatePriority) returns (ResultUpdatePriority) {} 14 | rpc RetrievePriority(CommandRetrievePriority) returns (ResultRetrievePriority) {} 15 | } 16 | 17 | enum Place { 18 | BACKLOG = 0; 19 | TODO = 1; 20 | DOING = 2; 21 | DONE = 3; 22 | } 23 | 24 | message Task { 25 | string id = 1; 26 | string name = 2; 27 | Place place = 3; 28 | bool isDeleted = 4; 29 | bool isDirty = 5; 30 | } 31 | 32 | message Priority { 33 | repeated string ids = 1; 34 | bool isDirty = 2; 35 | } 36 | 37 | message CommandCreate { 38 | Task task = 1; 39 | } 40 | 41 | message CommandUpdate { 42 | Task task = 1; 43 | } 44 | 45 | message CommandDelete { 46 | string id = 1; 47 | } 48 | 49 | message CommandPhysicalDelete { 50 | string id = 1; 51 | } 52 | 53 | message CommandRetrieve { 54 | bool excludeDeleted = 1; 55 | } 56 | 57 | message CommandUpdatePriority { 58 | map priorities = 1; 59 | } 60 | 61 | message CommandRetrievePriority { 62 | } 63 | 64 | message ResultCreate { 65 | Task task = 1; 66 | } 67 | 68 | message ResultUpdate { 69 | Task task = 1; 70 | } 71 | 72 | message ResultDelete { 73 | Task task = 1; 74 | } 75 | 76 | message ResultPhysicalDelete { 77 | } 78 | 79 | message ResultRetrieve { 80 | map tasks = 1; 81 | } 82 | 83 | message ResultUpdatePriority { 84 | map priorities = 1; 85 | } 86 | 87 | message ResultRetrievePriority { 88 | map priorities = 1; 89 | } 90 | -------------------------------------------------------------------------------- /sync/client.go: -------------------------------------------------------------------------------- 1 | package sync 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | ) 10 | 11 | type Client struct{} 12 | 13 | func NewClient() *Client { 14 | return &Client{} 15 | } 16 | 17 | func (c *Client) TestAccessToken(accesstoken string) error { 18 | req, err := http.NewRequest( 19 | http.MethodGet, 20 | "https://asia-northeast1-hashira-web.cloudfunctions.net/test-access-token", nil) 21 | if err != nil { 22 | return fmt.Errorf("failed to create new request: %w", err) 23 | } 24 | 25 | req.Header.Add("Authorization", fmt.Sprintf("bearer %s", accesstoken)) 26 | 27 | cli := http.Client{} 28 | resp, err := cli.Do(req) 29 | if err != nil { 30 | return fmt.Errorf("request failed: %w", err) 31 | } 32 | defer func() { 33 | if err := resp.Body.Close(); err != nil { 34 | log.Printf("failed to close response body: %v", err) 35 | } 36 | }() 37 | 38 | if resp.StatusCode != http.StatusOK { 39 | return fmt.Errorf("this accesstoken is not valid. Please check HASHIRA_ACCESS_TOKEN is correct and try again [%d]", resp.StatusCode) 40 | } 41 | 42 | return nil 43 | } 44 | 45 | type Task struct { 46 | ID string 47 | Name string 48 | Place string 49 | IsDeleted bool 50 | } 51 | 52 | type Priority map[string][]string 53 | 54 | // priority's key should be one of following strings: 55 | // "BACKLOG", "TODO", "DOING", "DONE" 56 | type UploadRequest struct { 57 | Tasks map[string]Task `json:"tasks"` 58 | Priority Priority `json:"priority"` 59 | } 60 | 61 | func (c *Client) Upload(accesstoken string, ur UploadRequest) error { 62 | body, err := json.Marshal(ur) 63 | if err != nil { 64 | return fmt.Errorf("failed to marshal upload request: %w", err) 65 | } 66 | 67 | req, err := http.NewRequest( 68 | http.MethodPost, 69 | "https://asia-northeast1-hashira-web.cloudfunctions.net/upload", 70 | bytes.NewBuffer(body)) 71 | if err != nil { 72 | return fmt.Errorf("failed to create new request: %w", err) 73 | } 74 | 75 | req.Header.Add("Authorization", fmt.Sprintf("bearer %s", accesstoken)) 76 | 77 | httpcli := http.Client{} 78 | resp, err := httpcli.Do(req) 79 | if err != nil { 80 | return fmt.Errorf("http request failed: %w", err) 81 | } 82 | defer resp.Body.Close() 83 | 84 | return nil 85 | } 86 | 87 | type DownloadResult UploadRequest 88 | 89 | func (c *Client) Download(accesstoken string) (DownloadResult, error) { 90 | req, err := http.NewRequest( 91 | http.MethodGet, 92 | "https://asia-northeast1-hashira-web.cloudfunctions.net/download", nil) 93 | if err != nil { 94 | return DownloadResult{}, fmt.Errorf("failed to prepare request: %w", err) 95 | } 96 | 97 | req.Header.Add("Authorization", fmt.Sprintf("bearer %s", accesstoken)) 98 | 99 | httpcli := http.Client{} 100 | resp, err := httpcli.Do(req) 101 | if err != nil { 102 | return DownloadResult{}, fmt.Errorf("failed to download tasks and priorities: %w", err) 103 | } 104 | defer resp.Body.Close() 105 | 106 | if resp.StatusCode >= 400 { 107 | return DownloadResult{}, fmt.Errorf("download request failed: status code: %d", resp.StatusCode) 108 | } 109 | 110 | var ret DownloadResult 111 | if err := json.NewDecoder(resp.Body).Decode(&ret); err != nil { 112 | return DownloadResult{}, fmt.Errorf("failed to decode response body: %w", err) 113 | } 114 | 115 | return ret, nil 116 | } 117 | -------------------------------------------------------------------------------- /sync/package.go: -------------------------------------------------------------------------------- 1 | // sync package has responsibility to synchronization tasks with cloud. 2 | // Current implementation assumes to use firebase (firestore and cloud functions) 3 | package sync 4 | -------------------------------------------------------------------------------- /sync/syncutil/client.go: -------------------------------------------------------------------------------- 1 | package syncutil 2 | 3 | type Client struct { 4 | DaemonPort int 5 | } 6 | -------------------------------------------------------------------------------- /sync/syncutil/download.go: -------------------------------------------------------------------------------- 1 | package syncutil 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strconv" 7 | 8 | hc "github.com/pankona/hashira/client" 9 | "github.com/pankona/hashira/service" 10 | "github.com/pankona/hashira/sync" 11 | ) 12 | 13 | func (c *Client) Download(accesstoken string) error { 14 | cli := &hc.Client{Address: "localhost:" + strconv.Itoa(c.DaemonPort)} 15 | 16 | allTasks, err := cli.RetrieveAll(context.Background()) 17 | if err != nil { 18 | return fmt.Errorf("failed to retrieve tasks: %w", err) 19 | } 20 | allPriorities, err := cli.RetrievePriority(context.Background()) 21 | if err != nil { 22 | return fmt.Errorf("failed to retrieve priorities: %w", err) 23 | } 24 | 25 | if dirtyTaskOrPriorityExists(allTasks, allPriorities) { 26 | // don't download and overwrite tasks and priorities since there're some dirty task 27 | return nil 28 | } 29 | 30 | sc := sync.NewClient() 31 | result, err := sc.Download(accesstoken) 32 | if err != nil { 33 | return fmt.Errorf("failed to download task and priority: %w", err) 34 | } 35 | 36 | for _, task := range result.Tasks { 37 | err = cli.Update(context.Background(), &service.Task{ 38 | Id: task.ID, 39 | Name: task.Name, 40 | Place: service.Place(service.Place_value[task.Place]), 41 | IsDeleted: task.IsDeleted, 42 | IsDirty: false, 43 | }) 44 | if err != nil { 45 | return fmt.Errorf("failed to update task: %w", err) 46 | } 47 | } 48 | 49 | newPriorities := map[string]*service.Priority{} 50 | for k, v := range result.Priority { 51 | newPriorities[k] = &service.Priority{Ids: v, IsDirty: false} 52 | } 53 | 54 | oldPriorities, err := cli.RetrievePriority(context.Background()) 55 | if err != nil { 56 | return fmt.Errorf("failed to retrieve old priorities: %w", err) 57 | } 58 | 59 | priorities := mergePriorities(newPriorities, oldPriorities) 60 | 61 | _, err = cli.UpdatePriority(context.Background(), priorities) 62 | if err != nil { 63 | return fmt.Errorf("failed to update priority: %w", err) 64 | } 65 | 66 | return nil 67 | } 68 | 69 | func mergePriorities(newPriorities, oldPriorities map[string]*service.Priority) map[string]*service.Priority { 70 | ret := map[string]*service.Priority{} 71 | for k, oldPriority := range oldPriorities { 72 | ret[k] = &service.Priority{ 73 | Ids: append(newPriorities[k].Ids, oldPriority.Ids...), 74 | } 75 | ret[k].Ids = unique(ret[k].Ids) 76 | } 77 | return ret 78 | } 79 | 80 | func unique(ss []string) []string { 81 | keys := make(map[string]struct{}) 82 | ids := []string{} 83 | 84 | for _, id := range ss { 85 | if _, ok := keys[id]; !ok { 86 | keys[id] = struct{}{} 87 | ids = append(ids, id) 88 | } 89 | } 90 | return ids 91 | } 92 | 93 | func dirtyTaskOrPriorityExists(tasks map[string]*service.Task, priorities map[string]*service.Priority) bool { 94 | for _, v := range tasks { 95 | if v.IsDirty { 96 | return true 97 | } 98 | } 99 | for _, v := range priorities { 100 | if v.IsDirty { 101 | return true 102 | } 103 | } 104 | return false 105 | } 106 | -------------------------------------------------------------------------------- /sync/syncutil/testaccesstoken.go: -------------------------------------------------------------------------------- 1 | package syncutil 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/pankona/hashira/sync" 7 | ) 8 | 9 | func (c *Client) TestAccessToken(accesstoken string) error { 10 | sc := sync.NewClient() 11 | if err := sc.TestAccessToken(accesstoken); err != nil { 12 | return fmt.Errorf("test access token failed: %w", err) 13 | } 14 | return nil 15 | } 16 | -------------------------------------------------------------------------------- /sync/syncutil/upload.go: -------------------------------------------------------------------------------- 1 | package syncutil 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strconv" 7 | 8 | hc "github.com/pankona/hashira/client" 9 | "github.com/pankona/hashira/service" 10 | "github.com/pankona/hashira/sync" 11 | ) 12 | 13 | type UploadTarget int 14 | 15 | const ( 16 | UploadAll UploadTarget = iota 17 | UploadDirtyOnly 18 | ) 19 | 20 | func newUploadRequest(tasks map[string]*service.Task, priorities map[string]*service.Priority, uploadTarget UploadTarget) sync.UploadRequest { 21 | ur := sync.UploadRequest{ 22 | Tasks: map[string]sync.Task{}, 23 | } 24 | 25 | for k, v := range tasks { 26 | if uploadTarget == UploadDirtyOnly && !v.IsDirty { 27 | continue 28 | } 29 | ur.Tasks[k] = sync.Task{ 30 | ID: v.Id, 31 | Name: v.Name, 32 | Place: v.Place.String(), 33 | IsDeleted: v.IsDeleted, 34 | } 35 | } 36 | 37 | ur.Priority = sync.Priority{} 38 | for k, v := range priorities { 39 | ur.Priority[k] = v.Ids 40 | } 41 | 42 | return ur 43 | } 44 | 45 | func (c *Client) Upload(accesstoken string, uploadTarget UploadTarget) error { 46 | cli := &hc.Client{Address: "localhost:" + strconv.Itoa(c.DaemonPort)} 47 | 48 | allTasks, err := cli.RetrieveAll(context.Background()) 49 | if err != nil { 50 | return fmt.Errorf("failed to retrieve tasks: %w", err) 51 | } 52 | allPriorities, err := cli.RetrievePriority(context.Background()) 53 | if err != nil { 54 | return fmt.Errorf("failed to retrieve priorities: %w", err) 55 | } 56 | 57 | ur := newUploadRequest(allTasks, allPriorities, uploadTarget) 58 | 59 | if len(ur.Tasks) == 0 && !isPriorityDirty(allPriorities) { 60 | // there's no task to upload 61 | return nil 62 | } 63 | 64 | sc := sync.NewClient() 65 | err = sc.Upload(accesstoken, ur) 66 | if err != nil { 67 | return fmt.Errorf("failed to upload: %w", err) 68 | } 69 | 70 | for _, task := range allTasks { 71 | if !task.IsDeleted { 72 | continue 73 | } 74 | if err := cli.PhysicalDelete(context.Background(), task.Id); err != nil { 75 | return fmt.Errorf("failed to physical delete a task: %w", err) 76 | } 77 | } 78 | 79 | // make all tasks and priorities clean since uploading is completed 80 | for _, task := range allTasks { 81 | if !task.IsDirty { 82 | continue 83 | } 84 | err = cli.Update(context.Background(), &service.Task{ 85 | Id: task.Id, 86 | Name: task.Name, 87 | Place: task.Place, 88 | IsDeleted: task.IsDeleted, 89 | IsDirty: false, 90 | }) 91 | if err != nil { 92 | return fmt.Errorf("failed to update task: %w", err) 93 | } 94 | } 95 | 96 | for _, priority := range allPriorities { 97 | priority.IsDirty = false 98 | } 99 | _, err = cli.UpdatePriority(context.Background(), allPriorities) 100 | if err != nil { 101 | return fmt.Errorf("failed to update priority: %w", err) 102 | } 103 | 104 | return nil 105 | } 106 | 107 | func isPriorityDirty(p map[string]*service.Priority) bool { 108 | for _, v := range p { 109 | if v.IsDirty { 110 | return true 111 | } 112 | } 113 | return false 114 | } 115 | -------------------------------------------------------------------------------- /tool/Makefile: -------------------------------------------------------------------------------- 1 | all: 2 | cd $(CURDIR) && \ 3 | protoc_install.sh && \ 4 | protoc_gen_go_install.sh 5 | -------------------------------------------------------------------------------- /tool/protoc_gen_go_install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | go install google.golang.org/protobuf/cmd/protoc-gen-go@latest 4 | go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest 5 | -------------------------------------------------------------------------------- /tool/protoc_install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | rm -rf ./protoc 4 | mkdir ./protoc 5 | cd ./protoc 6 | wget https://github.com/protocolbuffers/protobuf/releases/download/v3.18.0/protoc-3.18.0-linux-x86_64.zip 7 | unzip ./protoc-3.18.0-linux-x86_64.zip 8 | ./bin/protoc --version 9 | -------------------------------------------------------------------------------- /xdg/xdg.go: -------------------------------------------------------------------------------- 1 | package xdg 2 | 3 | import ( 4 | "os" 5 | "os/user" 6 | "path/filepath" 7 | ) 8 | 9 | type Xdg struct { 10 | User user.User 11 | } 12 | 13 | func (x *Xdg) DataHome() string { 14 | fromEnv := os.Getenv("XDG_DATA_HOME") 15 | if fromEnv == "" { 16 | return filepath.Join(x.User.HomeDir, ".local", "share") 17 | } 18 | 19 | return fromEnv 20 | } 21 | -------------------------------------------------------------------------------- /xdg/xdg_test.go: -------------------------------------------------------------------------------- 1 | package xdg 2 | 3 | import ( 4 | "os/user" 5 | "testing" 6 | ) 7 | 8 | func setup(t *testing.T, envs map[string]string) { 9 | for key, value := range envs { 10 | t.Setenv(key, value) 11 | } 12 | } 13 | 14 | func TestDataHome(t *testing.T) { 15 | usr := user.User{HomeDir: "/home/hashirafan"} 16 | x := Xdg{User: usr} 17 | testCases := []struct { 18 | description string 19 | want string 20 | env map[string]string 21 | }{ 22 | {"empty", "/home/hashirafan/.local/share", map[string]string{"XDG_DATA_HOME": ""}}, 23 | {"special", "/home/hashirafan/opinionated/data", map[string]string{"XDG_DATA_HOME": "/home/hashirafan/opinionated/data"}}, 24 | {"special and others", "/home/hashirafan/opinionated/data", map[string]string{ 25 | "XDG_DATA_HOME": "/home/hashirafan/opinionated/data", 26 | "XDG_CONFIG_HOME": "/home/hashirafan/.config", 27 | "XDG_STATE_HOME": "/home/hashirafan/.local/state", 28 | "XDG_CACHE_HOME": "/home/hashirafan/.cache", 29 | "XDG_DATA_DIRS": "/usr/local/share:/usr/share", 30 | }}, 31 | {"empty and others", "/home/hashirafan/.local/share", map[string]string{ 32 | "XDG_DATA_HOME": "", 33 | "XDG_CONFIG_HOME": "/home/hashirafan/.config", 34 | "XDG_STATE_HOME": "/home/hashirafan/.local/state", 35 | "XDG_CACHE_HOME": "/home/hashirafan/.cache", 36 | "XDG_DATA_DIRS": "/usr/local/share:/usr/share", 37 | }}, 38 | } 39 | 40 | for _, tc := range testCases { 41 | t.Run(tc.description, func(t *testing.T) { 42 | setup(t, tc.env) 43 | if got := x.DataHome(); got != tc.want { 44 | t.Fatalf("xdg returned wrong DataHome. [got] %s [want] %s", got, tc.want) 45 | } 46 | }) 47 | } 48 | } 49 | --------------------------------------------------------------------------------