diff --git a/.gitignore b/.gitignore index 273af55..a60b148 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,6 @@ *.log tmp/ *.egg-info -poetry.lock *.py[cod] *.egg diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8ea6148 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,45 @@ +FROM python:3.9.6-alpine3.14 as base + +WORKDIR /app + +FROM base as builder + +ENV PIP_DEFAULT_TIMEOUT=100 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PIP_NO_CACHE_DIR=1 \ + POETRY_VERSION=1.1.4 + +RUN apk add --no-cache gcc libffi-dev musl-dev openssl-dev rust cargo +RUN pip install "poetry==$POETRY_VERSION" +RUN python -m venv /venv + +COPY pyproject.toml poetry.lock ./ +RUN poetry export -f requirements.txt | /venv/bin/pip install -r /dev/stdin + +COPY . . +RUN poetry build && /venv/bin/pip install dist/*.whl + +FROM base as frontend + +RUN apk add --no-cache curl +COPY www /www +RUN curl -sL --output elm.gz https://github.com/elm/compiler/releases/download/0.19.1/binary-for-linux-64-bit.gz \ + && gunzip elm.gz \ + && chmod +x elm \ + && cd /www \ + && /app/elm make /www/src/App.elm --output /www/public/elm.js + +FROM base as final + +EXPOSE 8888 + +ENV TUTOR_PORT=8888 \ + TUTOR_DATABASE=/tutor.db \ + TUTOR_STATIC=/www + +RUN apk add sqlite +COPY --from=builder /venv /venv +COPY --from=frontend /www/public /www +COPY docker-entrypoint.sh tables.sql ./ +ENTRYPOINT ["./docker-entrypoint.sh"] +CMD ["server"] diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 0000000..c10a180 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,7 @@ +#!/bin/sh +set -e + +. /venv/bin/activate + +sqlite3 ${TUTOR_DATABASE} <./tables.sql +exec tutor $@ diff --git a/tutor/__main__.py b/tutor/__main__.py index ffa7039..9658051 100644 --- a/tutor/__main__.py +++ b/tutor/__main__.py @@ -15,7 +15,12 @@ import tutor.server @click.group() -@click.option("--database", type=click.Path(dir_okay=False), required=True) +@click.option( + "--database", + envvar="TUTOR_DATABASE", + type=click.Path(dir_okay=False), + required=True, +) @click.option( "--log-level", type=click.Choice( @@ -39,11 +44,18 @@ def cli(ctx, database, log_level): @cli.command() -@click.option("--port", type=int, default=8888) +@click.option("--port", type=int, envvar="TUTOR_PORT", default=8888) +@click.option("--static", envvar="TUTOR_STATIC", type=click.Path(file_okay=False)) @click.option("--debug", is_flag=True) @click.pass_context -def server(ctx, port, debug): - app = tutor.server.make_app({**ctx.obj, "debug": debug}) +def server(ctx, port, static, debug): + app = tutor.server.make_app( + { + **ctx.obj, + "static": static, + "debug": debug, + } + ) app.listen(port) tornado.ioloop.IOLoop.current().start() diff --git a/tutor/database.py b/tutor/database.py index 50fd602..bbcaf29 100644 --- a/tutor/database.py +++ b/tutor/database.py @@ -19,8 +19,10 @@ async def search( scryfall_id: typing.Optional[str] = None, limit: int = 10, distinct: bool = True, + in_collection: typing.Optional[bool] = None, ) -> typing.List[tutor.models.Card]: db.row_factory = aiosqlite.Row + joins = [] constraints = [] params = {} if name is not None: @@ -44,10 +46,17 @@ async def search( if scryfall_id is not None: constraints.append("cards.scryfall_id LIKE :scryfall_id") params["scryfall_id"] = scryfall_id + if in_collection is not None: + if in_collection: + joins.append("JOIN copies USING (scryfall_id)") + else: + joins.append("LEFT JOIN copies USING (scryfall_id)") + constraints.append("copies.id IS NULL") + joins.append("JOIN sets USING (set_code)") query = " ".join( [ "SELECT cards.* FROM cards", - "JOIN sets USING (set_code)", + " ".join(joins), "WHERE" if constraints else "", " AND ".join(constraints), f"LIMIT {limit}", diff --git a/tutor/server.py b/tutor/server.py index e44f062..6e1f908 100644 --- a/tutor/server.py +++ b/tutor/server.py @@ -10,10 +10,17 @@ import tutor.models class SearchHandler(tornado.web.RequestHandler): async def get(self) -> None: async with aiosqlite.connect(self.application.settings["database"]) as db: + name = self.get_argument("name", None) + in_collection = self.get_argument("in_collection", None) + limit = int(self.get_argument("limit", 10)) cards = await tutor.database.search( - db, name=self.get_argument("name", None) + db, + name=f"%{name}%" if name else None, + in_collection=in_collection.lower() == "yes" if in_collection else None, + limit=limit, ) self.set_header("Content-Type", "application/json") + self.set_header("Access-Control-Allow-Origin", "*") self.write( json.dumps( [ @@ -22,7 +29,7 @@ class SearchHandler(tornado.web.RequestHandler): "name": card.name, "set_code": card.set_code, "collector_number": card.collector_number, - "rarity": card.rarity, + "rarity": str(card.rarity), "color_identity": tutor.models.Color.to_string( card.color_identity ), @@ -34,9 +41,16 @@ class SearchHandler(tornado.web.RequestHandler): def make_app(settings) -> tornado.web.Application: - return tornado.web.Application( - [ - (r"/", SearchHandler), - ], - **settings, - ) + paths = [ + (r"/search", SearchHandler), + ] + if static_path := settings.get("static"): + paths.extend( + [ + (r"/", tornado.web.RedirectHandler, {"url": "/app/index.html"}), + (r"/app", tornado.web.RedirectHandler, {"url": "/app/index.html"}), + (r"/app/", tornado.web.RedirectHandler, {"url": "/app/index.html"}), + (r"/app/(.*)", tornado.web.StaticFileHandler, {"path": static_path}), + ] + ) + return tornado.web.Application(paths, **settings) diff --git a/www/.gitignore b/www/.gitignore new file mode 100644 index 0000000..7be18a7 --- /dev/null +++ b/www/.gitignore @@ -0,0 +1,4 @@ +elm-stuff +node_modules +package-lock.json +public/elm.js diff --git a/www/Makefile b/www/Makefile new file mode 100644 index 0000000..1ef9c77 --- /dev/null +++ b/www/Makefile @@ -0,0 +1,33 @@ +.PHONY: all node-deps clean run + +TARGET=public/elm.js +SOURCE=src/App.elm + +ELM_FILES = $(shell find src -type f -name '*.elm') +NODE_BIN = ./node_modules/.bin +ELM = $(NODE_BIN)/elm +ELM_LIVE = $(NODE_BIN)/elm-live + +ELMMAKE_FLAGS = +ifeq ($(DEBUG),1) + ELMMAKE_FLAGS += --debug +endif + +all: node-deps $(TARGET) + +node-deps: + npm i + +$(TARGET): $(ELM_FILES) + $(ELM) make $(ELMMAKE_FLAGS) $(SOURCE) --output $@ + +clean-deps: + rm -rf elm-stuff + rm -rf node_modules + +clean: + rm -f $(TARGET) + rm -rf elm-stuff/build-artifacts + +run: all + PATH="$(NODE_BIN):$$PATH" $(ELM_LIVE) $(SOURCE) --dir public --open -- $(ELMMAKE_FLAGS) --output $(TARGET) diff --git a/www/elm.json b/www/elm.json new file mode 100644 index 0000000..c6885f9 --- /dev/null +++ b/www/elm.json @@ -0,0 +1,29 @@ +{ + "type": "application", + "source-directories": [ + "src" + ], + "elm-version": "0.19.1", + "dependencies": { + "direct": { + "NoRedInk/elm-json-decode-pipeline": "1.0.0", + "elm/browser": "1.0.2", + "elm/core": "1.0.5", + "elm/html": "1.0.0", + "elm/http": "2.0.0", + "elm/json": "1.1.3", + "elm/url": "1.0.0", + "mdgriffith/elm-ui": "1.1.8" + }, + "indirect": { + "elm/bytes": "1.0.8", + "elm/file": "1.0.5", + "elm/time": "1.0.0", + "elm/virtual-dom": "1.0.2" + } + }, + "test-dependencies": { + "direct": {}, + "indirect": {} + } +} diff --git a/www/package.json b/www/package.json new file mode 100644 index 0000000..b2972f6 --- /dev/null +++ b/www/package.json @@ -0,0 +1,13 @@ +{ + "name": "tutor", + "description": "Tutor", + "version": "1.0.0", + "author": "Correl Roush ", + "license": "MIT", + "devDependencies": { + "elm": "0.19.1" + }, + "dependencies": { + "elm-live": "^4.0.2" + } +} diff --git a/www/public/index.html b/www/public/index.html new file mode 100644 index 0000000..064d4ca --- /dev/null +++ b/www/public/index.html @@ -0,0 +1,11 @@ + + + Bulk Tagging Dashboard + + + + + + diff --git a/www/src/App.elm b/www/src/App.elm new file mode 100644 index 0000000..3d1c6c7 --- /dev/null +++ b/www/src/App.elm @@ -0,0 +1,176 @@ +module App exposing (main) + +import Browser +import Element as E +import Element.Input as EI +import Html +import Http +import Json.Decode +import Json.Decode.Pipeline as JDP +import Url +import Url.Builder + + +type alias Criteria = + { name : String + , ownedOnly : Bool + } + + +type alias Card = + { scryfallId : String + , name : String + , setCode : String + , rarity : String + } + + +type alias Model = + { criteria : Criteria + , cards : List Card + } + + +type Msg + = UrlChanged Url.Url + | LinkClicked Browser.UrlRequest + | UpdateCriteria CriteriaMsg + | Search Criteria + | FoundCards (Result Http.Error (List Card)) + + +type CriteriaMsg + = UpdateName String + | UpdateOwnedOnly Bool + + +search : Criteria -> Cmd Msg +search criteria = + Http.get + { url = + Url.Builder.absolute + [ "search" ] + [ Url.Builder.string "name" criteria.name + , Url.Builder.string "in_collection" + (if criteria.ownedOnly then + "yes" + + else + "" + ) + , Url.Builder.int "limit" 20 + ] + , expect = Http.expectJson FoundCards (Json.Decode.list decodeCard) + } + + +decodeCard : Json.Decode.Decoder Card +decodeCard = + Json.Decode.succeed Card + |> JDP.required "scryfall_id" Json.Decode.string + |> JDP.required "name" Json.Decode.string + |> JDP.required "set_code" Json.Decode.string + |> JDP.required "rarity" Json.Decode.string + + +init : Json.Decode.Value -> url -> key -> ( Model, Cmd Msg ) +init flag url key = + let + criteria = + { name = "", ownedOnly = True } + in + ( { criteria = criteria + , cards = [] + } + , search criteria + ) + + +updateCriteria msg model = + case msg of + UpdateName text -> + { model | name = text } + + UpdateOwnedOnly value -> + { model | ownedOnly = value } + + +update msg model = + case msg of + UpdateCriteria criteriaMsg -> + let + newCriteria = + updateCriteria criteriaMsg model.criteria + in + ( { model | criteria = newCriteria }, search newCriteria ) + + FoundCards (Ok cards) -> + ( { model | cards = cards }, Cmd.none ) + + _ -> + ( model, Cmd.none ) + + +viewCard model = + E.column [] + [ -- E.row [ E.spacing 5 ] + -- [ E.text model.name + -- , E.image [ E.width <| E.px 20, E.height <| E.px 20 ] + -- { src = + -- if String.length model.setCode == 3 then + -- String.concat [ "images/sets/", String.toLower model.setCode, ".svg" ] + -- else + -- "images/sets/default.svg" + -- , description = model.setCode + -- } + -- ] + -- , + E.image [ E.height <| E.px 300 ] + { src = + Url.Builder.crossOrigin "https://api.scryfall.com" + [ "cards", model.scryfallId ] + [ Url.Builder.string "format" "image" + , Url.Builder.string "version" "border_crop" + ] + , description = model.name + } + ] + + +view model = + { title = "Tutor" + , body = + [ E.layout [] <| + E.column [] + [ E.row [] + [ EI.text [] + { onChange = UpdateCriteria << UpdateName + , text = model.criteria.name + , placeholder = Nothing + , label = EI.labelHidden "Search Input" + } + , EI.checkbox [] + { onChange = UpdateCriteria << UpdateOwnedOnly + , icon = EI.defaultCheckbox + , checked = model.criteria.ownedOnly + , label = EI.labelRight [] (E.text "Owned only?") + } + ] + , E.wrappedRow [ E.spacing 5 ] <| List.map viewCard model.cards + ] + ] + } + + +main = + Browser.application + { init = init + , onUrlChange = UrlChanged + , onUrlRequest = LinkClicked + , view = view + , update = update + , subscriptions = + \_ -> + Sub.batch + [] + }