Compare commits

...

157 Commits
3.2.0 ... main

Author SHA1 Message Date
cea9a47de4 Fix filtering to use queries rather than ad-hoc code 2025-12-23 05:35:20 -05:00
6a24cd6546 Add confirmation menu to removing cards as well 2025-12-22 20:10:11 -05:00
b58718b537 Adding cards now requires confirmation 2025-12-21 11:02:09 -05:00
c4b4967777 Slightly increase image size 2025-12-20 23:27:50 -05:00
3e4a977bef Add basic in-deck tracking 2025-12-20 23:09:45 -05:00
bfa34d5c06 Add initial image support via kitty terminal protocol 2025-12-20 22:00:11 -05:00
Florian Baumann
f75888a1a9 Adjustments for docker image to comply with update step 2025-12-03 08:53:02 +01:00
Florian Baumann
806137de61 Update to go 1.25 2025-12-03 08:11:28 +01:00
Florian Baumann
34686494dd Quit update on err 2025-12-03 08:06:53 +01:00
Florian Baumann
507eef148a Fetch updates from scryfall bulk file
* Switch update to bulkupdate
* Remove tmpfile
* Better output for update
2025-12-03 08:03:59 +01:00
Florian Baumann
a9d1fbc2cd Rename Id to ID 2025-10-17 11:31:34 +02:00
Florian Baumann
3c593f5fdc Remove unused unique flag from removeInteractive 2025-10-17 11:31:18 +02:00
Florian Baumann
7d23bca7f1 Rename count_reserved to countReserved 2025-10-17 11:29:19 +02:00
Florian Baumann
956ae53b59 Rename exportJson to exportJSON 2025-10-17 11:27:58 +02:00
Florian Baumann
7a1803df3a Rename count_all to countAll in stats.go 2025-10-17 11:27:48 +02:00
Florian Baumann
6e961a708c Add package comment 2025-10-17 11:25:32 +02:00
Florian Baumann
dab28a044a Fix export of foils 2025-04-04 09:15:04 +02:00
Florian Baumann
e2c7e54c16 New missing view 2025-03-23 12:05:37 +01:00
Florian Baumann
d2728b455b Add colored output to values 2024-12-10 14:03:44 +01:00
Florian Baumann
cdc38ce2a0 update deps 2024-10-21 11:24:48 +02:00
Florian Baumann
7570d240ab fix fish prompt 2024-10-14 09:46:31 +02:00
Florian Baumann
7143eb84d2 bulk remove script 2024-10-11 09:57:14 +02:00
Florian Baumann
541a4bbd19 Fix missing foil count in web view 2024-09-14 20:23:49 +02:00
Florian Baumann
4d0e75d806 Add url in check detail output 2024-09-05 11:55:17 +02:00
Florian Baumann
fadb1c6aa4 Fix homebrew 2024-09-05 11:46:56 +02:00
Florian Baumann
1c48c5c1e4 Migrate goreleaser to v2 2024-09-05 11:41:23 +02:00
Florian Baumann
0c575021b3 fix check output 2024-09-05 11:35:05 +02:00
Florian Baumann
1229b9d2c6 Fix docker version injection 2024-03-19 13:42:22 +01:00
Florian Baumann
515d32d491 Fix dockerfile issues 2024-03-19 13:38:39 +01:00
Florian Baumann
a42a58f5b9 fix path 2024-03-19 13:30:04 +01:00
Florian Baumann
3794d1813f add cmd 2024-03-19 11:47:13 +01:00
Florian Baumann
4d1fbf0255 fix path 2024-03-19 11:42:23 +01:00
Florian Baumann
23125bea5e Fix bug of empty result 2024-03-19 11:39:08 +01:00
Florian Baumann
f984c69100 fix log output 2024-03-19 11:10:36 +01:00
Florian Baumann
3691890b95 Restructure stats command 2024-03-19 11:04:48 +01:00
Florian Baumann
1ce3920c03 Restructure according to https://github.com/golang-standards/project-layout/ 2024-03-19 10:28:21 +01:00
Florian Baumann
29d6987275 move to scripts 2024-03-19 10:15:29 +01:00
Florian Baumann
7ad859704d Add export to tcghome and moxfield 2024-03-14 09:46:34 +01:00
Florian Baumann
51e139a964 add mongo:6 2024-03-13 11:02:44 +01:00
Florian Baumann
6a88b536bf Mythic filter was missing 2024-02-26 08:08:30 +01:00
Florian Baumann
1be271715c change export format tcgpowertools 2024-02-22 10:08:16 +01:00
Florian Baumann
7bee2d4540 Replace deprecated ioutil with io 2024-02-19 16:08:19 +01:00
Florian Baumann
542fdfd9a6 fix: lowercase error messages 2024-02-19 08:17:16 +01:00
Florian Baumann
84122683c4 Add value output to all add outputs 2024-02-15 12:21:56 +01:00
Florian Baumann
fd5067e66e Fix set name color 2024-02-12 10:18:29 +01:00
Florian Baumann
ee42318b98 Add artist search in card command 2024-02-12 09:28:20 +01:00
Florian Baumann
f2a2b4e65d add cmc card search feature 2024-01-30 12:12:24 +01:00
Florian Baumann
c674e23d93 Remove debug message 2023-11-29 14:58:51 +01:00
Florian Baumann
eb46a898cc add color identity to search 2023-11-29 14:48:03 +01:00
Florian Baumann
2fe24d4781 add export min-count flag 2023-11-27 08:20:00 +01:00
Florian Baumann
3347adadaf Fix count on interactive add 2023-11-10 12:34:06 +01:00
Florian Baumann
af716ae755 Fix missing linebreak on missing command 2023-11-10 11:47:25 +01:00
Florian Baumann
0109d57bd9 Fix interactive foil 2023-11-10 09:31:49 +01:00
Florian Baumann
c5b8ad7270 Add shortcuts to interactive add mode 2023-11-08 14:44:41 +01:00
Florian Baumann
7e7bcd61e9 resturcutre stats 2023-11-08 13:17:27 +01:00
Florian Baumann
58a574b627 Introduced cards added over time 2023-11-08 10:52:09 +01:00
Florian Baumann
1f5e574ca3 Cards added 2023-11-07 17:21:36 +01:00
Florian Baumann
ed7294cadb Goreleaser update 2023-11-02 08:09:53 +01:00
Florian Baumann
240342d52f fix 2023-11-02 07:59:47 +01:00
Florian Baumann
f1c3de2836 Fix format print on set output 2023-11-02 07:48:26 +01:00
Florian Baumann
64607f3680 Add card validation to check 2023-11-02 07:48:26 +01:00
Florian Baumann
f34e49ed71 Update 2023-10-28 07:18:15 +02:00
Florian Baumann
b342a5df75 Add error handling for remove command 2023-10-19 14:32:29 +02:00
Florian Baumann
dcbd275c18 Set increase/decrease log level to warn 2023-10-19 14:30:35 +02:00
Florian Baumann
4cb840558f Add json export 2023-10-18 15:10:25 +02:00
Florian Baumann
07e9a962d2 Fix export 2023-10-18 14:39:02 +02:00
Florian Baumann
0b72fa0265 Add export function 2023-10-18 14:16:00 +02:00
Florian Baumann
c7052c9aec better info on card modifications 2023-09-26 10:19:56 +02:00
Florian Baumann
5578df8e19 Wording 2023-09-15 08:48:44 +02:00
Florian Baumann
a4268afb7f Adjust add output 2023-09-14 17:11:49 +02:00
Florian Baumann
5fb1cb63a2 Fix outputs of missing 2023-09-14 17:08:14 +02:00
Florian Baumann
135f4a93a5 Remove LogMessage and replace with charmbraclet/log 2023-09-14 16:53:08 +02:00
Florian Baumann
71259001d3 remove debug output 2023-09-14 11:16:35 +02:00
Florian Baumann
55e2f72b27 update go 2023-09-14 09:25:38 +02:00
Florian Baumann
ff5575b579 Fixes foil card result list #23 2023-09-13 16:05:17 +02:00
Florian Baumann
cbfbfeef9e fix taskfile release version sorting 2023-09-08 13:20:29 +02:00
Florian Baumann
ef33194e16 Improve wording for check command 2023-08-22 10:42:16 +02:00
Florian Baumann
f0e90350b9 lower setname 2023-08-22 09:50:46 +02:00
Florian Baumann
3870dc8172 Fix: Handle input errors for add commnand 2023-08-14 08:00:36 +02:00
Florian Baumann
23006311d2 Fix search of web 2023-06-30 10:45:22 +02:00
Florian Baumann
446936496f Fix search of web 2023-06-30 10:31:43 +02:00
Florian Baumann
b4f0067b4a Fix issue printing correct currency values in set and card 2023-06-30 09:53:54 +02:00
Florian Baumann
884fbdf96d fix spelling 2023-06-30 09:13:37 +02:00
Florian Baumann
10e84868e7
Update readme.md 2023-06-28 11:12:21 +02:00
Florian Baumann
ca04ee1ab1 add export and import util to support v1 to v2 upgrade 2023-06-28 09:33:04 +02:00
Florian Baumann
f0c82cf325
Update readme.md 2023-06-28 09:32:12 +02:00
Florian Baumann
2de8d089b8
Update readme.md 2023-06-28 09:26:57 +02:00
Florian Baumann
4580748ebe add check command 2023-06-27 16:17:14 +02:00
Florian Baumann
5eac08c40b Add Upgrade Guide fixes #16 2023-06-27 15:57:36 +02:00
Florian Baumann
5f3d643b92 upgrades 2023-06-26 15:29:01 +02:00
Florian Baumann
924c222fe5 new search always starts at page 0 2023-06-26 11:14:52 +02:00
Florian Baumann
697365c518 add efficient mongo query for page calculation 2023-06-26 11:12:48 +02:00
Florian Baumann
f34aa42e8f fix limit issues 2023-06-26 10:29:15 +02:00
Florian Baumann
5f135d07cb Fix next/prev pages 2023-06-16 09:51:48 +02:00
Florian Baumann
8ccaafc140 Add Pagination 2023-06-14 16:57:19 +02:00
Florian Baumann
0140463e09 Update Libs 2023-06-13 13:48:30 +02:00
Florian Baumann
d1dac680e0 Merge branch 'feature/use-cmc-as-manacurve' 2023-06-13 12:25:05 +02:00
Florian Baumann
bb823aeb20 Mana Curve 2023-06-13 12:24:55 +02:00
Florian Baumann
d704528d8e update cmc and cardcount on update command 2023-06-13 09:30:58 +02:00
Florian Baumann
c6e35c48c9 Use cmc as manacurve source 2023-06-12 09:13:30 +02:00
Florian Baumann
c20f84562e Refactor 2023-05-22 11:25:48 +02:00
Corentin Barreau
510e7fe9da Small cosmetics fixes 2023-05-22 11:07:29 +02:00
Corentin Barreau
f0fc422e30 Small cosmetics fixes 2023-05-22 11:07:29 +02:00
Corentin Barreau
73e982ab92 Trim leading 0 when adding or removing cards as Scryfall don't use them 2023-05-22 11:07:29 +02:00
Florian Baumann
d1fd624ba8 Add mana curve into statistics 2023-05-09 11:26:12 +02:00
Florian Baumann
71f451bbd1 Add top artists to stats. fixes #11 2023-05-09 09:44:54 +02:00
Florian Baumann
d293586eba Fix --count option in remove command 2023-05-08 14:29:38 +02:00
Florian Baumann
d03931c091 Add flag for amount of card 2023-05-04 10:53:49 +02:00
Florian Baumann
030bc2c3d8 Remove debug print 2023-04-27 10:25:23 +02:00
Florian Baumann
af4af39adc Filter for foil and reserved list. 2023-04-27 08:05:29 +02:00
Florian Baumann
7b6addcb05 remove etched part of analysis 2023-04-27 07:47:04 +02:00
Florian Baumann
578ad203ca rework set command 2023-04-27 07:20:43 +02:00
Florian Baumann
d9bb463be9 replace foil script 2023-04-27 07:20:43 +02:00
Florian Baumann
104b9b6e96
Create LICENSE 2023-04-26 13:33:31 +02:00
Florian Baumann
af8a6af2ec Get rid of the python person I was :P 2023-04-26 12:46:57 +02:00
Florian Baumann
27be6bf772 Rename fetch_sets to comply to Golang standards 2023-04-26 09:36:40 +02:00
Florian Baumann
339cb56eb2 Format missing output 2023-04-26 09:34:26 +02:00
Corentin Barreau
03a15d998e Show the card's ID when printing missing cards 2023-04-26 09:23:22 +02:00
Florian Baumann
40b08d9846 Fix leftover space in update total 2023-04-26 09:15:18 +02:00
Florian Baumann
6965317ea5 Add documentation for cardranges 2023-04-26 09:13:47 +02:00
Florian Baumann
8e22505ca0 fix detail view 2023-04-26 09:11:46 +02:00
Corentin Barreau
fa59a96db2 feat: refactor webCmd and improve UI consistency
- Add `address` and `port` flags to `webCmd`
- Change the landing page comment to start with a lowercase letter

Signed-off-by: Corentin Barreau <corentin@archive.org>
2023-04-26 09:07:23 +02:00
Corentin Barreau
38ffc25b44 feat: improve user input handling for card range selection
- Add detection for range input in interactive mode
- Add ability to add a range of cards with a single input
- Use strconv instead of fmt for converting strings to integers

Signed-off-by: Corentin Barreau <corentin@archive.org>
2023-04-26 09:06:05 +02:00
Florian Baumann
96731b8fe0 Add --detail flag to card list output 2023-04-24 11:21:28 +02:00
Florian Baumann
755b333bbb Switch to symbols instead of currency names 2023-04-24 11:02:51 +02:00
Florian Baumann
ba6ae6db71 Switch to symbols instead of currency names 2023-04-24 11:01:11 +02:00
Florian Baumann
040561c462 Card overview fixed 2023-04-24 10:52:49 +02:00
Florian Baumann
f2f6317234 Remove foil card from set most value view for now 2023-04-24 10:07:47 +02:00
Florian Baumann
8258bca36b Merge in --foil feature 2023-04-21 11:44:54 +02:00
Florian Baumann
e4b5f87be8 fix reserved list output 2023-04-21 11:42:09 +02:00
Florian Baumann
637d385e74 Fix update output on total value 2023-04-20 12:12:18 +02:00
Florian Baumann
2ed9467f6a Add price lists capability for foils 2023-04-20 12:05:14 +02:00
Florian Baumann
e0d90ae12d stats output for foil 2023-04-20 11:47:30 +02:00
Florian Baumann
c784477241 Fix 2023-04-20 11:07:10 +02:00
Florian Baumann
4a1147b1af merge 2023-04-20 11:06:22 +02:00
Florian Baumann
c106a01a28 Introduce proper flags for tops/flops. fixes #1 2023-04-20 11:06:22 +02:00
Florian Baumann
0d51645516 Reduce docker image size from 1.2gb to 22mb 2023-04-20 11:06:22 +02:00
Florian Baumann
927c568059 Sort on card numbers is now numeric. fixes #8 2023-04-20 11:06:19 +02:00
Florian Baumann
5c3c06d1fc Introduce proper flags for tops/flops. fixes #1 2023-04-17 15:35:50 +02:00
Florian Baumann
8362829617 Reduce docker image size from 1.2gb to 22mb 2023-04-17 15:11:44 +02:00
Florian Baumann
b5139e8af4 Sort on card numbers is now numeric. fixes #8 2023-04-17 14:16:53 +02:00
Florian Baumann
ed1bb5c5a4 Catch non-existent reserved list bug 2023-04-12 09:38:20 +02:00
Florian Baumann
a11c17fbf5 Catch non-existent reserved list bug 2023-04-12 09:37:56 +02:00
Ryan Castner
f6621e9bd8 upgrade mongodb driver due to data race bug found in versions v1.11.0 through v1.11.2 2023-04-12 09:15:20 +02:00
Florian Baumann
1110501c7e
Merge pull request #17 from audiolion/mongodb-driver-fix
upgrade mongodb driver due to data race bug found in versions v1.11.0 through v1.11.2
2023-04-11 15:59:48 +02:00
Ryan Castner
de868f58b1 working version of adding/removing foils and adding to collection value 2023-03-18 21:51:11 -04:00
Ryan Castner
eb7b0c5b7e upgrade mongodb driver due to data race bug found in versions v1.11.0 through v1.11.2 2023-03-18 18:43:07 -04:00
Florian Baumann
e6befab9ed
Update readme.md 2023-03-02 11:26:24 +01:00
Florian Baumann
6f4df926a2 Catch card format not correct 2023-03-02 08:40:26 +01:00
Florian Baumann
be949a0174 rename sort to sortby 2023-03-01 13:53:28 +01:00
Florian Baumann
7a24cecd93 Footer link and simpler css 2023-03-01 09:34:32 +01:00
Florian Baumann
bcde7b3699 code 2023-02-28 14:32:17 +01:00
Florian Baumann
52490771eb Improve mobile 2023-02-28 14:31:55 +01:00
Florian Baumann
a3d17da32c remove demo 2023-02-28 08:09:42 +01:00
Florian Baumann
f950595767 Add git for Version 2023-02-28 08:08:40 +01:00
Florian Baumann
dfaa79d12a header+footer 2023-02-27 16:28:34 +01:00
Florian Baumann
04d5f20b46 Lazy load images 2023-02-27 15:20:39 +01:00
47 changed files with 2654 additions and 1393 deletions

3
.gitignore vendored
View File

@ -1,7 +1,6 @@
.DS_Store
serra
!src/serra
_db/*
/serra
!_db/.placeholder
_backup/*
!_backup/.placeholder

51
.goreleaser.yaml Normal file
View File

@ -0,0 +1,51 @@
version: 2
before:
hooks:
- go mod tidy
- go generate ./...
builds:
- env:
- CGO_ENABLED=0
goos:
- linux
- windows
- darwin
- openbsd
ldflags:
- -X github.com/noqqe/serra/pkg/serra.Version={{.Tag}}
flags:
- -v
main: ./cmd/serra
archives:
- id: serra
name_template: >-
{{ .ProjectName }}_
{{- title .Os }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
changelog:
sort: asc
filters:
exclude:
- '^docs:'
- '^test:'
brews:
- name: serra
goarm: 6
repository:
owner: noqqe
name: homebrew-tap
download_strategy: CurlDownloadStrategy
directory: Formula
commit_author:
name: goreleaserbot
email: goreleaser@carlosbecker.com
homepage: "https://github.com/noqqe/serra"
description: "serra - Personal Magic: The Gathering Collection Tracker "
license: "MIT"
test: |
system "#{bin}/serra --version"
install: |
bin.install "serra"

View File

@ -1,97 +0,0 @@
# This is an example .goreleaser.yml file with some sane defaults.
# Make sure to check the documentation at http://goreleaser.com
before:
hooks:
# You may remove this if you don't use go modules.
- go mod tidy
# you may remove this if you don't need go generate
- go generate ./...
builds:
- env:
- CGO_ENABLED=0
goos:
- linux
- windows
- darwin
- openbsd
ldflags:
- -X github.com/noqqe/serra/src/serra.Version={{.Tag}}
flags:
- -v
archives:
- id: serra
name_template: >-
{{ .ProjectName }}_
{{- title .Os }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
checksum:
name_template: 'checksums.txt'
snapshot:
name_template: "{{ .Tag }}-next"
changelog:
sort: asc
filters:
exclude:
- '^docs:'
- '^test:'
brews:
-
# Name template of the recipe
# Default to project name
name: serra
# GOARM to specify which 32-bit arm version to use if there are multiple versions
# from the build section. Brew formulas support atm only one 32-bit version.
# Default is 6 for all artifacts or each id if there a multiple versions.
goarm: 6
# NOTE: make sure the url_template, the token and given repo (github or gitlab) owner and name are from the
# same kind. We will probably unify this in the next major version like it is done with scoop.
# GitHub/GitLab repository to push the formula to
# Gitea is not supported yet, but the support coming
tap:
owner: noqqe
name: homebrew-tap
# Optionally a token can be provided, if it differs from the token provided to GoReleaser
# Allows you to set a custom download strategy. Note that you'll need
# to implement the strategy and add it to your tap repository.
# Example: https://docs.brew.sh/Formula-Cookbook#specifying-the-download-strategy-explicitly
# Default is empty.
download_strategy: CurlDownloadStrategy
# Git author used to commit to the repository.
# Defaults are shown.
commit_author:
name: goreleaserbot
email: goreleaser@carlosbecker.com
# Folder inside the repository to put the formula.
# Default is the root folder.
folder: Formula
# Your app's homepage.
# Default is empty.
homepage: "https://github.com/noqqe/serra"
# Your app's description.
# Default is empty.
description: "serra - Personal Magic: The Gathering Collection Tracker "
# SPDX identifier of your app's license.
# Default is empty.
license: "MIT"
# So you can `brew test` your formula.
# Default is empty.
test: |
system "#{bin}/serra --version"
# Custom install script for brew.
# Default is 'bin.install "program"'.
install: |
bin.install "serra"

View File

@ -1 +1 @@
golang 1.20
golang 1.21.3

View File

@ -1,16 +1,26 @@
FROM golang:1.20
FROM golang:1.25-alpine AS build
RUN apk update && apk add --no-cache git ca-certificates curl
WORKDIR /go/src/app
COPY src /go/src/app/src
COPY pkg /go/src/app/pkg
COPY cmd /go/src/app/cmd
COPY templates /go/src/app/templates
COPY go.mod /go/src/app/go.mod
COPY go.sum /go/src/app/go.sum
COPY serra.go /go/src/app/serra.go
COPY .git /go/src/app/.git
# build
RUN go build -ldflags "-X github.com/noqqe/serra/pkg/serra.Version=`git describe --tags`" -v cmd/serra/serra.go
RUN go get -v ./...
RUN go build -ldflags "-X github.com/noqqe/serra/src/serra.Version=`git describe --tags`" -v serra.go
# copy
FROM scratch
WORKDIR /go/src/app
COPY --from=build /go/src/app/serra /go/src/app/serra
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=build /tmp /tmp
COPY templates /go/src/app/templates
# Run radsportsalat
# run
EXPOSE 8080
CMD [ "./serra", "web" ]
CMD [ "/go/src/app/serra", "web" ]

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Florian Baumann
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -5,17 +5,17 @@ version: '3'
tasks:
build:
cmds:
- go build -ldflags "-X github.com/noqqe/serra/src/serra.Version=`git describe --tags`" -v serra.go
- go build -ldflags "-X github.com/noqqe/serra/pkg/serra.Version=`git describe --tags`" -v cmd/serra/serra.go
sources:
- "src/serra/**/*.go"
- "serra.go"
- "pkg/serra/**/*.go"
- "cmd/serra/serra.go"
generates:
- "./serra"
release:
interactive: true
cmds:
- git tag | tail -5
- git tag | sort -t. -k 1,1n -k 2,2n -k 3,3n | tail -5
- read -p "Version v1.1.1 " version ; git tag $version
- git push --tags
- goreleaser release --clean

View File

@ -1,7 +1,7 @@
// Package main provides a typing test
package main
import "github.com/noqqe/serra/src/serra"
import "github.com/noqqe/serra/pkg/serra"
func main() {
serra.Execute()

BIN
demo.gif

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

View File

@ -1,51 +0,0 @@
# VHS documentation
#
# Output:
# Output <path>.gif Create a GIF output at the given <path>
# Output <path>.mp4 Create an MP4 output at the given <path>
# Output <path>.webm Create a WebM output at the given <path>
#
# Settings:
# Set FontSize <number> Set the font size of the terminal
# Set FontFamily <string> Set the font family of the terminal
# Set Height <number> Set the height of the terminal
# Set Width <number> Set the width of the terminal
# Set LetterSpacing <float> Set the font letter spacing (tracking)
# Set LineHeight <float> Set the font line height
# Set Theme <string> Set the theme of the terminal (JSON)
# Set Padding <number> Set the padding of the terminal
# Set Framerate <number> Set the framerate of the recording
# Set PlaybackSpeed <float> Set the playback speed of the recording
#
# Sleep:
# Sleep <time> Sleep for a set amount of <time> in seconds
#
# Type:
# Type[@<time>] "<characters>" Type <characters> into the terminal with a
# <time> delay between each character
#
# Keys:
# Backspace[@<time>] [number] Press the Backspace key
# Down[@<time>] [number] Press the Down key
# Enter[@<time>] [number] Press the Enter key
# Space[@<time>] [number] Press the Space key
# Tab[@<time>] [number] Press the Tab key
# Left[@<time>] [number] Press the Left Arrow key
# Right[@<time>] [number] Press the Right Arrow key
# Up[@<time>] [number] Press the Up Arrow key
# Down[@<time>] [number] Press the Down Arrow key
# Ctrl+<key> Press the Control key + <key> (e.g. Ctrl+C)
#
# Display:
# Hide Hide the subsequent commands from the output
# Show Show the subsequent commands in the output
Output demo.gif
Set FontSize 32
Set Width 1200
Set Height 600
Type "serra --version" Sleep 500ms Enter
Sleep 5s

View File

@ -2,7 +2,7 @@ version: '3.6'
services:
mongo:
image: mongo
image: mongo:6
restart: always
ports:
- 27017:27017

76
go.mod
View File

@ -1,23 +1,67 @@
module github.com/noqqe/serra
go 1.14
go 1.25.0
toolchain go1.25.4
require (
github.com/charmbracelet/log v0.4.0
github.com/chzyer/readline v1.5.1
github.com/gin-gonic/gin v1.9.0
github.com/golang/snappy v0.0.4 // indirect
github.com/google/go-cmp v0.5.6 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/compress v1.15.15 // indirect
github.com/gin-gonic/gin v1.10.0
github.com/mitchellh/mapstructure v1.5.0
github.com/montanaflynn/stats v0.7.0 // indirect
github.com/rivo/uniseg v0.4.4 // indirect
github.com/schollz/progressbar/v3 v3.13.0
github.com/spf13/cobra v1.6.1
github.com/xdg-go/scram v1.1.2 // indirect
github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a // indirect
go.mongodb.org/mongo-driver v1.11.2
golang.org/x/crypto v0.6.0 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
github.com/schollz/progressbar/v3 v3.16.1
github.com/spf13/cobra v1.8.1
go.mongodb.org/mongo-driver v1.17.1
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/bytedance/sonic v1.12.3 // indirect
github.com/bytedance/sonic/loader v0.2.1 // indirect
github.com/charmbracelet/lipgloss v0.13.0 // indirect
github.com/charmbracelet/x/ansi v0.3.2 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/dolmen-go/kittyimg v0.0.0-20250610224728-874967bd8ea4 // indirect
github.com/gabriel-vasile/mimetype v1.4.6 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.22.1 // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect
github.com/muesli/termenv v0.15.2 // indirect
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.1.2 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
golang.org/x/arch v0.11.0 // indirect
golang.org/x/crypto v0.28.0 // indirect
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect
golang.org/x/net v0.30.0 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/term v0.29.0 // indirect
golang.org/x/text v0.19.0 // indirect
google.golang.org/protobuf v1.35.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

207
go.sum
View File

@ -1,195 +1,180 @@
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.8.0 h1:ea0Xadu+sHlu7x5O3gKhRpQ1IKiMrSiHttPF0ybECuA=
github.com/bytedance/sonic v1.8.0/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/bytedance/sonic v1.12.3 h1:W2MGa7RCU1QTeYRTPE3+88mVC0yXmsRQRChiyVocVjU=
github.com/bytedance/sonic v1.12.3/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.1 h1:1GgorWTqf12TA8mma4DDSbaQigE2wOgQo7iCjjJv3+E=
github.com/bytedance/sonic/loader v0.2.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw=
github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY=
github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM=
github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM=
github.com/charmbracelet/x/ansi v0.3.2 h1:wsEwgAN+C9U06l9dCVMX0/L3x7ptvY1qmjMwyfE6USY=
github.com/charmbracelet/x/ansi v0.3.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM=
github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY=
github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dolmen-go/kittyimg v0.0.0-20250610224728-874967bd8ea4 h1:KGRb+vxMx5pGsfDjDSW2Th+b2OEflb0yC3s0daCmiYU=
github.com/dolmen-go/kittyimg v0.0.0-20250610224728-874967bd8ea4/go.mod h1:2vk7ATPVcI7uW4Sh6PrSQvtO+Czmq8509xcg/y8Osd0=
github.com/gabriel-vasile/mimetype v1.4.6 h1:3+PzJTKLkvgjeTbts6msPJt4DixhT4YtFNf1gtGe3zc=
github.com/gabriel-vasile/mimetype v1.4.6/go.mod h1:JX1qVKqZd40hUPpAfiNTe0Sne7hdfKSbOqqmkq8GCXc=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.0 h1:OjyFBKICoexlu99ctXNR2gg+c5pKrKMuyjgARg9qeY8=
github.com/gin-gonic/gin v1.9.0/go.mod h1:W1Me9+hsUSyj3CePGrd1/QrKJMSJ1Tu/0hFEH89961k=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.11.2 h1:q3SHpufmypg+erIExEKUmsgmhDTyhcJ38oeKGACXohU=
github.com/go-playground/validator/v10 v10.11.2/go.mod h1:NieE624vt4SCTJtD87arVLvdmjPAeV8BQlHtMnw9D7s=
github.com/goccy/go-json v0.10.0 h1:mXKd9Qw4NuzShiRlOXKews24ufknHO7gx30lsDyokKA=
github.com/goccy/go-json v0.10.0/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA=
github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw=
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/compress v1.15.15 h1:EF27CXIuDsYJ6mmvtBRlEuB2UVOqHG1tAXgZ7yIO+lw=
github.com/klauspost/compress v1.15.15/go.mod h1:ZcK2JAFqKOpnBlxcLsJzYfrS9X1akm9fHZNnD9+Vo/4=
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
github.com/montanaflynn/stats v0.7.0 h1:r3y12KyNxj/Sb/iOE46ws+3mS1+MZca1wlHQFPsY/JU=
github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/pelletier/go-toml/v2 v2.0.6 h1:nrzqCb7j9cDFj2coyLNLaZuJTLjWjlaz6nvTvIwycIU=
github.com/pelletier/go-toml/v2 v2.0.6/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/schollz/progressbar/v3 v3.13.0 h1:9TeeWRcjW2qd05I8Kf9knPkW4vLM/hYoa6z9ABvxje8=
github.com/schollz/progressbar/v3 v3.13.0/go.mod h1:ZBYnSuLAX2LU8P8UiKN/KgF2DY58AJC8yfVYLPC8Ly4=
github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA=
github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY=
github.com/schollz/progressbar/v3 v3.16.1 h1:RnF1neWZFzLCoGx8yp1yF7SDl4AzNDI5y4I0aUJRrZQ=
github.com/schollz/progressbar/v3 v3.16.1/go.mod h1:I2ILR76gz5VXqYMIY/LdLecvMHDPVcQm3W/MSKi1TME=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/tidwall/pretty v1.0.0 h1:HsD+QiTn7sK6flMKIvNmpqz1qrpP3Ps6jOKIKMooyg4=
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.9 h1:rmenucSohSTiyL09Y+l2OCk+FrMxGMzho2+tjr5ticU=
github.com/ugorji/go/codec v1.2.9/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a h1:fZHgsYlfvtyqToslyjUt3VOPF4J7aK/3MPcK7xp3PDk=
github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a/go.mod h1:ul22v+Nro/R083muKhosV54bj5niojjWZvU8xrevuH4=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mongodb.org/mongo-driver v1.11.2 h1:+1v2rDQUWNcGW7/7E0Jvdz51V38XXxJfhzbV17aNHCw=
go.mongodb.org/mongo-driver v1.11.2/go.mod h1:s7p5vEtfbeR1gYi6pnj3c3/urpbLv2T5Sfd6Rp2HBB8=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
go.mongodb.org/mongo-driver v1.17.1 h1:Wic5cJIwJgSpBhe3lx3+/RybR5PiYRMpVFgO7cOHyIM=
go.mongodb.org/mongo-driver v1.17.1/go.mod h1:wwWm/+BuOddhcq3n68LKRmgk2wXzmF6s0SFOa0GINL4=
golang.org/x/arch v0.11.0 h1:KXV8WWKCXm6tRpLirl2szsO5j/oOODwZf4hATmGVNs4=
golang.org/x/arch v0.11.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU=
golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY=
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24=
golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M=
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=

BIN
imgs/check.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

190
pkg/serra/add.go Normal file
View File

@ -0,0 +1,190 @@
package serra
import (
"fmt"
"strconv"
"strings"
"github.com/chzyer/readline"
"github.com/spf13/cobra"
"go.mongodb.org/mongo-driver/bson"
)
func init() {
addCmd.Flags().Int64VarP(&count, "count", "c", 1, "Amount of cards to add")
addCmd.Flags().BoolVarP(&unique, "unique", "u", false, "Only add card if not existent yet")
addCmd.Flags().BoolVarP(&interactive, "interactive", "i", false, "Spin up interactive terminal")
addCmd.Flags().StringVarP(&set, "set", "s", "", "Filter by set code (usg/mmq/vow)")
addCmd.Flags().BoolVarP(&foil, "foil", "f", false, "Add foil variant of card")
rootCmd.AddCommand(addCmd)
}
var addCmd = &cobra.Command{
Aliases: []string{"a"},
Use: "add",
Short: "Add a card to your collection",
Long: "Adds a card from scryfall to your collection. Amount can be modified using flags",
SilenceErrors: true,
RunE: func(cmd *cobra.Command, cards []string) error {
if interactive {
addCardsInteractive(unique, set)
} else {
addCards(cards, unique, count)
}
return nil
},
}
func addCardsInteractive(unique bool, set string) {
l := Logger()
if len(set) == 0 {
l.Fatal("Option --set <set> must be given in interactive mode")
}
rl, err := readline.New(fmt.Sprintf("%s> ", set))
if err != nil {
panic(err)
}
defer rl.Close()
for {
line, err := rl.Readline()
if err != nil { // io.EOF
break
}
// default is no foil
foil = false
// default is count 1
count = 1
// construct card input for addCards
card := []string{}
// Detect if input contains a dash, if it does it means the user wants to add a range of cards
if strings.Contains(line, "-") {
// Split input into two parts
parts := strings.Split(line, "-")
// Check if both parts are numbers
if _, err := strconv.Atoi(parts[0]); err == nil {
if _, err = strconv.Atoi(parts[1]); err == nil {
// Loop over range and add each card to card slice
start, _ := strconv.Atoi(parts[0])
end, _ := strconv.Atoi(parts[1])
for i := start; i <= end; i++ {
card = append(card, fmt.Sprintf("%s/%d", set, i))
}
}
}
} else {
card = append(card, fmt.Sprintf("%s/%s", set, strings.Split(line, " ")[0]))
}
// Are there extra arguments?
if len(strings.Split(line, " ")) == 2 {
// foil shortcut
if strings.Split(line, " ")[1] == "f" {
foil = true
}
// amount shortcut
if amount, err := strconv.Atoi(strings.Split(line, " ")[1]); err == nil {
if amount > 1 {
count = int64(amount)
}
}
}
addCards(card, unique, count)
}
}
func addCards(cards []string, unique bool, count int64) error {
client := storageConnect()
coll := &Collection{client.Database("serra").Collection("cards")}
l := Logger()
defer storageDisconnect(client)
// Loop over different cards
for _, card := range cards {
// Extract collector number and set name from card input & trim any leading 0 from collector number
if !strings.Contains(card, "/") {
l.Errorf("Invalid card format %s. Needs to be set/collector number i.e. \"usg/13\"", card)
continue
}
setName := strings.ToLower(strings.Split(card, "/")[0])
collectorNumber := strings.TrimLeft(strings.Split(card, "/")[1], "0")
if collectorNumber == "" {
l.Errorf("Invalid card format %s. Needs to be set/collector number i.e. \"usg/13\"", card)
continue
}
// Check if card is already in collection
co, err := coll.storageFind(bson.D{{"set", setName}, {"collectornumber", collectorNumber}}, bson.D{}, 0, 0)
if err != nil {
l.Error(err)
continue
}
if len(co) >= 1 {
c := co[0]
outputColor := coloredValue(c.getValue(foil))
if unique {
l.Warnf("%dx \"%s\" (%s, %s%.2f%s%s) not added, because it already exists", count, c.Name, c.Rarity, outputColor, c.getValue(foil), getCurrency(), Reset)
continue
}
if askConfirmation(&c) {
modifyCardCount(coll, &c, count, foil)
}
} else {
// Fetch card from scryfall
c, err := fetchCard(setName, collectorNumber)
outputColor := coloredValue(c.getValue(foil))
if err != nil {
l.Warn(err)
continue
}
// Write card to mongodb
var total int64 = 0
if foil {
c.SerraCountFoil = count
c.SerraCountFoilDeck = 0
total = c.SerraCountFoil
} else {
c.SerraCount = count
c.SerraCountDeck = 0
total = c.SerraCount
}
if !askConfirmation(c) {
continue
}
err = coll.storageAdd(c)
if err != nil {
l.Warn(err)
continue
}
// Give feedback of successfully added card
if foil {
l.Infof("%dx \"%s\" (%s, %s%.2f%s%s, foil) added", total, c.Name, c.Rarity, outputColor, c.getValue(foil), getCurrency(), Reset)
} else {
l.Infof("%dx \"%s\" (%s, %s%.2f%s%s) added", total, c.Name, c.Rarity, outputColor, c.getValue(foil), getCurrency(), Reset)
}
}
}
storageDisconnect(client)
return nil
}

243
pkg/serra/card.go Normal file
View File

@ -0,0 +1,243 @@
package serra
import (
"bytes"
"encoding/base64"
"fmt"
"image/png"
"os"
"sort"
"strings"
"github.com/dolmen-go/kittyimg"
"github.com/nfnt/resize"
"github.com/spf13/cobra"
"go.mongodb.org/mongo-driver/bson"
)
func init() {
cardCmd.Flags().StringVarP(&artist, "artist", "a", "", "Filter by name of artist")
cardCmd.Flags().StringVarP(&rarity, "rarity", "r", "", "Filter by rarity of cards (mythic, rare, uncommon, common)")
cardCmd.Flags().StringVarP(&set, "set", "e", "", "Filter by set code (usg/mmq/vow)")
cardCmd.Flags().StringVarP(&sortby, "sort", "s", "name", "How to sort cards (value/number/name/added)")
cardCmd.Flags().StringVarP(&name, "name", "n", "", "Name of the card (regex compatible)")
cardCmd.Flags().Int64VarP(&cmc, "cmc", "m", -1, "Cumulative mana cost of card")
cardCmd.Flags().StringVarP(&color, "color", "i", "", "Color identity of card (w,u,b,r,g)")
cardCmd.Flags().StringVarP(&oracle, "oracle", "o", "", "Contains string in card text")
cardCmd.Flags().StringVarP(&cardType, "type", "t", "", "Contains string in card type line")
cardCmd.Flags().Int64VarP(&count, "min-count", "c", 0, "Occource more than X in your collection")
cardCmd.Flags().BoolVarP(&detail, "detail", "d", false, "Show details for cards (url)")
cardCmd.Flags().BoolVarP(&reserved, "reserved", "w", false, "If card is on reserved list")
cardCmd.Flags().BoolVarP(&foil, "foil", "f", false, "If card is foil list")
cardCmd.Flags().BoolVarP(&drawImg, "image", "g", false, "Draw card image using kitty format")
cardCmd.Flags().BoolVarP(&omitInDeck, "omit-in-deck", "q", false, "Omit cards that are in decks")
rootCmd.AddCommand(cardCmd)
}
var cardCmd = &cobra.Command{
Aliases: []string{"cards"},
Use: "card [card]",
Short: "Search & show cards from your collection",
Long: `Search and show cards from your collection.
If you directly put a card as an argument, it will be displayed
otherwise you'll get a list of cards as a search result.`,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, cards []string) error {
if len(cards) == 0 {
cardList := Cards(rarity, set, sortby, name, oracle, cardType, reserved, foil, 0, 0, omitInDeck)
showCardList(cardList, detail)
} else {
ShowCard(cards)
}
return nil
},
}
func ShowCard(cardids []string) {
client := storageConnect()
coll := &Collection{client.Database("serra").Collection("cards")}
l := Logger()
defer storageDisconnect(client)
for _, v := range cardids {
if len(strings.Split(v, "/")) < 2 || strings.Split(v, "/")[1] == "" {
l.Warnf("Invalid card %s", v)
continue
}
cards, _ := coll.storageFind(bson.D{{"set", strings.Split(v, "/")[0]}, {"collectornumber", strings.Split(v, "/")[1]}}, bson.D{{"name", 1}}, 0, 0)
for _, card := range cards {
showCardDetails(&card)
}
}
}
func Cards(rarity, set, sortby, name, oracle, cardType string, reserved, foil bool, skip, limit int64, omitInDeck bool) []Card {
client := storageConnect()
coll := &Collection{client.Database("serra").Collection("cards")}
defer storageDisconnect(client)
filter := bson.D{}
switch rarity {
case "uncommon":
filter = append(filter, bson.E{"rarity", "uncommon"})
case "common":
filter = append(filter, bson.E{"rarity", "common"})
case "rare":
filter = append(filter, bson.E{"rarity", "rare"})
case "mythic":
filter = append(filter, bson.E{"rarity", "mythic"})
}
var sortStage bson.D
switch sortby {
case "value":
if getCurrency() == EUR {
sortStage = bson.D{{"prices.eur", 1}}
} else {
sortStage = bson.D{{"prices.usd", 1}}
}
case "number":
sortStage = bson.D{{"collectornumber", 1}}
case "name":
sortStage = bson.D{{"name", 1}}
case "added":
sortStage = bson.D{{"serra_created", 1}}
default:
sortStage = bson.D{{"name", 1}}
}
if len(set) > 0 {
filter = append(filter, bson.E{"set", set})
}
if len(name) > 0 {
filter = append(filter, bson.E{"name", bson.D{{"$regex", ".*" + name + ".*"}, {"$options", "i"}}})
}
if len(artist) > 0 {
filter = append(filter, bson.E{"artist", bson.D{{"$regex", ".*" + artist + ".*"}, {"$options", "i"}}})
}
if cmc > -1 {
filter = append(filter, bson.E{"cmc", cmc})
}
if len(oracle) > 0 {
filter = append(filter, bson.E{"oracletext", bson.D{{"$regex", ".*" + oracle + ".*"}, {"$options", "i"}}})
}
if len(cardType) > 0 {
filter = append(filter, bson.E{"typeline", bson.D{{"$regex", ".*" + cardType + ".*"}, {"$options", "i"}}})
}
if len(color) > 0 {
colorArr := strings.Split(strings.ToUpper(color), ",")
filter = append(filter, bson.E{"coloridentity", colorArr})
}
if reserved {
filter = append(filter, bson.E{"reserved", true})
}
if foil {
filter = append(filter, bson.E{"serra_count_foil", bson.D{{"$gt", 0}}})
}
if omitInDeck {
filter = append(filter, bson.E{ "$expr", bson.D{{"$or",bson.A{
bson.D{{"$ne", bson.A{"$serra_count", "$serra_count_deck"}}},
bson.D{{"$ne", bson.A{"$serra_count_foil", "$serra_count_foil_deck"}}},
}}}})
}
cards, _ := coll.storageFind(filter, sortStage, skip, limit)
// This is needed because collectornumbers are strings (ie. "23a") but still we
// want it to be sorted numerically ... 1,2,3,10,11,100.
if sortby == "number" {
sort.Slice(cards, func(i, j int) bool {
return filterForDigits(cards[i].CollectorNumber) < filterForDigits(cards[j].CollectorNumber)
})
}
// filter out cards that do not reach the minimum amount (--min-count)
// this is done after query result because find query constructed does not support
// aggregating fields (of count and countFoil).
temp := cards[:0]
for _, card := range cards {
if (card.SerraCount + card.SerraCountFoil) >= count {
temp = append(temp, card)
}
}
cards = temp
return cards
}
func showCardList(cards []Card, detail bool) {
var total float64
if drawImg {
for _, card := range cards {
drawImage(&card)
}
} else if detail {
for _, card := range cards {
fmt.Printf("* %dx %s%s%s (%s/%s) %s%.2f%s %s %s %s\n", card.SerraCount+card.SerraCountFoil+card.SerraCountEtched, Purple, card.Name, Reset, card.Set, card.CollectorNumber, Yellow, card.getValue(false), getCurrency(), Background, strings.Replace(card.ScryfallURI, "?utm_source=api", "", 1), Reset)
total = total + card.getValue(false)*float64(card.SerraCount) + card.getValue(true)*float64(card.SerraCountFoil)
}
} else {
for _, card := range cards {
fmt.Printf("* %dx (%dx) %s%s%s (%s/%s) %s%.2f%s%s\n",
card.SerraCount+card.SerraCountFoil+card.SerraCountEtched-card.SerraCountDeck-card.SerraCountFoilDeck-card.SerraCountEtchedDeck,
card.SerraCount+card.SerraCountFoil+card.SerraCountEtched,
Purple, card.Name, Reset, card.Set, card.CollectorNumber, Yellow, card.getValue(false), getCurrency(), Reset)
total = total + card.getValue(false)*float64(card.SerraCount) + card.getValue(true)*float64(card.SerraCountFoil)
}
}
if !drawImg {
fmt.Printf("\nTotal Value: %s%.2f%s%s\n", Yellow, total, getCurrency(), Reset)
}
}
func showCardDetails(card *Card) error {
if drawImg {
drawImage(card)
} else {
fmt.Printf("%s%s%s (%s/%s)\n", Purple, card.Name, Reset, card.Set, card.CollectorNumber)
fmt.Printf("Available: %d / %d\n", card.SerraCount + card.SerraCountFoil - card.SerraCountDeck - card.SerraCountFoilDeck, card.SerraCount + card.SerraCountFoil)
fmt.Printf("Added: %s\n", stringToTime(card.SerraCreated))
fmt.Printf("Rarity: %s\n", card.Rarity)
fmt.Printf("Scryfall: %s\n", strings.Replace(card.ScryfallURI, "?utm_source=api", "", 1))
fmt.Printf("\n%sCurrent Value%s\n", Green, Reset)
fmt.Printf("* Normal: %dx %s%.2f%s%s\n", card.SerraCount, Yellow, card.getValue(false), getCurrency(), Reset)
if card.SerraCountFoil > 0 {
fmt.Printf("* Foil: %dx %s%.2f%s%s\n", card.SerraCountFoil, Yellow, card.getValue(true), getCurrency(), Reset)
}
fmt.Printf("\n%sValue History%s\n", Green, Reset)
showPriceHistory(card.SerraPrices, "* ", false)
fmt.Println()
}
return nil
}
func drawImage(card *Card) {
fmt.Printf("%s - %s (%s/%s)\n", card.Name, card.SetName, card.Set, card.CollectorNumber)
data, err := base64.StdEncoding.DecodeString(card.SerraImage64)
if err == nil {
reader := bytes.NewReader(data)
img, err := png.Decode(reader)
img = resize.Resize(305, 425, img, resize.Bicubic)
if err == nil {
kittyimg.Fprintln(os.Stdout, img)
}
}
fmt.Println()
}

77
pkg/serra/check.go Normal file
View File

@ -0,0 +1,77 @@
package serra
import (
"fmt"
"strings"
"github.com/spf13/cobra"
"go.mongodb.org/mongo-driver/bson"
)
func init() {
checkCmd.Flags().StringVarP(&set, "set", "s", "", "Filter by set code (usg/mmq/vow)")
checkCmd.Flags().BoolVarP(&detail, "detail", "d", false, "Show details for cards (url)")
rootCmd.AddCommand(checkCmd)
}
var checkCmd = &cobra.Command{
Aliases: []string{"c"},
Use: "check",
Short: "Check if a card is in your collection",
Long: "Check if a card is in your collection. Useful for list comparsions",
SilenceErrors: true,
RunE: func(cmd *cobra.Command, cards []string) error {
checkCards(cards, detail)
return nil
},
}
func checkCards(cards []string, detail bool) error {
client := storageConnect()
coll := &Collection{client.Database("serra").Collection("cards")}
defer storageDisconnect(client)
l := Logger()
// Loop over different cards
for _, card := range cards {
if !strings.Contains(card, "/") {
l.Errorf("Invalid card format %s. Needs to be set/collector number i.e. \"usg/13\"", card)
continue
}
// Extract collector number and set name from card input & trim any leading 0 from collector number
collectorNumber := strings.TrimLeft(strings.Split(card, "/")[1], "0")
setName := strings.ToLower(strings.Split(card, "/")[0])
if collectorNumber == "" {
l.Errorf("Invalid card format %s. Needs to be set/collector number i.e. \"usg/13\"", card)
continue
}
// Check if card is already in collection
co, err := coll.storageFind(bson.D{{"set", setName}, {"collectornumber", collectorNumber}}, bson.D{}, 0, 0)
if err != nil {
l.Warn(err)
continue
}
// If Card is in collection, print yes.
if len(co) >= 1 {
c := co[0]
fmt.Printf("PRESENT %s \"%s\" (%s, %.2f%s) %s\n", card, c.Name, c.Rarity, c.getValue(foil), getCurrency(), strings.Replace(c.ScryfallURI, "?utm_source=api", "", 1))
continue
} else {
if detail {
// fetch card from scyrfall if --detail was given
c, _ := fetchCard(setName, collectorNumber)
fmt.Printf("MISSING %s \"%s\" (%s, %.2f%s) %s\n", card, c.Name, c.Rarity, c.getValue(foil), getCurrency(), strings.Replace(c.ScryfallURI, "?utm_source=api", "", 1))
} else {
// Just print, the card name was not found
fmt.Printf("MISSING \"%s\"\n", card)
}
}
}
storageDisconnect(client)
return nil
}

67
pkg/serra/deck.go Normal file
View File

@ -0,0 +1,67 @@
package serra
import (
"strings"
"github.com/spf13/cobra"
"go.mongodb.org/mongo-driver/bson"
)
func init() {
deckCmd.Flags().Int64VarP(&count, "count", "c", 1, "Amount of cards to add")
deckCmd.Flags().BoolVarP(&foil, "foil", "f", false, "Add foil variant of card")
rootCmd.AddCommand(deckCmd)
}
var deckCmd = &cobra.Command{
Aliases: []string{"d"},
Use: "deck",
Short: "Mark a card as in a deck",
Long: "Mark a card as in a deck",
SilenceErrors: true,
RunE: func(cmd *cobra.Command, cards []string) error {
deckCards(cards, count)
return nil
},
}
func deckCards(cards []string, count int64) error {
client := storageConnect()
coll := &Collection{client.Database("serra").Collection("cards")}
l := Logger()
defer storageDisconnect(client)
// Loop over different cards
for _, card := range cards {
// Extract collector number and set name from card input & trim any leading 0 from collector number
if !strings.Contains(card, "/") {
l.Errorf("Invalid card format %s. Needs to be set/collector number i.e. \"usg/13\"", card)
continue
}
setName := strings.ToLower(strings.Split(card, "/")[0])
collectorNumber := strings.TrimLeft(strings.Split(card, "/")[1], "0")
if collectorNumber == "" {
l.Errorf("Invalid card format %s. Needs to be set/collector number i.e. \"usg/13\"", card)
continue
}
// Check if card is already in collection
co, err := coll.storageFind(bson.D{{"set", setName}, {"collectornumber", collectorNumber}}, bson.D{}, 0, 0)
if err != nil {
l.Error(err)
continue
}
if len(co) >= 1 {
modifyCardDeckCount(coll, &co[0], count, foil)
} else {
l.Errorf("Card not in collection: %s", card)
continue
}
}
storageDisconnect(client)
return nil
}

33
pkg/serra/env.go Normal file
View File

@ -0,0 +1,33 @@
package serra
import (
"os"
)
const EUR = "€"
const USD = "$"
func getMongoDBURI() string {
l := Logger()
uri := os.Getenv("MONGODB_URI")
if uri == "" {
l.Fatal("You must set 'MONGODB_URI' environmental variable. See\n\t https://docs.mongodb.com/drivers/go/current/usage-examples/#environment-variable")
}
return uri
}
// Returns configured human readable name for
// the configured currency of the user
func getCurrency() string {
l := Logger()
switch os.Getenv("SERRA_CURRENCY") {
case "EUR":
return EUR
case "USD":
return USD
default:
l.Warn("You did not configure SERRA_CURRENCY. Assuming \"USD\"")
return "$"
}
}

127
pkg/serra/export.go Normal file
View File

@ -0,0 +1,127 @@
package serra
import (
"encoding/csv"
"encoding/json"
"fmt"
"log"
"os"
"github.com/spf13/cobra"
)
func init() {
exportCmd.Flags().StringVarP(&set, "set", "e", "", "Filter by set code (usg/mmq/vow)")
exportCmd.Flags().StringVarP(&format, "format", "f", "tcgpowertools", "Choose format to export (tcgpowertools/json)")
exportCmd.Flags().Int64VarP(&count, "min-count", "c", 0, "Occource more than X in your collection")
rootCmd.AddCommand(exportCmd)
}
var exportCmd = &cobra.Command{
Use: "export",
Short: "Export cards from your collection",
Long: `Export cards from your collection.
Your data. Your choice.
Supports multiple output formats depending on where you want to export your collection.`,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
cardList := Cards(rarity, set, sortby, name, oracle, cardType, reserved, foil, 0, 0, false)
// filter out cards that do not reach the minimum amount (--min-count)
// this is done after query result because find query constructed does not support
// aggregating fields (of count and countFoil).
temp := cardList[:0]
for _, card := range cardList {
if (card.SerraCount + card.SerraCountFoil) >= count {
temp = append(temp, card)
}
}
cardList = temp
switch format {
case "tcgpowertools":
exportTCGPowertools(cardList)
case "tcghome":
exportTCGHome(cardList)
case "moxfield":
exportMoxfield(cardList)
case "json":
exportJSON(cardList)
}
return nil
},
}
func exportTCGPowertools(cards []Card) {
// TCGPowertools.com Example
// idProduct,quantity,name,set,condition,language,isFoil,isPlayset,isSigned,isFirstEd,price,comment
// 260009,1,Totally Lost,Gatecrash,GD,English,true,true,,,1000,
// 260009,1,Totally Lost,Gatecrash,NM,English,true,true,,,1000,
fmt.Println("quantity,cardmarketId,name,set,condition,language,isFoil,isPlayset,price,comment")
for _, card := range cards {
fmt.Printf("%d,%.0f,%s,%s,EX,German,false,false,%.2f,\n", card.SerraCount+card.SerraCountFoil, card.CardmarketID, card.Name, card.SetName, card.getValue(false))
}
}
func exportMoxfield(cards []Card) {
// Structure
// https://www.moxfield.com/help/importing-collection
records := [][]string{{
"Count", "Name", "Edition", "Condition", "Language", "Foil", "Collector Number", "Alter", "Proxy", "Purchase Price"}}
w := csv.NewWriter(os.Stdout)
for _, card := range cards {
records = append(records,
[]string{fmt.Sprintf("%d", card.SerraCount+card.SerraCountFoil), card.Name, card.Set, "NM", "English", "FALSE", card.CollectorNumber, "FALSE", "FALSE", ""})
}
for _, record := range records {
if err := w.Write(record); err != nil {
log.Fatalln("error writing record to csv:", err)
}
}
w.Flush()
if err := w.Error(); err != nil {
log.Fatal(err)
}
}
func exportTCGHome(cards []Card) {
// Strucutre
// https://app.tcg-home.com/e686ea62-7078-4f52-bd6f-515e18c7dc6a
records := [][]string{{
"amount", "name", "finish", "set", "collector_number", "language", "condition", "scryfall_id", "purchase_price"}}
w := csv.NewWriter(os.Stdout)
for _, card := range cards {
records = append(records,
[]string{fmt.Sprintf("%d", card.SerraCount+card.SerraCountFoil), card.Name, "", card.Set, card.CollectorNumber, "English", "EX", card.ID, ""})
}
for _, record := range records {
if err := w.Write(record); err != nil {
log.Fatalln("error writing record to csv:", err)
}
}
w.Flush()
if err := w.Error(); err != nil {
log.Fatal(err)
}
}
func exportJSON(cards []Card) {
ehj, _ := json.MarshalIndent(cards, "", " ")
fmt.Println(string(ehj))
}

View File

@ -2,7 +2,6 @@ package serra
import (
"fmt"
"strconv"
"github.com/spf13/cobra"
"go.mongodb.org/mongo-driver/bson"
@ -13,9 +12,11 @@ func init() {
rootCmd.AddCommand(topsCmd)
rootCmd.AddCommand(flopsCmd)
topsCmd.Flags().Float64VarP(&limit, "limit", "l", 0, "Minimum card price to be shown in analysis")
topsCmd.Flags().StringVarP(&since, "since", "s", "0", "Since when should the gains be calculated")
topsCmd.Flags().BoolVarP(&sinceLastUpdate, "since-last-update", "u", false, "Show gains since last update")
topsCmd.Flags().BoolVarP(&sinceBeginning, "since-beginning", "b", true, "Show gains since beginning of records")
flopsCmd.Flags().Float64VarP(&limit, "limit", "l", 0, "Minimum card price to be shown in analysis")
flopsCmd.Flags().StringVarP(&since, "since", "s", "0", "Since when should the losses be calculated")
flopsCmd.Flags().BoolVarP(&sinceLastUpdate, "since-last-update", "u", false, "Show losses since last update")
flopsCmd.Flags().BoolVarP(&sinceBeginning, "since-beginning", "b", true, "Show losses since beginning of records")
}
var topsCmd = &cobra.Command{
@ -24,7 +25,7 @@ var topsCmd = &cobra.Command{
Short: "What cards gained most value",
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
Gains(limit, -1, since)
Gains(limit, -1)
return nil
},
}
@ -35,26 +36,32 @@ var flopsCmd = &cobra.Command{
Short: "What cards lost most value",
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
Gains(limit, 1, since)
Gains(limit, 1)
return nil
},
}
func Gains(limit float64, sort int, since string) error {
func Gains(limit float64, sort int) error {
client := storage_connect()
client := storageConnect()
coll := &Collection{client.Database("serra").Collection("cards")}
setcoll := &Collection{client.Database("serra").Collection("sets")}
defer storage_disconnect(client)
defer storageDisconnect(client)
old, _ := strconv.Atoi(since)
var old int
if sinceBeginning {
old = 0
}
if sinceLastUpdate {
old = -2
}
currencyField := "$serra_prices.usd"
if getCurrency() == "EUR" {
if getCurrency() == EUR {
currencyField = "$serra_prices.eur"
}
raise_pipeline := mongo.Pipeline{
raisePipeline := mongo.Pipeline{
bson.D{{"$project",
bson.D{
{"name", true},
@ -102,9 +109,9 @@ func Gains(limit float64, sort int, since string) error {
bson.D{{"rate", sort}}}},
bson.D{{"$limit", 20}},
}
raise, _ := coll.storage_aggregate(raise_pipeline)
raise, _ := coll.storageAggregate(raisePipeline)
sraise_pipeline := mongo.Pipeline{
sraisePipeline := mongo.Pipeline{
bson.D{{"$project",
bson.D{
{"name", true},
@ -150,25 +157,25 @@ func Gains(limit float64, sort int, since string) error {
bson.D{{"rate", sort}}}},
bson.D{{"$limit", 10}},
}
sraise, _ := setcoll.storage_aggregate(sraise_pipeline)
sraise, _ := setcoll.storageAggregate(sraisePipeline)
// percentage coloring
var p_color string
var pColor string
if sort == 1 {
p_color = Red
pColor = Red
} else {
p_color = Green
pColor = Green
}
fmt.Printf("%sCards%s\n", Purple, Reset)
// print each card
for _, e := range raise {
fmt.Printf("%s%+.0f%%%s %s %s(%s/%s)%s (%.2f->%s%.2f %s%s) \n", p_color, e["rate"], Reset, e["name"], Yellow, e["set"], e["collectornumber"], Reset, e["old"], Green, e["current"], getCurrency(), Reset)
fmt.Printf("%s%+.0f%%%s %s %s(%s/%s)%s (%.2f->%s%.2f%s%s) \n", pColor, e["rate"], Reset, e["name"], Yellow, e["set"], e["collectornumber"], Reset, e["old"], Green, e["current"], getCurrency(), Reset)
}
fmt.Printf("\n%sSets%s\n", Purple, Reset)
for _, e := range sraise {
fmt.Printf("%s%+.0f%%%s %s %s(%s)%s (%.2f->%s%.2f %s%s) \n", p_color, e["rate"], Reset, e["name"], Yellow, e["code"], Reset, e["old"], Green, e["current"], getCurrency(), Reset)
fmt.Printf("%s%+.0f%%%s %s %s(%s)%s (%.2f->%s%.2f%s%s) \n", pColor, e["rate"], Reset, e["name"], Yellow, e["code"], Reset, e["old"], Green, e["current"], getCurrency(), Reset)
}
return nil

323
pkg/serra/helpers.go Normal file
View File

@ -0,0 +1,323 @@
package serra
import (
"errors"
"fmt"
"math"
"os"
"strconv"
"time"
"unicode"
"github.com/charmbracelet/log"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type Rarities struct {
Rares, Uncommons, Commons, Mythics float64
}
var (
Icon = "\U0001F9D9\U0001F3FC"
Reset = "\033[0m"
Background = "\033[38;5;59m"
CurrentLine = "\033[38;5;60m"
Foreground = "\033[38;5;231m"
Comment = "\033[38;5;103m"
Cyan = "\033[38;5;159m"
Green = "\033[38;5;120m"
Orange = "\033[38;5;222m"
Pink = "\033[38;5;212m"
Purple = "\033[38;5;183m"
Red = "\033[38;5;210m"
Yellow = "\033[38;5;229m"
)
func Logger() *log.Logger {
l := log.New(os.Stderr)
l.SetReportTimestamp(false)
return l
}
func getStoredCard(coll *Collection, c *Card) (Card, error) {
// find already existing card
sort := bson.D{{"_id", 1}}
searchFilter := bson.D{{"_id", c.ID}}
storedCards, err := coll.storageFind(searchFilter, sort, 0, 0)
if err != nil {
return Card{}, err
}
return storedCards[0], nil
}
func modifyCardCount(coll *Collection, c *Card, amount int64, foil bool) error {
l := Logger()
storedCard, err := getStoredCard(coll, c)
if err != nil {
return err
}
// update card amount
var update bson.M
if foil {
update = bson.M{
"$set": bson.M{"serra_count_foil": storedCard.SerraCountFoil + amount},
}
} else {
update = bson.M{
"$set": bson.M{"serra_count": storedCard.SerraCount + amount},
}
}
coll.storageUpdate(bson.M{"_id": bson.M{"$eq": c.ID}}, update)
var total int64
if foil {
total = storedCard.SerraCountFoil + amount
if amount < 0 {
l.Warnf("Reduced card amount of \"%s\" (%.2f%s, foil) from %d to %d", storedCard.Name, storedCard.getValue(true), getCurrency(), storedCard.SerraCountFoil, total)
} else {
l.Warnf("Increased card amount of \"%s\" (%.2f%s, foil) from %d to %d", storedCard.Name, storedCard.getValue(true), getCurrency(), storedCard.SerraCountFoil, total)
}
} else {
total = storedCard.SerraCount + amount
if amount < 0 {
l.Warnf("Reduced card amount of \"%s\" (%.2f%s) from %d to %d", storedCard.Name, storedCard.getValue(false), getCurrency(), storedCard.SerraCount, total)
} else {
l.Warnf("Increased card amount of \"%s\" (%.2f%s) from %d to %d", storedCard.Name, storedCard.getValue(false), getCurrency(), storedCard.SerraCount, total)
}
}
return nil
}
func modifyCardDeckCount(coll *Collection, c *Card, amount int64, foil bool) error {
l := Logger()
storedCard, err := getStoredCard(coll, c)
if err != nil {
return err
}
// update card amount
var update bson.M
if foil {
newAmount := min(max(storedCard.SerraCountFoilDeck + amount, 0), storedCard.SerraCountFoil)
l.Infof("%d / %d available", storedCard.SerraCountFoil - newAmount, storedCard.SerraCountFoil)
update = bson.M{
"$set": bson.M{"serra_count_foil_deck": newAmount},
}
} else {
newAmount := min(max(storedCard.SerraCountDeck + amount, 0), storedCard.SerraCount)
l.Infof("%d / %d available", storedCard.SerraCount - newAmount, storedCard.SerraCount)
update = bson.M{
"$set": bson.M{"serra_count_deck": newAmount},
}
}
coll.storageUpdate(bson.M{"_id": bson.M{"$eq": c.ID}}, update)
return nil
}
func findCardByCollectorNumber(coll *Collection, setCode string, collectorNumber string) (*Card, error) {
sort := bson.D{{"_id", 1}}
searchFilter := bson.D{{"set", setCode}, {"collectornumber", collectorNumber}}
storedCards, err := coll.storageFind(searchFilter, sort, 0, 0)
if err != nil {
return &Card{}, err
}
if len(storedCards) < 1 {
return &Card{}, errors.New("Card not found")
}
return &storedCards[0], nil
}
func stringToTime(s primitive.DateTime) string {
return time.UnixMilli(int64(s)).Format("2006-01-02")
}
// missing compares two slices and returns slice of differences
func missing(a, b []string) []string {
type void struct{}
// create map with length of the 'a' slice
ma := make(map[string]void, len(a))
diffs := []string{}
// Convert first slice to map with empty struct (0 bytes)
for _, ka := range a {
ma[ka] = void{}
}
// find missing values in a
for _, kb := range b {
if _, ok := ma[kb]; !ok {
diffs = append(diffs, kb)
}
}
return diffs
}
func findSetByCode(coll *Collection, setcode string) (*Set, error) {
storedSets, err := coll.storageFindSet(bson.D{{"code", setcode}}, bson.D{{"_id", 1}})
if err != nil {
return &Set{}, err
}
if len(storedSets) < 1 {
return &Set{}, errors.New("Set not found")
}
return &storedSets[0], nil
}
func convertManaSymbols(sym []interface{}) string {
var mana string
if len(sym) == 0 {
mana = mana + "None" //probibited sign for lands
}
for _, v := range sym {
switch v {
case "B":
mana = mana + "Black" //black
case "R":
mana = mana + "Red" //red
case "G":
mana = mana + "Green" //green
case "U":
mana = mana + "Blue" //blue
case "W":
mana = mana + "White" //white
}
}
return mana
}
func convertRarities(rar []primitive.M) Rarities {
// this is maybe the ugliest way someone could choose to verify, if a rarity type is missing
// [
// { _id: { rarity: 'common' }, count: 20 },
// { _id: { rarity: 'uncommon' }, count: 2 }
// ]
// if a result like this is there, 1 rarity type "rare" is not in the array. and needs to be
// initialized with 0, otherwise we get a panic
var ri Rarities
for _, r := range rar {
switch r["_id"] {
case "rare":
ri.Rares = r["count"].(float64)
case "uncommon":
ri.Uncommons = r["count"].(float64)
case "common":
ri.Commons = r["count"].(float64)
case "mythic":
ri.Mythics = r["count"].(float64)
}
}
return ri
}
func showPriceHistory(prices []PriceEntry, prefix string, total bool) {
var before float64
for _, e := range prices {
var value float64
if total {
if getCurrency() == EUR {
value = e.Eur + e.EurFoil
} else {
value = e.Usd + e.UsdFoil
}
} else {
if getCurrency() == EUR {
value = e.Eur
} else {
value = e.Usd
}
}
if value > before && before != 0 {
fmt.Printf("%s%s%s %.2f%s%s (%+.2f%%, %+.2f%s)\n", prefix, stringToTime(e.Date), Green, value, getCurrency(), Reset, (value/before*100)-100, value-before, getCurrency())
} else if value < before {
fmt.Printf("%s%s%s %.2f%s%s (%+.2f%%, %+.2f%s)\n", prefix, stringToTime(e.Date), Red, value, getCurrency(), Reset, (value/before*100)-100, value-before, getCurrency())
} else {
fmt.Printf("%s%s %.2f%s%s\n", prefix, stringToTime(e.Date), value, getCurrency(), Reset)
}
before = value
}
}
func filterForDigits(str string) int {
var numStr string
for _, c := range str {
if unicode.IsDigit(c) {
numStr += string(c)
}
}
s, _ := strconv.Atoi(numStr)
return s
}
func getFloat64(unknown interface{}) (float64, error) {
switch i := unknown.(type) {
case float64:
return i, nil
case float32:
return float64(i), nil
case int64:
return float64(i), nil
case int32:
return float64(i), nil
case int:
return float64(i), nil
case uint64:
return float64(i), nil
case uint32:
return float64(i), nil
case uint:
return float64(i), nil
default:
return math.NaN(), errors.New("non-numeric type could not be converted to float")
}
}
func coloredValue(value float64) string {
outputColor := Reset
if value > 1 {
outputColor = Green
}
if value > 5 {
outputColor = Yellow
}
if value > 10 {
outputColor = Red
}
return outputColor
}
func askConfirmation(card *Card) bool {
drawImage(card)
fmt.Println("Is this correct (y/n)?")
var char = 'x'
for char != 'y' && char != 'n' {
fmt.Scanf("%c\n", &char)
}
return char == 'y'
}

84
pkg/serra/missing.go Normal file
View File

@ -0,0 +1,84 @@
package serra
import (
"fmt"
"sort"
"strconv"
"github.com/spf13/cobra"
"go.mongodb.org/mongo-driver/bson"
)
func init() {
rootCmd.AddCommand(missingCmd)
}
var missingCmd = &cobra.Command{
Aliases: []string{"m"},
Use: "missing <set>",
Short: "Display missing cards from a set",
Long: `In case you are a set collector, you can generate a list of
cards you dont own (yet) :)`,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, setName []string) error {
client := storageConnect()
coll := &Collection{client.Database("serra").Collection("cards")}
l := Logger()
defer storageDisconnect(client)
// fetch all cards in set
cards, err := coll.storageFind(bson.D{{"set", setName[0]}}, bson.D{{"collectornumber", 1}}, 0, 0)
if (err != nil) || len(cards) == 0 {
l.Errorf("Set %s not found or no card in your collection.", setName[0])
return err
}
// fetch set informations
setcoll := &Collection{client.Database("serra").Collection("sets")}
sets, _ := setcoll.storageFindSet(bson.D{{"code", setName[0]}}, bson.D{{"_id", 1}})
set := sets[0]
fmt.Printf("Missing cards in %s\n", sets[0].Name)
// generate set with all setnumbers
var (
completeSet []string
i int64
)
for i = 1; i <= set.CardCount; i++ {
completeSet = append(completeSet, strconv.FormatInt(i, 10))
}
// iterate over all cards in collection
var inCollection []string
for _, c := range cards {
inCollection = append(inCollection, c.CollectorNumber)
}
misses := missing(inCollection, completeSet)
// Fetch all missing cards
missingCards := []*Card{}
for _, m := range misses {
card, err := fetchCard(setName[0], m)
if err != nil {
continue
}
missingCards = append(missingCards, card)
}
// Sort the missing cards by ID
sort.Slice(missingCards, func(i, j int) bool {
id1, _ := strconv.Atoi(missingCards[i].CollectorNumber)
id2, _ := strconv.Atoi(missingCards[j].CollectorNumber)
return id1 < id2
})
for _, card := range missingCards {
fmt.Printf("%s%s/%s\t%s(%s, %shttps://scryfall.com/card/%s/%s%s)\t%s%.02f%s%s\t%s (%s)\n", Purple, card.Set, card.CollectorNumber, Reset, string([]rune(card.Rarity)[0]), Background, card.Set, card.CollectorNumber, Reset, Green, card.getValue(false), Reset, getCurrency(), card.Name, card.SetName)
}
return nil
},
}

View File

@ -2,7 +2,6 @@ package serra
import (
"fmt"
"os"
"strings"
"github.com/chzyer/readline"
@ -14,6 +13,7 @@ func init() {
removeCmd.Flags().Int64VarP(&count, "count", "c", 1, "Amount of cards to remove")
removeCmd.Flags().BoolVarP(&interactive, "interactive", "i", false, "Spin up interactive terminal")
removeCmd.Flags().StringVarP(&set, "set", "s", "", "Filter by set code (usg/mmq/vow)")
removeCmd.Flags().BoolVarP(&foil, "foil", "f", false, "Remove foil variant of card")
rootCmd.AddCommand(removeCmd)
}
@ -26,7 +26,7 @@ var removeCmd = &cobra.Command{
RunE: func(cmd *cobra.Command, cards []string) error {
if interactive {
removeCardsInteractive(unique, set)
removeCardsInteractive(set)
} else {
removeCards(cards, count)
}
@ -34,11 +34,11 @@ var removeCmd = &cobra.Command{
},
}
func removeCardsInteractive(unique bool, set string) {
func removeCardsInteractive(set string) {
l := Logger()
if len(set) == 0 {
LogMessage("Error: --set must be given in interactive mode", "red")
os.Exit(1)
l.Fatal("Option --set must be given in interactive mode")
}
rl, err := readline.New(fmt.Sprintf("%s> ", set))
@ -63,27 +63,57 @@ func removeCardsInteractive(unique bool, set string) {
}
func removeCards(cards []string, count int64) error {
client := storage_connect()
// Connect to the DB & load the collection
client := storageConnect()
coll := &Collection{client.Database("serra").Collection("cards")}
defer storage_disconnect(client)
l := Logger()
defer storageDisconnect(client)
// Loop over different cards
for _, card := range cards {
// Fetch card from scryfall
c, err := find_card_by_setcollectornumber(coll, strings.Split(card, "/")[0], strings.Split(card, "/")[1])
if err != nil {
LogMessage(fmt.Sprintf("%v", err), "red")
if !strings.Contains(card, "/") {
l.Errorf("Invalid card format %s. Needs to be set/collector number i.e. \"usg/13\"", card)
continue
}
if c.SerraCount > 1 {
modify_count_of_card(coll, c, -1)
} else {
coll.storage_remove(bson.M{"_id": c.ID})
LogMessage(fmt.Sprintf("\"%s\" (%.2f %s) removed from the Collection.", c.Name, c.getValue(), getCurrency()), "green")
// Extract collector number and set name from input & remove leading zeros
collectorNumber := strings.TrimLeft(strings.Split(card, "/")[1], "0")
setName := strings.Split(card, "/")[0]
if collectorNumber == "" {
l.Errorf("Invalid card format %s. Needs to be set/collector number i.e. \"usg/13\"", card)
continue
}
// Fetch card from scryfall
c, err := findCardByCollectorNumber(coll, setName, collectorNumber)
if err != nil {
l.Error(err)
continue
}
if foil && c.SerraCountFoil < 1 {
l.Errorf("No foil \"%s\" in the collection", c.Name)
continue
}
if !foil && c.SerraCount < 1 {
l.Errorf("No normal \"%s\" in the collection", c.Name)
continue
}
if !askConfirmation(c) {
continue
}
if foil && c.SerraCountFoil == 1 && c.SerraCount == 0 || !foil && c.SerraCount == 1 && c.SerraCountFoil == 0 {
coll.storageRemove(bson.M{"_id": c.ID})
l.Infof("\"%s\" (%.2f%s) removed", c.Name, c.getValue(foil), getCurrency())
} else {
modifyCardCount(coll, c, -count, foil)
}
}
return nil
}

52
pkg/serra/root.go Normal file
View File

@ -0,0 +1,52 @@
// Package serra
//
// It implements base functions and also cli wrappers
// The entire tool consists only of this one package.
package serra
import (
"github.com/spf13/cobra"
)
var (
Version = "unknown"
address string
artist string
cardType string
color string
cmc int64
count int64
detail bool
drawImg bool
foil bool
format string
interactive bool
limit float64
name string
omitInDeck bool
oracle string
port uint64
rarity string
reserved bool
set string
sinceBeginning bool
sinceLastUpdate bool
sortby string
unique bool
)
var rootCmd = &cobra.Command{
Version: Version,
Long: `serra - Magic: The Gathering Collection Tracker`,
Use: "serra",
DisableFlagsInUseLine: true,
SilenceErrors: true,
}
func Execute() {
l := Logger()
if err := rootCmd.Execute(); err != nil {
l.Fatal(err)
}
}

View File

@ -2,13 +2,14 @@ package serra
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"log"
"net/http"
"strings"
"os"
"time"
"go.mongodb.org/mongo-driver/bson/primitive"
@ -16,19 +17,24 @@ import (
type Card struct {
// Added by Serra
SerraCount int64 `bson:"serra_count"`
SerraCountFoil int64 `bson:"serra_count_foil"`
SerraCountEtched int64 `bson:"serra_count_etched"`
SerraPrices []PriceEntry `bson:"serra_prices"`
SerraCreated primitive.DateTime `bson:"serra_created"`
SerraUpdated primitive.DateTime `bson:"serra_updated"`
SerraCount int64 `bson:"serra_count"`
SerraCountFoil int64 `bson:"serra_count_foil"`
SerraCountEtched int64 `bson:"serra_count_etched"`
SerraCountDeck int64 `bson:"serra_count_deck"`
SerraCountFoilDeck int64 `bson:"serra_count_foil_deck"`
SerraCountEtchedDeck int64 `bson:"serra_count_etched_deck"`
SerraPrices []PriceEntry `bson:"serra_prices"`
SerraCreated primitive.DateTime `bson:"serra_created"`
SerraUpdated primitive.DateTime `bson:"serra_updated"`
SerraImage64 string `bson:"serra_image"`
Artist string `json:"artist"`
ArtistIds []string `json:"artist_ids"`
Booster bool `json:"booster"`
BorderColor string `json:"border_color"`
CardBackID string `json:"card_back_id"`
Cmc int64 `json:"cmc"`
CardmarketID float64 `json:"cardmarket_id"`
Cmc float64 `json:"cmc"`
CollectorNumber string `json:"collector_number"`
ColorIdentity []string `json:"color_identity"`
Colors []string `json:"colors"`
@ -100,31 +106,139 @@ type Card struct {
TcgplayerInfiniteArticles string `json:"tcgplayer_infinite_articles"`
TcgplayerInfiniteDecks string `json:"tcgplayer_infinite_decks"`
} `json:"related_uris"`
ReleasedAt string `json:"released_at"`
Reprint bool `json:"reprint"`
Reserved bool `json:"reserved"`
RulingsURI string `json:"rulings_uri"`
ScryfallSetURI string `json:"scryfall_set_uri"`
ScryfallURI string `json:"scryfall_uri"`
Set string `json:"set"`
SetID string `json:"set_id"`
SetName string `json:"set_name"`
SetSearchURI string `json:"set_search_uri"`
SetType string `json:"set_type"`
SetURI string `json:"set_uri"`
StorySpotlight bool `json:"story_spotlight"`
Textless bool `json:"textless"`
TypeLine string `json:"type_line"`
URI string `json:"uri"`
Variation bool `json:"variation"`
ReleasedAt string `json:"released_at"`
Reprint bool `json:"reprint"`
Reserved bool `json:"reserved"`
RulingsURI string `json:"rulings_uri"`
ScryfallSetURI string `json:"scryfall_set_uri"`
ScryfallURI string `json:"scryfall_uri"`
Set string `json:"set"`
SetID string `json:"set_id"`
SetName string `json:"set_name"`
SetSearchURI string `json:"set_search_uri"`
SetType string `json:"set_type"`
SetURI string `json:"set_uri"`
StorySpotlight bool `json:"story_spotlight"`
Textless bool `json:"textless"`
TCGPlayerID float64 `json:"tcgplayer_id"`
TypeLine string `json:"type_line"`
URI string `json:"uri"`
Variation bool `json:"variation"`
}
// Getter for currency specific value
func (c Card) getValue() float64 {
if getCurrency() == "EUR" {
return c.Prices.Eur
type BulkIndex struct {
Object string `json:"object"`
HasMore bool `json:"has_more"`
Data []struct {
Object string `json:"object"`
ID string `json:"id"`
Type string `json:"type"`
UpdatedAt time.Time `json:"updated_at"`
URI string `json:"uri"`
Name string `json:"name"`
Description string `json:"description"`
Size int `json:"size"`
DownloadURI string `json:"download_uri"`
ContentType string `json:"content_type"`
ContentEncoding string `json:"content_encoding"`
} `json:"data"`
}
func fetchBulkDownloadURL() (string, error) {
url := "https://api.scryfall.com/bulk-data"
downloadURL := ""
// Make an HTTP GET request
resp, err := http.Get(url)
if err != nil {
log.Fatalf("Error fetching data: %v", err)
}
return c.Prices.Usd
defer resp.Body.Close()
// Read the response body
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
log.Fatalf("Error reading response body: %v", err)
}
// Unmarshal the JSON response
var bulkData BulkIndex
if err := json.Unmarshal(body, &bulkData); err != nil {
log.Fatalf("Error unmarshaling JSON: %v", err)
}
// Find and print the unique cards URL
for _, item := range bulkData.Data {
if item.Type == "default_cards" {
downloadURL = item.DownloadURI
}
}
return downloadURL, nil
}
func downloadBulkData(downloadURL string) (string, error) {
// Create a temporary directory
tempDir, err := os.MkdirTemp("", "download")
if err != nil {
log.Fatalf("Error creating temporary directory: %v", err)
}
// defer os.RemoveAll(tempDir) // Clean up the directory when done
// Create a temporary file in the temporary directory
tempFile, err := os.CreateTemp(tempDir, "downloaded-*.json") // Adjust the extension if necessary
if err != nil {
log.Fatalf("Error creating temporary file: %v", err)
}
// defer tempFile.Close() // Ensure we close the file when we're done
// Download the file
resp, err := http.Get(downloadURL)
if err != nil {
log.Fatalf("Error downloading file: %v", err)
}
defer resp.Body.Close() // Make sure to close the response body
// Check for a successful response
if resp.StatusCode != http.StatusOK {
log.Fatalf("Error: received status code %d", resp.StatusCode)
}
// Copy the response body to the temporary file
_, err = io.Copy(tempFile, resp.Body)
if err != nil {
log.Fatalf("Error saving file: %v", err)
}
return tempFile.Name(), nil
}
func loadBulkFile(bulkFilePath string) ([]Card, error) {
var cards []Card
fileBytes, _ := os.ReadFile(bulkFilePath)
defer os.Remove(bulkFilePath)
err := json.Unmarshal(fileBytes, &cards)
if err != nil {
fmt.Println("Error unmarshalling bulk file:", err)
return cards, nil
}
return cards, nil
}
func getCardFromBulk(cards []Card, setName, collectorNumber string) (*Card, error) {
var foundCard Card
for _, v := range cards {
if v.CollectorNumber == collectorNumber && v.Set == setName {
foundCard = v
return &foundCard, nil
}
}
return &Card{}, fmt.Errorf("Card %s/%s not found in bulk data", setName, collectorNumber)
}
type PriceEntry struct {
@ -164,29 +278,35 @@ type Set struct {
URI string `json:"uri"`
}
func fetch_card(path string) (*Card, error) {
if !strings.Contains(path, "/") {
err := errors.New(fmt.Sprintf("Card must follow format <set>/<number>, for example: ath/15"))
return &Card{}, err
// Getter for currency specific value
func (c Card) getValue(foil bool) float64 {
if getCurrency() == EUR {
if foil {
return c.Prices.EurFoil
}
return c.Prices.Eur
}
if foil {
return c.Prices.UsdFoil
}
return c.Prices.Usd
}
// TODO better URL Building...
resp, err := http.Get(fmt.Sprintf("https://api.scryfall.com/cards/%s/", path))
func fetchCard(setName, collectorNumber string) (*Card, error) {
resp, err := http.Get(fmt.Sprintf("https://api.scryfall.com/cards/%s/%s/", setName, collectorNumber))
if err != nil {
log.Fatalln(err)
return &Card{}, err
}
if resp.StatusCode != 200 {
err := errors.New(fmt.Sprintf("Error: %s not found", path))
return &Card{}, err
return &Card{}, fmt.Errorf("Card %s/%s not found", setName, collectorNumber)
}
//We Read the response body on the line below.
body, err := ioutil.ReadAll(resp.Body)
//we read the response body on the line below.
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Fatalln(err)
log.Fatalf("%s", err)
return &Card{}, err
}
@ -194,6 +314,9 @@ func fetch_card(path string) (*Card, error) {
decoder := json.NewDecoder(r)
val := &Card{}
err = decoder.Decode(val)
if err != nil {
log.Fatalf("%s", err)
}
// Set created Time
val.SerraCreated = primitive.NewDateTimeFromTime(time.Now())
@ -202,24 +325,36 @@ func fetch_card(path string) (*Card, error) {
val.Prices.Date = primitive.NewDateTimeFromTime(time.Now())
val.SerraPrices = append(val.SerraPrices, val.Prices)
imgResp, imgErr := http.Get(val.ImageUris.Png)
if imgErr != nil {
log.Fatalln(err)
return &Card{}, err
}
bytes, readErr := io.ReadAll(imgResp.Body)
if readErr != nil {
log.Fatalln(err)
return &Card{}, err
}
val.SerraImage64 = base64.StdEncoding.EncodeToString(bytes)
return val, nil
}
func fetch_sets() (*SetList, error) {
// TODO better URL Building...
resp, err := http.Get(fmt.Sprintf("https://api.scryfall.com/sets"))
func fetchSets() (*SetList, error) {
// TODO: better URL Building...
resp, err := http.Get("https://api.scryfall.com/sets")
if err != nil {
log.Fatalln(err)
return &SetList{}, err
}
if resp.StatusCode != 200 {
err := errors.New(fmt.Sprintf("Error: /sets not found"))
return &SetList{}, err
return &SetList{}, fmt.Errorf("/sets not found")
}
//We Read the response body on the line below.
body, err := ioutil.ReadAll(resp.Body)
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Fatalln(err)
return &SetList{}, err
@ -228,7 +363,11 @@ func fetch_sets() (*SetList, error) {
r := bytes.NewReader(body)
decoder := json.NewDecoder(r)
val := &SetList{}
err = decoder.Decode(val)
if err != nil {
log.Fatalln(err)
}
return val, nil
}

View File

@ -10,7 +10,7 @@ import (
)
func init() {
setCmd.Flags().StringVarP(&sort, "sort", "s", "release", "How to sort cards (release/value)")
setCmd.Flags().StringVarP(&sortby, "sort", "s", "release", "How to sort cards (release/value)")
rootCmd.AddCommand(setCmd)
}
@ -24,8 +24,8 @@ otherwise you'll get a list of sets as a search result.`,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, set []string) error {
if len(set) == 0 {
setList := Sets(sort)
show_set_list(setList)
setList := Sets(sortby)
showSetList(setList)
} else {
ShowSet(set[0])
}
@ -35,14 +35,15 @@ otherwise you'll get a list of sets as a search result.`,
func Sets(sort string) []primitive.M {
client := storage_connect()
client := storageConnect()
coll := &Collection{client.Database("serra").Collection("cards")}
defer storage_disconnect(client)
defer storageDisconnect(client)
groupStage := bson.D{
{"$group", bson.D{
{"_id", "$setname"},
{"value", bson.D{{"$sum", bson.D{{"$multiply", bson.A{getCurrencyField(), "$serra_count"}}}}}},
{"value", bson.D{{"$sum", bson.D{{"$multiply", bson.A{getCurrencyField(false), "$serra_count"}}}}}},
{"value_foil", bson.D{{"$sum", bson.D{{"$multiply", bson.A{getCurrencyField(true), "$serra_count_foil"}}}}}},
{"count", bson.D{{"$sum", bson.D{{"$multiply", bson.A{1.0, "$serra_count"}}}}}},
{"unique", bson.D{{"$sum", 1}}},
{"code", bson.D{{"$last", "$set"}}},
@ -64,45 +65,46 @@ func Sets(sort string) []primitive.M {
}}}
}
sets, _ := coll.storage_aggregate(mongo.Pipeline{groupStage, sortStage})
sets, _ := coll.storageAggregate(mongo.Pipeline{groupStage, sortStage})
return sets
}
func show_set_list(sets []primitive.M) {
func showSetList(sets []primitive.M) {
client := storage_connect()
client := storageConnect()
setscoll := &Collection{client.Database("serra").Collection("sets")}
for _, set := range sets {
setobj, _ := find_set_by_code(setscoll, set["code"].(string))
setobj, _ := findSetByCode(setscoll, set["code"].(string))
fmt.Printf("* %s %s%s%s (%s%s%s)\n", set["release"].(string)[0:4], Purple, set["_id"], Reset, Cyan, set["code"], Reset)
fmt.Printf(" Cards: %s%d/%d%s Total: %.0f \n", Yellow, set["unique"], setobj.CardCount, Reset, set["count"])
fmt.Printf(" Value: %s%.2f %s%s\n", Pink, set["value"], getCurrency(), Reset)
fmt.Printf(" Value: %s%.2f%s%s\n", Pink, set["value"], getCurrency(), Reset)
fmt.Println()
}
}
func ShowSet(setname string) error {
client := storage_connect()
client := storageConnect()
coll := &Collection{client.Database("serra").Collection("cards")}
defer storage_disconnect(client)
l := Logger()
defer storageDisconnect(client)
// fetch all cards in set ordered by currently used currency
cardSortCurrency := bson.D{{"prices.usd", -1}}
if getCurrency() == "EUR" {
if getCurrency() == EUR {
cardSortCurrency = bson.D{{"prices.eur", -1}}
}
cards, err := coll.storage_find(bson.D{{"set", setname}}, cardSortCurrency)
cards, err := coll.storageFind(bson.D{{"set", setname}}, cardSortCurrency, 0, 0)
if (err != nil) || len(cards) == 0 {
LogMessage(fmt.Sprintf("Error: Set %s not found or no card in your collection.", setname), "red")
l.Errorf("Set %s not found or no card in your collection.", setname)
return err
}
// fetch set informations
setcoll := &Collection{client.Database("serra").Collection("sets")}
sets, _ := setcoll.storage_find_set(bson.D{{"code", setname}}, bson.D{{"_id", 1}})
sets, _ := setcoll.storageFindSet(bson.D{{"code", setname}}, bson.D{{"_id", 1}})
// set values
matchStage := bson.D{
@ -113,11 +115,13 @@ func ShowSet(setname string) error {
groupStage := bson.D{
{"$group", bson.D{
{"_id", "$setname"},
{"value", bson.D{{"$sum", bson.D{{"$multiply", bson.A{getCurrencyField(), "$serra_count"}}}}}},
{"value", bson.D{{"$sum", bson.D{{"$multiply", bson.A{getCurrencyField(false), "$serra_count"}}}}}},
{"value_foil", bson.D{{"$sum", bson.D{{"$multiply", bson.A{getCurrencyField(true), "$serra_count_foil"}}}}}},
{"count", bson.D{{"$sum", bson.D{{"$multiply", bson.A{1.0, "$serra_count"}}}}}},
{"count_foil", bson.D{{"$sum", bson.D{{"$multiply", bson.A{1.0, "$serra_count_foil"}}}}}},
}},
}
stats, _ := coll.storage_aggregate(mongo.Pipeline{matchStage, groupStage})
stats, _ := coll.storageAggregate(mongo.Pipeline{matchStage, groupStage})
// set rarities
matchStage = bson.D{
@ -135,21 +139,44 @@ func ShowSet(setname string) error {
{"$sort", bson.D{
{"_id", 1},
}}}
rar, _ := coll.storage_aggregate(mongo.Pipeline{matchStage, groupStage, sortStage})
rar, _ := coll.storageAggregate(mongo.Pipeline{matchStage, groupStage, sortStage})
ri := convert_rarities(rar)
ri := convertRarities(rar)
fmt.Printf("%s%s%s\n", Green, sets[0].Name, Reset)
fmt.Printf("Released: %s\n", sets[0].ReleasedAt)
fmt.Printf("Set Cards: %d/%d\n", len(cards), sets[0].CardCount)
fmt.Printf("Total Cards: %.0f\n", stats[0]["count"])
fmt.Printf("Foil Cards: %.0f\n", stats[0]["count_foil"])
normalValue, err := getFloat64(stats[0]["value"])
if err != nil {
l.Error(err)
normalValue = 0
}
foilValue, err := getFloat64(stats[0]["value_foil"])
if err != nil {
l.Error(err)
foilValue = 0
}
totalValue := normalValue + foilValue
normalCount, _ := getFloat64(stats[0]["count"])
foilCount, _ := getFloat64(stats[0]["count_foil"])
fmt.Printf("\n%sCurrent Value%s\n", Purple, Reset)
fmt.Printf("Total: %.0fx %s%.2f%s%s\n", normalCount+foilCount, Yellow, totalValue, getCurrency(), Reset)
fmt.Printf("Normal: %.0fx %s%.2f%s%s\n", stats[0]["count"], Yellow, normalValue, getCurrency(), Reset)
fmt.Printf("Foil: %.0fx %s%.2f%s%s\n", stats[0]["count_foil"], Yellow, foilValue, getCurrency(), Reset)
fmt.Printf("\n%sRarities%s\n", Purple, Reset)
fmt.Printf("Mythics: %.0f\n", ri.Mythics)
fmt.Printf("Rares: %.0f\n", ri.Rares)
fmt.Printf("Uncommons: %.0f\n", ri.Uncommons)
fmt.Printf("Commons: %.0f\n", ri.Commons)
LogMessage(fmt.Sprintf("%s", sets[0].Name), "green")
LogMessage(fmt.Sprintf("Set Cards: %d/%d", len(cards), sets[0].CardCount), "normal")
LogMessage(fmt.Sprintf("Total Cards: %.0f", stats[0]["count"]), "normal")
LogMessage(fmt.Sprintf("Total Value: %.2f %s", stats[0]["value"], getCurrency()), "normal")
LogMessage(fmt.Sprintf("Released: %s", sets[0].ReleasedAt), "normal")
LogMessage(fmt.Sprintf("Mythics: %.0f", ri.Mythics), "normal")
LogMessage(fmt.Sprintf("Rares: %.0f", ri.Rares), "normal")
LogMessage(fmt.Sprintf("Uncommons: %.0f", ri.Uncommons), "normal")
LogMessage(fmt.Sprintf("Commons: %.0f", ri.Commons), "normal")
fmt.Printf("\n%sPrice History:%s\n", Pink, Reset)
print_price_history(sets[0].SerraPrices, "* ")
showPriceHistory(sets[0].SerraPrices, "* ", true)
fmt.Printf("\n%sMost valuable cards%s\n", Pink, Reset)
@ -163,7 +190,7 @@ func ShowSet(setname string) error {
for i := 0; i < ccards; i++ {
card := cards[i]
fmt.Printf("* %dx %s%s%s (%s/%s) %s%.2f %s%s\n", card.SerraCount, Purple, card.Name, Reset, sets[0].Code, card.CollectorNumber, Yellow, card.getValue(), getCurrency(), Reset)
fmt.Printf("* %s%s%s (%s/%s) %s%.2f%s%s\n", Purple, card.Name, Reset, sets[0].Code, card.CollectorNumber, Yellow, card.getValue(false), getCurrency(), Reset)
}
return nil

245
pkg/serra/stats.go Normal file
View File

@ -0,0 +1,245 @@
package serra
import (
"fmt"
"github.com/mitchellh/mapstructure"
"github.com/spf13/cobra"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
)
func init() {
rootCmd.AddCommand(statsCmd)
}
var statsCmd = &cobra.Command{
Aliases: []string{"stats"},
Use: "stats",
Short: "Shows statistics of the collection",
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
Stats()
return nil
},
}
func Stats() {
client := storageConnect()
coll := &Collection{client.Database("serra").Collection("cards")}
totalcoll := &Collection{client.Database("serra").Collection("total")}
defer storageDisconnect(client)
// Show Value Stats
showValueStats(coll, totalcoll)
// Reserved List
showReservedListStats(coll)
// Rarities
showRarityStats(coll)
// Colors
showColorStats(coll)
// Artists
showArtistStats(coll)
// Mana Curve of Collection
showManaCurveStats(coll)
// Show cards added per month
showCardsAddedPerMonth(coll)
}
func showValueStats(coll *Collection, totalcoll *Collection) {
l := Logger()
// Value and Card Numbers
stats, _ := coll.storageAggregate(mongo.Pipeline{
bson.D{
{"$group", bson.D{
{"_id", nil},
{"value", bson.D{{"$sum", bson.D{{"$multiply", bson.A{getCurrencyField(false), "$serra_count"}}}}}},
{"value_foil", bson.D{{"$sum", bson.D{{"$multiply", bson.A{getCurrencyField(true), "$serra_count_foil"}}}}}},
{"count", bson.D{{"$sum", bson.D{{"$multiply", bson.A{1.0, "$serra_count"}}}}}},
{"count_foil", bson.D{{"$sum", "$serra_count_foil"}}},
{"rarity", bson.D{{"$sum", "$rarity"}}},
{"unique", bson.D{{"$sum", 1}}},
}},
},
bson.D{
{"$addFields", bson.D{
{"count_all", bson.D{{"$sum", bson.A{"$count", "$count_foil"}}}},
}},
},
})
fmt.Printf("%sCards %s\n", Green, Reset)
fmt.Printf("Total: %s%.0f%s\n", Yellow, stats[0]["count_all"], Reset)
fmt.Printf("Unique: %s%d%s\n", Purple, stats[0]["unique"], Reset)
fmt.Printf("Normal: %s%.0f%s\n", Purple, stats[0]["count"], Reset)
fmt.Printf("Foil: %s%d%s\n", Purple, stats[0]["count_foil"], Reset)
// Total Value
fmt.Printf("\n%sTotal Value%s\n", Green, Reset)
normalValue, err := getFloat64(stats[0]["value"])
if err != nil {
l.Error(err)
normalValue = 0
}
foilValue, err := getFloat64(stats[0]["value_foil"])
if err != nil {
l.Error(err)
foilValue = 0
}
countAll, err := getFloat64(stats[0]["count_all"])
if err != nil {
l.Error(err)
foilValue = 0
}
totalValue := normalValue + foilValue
fmt.Printf("Total: %s%.2f%s%s\n", Pink, totalValue, getCurrency(), Reset)
fmt.Printf("Normal: %s%.2f%s%s\n", Pink, normalValue, getCurrency(), Reset)
fmt.Printf("Foils: %s%.2f%s%s\n", Pink, foilValue, getCurrency(), Reset)
fmt.Printf("Average Card: %s%.2f%s%s\n", Pink, totalValue/countAll, getCurrency(), Reset)
total, _ := totalcoll.storageFindTotal()
fmt.Printf("History: \n")
showPriceHistory(total.Value, "* ", true)
}
func showReservedListStats(coll *Collection) {
reserved, _ := coll.storageAggregate(mongo.Pipeline{
bson.D{
{"$match", bson.D{
{"reserved", true}}}},
bson.D{
{"$group", bson.D{
{"_id", nil},
{"count", bson.D{{"$sum", 1}}},
}}},
})
var countReserved int32
if len(reserved) > 0 {
countReserved = reserved[0]["count"].(int32)
}
fmt.Printf("Reserved List: %s%d%s\n", Yellow, countReserved, Reset)
}
func showRarityStats(coll *Collection) {
rar, _ := coll.storageAggregate(mongo.Pipeline{
bson.D{
{"$group", bson.D{
{"_id", "$rarity"},
{"count", bson.D{{"$sum", bson.D{{"$multiply", bson.A{1.0, "$serra_count"}}}}}},
}}},
bson.D{
{"$sort", bson.D{
{"_id", 1},
}}},
})
ri := convertRarities(rar)
fmt.Printf("\n%sRarity%s\n", Green, Reset)
fmt.Printf("Mythics: %s%.0f%s\n", Pink, ri.Mythics, Reset)
fmt.Printf("Rares: %s%.0f%s\n", Pink, ri.Rares, Reset)
fmt.Printf("Uncommons: %s%.0f%s\n", Yellow, ri.Uncommons, Reset)
fmt.Printf("Commons: %s%.0f%s\n", Purple, ri.Commons, Reset)
}
func showCardsAddedPerMonth(coll *Collection) {
fmt.Printf("\n%sCards added over time%s\n", Green, Reset)
type Caot struct {
ID struct {
Year int32 `mapstructure:"year"`
Month int32 `mapstructure:"month"`
} `mapstructure:"_id"`
Count int32 `mapstructure:"count"`
}
caot, _ := coll.storageAggregate(mongo.Pipeline{
bson.D{
{"$project", bson.D{
{"month", bson.D{
{"$month", "$serra_created"}}},
{"year", bson.D{
{"$year", "$serra_created"}},
}},
}},
bson.D{
{"$group", bson.D{
{"_id", bson.D{{"month", "$month"}, {"year", "$year"}}},
{"count", bson.D{{"$sum", 1}}},
}},
},
bson.D{
{"$sort", bson.D{{"_id.year", 1}, {"_id.month", 1}}},
},
})
for _, mo := range caot {
moo := new(Caot)
mapstructure.Decode(mo, moo)
fmt.Printf("%d-%02d: %s%d%s\n", moo.ID.Year, moo.ID.Month, Purple, moo.Count, Reset)
}
}
func showManaCurveStats(coll *Collection) {
cmc, _ := coll.storageAggregate(mongo.Pipeline{
bson.D{
{"$group", bson.D{
{"_id", "$cmc"},
{"count", bson.D{{"$sum", 1}}},
}}},
bson.D{
{"$sort", bson.D{
{"_id", 1},
}}},
})
fmt.Printf("\n%sMana Curve%s\n", Green, Reset)
for _, mc := range cmc {
fmt.Printf("%.0f: %s%d%s\n", mc["_id"], Purple, mc["count"], Reset)
}
}
func showArtistStats(coll *Collection) {
artists, _ := coll.storageAggregate(mongo.Pipeline{
bson.D{
{"$group", bson.D{
{"_id", "$artist"},
{"count", bson.D{{"$sum", 1}}},
}}},
bson.D{
{"$sort", bson.D{
{"count", -1},
}}},
bson.D{
{"$limit", 10}},
})
fmt.Printf("\n%sTop Artists%s\n", Green, Reset)
for _, artist := range artists {
fmt.Printf("%s: %s%d%s\n", artist["_id"].(string), Purple, artist["count"], Reset)
}
}
func showColorStats(coll *Collection) {
sets, _ := coll.storageAggregate(mongo.Pipeline{
bson.D{
{"$match", bson.D{
{"coloridentity", bson.D{{"$size", 1}}}}}},
bson.D{
{"$group", bson.D{
{"_id", "$coloridentity"},
{"count", bson.D{{"$sum", bson.D{{"$multiply", bson.A{1.0, "$serra_count"}}}}}},
}}},
bson.D{
{"$sort", bson.D{
{"count", -1},
}}},
})
fmt.Printf("\n%sColors%s\n", Green, Reset)
for _, set := range sets {
x, _ := set["_id"].(primitive.A)
s := []interface{}(x)
fmt.Printf("%s: %s%.0f%s\n", convertManaSymbols(s), Purple, set["count"], Reset)
}
}

View File

@ -3,7 +3,6 @@ package serra
import (
"context"
"fmt"
"log"
"os"
"time"
@ -18,38 +17,48 @@ type Total struct {
Value []PriceEntry `bson:"value"`
}
// https://siongui.github.io/2017/02/11/go-add-method-function-to-type-in-external-package/
// Collection Struct
// reason: https://siongui.github.io/2017/02/11/go-add-method-function-to-type-in-external-package/
type Collection struct {
*mongo.Collection
}
// Returns configured human readable name for
// the configured currency of the user
func getCurrencyField() string {
func getCurrencyField(foil bool) string {
switch os.Getenv("SERRA_CURRENCY") {
case "EUR":
if foil {
return "$prices.eur_foil"
}
return "$prices.eur"
case "USD":
if foil {
return "$prices.usd_foil"
}
return "$prices.usd"
default:
if foil {
return "$prices.usd_foil"
}
return "$prices.usd"
}
}
func storage_connect() *mongo.Client {
func storageConnect() *mongo.Client {
l := Logger()
uri := getMongoDBURI()
client, err := mongo.Connect(context.TODO(), options.Client().ApplyURI(uri))
if err != nil {
LogMessage(fmt.Sprintf("Could not connect to mongodb at %s", uri), "red")
os.Exit(1)
l.Fatalf("Could not connect to mongodb at %s", uri)
}
return client
}
func (coll Collection) storage_add(card *Card) error {
func (coll Collection) storageAdd(card *Card) error {
card.SerraUpdated = primitive.NewDateTimeFromTime(time.Now())
@ -61,7 +70,7 @@ func (coll Collection) storage_add(card *Card) error {
}
func (coll Collection) storage_add_set(set *Set) (*mongo.InsertOneResult, error) {
func (coll Collection) storageAddSet(set *Set) (*mongo.InsertOneResult, error) {
id, err := coll.InsertOne(context.TODO(), set)
if err != nil {
@ -71,7 +80,7 @@ func (coll Collection) storage_add_set(set *Set) (*mongo.InsertOneResult, error)
}
func (coll Collection) storage_add_total(p PriceEntry) error {
func (coll Collection) storageAddTotal(p PriceEntry) error {
// create total object if not exists...
coll.InsertOne(context.TODO(), Total{ID: "1", Value: []PriceEntry{}})
@ -92,91 +101,89 @@ func (coll Collection) storage_add_total(p PriceEntry) error {
return nil
}
func (coll Collection) storage_find(filter, sort bson.D) ([]Card, error) {
opts := options.Find().SetSort(sort)
func (coll Collection) storageFind(filter, sort bson.D, skip, limit int64) ([]Card, error) {
opts := options.Find().SetSort(sort).SetSkip(skip).SetLimit(limit)
cursor, err := coll.Find(context.TODO(), filter, opts)
l := Logger()
if err != nil {
LogMessage("Could not query data due to connection errors to database", "red")
os.Exit(1)
l.Fatalf("Could not query data due to connection errors to database: %s", err.Error())
}
var results []Card
if err = cursor.All(context.TODO(), &results); err != nil {
log.Fatal(err)
l.Fatal(err)
return []Card{}, err
}
return results, nil
}
func (coll Collection) storage_find_set(filter, sort bson.D) ([]Set, error) {
func (coll Collection) storageFindSet(filter, sort bson.D) ([]Set, error) {
l := Logger()
opts := options.Find().SetSort(sort)
cursor, err := coll.Find(context.TODO(), filter, opts)
if err != nil {
LogMessage("Could not query set data due to connection errors to database", "red")
os.Exit(1)
l.Fatalf("Could not query set data due to connection errors to database: %s", err.Error())
}
var results []Set
if err = cursor.All(context.TODO(), &results); err != nil {
log.Fatal(err)
l.Fatal(err)
return []Set{}, err
}
return results, nil
}
func (coll Collection) storage_find_total() (Total, error) {
func (coll Collection) storageFindTotal() (Total, error) {
var total Total
l := Logger()
err := coll.FindOne(context.TODO(), bson.D{{"_id", "1"}}).Decode(&total)
if err != nil {
LogMessage("Could not query total data due to connection errors to database", "red")
os.Exit(1)
l.Fatalf("Could not query total data due to connection errors to database: %s", err.Error())
}
return total, nil
return total, nil
}
func (coll Collection) storage_remove(filter bson.M) error {
func (coll Collection) storageRemove(filter bson.M) error {
l := Logger()
_, err := coll.DeleteOne(context.TODO(), filter)
if err != nil {
LogMessage("Could remove card data due to connection errors to database", "red")
os.Exit(1)
l.Fatalf("Could remove card data due to connection errors to database: %s", err.Error())
}
return nil
return nil
}
func (coll Collection) storage_aggregate(pipeline mongo.Pipeline) ([]primitive.M, error) {
func (coll Collection) storageAggregate(pipeline mongo.Pipeline) ([]primitive.M, error) {
l := Logger()
opts := options.Aggregate()
cursor, err := coll.Aggregate(
context.TODO(),
pipeline,
opts)
if err != nil {
LogMessage(fmt.Sprintf("%v", err), "red")
LogMessage("Could not aggregate data due to connection errors to database", "red")
os.Exit(1)
l.Fatalf("Could not aggregate data due to connection errors to database: %s", err.Error())
}
// Get a list of all returned documents and print them out.
// See the mongo.Cursor documentation for more examples of using cursors.
var results []bson.M
if err = cursor.All(context.TODO(), &results); err != nil {
log.Fatal(err)
l.Fatal(err)
}
return results, nil
return results, nil
}
func (coll Collection) storage_update(filter, update bson.M) error {
func (coll Collection) storageUpdate(filter, update bson.M) error {
l := Logger()
// Call the driver's UpdateOne() method and pass filter and update to it
_, err := coll.UpdateOne(
context.Background(),
@ -184,14 +191,13 @@ func (coll Collection) storage_update(filter, update bson.M) error {
update,
)
if err != nil {
LogMessage("Could not update data due to connection errors to database", "red")
os.Exit(1)
l.Fatalf("Could not update data due to connection errors to database: %s", err.Error())
}
return nil
}
func storage_disconnect(client *mongo.Client) error {
func storageDisconnect(client *mongo.Client) error {
if err := client.Disconnect(context.TODO()); err != nil {
return err
}

23
pkg/serra/undeck.go Normal file
View File

@ -0,0 +1,23 @@
package serra
import (
"github.com/spf13/cobra"
)
func init() {
undeckCmd.Flags().Int64VarP(&count, "count", "c", 1, "Amount of cards to add")
undeckCmd.Flags().BoolVarP(&foil, "foil", "f", false, "Add foil variant of card")
rootCmd.AddCommand(undeckCmd)
}
var undeckCmd = &cobra.Command{
Aliases: []string{"u"},
Use: "undeck",
Short: "Unmark a card as in a deck",
Long: "Unmark a card as in a deck",
SilenceErrors: true,
RunE: func(cmd *cobra.Command, cards []string) error {
deckCards(cards, -count)
return nil
},
}

View File

@ -19,24 +19,25 @@ func init() {
var updateCmd = &cobra.Command{
Aliases: []string{"u"},
Use: "update",
Short: "Update card values from scryfall",
Long: `The update mechanism iterates over each card in your collection and fetches its price. After all cards you own in a set are updated, the set value will update. After all Sets are updated, the whole collection value is updated.`,
Short: "update card values from scryfall",
Long: `the update mechanism iterates over each card in your collection and fetches its price. after all cards you own in a set are updated, the set value will update. after all sets are updated, the whole collection value is updated.`,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
client := storage_connect()
defer storage_disconnect(client)
client := storageConnect()
l := Logger()
defer storageDisconnect(client)
// update sets
setscoll := &Collection{client.Database("serra").Collection("sets")}
coll := &Collection{client.Database("serra").Collection("cards")}
totalcoll := &Collection{client.Database("serra").Collection("total")}
// predefine query for set analysis. used for total stats later
projectStage := bson.D{{"$project",
bson.D{
{"serra_count", true},
{"serra_count_foil", true},
{"serra_count_etched", true},
{"set", true},
{"last_price", bson.D{{"$arrayElemAt", bson.A{"$serra_prices", -1}}}}}}}
groupStage := bson.D{
@ -45,19 +46,42 @@ var updateCmd = &cobra.Command{
{"eur", bson.D{{"$sum", bson.D{{"$multiply", bson.A{"$last_price.eur", "$serra_count"}}}}}},
{"eurfoil", bson.D{{"$sum", bson.D{{"$multiply", bson.A{"$last_price.eur_foil", "$serra_count_foil"}}}}}},
{"usd", bson.D{{"$sum", bson.D{{"$multiply", bson.A{"$last_price.usd", "$serra_count"}}}}}},
{"usdetched", bson.D{{"$sum", bson.D{{"$multiply", bson.A{"$last_price.usd_etched", "$serra_count_etched"}}}}}},
{"usdfoil", bson.D{{"$sum", bson.D{{"$multiply", bson.A{"$last_price.usd_foil", "$serra_count_foil"}}}}}},
}}}
sets, _ := fetch_sets()
l.Info("Fetching bulk data from scryfall...")
downloadURL, err := fetchBulkDownloadURL()
if err != nil {
l.Error("Could not extract bulk download URL:", err)
return err
}
l.Infof("Found latest bulkfile url: %s", downloadURL)
l.Info("Downloading bulk data file...")
bulkFilePath, err := downloadBulkData(downloadURL)
if err != nil {
l.Error("Could not fetch bulk json from scryfall", err)
return err
}
l.Info("Loading bulk data file...")
updatedCards, err := loadBulkFile(bulkFilePath)
if err != nil {
l.Error("Could not load bulk file:", err)
return err
}
l.Infof("Successfully loaded %d cards. Starting Update.", len(updatedCards))
sets, _ := fetchSets()
for _, set := range sets.Data {
// When downloading new sets, PriceList needs to be initialized
// This query silently fails if set was already downloaded. Not nice but ok for now.
// TODO: make this not fail silently
set.SerraPrices = []PriceEntry{}
setscoll.storage_add_set(&set)
setscoll.storageAddSet(&set)
cards, _ := coll.storage_find(bson.D{{"set", set.Code}}, bson.D{{"_id", 1}})
cards, _ := coll.storageFind(bson.D{{"set", set.Code}}, bson.D{{"_id", 1}}, 0, 0)
// if no cards in collection for this set, skip it
if len(cards) == 0 {
@ -74,24 +98,25 @@ var updateCmd = &cobra.Command{
SaucerHead: "[green]>[reset]",
SaucerPadding: " ",
BarStart: "|",
BarEnd: fmt.Sprintf("| %s%s%s", Pink, set.Name, Reset),
BarEnd: "| " + set.Name,
}),
)
for _, card := range cards {
bar.Add(1)
updated_card, err := fetch_card(fmt.Sprintf("%s/%s", card.Set, card.CollectorNumber))
updatedCard, err := getCardFromBulk(updatedCards, card.Set, card.CollectorNumber)
if err != nil {
LogMessage(fmt.Sprintf("%v", err), "red")
l.Error(err)
continue
}
updated_card.Prices.Date = primitive.NewDateTimeFromTime(time.Now())
updatedCard.Prices.Date = primitive.NewDateTimeFromTime(time.Now())
update := bson.M{
"$set": bson.M{"serra_updated": primitive.NewDateTimeFromTime(time.Now()), "prices": updated_card.Prices, "collectornumber": updated_card.CollectorNumber},
"$push": bson.M{"serra_prices": updated_card.Prices},
"$set": bson.M{"serra_updated": primitive.NewDateTimeFromTime(time.Now()), "prices": updatedCard.Prices, "cmc": updatedCard.Cmc, "cardmarketid": updatedCard.CardmarketID, "tcgplayerid": updatedCard.TCGPlayerID},
"$push": bson.M{"serra_prices": updatedCard.Prices},
}
coll.storage_update(bson.M{"_id": bson.M{"$eq": card.ID}}, update)
coll.storageUpdate(bson.M{"_id": bson.M{"$eq": card.ID}}, update)
}
fmt.Println()
@ -99,10 +124,10 @@ var updateCmd = &cobra.Command{
// calculate value summary
matchStage := bson.D{{"$match", bson.D{{"set", set.Code}}}}
setvalue, _ := coll.storage_aggregate(mongo.Pipeline{matchStage, projectStage, groupStage})
setValue, _ := coll.storageAggregate(mongo.Pipeline{matchStage, projectStage, groupStage})
p := PriceEntry{}
s := setvalue[0]
s := setValue[0]
p.Date = primitive.NewDateTimeFromTime(time.Now())
@ -110,27 +135,26 @@ var updateCmd = &cobra.Command{
mapstructure.Decode(s, &p)
// do the update
set_update := bson.M{
"$set": bson.M{"serra_updated": p.Date},
setUpdate := bson.M{
"$set": bson.M{"serra_updated": p.Date, "cardcount": set.CardCount},
"$push": bson.M{"serra_prices": p},
}
// fmt.Printf("Set %s%s%s (%s) is now worth %s%.02f EUR%s\n", Pink, set.Name, Reset, set.Code, Yellow, setvalue[0]["value"], Reset)
setscoll.storage_update(bson.M{"code": bson.M{"$eq": set.Code}}, set_update)
setscoll.storageUpdate(bson.M{"code": bson.M{"$eq": set.Code}}, setUpdate)
}
totalvalue, _ := coll.storage_aggregate(mongo.Pipeline{projectStage, groupStage})
totalValue, _ := coll.storageAggregate(mongo.Pipeline{projectStage, groupStage})
t := PriceEntry{}
t.Date = primitive.NewDateTimeFromTime(time.Now())
mapstructure.Decode(totalvalue[0], &t)
mapstructure.Decode(totalValue[0], &t)
// This is here to be able to fetch currency from
// constructed new priceentry
tmpCard := Card{}
tmpCard.Prices = t
fmt.Printf("\n%sUpdating total value of collection to: %s%.02f %s%s\n", Green, Yellow, tmpCard.getValue(), getCurrency(), Reset)
totalcoll.storage_add_total(t)
l.Info("\n%sUpdating total value of collection to: %s%.02f%s%s\n", Green, Yellow, tmpCard.getValue(false)+tmpCard.getValue(true), getCurrency(), Reset)
totalcoll.storageAddTotal(t)
return nil
},

121
pkg/serra/web.go Normal file
View File

@ -0,0 +1,121 @@
package serra
import (
"net/http"
"strconv"
"text/template"
"github.com/gin-gonic/gin"
"github.com/spf13/cobra"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
)
func init() {
webCmd.Flags().StringVarP(&address, "address", "a", "0.0.0.0", "Address to listen on")
webCmd.Flags().Uint64VarP(&port, "port", "p", 8080, "Port to listen on")
rootCmd.AddCommand(webCmd)
}
func add(a, b int64) int64 {
return a + b
}
var webCmd = &cobra.Command{
Aliases: []string{"a"},
Use: "web",
Short: "Startup web interface",
Long: "Start a tiny web interface to have a web view of your collection",
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
startWeb()
return nil
},
}
type Query struct {
Name string `form:"name"`
Set string `form:"set"`
Sort string `form:"sort"`
Limit int64 `form:"limit"`
Page int64 `form:"page"`
}
func startWeb() error {
router := gin.Default()
router.SetFuncMap(template.FuncMap{
"add": add,
})
router.LoadHTMLGlob("templates/*.tmpl")
router.Static("/assets", "./assets")
// Landing page
router.GET("/", landingPage)
router.Run(address + ":" + strconv.FormatUint(port, 10))
return nil
}
func landingPage(c *gin.Context) {
var query Query
if c.ShouldBind(&query) == nil {
// Construct per Page results "limit"
strLimit := c.DefaultQuery("limit", "500")
limit, _ := strconv.ParseInt(strLimit, 10, 64)
if limit == 0 {
limit = 500
}
// Fetch all sets for Dropdown
sets := Sets("release")
// Fetch all results based on filter criteria
cards := Cards("", query.Set, query.Sort, query.Name, "", "", false, false, query.Page*int64(limit), limit, false)
// Construct quick way for counting results
filter := bson.D{}
client := storageConnect()
coll := &Collection{client.Database("serra").Collection("cards")}
if query.Set != "" {
filter = append(filter, bson.E{"set", query.Set})
}
if query.Name != "" {
filter = append(filter, bson.E{"name", bson.D{{"$regex", ".*" + query.Name + ".*"}, {"$options", "i"}}})
}
counts, _ := coll.storageAggregate(mongo.Pipeline{
bson.D{
{"$match", filter},
},
bson.D{
{"$group", bson.D{
{"_id", nil},
{"count", bson.D{{"$sum", 1}}},
}}},
})
defer storageDisconnect(client)
// Catch index error on no results
var numCards int32
if len(counts) != 0 {
numCards = counts[0]["count"].(int32)
}
c.HTML(http.StatusOK, "index.tmpl", gin.H{
"title": "Serra",
"cards": cards,
"sets": sets,
"query": query,
"version": Version,
"prevPage": query.Page - 1,
"page": query.Page,
"nextPage": query.Page + 1,
"limit": limit,
"numCards": int64(numCards),
"numPages": int64(numCards) / limit,
})
}
}

133
readme.md
View File

@ -11,21 +11,24 @@ Collection Tracker Websites that are:
So I started my own Collection Tracker using [Golang](https://golang.org),
[MongoDB](https://mongodb.com) and [Scryfall](https://scryfall.com) to have
an overview in what cards you own and what value they are.
an overview in what cards you own and what value they have.
## What Serra does
**What Serra does**
* Tracks prices
* Calculates statistics
* Query/filter all of your cards
* Shows what cards/sets do best in value development.
## What Serra does not
**What Serra does not**
* Does not give a shit about conditions (NM, M, GD...)
* Does not care about conditions (NM, M, GD...)
* Does not track etched cards. Only normal and foil.
# Quickstart
## Install Binaries
on macOS you can use
brew install noqqe/tap/serra
@ -34,16 +37,25 @@ on Linux/BSD/Windows you can download binaries from
https://github.com/noqqe/serra/releases
After that you need to spin up a MongoDB yourself or use the docker-compose
setup included in this Repo:
## Spin up Database
To run serra, a MongoDB Database is required. The best way is to setup one by yourself. Any way it connects is fine.
You can also use the docker-compose setup included in this Repo:
docker-compose up -d
## Configure the Database
Configure `serra` via Environment variables
export MONGODB_URI='mongodb://root:root@localhost:27017'
export SERRA_CURRENCY=USD # or EUR
After that, you can add a card
./serra add usg/17
./serra update
Start exploring :) (the more cards you add, the more fun it is)
@ -51,19 +63,29 @@ Start exploring :) (the more cards you add, the more fun it is)
The overall usage is described in `--help` text. But below are some examples.
```
./serra
Usage:
serra add <cardid>... [--count=<number>]
serra remove <cardid>...
serra cards [--rarity=<rarity>] [--set=<setcode>] [--sort=<sort>]
serra card <cardid>...
serra tops [--limit=<limit>]
serra flops [--limit=<limit>]
serra missing <setcode>
serra set <setcode>
serra sets
serra update
serra stats
serra [command]
Available Commands:
add Add a card to your collection
card Search & show cards from your collection
check Check if a card is in your collection
completion Generate the autocompletion script for the specified shell
flops What cards lost most value
help Help about any command
missing Display missing cards from a set
remove Remove a card from your collection
set Search & show sets from your collection
stats Shows statistics of the collection
tops What cards gained most value
update Update card values from scryfall
web Startup web interface
Flags:
-h, --help help for serra
-v, --version version for serra
Use "serra [command] --help" for more information about a command.
```
## Add
@ -116,27 +138,90 @@ update. After all Sets are updated, the whole collection value is updated.
![](https://github.com/noqqe/serra/blob/main/imgs/update.png)
## Check
To add a card to your collection.
![](https://github.com/noqqe/serra/blob/main/imgs/check.png)
## Adding all those cards, manually?
Yes. While there are serveral OCR/Photo Scanners for mtg cards, I found they
are not accurate enough. They guess Editions wrong, they have problems with
blue/black cards and so on.
I add my cards using a tiny shell wrapper, since they are sorted by editions
I add my cards the `add --interactive` feature, since they are sorted by editions
anyways.
```
./add-card-wrapper.fish usg
read> 17
Updating Card "Herald of Serra" amount to 2
> ./serra add --interactive --unique --set one
one> 1
1x "Against All Odds" (uncommon, 0.06 USD) added to Collection.
one> 1
Not adding "Against All Odds" (uncommon, 0.06 USD) to Collection because it already exists.
one> 3
1x "Apostle of Invasion" (uncommon, 0.03 USD) added to Collection.
```
read> 18
...
It also supports ranges of cards
```
dmr> 1-3
1x "Auramancer" (common, 0.02$) added to Collection.
1x "Battle Screech" (uncommon, 0.09$) added to Collection.
1x "Cleric of the Forward Order" (common, 0.01$) added to Collection.
```
Its basically typing 2-3 digit numbers and hitting enter. I was way faster
with this approach then Smartphone scanners.
# Upgrade
If you want to upgrade, go to [releases](https://github.com/noqqe/serra/releases) Page and download the corresponding release for your platform.
For example:
```
wget https://github.com/noqqe/serra/releases/download/3.10.0/serra_Darwin_x86_64.tar.gz
tar zxfv serra_Darwin_x86_64.tar.gz
./serra
```
## Upgrade Notes
### 2.x.x -> 3.x.x
No extra steps needed. Only new Webinterface and Foil support
### 1.5.3 -> 2.0.0
In this stage of the development of serra, I was breaking the original
database "schema" without migration.
Sadly you need to export the cards from the mongodb and import it again using `serra add ` commands
I wrote a little helper script in python to export all the cards in format set/number and generate some queries
```
python3 export.py > add_commands.sh
head add_commands.sh
./serra add 5ed/3 -c 1
./serra add mmq/2 -c 1
./serra add p02/4 -c 1
./serra add chr/44 -c 1
./serra add 4ed/291 -c 1
./serra add 4ed/292 -c 1
./serra add mir/2 -c 1
./serra add usg/231 -c 1
./serra add mir/155 -c 1
./serra add pcy/29 -c 2
<do the upgrade of serra (download new binary>
<delete the old mongodb or just empty it completly>
bash add_commands.sh
```
# Development
## Install

15
scripts/export.py Normal file
View File

@ -0,0 +1,15 @@
from pymongo import MongoClient
import os
import pymongo
CONNECTION_STRING = os.getenv("MONGODB_URI")
client = MongoClient(CONNECTION_STRING+'/admin')
# Create a new collection
collection = client["serra"]["cards"]
cards=collection.find()
for c in cards:
print("./serra add %s/%s -c %s" % (c["set"], c["collectornumber"], c["serra_count"]))

19
scripts/removebulk.fish Executable file
View File

@ -0,0 +1,19 @@
#!/usr/bin/env fish
set SET $argv[1]
# export and remove colors
serra cards --set $SET --min-count 2 --sort value | gsed 's/\x1B[@A-Z\\\]^_]\|\x1B\[[0-9:;<=>?]*[-!"#$%&'"'"'()*+,.\/]*[][\\@A-Z^_`a-z{|}~]//g' > $SET
# edit
nvim $SET
# remove formatting
cat $SET | gsed 's/^\* //' | gsed 's/x.*(/ /' | gsed 's/).*//' | grep -v "Total Value" > {$SET}.txt
# delete everything from serra
for x in (cat {$SET}.txt) ; echo serra remove -c $x ; end
# cleanup
rm $SET
rm {$SET}.txt

13
scripts/replace.sh Executable file
View File

@ -0,0 +1,13 @@
#!/usr/bin/env bash
# This script replaces a normal card with a foil card I needed this because the
# tracking of foils was only added in version 3.5.0 of serra
# give set code as $1 like "dmr"
: ${1:?}
while true; do
read -p "$1> " card
serra add --foil ${1}/${card}
serra remove ${1}/${card}
done

View File

@ -1,113 +0,0 @@
package serra
import (
"fmt"
"os"
"strings"
"github.com/chzyer/readline"
"github.com/spf13/cobra"
"go.mongodb.org/mongo-driver/bson"
)
func init() {
addCmd.Flags().Int64VarP(&count, "count", "c", 1, "Amount of cards to add")
addCmd.Flags().BoolVarP(&unique, "unique", "u", false, "Only add card if not existent yet")
addCmd.Flags().BoolVarP(&interactive, "interactive", "i", false, "Spin up interactive terminal")
addCmd.Flags().StringVarP(&set, "set", "s", "", "Filter by set code (usg/mmq/vow)")
rootCmd.AddCommand(addCmd)
}
var addCmd = &cobra.Command{
Aliases: []string{"a"},
Use: "add",
Short: "Add a card to your collection",
Long: "Adds a card from scryfall to your collection. Amount can be modified using flags",
SilenceErrors: true,
RunE: func(cmd *cobra.Command, cards []string) error {
if interactive {
addCardsInteractive(unique, set)
} else {
addCards(cards, unique, count)
}
return nil
},
}
func addCardsInteractive(unique bool, set string) {
if len(set) == 0 {
LogMessage("Error: --set must be given in interactive mode", "red")
os.Exit(1)
}
rl, err := readline.New(fmt.Sprintf("%s> ", set))
if err != nil {
panic(err)
}
defer rl.Close()
for {
line, err := rl.Readline()
if err != nil { // io.EOF
break
}
// construct card input for addCards
card := []string{}
card = append(card, fmt.Sprintf("%s/%s", set, strings.TrimSpace(line)))
addCards(card, unique, count)
}
}
func addCards(cards []string, unique bool, count int64) error {
client := storage_connect()
coll := &Collection{client.Database("serra").Collection("cards")}
defer storage_disconnect(client)
// Loop over different cards
for _, card := range cards {
// Check if card is already in collection
co, _ := coll.storage_find(bson.D{{"set", strings.Split(card, "/")[0]}, {"collectornumber", strings.Split(card, "/")[1]}}, bson.D{})
if len(co) >= 1 {
c := co[0]
if unique {
LogMessage(fmt.Sprintf("Not adding \"%s\" (%s, %.2f %s) to Collection because it already exists.", c.Name, c.Rarity, c.getValue(), getCurrency()), "red")
continue
}
modify_count_of_card(coll, &c, count)
// Give feedback of successfully added card
LogMessage(fmt.Sprintf("%dx \"%s\" (%s, %.2f %s) added to Collection.", c.SerraCount, c.Name, c.Rarity, c.getValue(), getCurrency()), "green")
// If card is not already in collection, fetching from scyfall
} else {
// Fetch card from scryfall
c, err := fetch_card(card)
if err != nil {
LogMessage(fmt.Sprintf("%v", err), "red")
continue
}
// Write card to mongodb
c.SerraCount = count
err = coll.storage_add(c)
if err != nil {
LogMessage(fmt.Sprintf("%v", err), "red")
continue
}
// Give feedback of successfully added card
LogMessage(fmt.Sprintf("%dx \"%s\" (%s, %.2f %s) added to Collection.", c.SerraCount, c.Name, c.Rarity, c.getValue(), getCurrency()), "green")
}
}
storage_disconnect(client)
return nil
}

View File

@ -1,135 +0,0 @@
package serra
import (
"fmt"
"strings"
"github.com/spf13/cobra"
"go.mongodb.org/mongo-driver/bson"
)
func init() {
cardCmd.Flags().StringVarP(&rarity, "rarity", "r", "", "Filter by rarity of cards (mythic, rare, uncommon, common)")
cardCmd.Flags().StringVarP(&set, "set", "e", "", "Filter by set code (usg/mmq/vow)")
cardCmd.Flags().StringVarP(&sort, "sort", "s", "name", "How to sort cards (value/number/name/added)")
cardCmd.Flags().StringVarP(&name, "name", "n", "", "Name of the card (regex compatible)")
cardCmd.Flags().StringVarP(&oracle, "oracle", "o", "", "Contains string in card text")
cardCmd.Flags().StringVarP(&cardType, "type", "t", "", "Contains string in card type line")
rootCmd.AddCommand(cardCmd)
}
var cardCmd = &cobra.Command{
Aliases: []string{"cards"},
Use: "card [card]",
Short: "Search & show cards from your collection",
Long: `Search and show cards from your collection.
If you directly put a card as an argument, it will be displayed
otherwise you'll get a list of cards as a search result.`,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, cards []string) error {
if len(cards) == 0 {
card_list := Cards(rarity, set, sort, name, oracle, cardType)
show_card_list(card_list)
} else {
ShowCard(cards)
}
return nil
},
}
func ShowCard(cardids []string) {
client := storage_connect()
coll := &Collection{client.Database("serra").Collection("cards")}
defer storage_disconnect(client)
for _, v := range cardids {
cards, _ := coll.storage_find(bson.D{{"set", strings.Split(v, "/")[0]}, {"collectornumber", strings.Split(v, "/")[1]}}, bson.D{{"name", 1}})
for _, card := range cards {
show_card_details(&card)
}
}
}
func Cards(rarity, set, sort, name, oracle, cardType string) []Card {
client := storage_connect()
coll := &Collection{client.Database("serra").Collection("cards")}
defer storage_disconnect(client)
filter := bson.D{}
switch rarity {
case "uncommon":
filter = append(filter, bson.E{"rarity", "uncommon"})
case "common":
filter = append(filter, bson.E{"rarity", "common"})
case "rare":
filter = append(filter, bson.E{"rarity", "rare"})
}
var sortStage bson.D
switch sort {
case "value":
if getCurrency() == "EUR" {
sortStage = bson.D{{"prices.eur", 1}}
} else {
sortStage = bson.D{{"prices.usd", 1}}
}
case "number":
sortStage = bson.D{{"collectornumber", 1}}
case "name":
sortStage = bson.D{{"name", 1}}
case "added":
sortStage = bson.D{{"serra_created", 1}}
default:
sortStage = bson.D{{"name", 1}}
}
if len(set) > 0 {
filter = append(filter, bson.E{"set", set})
}
if len(name) > 0 {
filter = append(filter, bson.E{"name", bson.D{{"$regex", ".*" + name + ".*"}, {"$options", "i"}}})
}
if len(oracle) > 0 {
filter = append(filter, bson.E{"oracletext", bson.D{{"$regex", ".*" + oracle + ".*"}, {"$options", "i"}}})
}
if len(cardType) > 0 {
filter = append(filter, bson.E{"typeline", bson.D{{"$regex", ".*" + cardType + ".*"}, {"$options", "i"}}})
}
cards, _ := coll.storage_find(filter, sortStage)
return cards
}
func show_card_list(cards []Card) {
var total float64
for _, card := range cards {
LogMessage(fmt.Sprintf("* %dx %s%s%s (%s/%s) %s%.2f %s%s", card.SerraCount, Purple, card.Name, Reset, card.Set, card.CollectorNumber, Yellow, card.getValue(), getCurrency(), Reset), "normal")
total = total + card.getValue()*float64(card.SerraCount)
}
fmt.Printf("\nTotal Value: %s%.2f %s%s\n", Yellow, total, getCurrency(), Reset)
}
func show_card_details(card *Card) error {
fmt.Printf("%s%s%s (%s/%s)\n", Purple, card.Name, Reset, card.Set, card.CollectorNumber)
fmt.Printf("Added: %s\n", stringToTime(card.SerraCreated))
fmt.Printf("Count: %dx\n", card.SerraCount)
fmt.Printf("Rarity: %s\n", card.Rarity)
fmt.Printf("Scryfall: %s\n", strings.Replace(card.ScryfallURI, "?utm_source=api", "", 1))
fmt.Printf("Current Value: %s%.2f %s%s\n", Yellow, card.getValue(), getCurrency(), Reset)
fmt.Printf("\n%sHistory%s\n", Green, Reset)
print_price_history(card.SerraPrices, "* ")
fmt.Println()
return nil
}

View File

@ -1,28 +0,0 @@
package serra
import (
"log"
"os"
)
func getMongoDBURI() string {
uri := os.Getenv("MONGODB_URI")
if uri == "" {
log.Fatal("You must set your 'MONGODB_URI' environmental variable. See\n\t https://docs.mongodb.com/drivers/go/current/usage-examples/#environment-variable")
}
return uri
}
// Returns configured human readable name for
// the configured currency of the user
func getCurrency() string {
switch os.Getenv("SERRA_CURRENCY") {
case "EUR":
return "EUR"
case "USD":
return "USD"
}
// default
LogMessage("Warning: You did not configure SERRA_CURRENCY. Assuming \"USD\"", "yellow")
return "USD"
}

View File

@ -1,169 +0,0 @@
package serra
import (
"errors"
"fmt"
"time"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type Rarities struct {
Rares, Uncommons, Commons, Mythics float64
}
func modify_count_of_card(coll *Collection, c *Card, amount int64) error {
// find already existing card
sort := bson.D{{"_id", 1}}
search_filter := bson.D{{"_id", c.ID}}
stored_cards, err := coll.storage_find(search_filter, sort)
if err != nil {
return err
}
stored_card := stored_cards[0]
// update card amount
update_filter := bson.M{"_id": bson.M{"$eq": c.ID}}
update := bson.M{
"$set": bson.M{"serra_count": stored_card.SerraCount + amount},
}
coll.storage_update(update_filter, update)
LogMessage(fmt.Sprintf("Updating Card \"%s\" amount to %d", stored_card.Name, stored_card.SerraCount+amount), "purple")
return nil
}
func find_card_by_setcollectornumber(coll *Collection, setcode string, collectornumber string) (*Card, error) {
sort := bson.D{{"_id", 1}}
search_filter := bson.D{{"set", setcode}, {"collectornumber", collectornumber}}
stored_cards, err := coll.storage_find(search_filter, sort)
if err != nil {
return &Card{}, err
}
if len(stored_cards) < 1 {
return &Card{}, errors.New("Card not found")
}
return &stored_cards[0], nil
}
func stringToTime(s primitive.DateTime) string {
return time.UnixMilli(int64(s)).Format("2006-01-02")
}
// missing compares two slices and returns slice of differences
func missing(a, b []string) []string {
type void struct{}
// create map with length of the 'a' slice
ma := make(map[string]void, len(a))
diffs := []string{}
// Convert first slice to map with empty struct (0 bytes)
for _, ka := range a {
ma[ka] = void{}
}
// find missing values in a
for _, kb := range b {
if _, ok := ma[kb]; !ok {
diffs = append(diffs, kb)
}
}
return diffs
}
func find_set_by_code(coll *Collection, setcode string) (*Set, error) {
stored_sets, err := coll.storage_find_set(bson.D{{"code", setcode}}, bson.D{{"_id", 1}})
if err != nil {
return &Set{}, err
}
if len(stored_sets) < 1 {
return &Set{}, errors.New("Set not found")
}
return &stored_sets[0], nil
}
func convert_mana_symbols(sym []interface{}) string {
var mana string
if len(sym) == 0 {
// mana = mana + "\U0001F6AB" //probibited sign for lands
mana = mana + "None" //probibited sign for lands
}
for _, v := range sym {
switch v {
case "B":
mana = mana + "Black" //black
//mana = mana + "\U000026AB" //black
case "R":
mana = mana + "Red" //red
// mana = mana + "\U0001F534" //red
case "G":
mana = mana + "Green" //green
// mana = mana + "\U0001F7E2" //green
case "U":
mana = mana + "Blue" //blue
//mana = mana + "\U0001F535" //blue
case "W":
mana = mana + "White" //white
// mana = mana + "\U000026AA" //white
}
}
return mana
}
func convert_rarities(rar []primitive.M) Rarities {
// this is maybe the ugliest way someone could choose to verify, if a rarity type is missing
// [
// { _id: { rarity: 'common' }, count: 20 },
// { _id: { rarity: 'uncommon' }, count: 2 }
// ]
// if a result like this is there, 1 rarity type "rare" is not in the array. and needs to be
// initialized with 0, otherwise we get a panic
var ri Rarities
for _, r := range rar {
switch r["_id"] {
case "rare":
ri.Rares = r["count"].(float64)
case "uncommon":
ri.Uncommons = r["count"].(float64)
case "common":
ri.Commons = r["count"].(float64)
case "mythic":
ri.Mythics = r["count"].(float64)
}
}
return ri
}
func print_price_history(prices []PriceEntry, prefix string) {
var before float64
for _, e := range prices {
// TODO: Make currency configurable
value := e.Usd
if getCurrency() == "EUR" {
value = e.Eur
}
if value > before && before != 0 {
fmt.Printf("%s%s%s %.2f %s%s (%+.2f%%, %+.2f %s)\n", prefix, stringToTime(e.Date), Green, value, getCurrency(), Reset, (value/before*100)-100, value-before, getCurrency())
} else if value < before {
fmt.Printf("%s%s%s %.2f %s%s (%+.2f%%, %+.2f %s)\n", prefix, stringToTime(e.Date), Red, value, getCurrency(), Reset, (value/before*100)-100, value-before, getCurrency())
} else {
fmt.Printf("%s%s %.2f %s%s\n", prefix, stringToTime(e.Date), value, getCurrency(), Reset)
}
before = value
}
}

View File

@ -1,65 +0,0 @@
package serra
import (
"fmt"
"strconv"
"github.com/spf13/cobra"
"go.mongodb.org/mongo-driver/bson"
)
func init() {
rootCmd.AddCommand(missingCmd)
}
var missingCmd = &cobra.Command{
Aliases: []string{"m"},
Use: "missing <set>",
Short: "Display missing cards from a set",
Long: `In case you are a set collector, you can generate a list of
cards you dont own (yet) :)`,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, setname []string) error {
client := storage_connect()
coll := &Collection{client.Database("serra").Collection("cards")}
defer storage_disconnect(client)
// fetch all cards in set
cards, err := coll.storage_find(bson.D{{"set", setname[0]}}, bson.D{{"collectornumber", 1}})
if (err != nil) || len(cards) == 0 {
LogMessage(fmt.Sprintf("Error: Set %s not found or no card in your collection.", setname[0]), "red")
return err
}
// fetch set informations
setcoll := &Collection{client.Database("serra").Collection("sets")}
sets, _ := setcoll.storage_find_set(bson.D{{"code", setname[0]}}, bson.D{{"_id", 1}})
set := sets[0]
LogMessage(fmt.Sprintf("Missing cards in %s", sets[0].Name), "green")
// generate set with all setnumbers
var complete_set []string
var i int64
for i = 1; i <= set.CardCount; i++ {
complete_set = append(complete_set, strconv.FormatInt(i, 10))
}
// iterate over all cards in collection
var in_collection []string
for _, c := range cards {
in_collection = append(in_collection, c.CollectorNumber)
}
misses := missing(in_collection, complete_set)
for _, m := range misses {
ncard, err := fetch_card(fmt.Sprintf("%s/%s", setname[0], m))
if err != nil {
continue
}
fmt.Printf("%.02f %s\t%s (%s)\n", ncard.getValue(), getCurrency(), ncard.Name, ncard.SetName)
}
return nil
},
}

View File

@ -1,37 +0,0 @@
package serra
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
var Version = "unknown"
var count int64
var limit float64
var interactive bool
var name string
var oracle string
var rarity string
var set string
var since string
var sort string
var cardType string
var unique bool
var rootCmd = &cobra.Command{
Version: Version,
Long: `serra - Magic: The Gathering Collection Tracker`,
Use: "serra",
DisableFlagsInUseLine: true,
SilenceErrors: true,
}
func Execute() {
if err := rootCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}

View File

@ -1,106 +0,0 @@
package serra
import (
"fmt"
"github.com/spf13/cobra"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
)
func init() {
rootCmd.AddCommand(statsCmd)
}
var statsCmd = &cobra.Command{
Aliases: []string{"stats"},
Use: "stats <prefix> <n>",
Short: "Shows statistics of the collection",
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
client := storage_connect()
coll := &Collection{client.Database("serra").Collection("cards")}
totalcoll := &Collection{client.Database("serra").Collection("total")}
defer storage_disconnect(client)
sets, _ := coll.storage_aggregate(mongo.Pipeline{
bson.D{
{"$match", bson.D{
{"coloridentity", bson.D{{"$size", 1}}}}}},
bson.D{
{"$group", bson.D{
{"_id", "$coloridentity"},
{"count", bson.D{{"$sum", bson.D{{"$multiply", bson.A{1.0, "$serra_count"}}}}}},
}}},
bson.D{
{"$sort", bson.D{
{"count", -1},
}}},
})
fmt.Printf("%sColors%s\n", Green, Reset)
for _, set := range sets {
x, _ := set["_id"].(primitive.A)
s := []interface{}(x)
fmt.Printf("%s: %s%.0f%s\n", convert_mana_symbols(s), Purple, set["count"], Reset)
}
stats, _ := coll.storage_aggregate(mongo.Pipeline{
bson.D{
{"$group", bson.D{
{"_id", nil},
{"value", bson.D{{"$sum", bson.D{{"$multiply", bson.A{getCurrencyField(), "$serra_count"}}}}}},
{"count", bson.D{{"$sum", bson.D{{"$multiply", bson.A{1.0, "$serra_count"}}}}}},
{"count_foil", bson.D{{"$sum", "$serra_count_foil"}}},
{"count_etched", bson.D{{"$sum", "$serra_count_etched"}}},
{"rarity", bson.D{{"$sum", "$rarity"}}},
{"unique", bson.D{{"$sum", 1}}},
}}},
})
fmt.Printf("\n%sCards %s\n", Green, Reset)
fmt.Printf("Total: %s%.0f%s\n", Yellow, stats[0]["count"], Reset)
fmt.Printf("Unique: %s%d%s\n", Purple, stats[0]["unique"], Reset)
fmt.Printf("Foil: %s%d%s\n", Purple, stats[0]["count_foil"], Reset)
fmt.Printf("Etched: %s%d%s\n", Purple, stats[0]["count_etched"], Reset)
reserved, _ := coll.storage_aggregate(mongo.Pipeline{
bson.D{
{"$match", bson.D{
{"reserved", true}}}},
bson.D{
{"$group", bson.D{
{"_id", nil},
{"count", bson.D{{"$sum", 1}}},
}}},
})
fmt.Printf("Reserved List: %s%d%s\n", Yellow, reserved[0]["count"], Reset)
rar, _ := coll.storage_aggregate(mongo.Pipeline{
bson.D{
{"$group", bson.D{
{"_id", "$rarity"},
{"count", bson.D{{"$sum", bson.D{{"$multiply", bson.A{1.0, "$serra_count"}}}}}},
}}},
bson.D{
{"$sort", bson.D{
{"_id", 1},
}}},
})
ri := convert_rarities(rar)
fmt.Printf("\n%sRarity%s\n", Green, Reset)
fmt.Printf("Mythics: %s%.0f%s\n", Pink, ri.Mythics, Reset)
fmt.Printf("Rares: %s%.0f%s\n", Pink, ri.Rares, Reset)
fmt.Printf("Uncommons: %s%.0f%s\n", Yellow, ri.Uncommons, Reset)
fmt.Printf("Commons: %s%.0f%s\n", Purple, ri.Commons, Reset)
fmt.Printf("\n%sTotal Value%s\n", Green, Reset)
fmt.Printf("Current: %s%.2f %s%s\n", Pink, stats[0]["value"], getCurrency(), Reset)
total, _ := totalcoll.storage_find_total()
fmt.Printf("History: \n")
print_price_history(total.Value, "* ")
return nil
},
}

View File

@ -1,32 +0,0 @@
package serra
import "fmt"
var (
Icon = "\U0001F9D9\U0001F3FC"
Reset = "\033[0m"
Background = "\033[38;5;59m"
CurrentLine = "\033[38;5;60m"
Foreground = "\033[38;5;231m"
Comment = "\033[38;5;103m"
Cyan = "\033[38;5;159m"
Green = "\033[38;5;120m"
Orange = "\033[38;5;222m"
Pink = "\033[38;5;212m"
Purple = "\033[38;5;183m"
Red = "\033[38;5;210m"
Yellow = "\033[38;5;229m"
)
// Colored output on commandline
func LogMessage(message string, color string) {
if color == "red" {
fmt.Printf("%s%s%s\n", Red, message, Reset)
} else if color == "green" {
fmt.Printf("%s%s%s\n", Green, message, Reset)
} else if color == "purple" {
fmt.Printf("%s%s%s\n", Purple, message, Reset)
} else {
fmt.Printf("%s\n", message)
}
}

View File

@ -1,57 +0,0 @@
package serra
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/spf13/cobra"
)
func init() {
rootCmd.AddCommand(webCmd)
}
var webCmd = &cobra.Command{
Aliases: []string{"a"},
Use: "web",
Short: "Startup web interface",
Long: "Start a tiny web interface to have a web view of your collection",
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
startWeb()
return nil
},
}
type Query struct {
Name string `form:"name"`
Set string `form:"set"`
Sort string `form:"sort"`
}
func startWeb() error {
router := gin.Default()
router.LoadHTMLGlob("templates/*.tmpl")
router.Static("/assets", "./assets")
// Landing Page
router.GET("/", landingPage)
router.Run(":8080")
return nil
}
func landingPage(c *gin.Context) {
var query Query
if c.ShouldBind(&query) == nil {
cards := Cards("", query.Set, query.Sort, query.Name, "", "")
sets := Sets("release")
c.HTML(http.StatusOK, "index.tmpl", gin.H{
"title": "Serraaaa",
"cards": cards,
"sets": sets,
})
}
}

View File

@ -4,180 +4,271 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Serra</title>
<!-- <link rel="stylesheet" href="https://unpkg.com/bulma-dracula"> -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
<title>{{.title}}{{ if .query.Set }} - Set: {{.query.Set}}{{end}}</title>
<link rel="stylesheet" href="https://jenil.github.io/bulmaswatch/cosmo/bulmaswatch.min.css">
<!-- <link rel="stylesheet" href="https://jenil.github.io/bulmaswatch/lumen/bulmaswatch.min.css"> -->
<!-- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css"> -->
<style>
.cardpreview {
position: relative;
display: inline-block;
border-bottom: 1px dotted black;
}
<style>
.cardpreview {
position: relative;
display: inline-block;
border-bottom: 1px dotted black;
}
.cardpreview .cardpreviewtext {
visibility: hidden;
width: 300px;
color: #fff;
.cardpreview .cardpreviewtext {
visibility: hidden;
display: none;
width: 300px;
color: #fff;
/* Position the cardpreview */
position: absolute;
z-index: 1;
}
/* Position the cardpreview */
position: absolute;
z-index: 1;
}
.cardpreview:hover .cardpreviewtext {
visibility: visible;
}
</style>
.cardpreview:hover .cardpreviewtext {
visibility: visible;
display: inline-block;
}
@media only screen and (max-width: 900px) {
.level-item {
justify-content: unset
}
table#cards td,
table#cards th {
display: none;
}
table#cards th:nth-child(1),
table#cards th:nth-child(2),
table#cards th:nth-child(4),
table#cards th:nth-child(5),
table#cards th:nth-child(9) {
display: revert;
}
table#cards td:nth-child(1),
table#cards td:nth-child(2),
table#cards td:nth-child(4),
table#cards td:nth-child(5),
table#cards td:nth-child(9) {
display: revert;
}
}
</style>
</head>
<body>
<!-- Site Title -->
<section class="section">
<div class="container">
<div class="block">
<h1 class="title is-1">
Serra
</h1>
<p class="subtitle">
<i>Magic: The Gathering</i> Collection
</p>
</div>
<section class="hero is-black">
<div class="hero-body">
<p class="title">
<a href="/">{{ .title }}</a>
</p>
<p class="subtitle">
<i>Magic: The Gathering</i> Collection
</p>
</div>
</section>
<!-- Nav Bar -->
<section class="section">
<div class="container">
<nav class="level">
<nav class="level">
<!-- Left side -->
<div class="level-left">
<div class="level-item">
<div class="field">
<label class="label">Set</label>
<div class="control">
<div class="select is-primary">
<select name="set" id="set" form="searchform">
<option value="">-</option>
{{range .sets}}
<option value="{{ index . "code" }}">{{ printf "%.25s" (index . "_id") }} ({{ index . "count" }})</option>
{{end}}
</select>
</div>
<!-- Left side -->
<div class="level-left">
<div class="level-item">
<div class="field">
<label class="label">Set</label>
<div class="control">
<div class="select">
<select name="set" id="set" form="searchform">
<option value="">-</option>
{{range .sets}}
<option value="{{ index . "code" }}">{{ printf "%.25s" (index . "_id") }} ({{ index . "count" }})
</option>
{{end}}
</select>
</div>
</div>
</div>
<div class="level-item">
<div class="field">
<label class="label">Name</label>
<div class="control">
<input form="searchform" name="name" class="input" id="name" type="text" placeholder="Tolarian Academy">
</div>
</div>
</div>
<div class="level-item">
<div class="field">
<label class="label">Sort</label>
<div class="control">
<div class="select is-primary">
<select name="sort" id="sort" form="searchform">
<option value="name" selected>Name</option>
<option value="value">Value</option>
<option value="number">Collector Number</option>
</select>
</div>
</div>
</div>
</div>
</div>
<!-- Right side -->
<div class="level-right">
<form action="/" id="searchform">
<input class="button is-primary" type="submit" value="Search">
</form>
<div class="level-item">
<div class="field">
<label class="label">Name</label>
<div class="control">
<input form="searchform" name="name" class="input" id="name" type="text" placeholder="Tolarian Academy">
</div>
</div>
</div>
</nav>
<!-- Results Table -->
<table class="table is-fullwidth">
<thead>
<tr>
<th><abbr title="Amount">#</abbr></th>
<th>Name</th>
<th>Type</th>
<th><abbr title="Set Code">Set</abbr></th>
<th><abbr title="Collector Number">C</abbr></th>
<th><abbr title="Rarity">Rar</abbr></th>
<th><abbr title="Price">$</abbr></th>
<th><abbr title="Price Foil">$*</abbr></th>
<th><abbr title="Price Eur">€</abbr></th>
<th><abbr title="Price Eur Foil">€*</abbr></th>
</tr>
</thead>
<tfoot>
<tr>
<th><abbr title="Amount">#</abbr></th>
<th>Name</th>
<th>Type</th>
<th><abbr title="Set Code">Set</abbr></th>
<th><abbr title="Collector Number">C</abbr></th>
<th><abbr title="Rarity">Rar</abbr></th>
<th><abbr title="Price">$</abbr></th>
<th><abbr title="Price Foil">$*</abbr></th>
<th><abbr title="Price Eur">€</abbr></th>
<th><abbr title="Price Eur Foil">€*</abbr></th>
</tr>
</tfoot>
<tbody>
{{range .cards}}
<tr>
<td>{{.SerraCount}}</td>
<td>
<div class="cardpreview"><strong>{{.Name }}</strong>
<span class="cardpreviewtext">
<img src="{{ .ImageUris.Normal }}" alt="" />
</span>
</div>
</td>
<td>{{.TypeLine}}</td>
<td>{{.Set}}</td>
<td>{{.CollectorNumber}}</td>
<td>{{.Rarity}}</td>
<td>{{.Prices.Usd}}</td>
<td>{{.Prices.UsdFoil}}</td>
<td>{{.Prices.Eur}}</td>
<td>{{.Prices.EurFoil}}</td>
</tr>
{{end}}
</tbody>
</table>
<input type="hidden" id="limit" name="limit" value="500" form="searchform">
<input type="hidden" id="page" name="page" value="0" form="searchform">
</div>
<div class="level-item">
<div class="field">
<label class="label">Sort</label>
<div class="control">
<div class="select">
<select name="sort" id="sort" form="searchform">
<option value="name" selected>Name</option>
<option value="value">Value</option>
<option value="number">Collector Number</option>
</select>
</div>
</div>
</div>
</div>
</div>
<!-- Right side -->
<div class="level-right">
<form action="/" id="searchform">
<input class="button is-primary" type="submit" value="Search">
</form>
</div>
</nav>
<!-- Results Table -->
<table id="cards" class="table is-fullwidth">
<thead>
<tr>
<th><abbr title="Amount">#</abbr></th>
<th>Name</th>
<th>Type</th>
<th><abbr title="Set Code">Set</abbr></th>
<th><abbr title="Collector Number">C</abbr></th>
<th><abbr title="Rarity">Rar</abbr></th>
<th><abbr title="Price">$</abbr></th>
<th><abbr title="Price Foil">$*</abbr></th>
<th><abbr title="Price Eur">€</abbr></th>
<th><abbr title="Price Eur Foil">€*</abbr></th>
</tr>
</thead>
<tfoot>
<tr>
<th><abbr title="Amount">#</abbr></th>
<th>Name</th>
<th>Type</th>
<th><abbr title="Set Code">Set</abbr></th>
<th><abbr title="Collector Number">C</abbr></th>
<th><abbr title="Rarity">Rar</abbr></th>
<th><abbr title="Price">$</abbr></th>
<th><abbr title="Price Foil">$*</abbr></th>
<th><abbr title="Price Eur">€</abbr></th>
<th><abbr title="Price Eur Foil">€*</abbr></th>
</tr>
</tfoot>
<tbody>
{{range .cards}}
<tr>
<td>{{ add .SerraCount .SerraCountFoil }}</td>
<td>
<div class="cardpreview"><strong>{{.Name }}</strong>
<span class="cardpreviewtext">
<img loading="lazy" src="{{ .ImageUris.Normal }}" alt="" />
</span>
</div>
</td>
<td>{{.TypeLine}}</td>
<td>{{.Set}}</td>
<td>{{.CollectorNumber}}</td>
<td>{{.Rarity}}</td>
<td>{{.Prices.Usd}}</td>
<td>{{.Prices.UsdFoil}}</td>
<td>{{.Prices.Eur}}</td>
<td>{{.Prices.EurFoil}}</td>
</tr>
{{end}}
</tbody>
</table>
</section>
{{ if ne .numPages 0 }}
<div class="hero-body">
<nav class="pagination" role="navigation" aria-label="pagination">
{{ if ge .prevPage 0 }}
<a href="/?set={{.query.Set}}&name={{.query.Name}}&sort={{.query.Sort}}&limit={{.limit}}&page={{.prevPage}}" class="pagination-previous">Previous</a>
{{ end }}
{{ if ( le .nextPage .numPages) }}
<a href="/?set={{.query.Set}}&name={{.query.Name}}&sort={{.query.Sort}}&limit={{.limit}}&page={{.nextPage}}" class="pagination-next">Next page</a>
{{ end }}
<ul class="pagination-list">
{{ if ne .page 0 }}
<li>
<a class="pagination-link" href="/?set={{.query.Set}}&name={{.query.Name}}&sort={{.query.Sort}}&limit={{.limit}}&page=0" aria-label="Goto page 0">0</a>
</li>
<li>
<span class="pagination-ellipsis">&hellip;</span>
</li>
{{end}}
{{ if gt .prevPage 0 }}
<li>
<a href="/?set={{.query.Set}}&name={{.query.Name}}&sort={{.query.Sort}}&limit={{.limit}}&page={{.prevPage}}" class="pagination-link" aria-label="Goto page {{.prevPage}}">{{.prevPage}}</a>
</li>
{{end}}
<li>
<a class="pagination-link is-current" href="/?set={{.query.Set}}&name={{.query.Name}}&sort={{.query.Sort}}&limit={{.limit}}&page={{.page}}" aria-label="Page {{ .page }}" aria-current="page">{{.page}}</a>
</li>
{{ if and (ne .nextPage .numPages) ( lt .nextPage .numPages) }}
<li>
<a href="/?set={{.query.Set}}&name={{.query.Name}}&sort={{.query.Sort}}&limit={{.limit}}&page={{.nextPage}}" class="pagination-link" aria-label="Goto page {{.nextPage}} ">{{.nextPage}}</a>
</li>
{{ end }}
{{ if ne .numPages .page }}
<li>
<span class="pagination-ellipsis">&hellip;</span>
</li>
<li>
<a class="pagination-link" href="/?set={{.query.Set}}&name={{.query.Name}}&sort={{.query.Sort}}&limit={{.limit}}&page={{.numPages}}" aria-label="Goto page {{.numPages}}">{{.numPages}}</a>
</li>
{{end}}
</ul>
</nav>
</div>
{{ end }}
<!-- Select set from last search -->
<script>
function getParam(paramName) {
return decodeURI(
(RegExp(paramName + '=' + '(.+?)(&|$)').exec(location.search) || [, 500])[1]
);
}
var selectedSetVal = getParam("set");
document.getElementById("set").value = selectedSetVal;
var selectedLimitVal = getParam("limit");
document.getElementById("limit").value = selectedLimitVal;
var selectedSortVal = getParam("sort");
document.getElementById("sort").value = selectedSortVal;
</script>
<footer class="footer">
<div class="content has-text-centered">
<p>
<strong><a href="https://github.com/noqqe/serra">Serra</a></strong> Version {{ .version }} by <a href="https://noqqe.de">noqqe</a>.
<a href="http://opensource.org/licenses/mit-license.php">MIT</a>.
</p>
</div>
</footer>
</body>
<!-- Select set from last search -->
<script>
function getParam(paramName) {
return decodeURI(
(RegExp(paramName + '=' + '(.+?)(&|$)').exec(location.search) || [, null])[1]
);
}
var selectedSetVal = getParam("set");
document.getElementById("set").value = selectedSetVal;
var selectedSortVal = getParam("sort");
document.getElementById("sort").value = selectedSortVal;
</script>
</html>