Compare commits

...

1 commit

Author SHA1 Message Date
30171c3c4a Add UI and Dockerfile 2021-07-07 22:19:25 -04:00
12 changed files with 366 additions and 14 deletions

1
.gitignore vendored
View file

@ -3,7 +3,6 @@
*.log
tmp/
*.egg-info
poetry.lock
*.py[cod]
*.egg

45
Dockerfile Normal file
View file

@ -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"]

7
docker-entrypoint.sh Executable file
View file

@ -0,0 +1,7 @@
#!/bin/sh
set -e
. /venv/bin/activate
sqlite3 ${TUTOR_DATABASE} <./tables.sql
exec tutor $@

View file

@ -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()

View file

@ -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}",

View file

@ -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(
paths = [
(r"/search", SearchHandler),
]
if static_path := settings.get("static"):
paths.extend(
[
(r"/", SearchHandler),
],
**settings,
(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)

4
www/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
elm-stuff
node_modules
package-lock.json
public/elm.js

33
www/Makefile Normal file
View file

@ -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)

29
www/elm.json Normal file
View file

@ -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": {}
}
}

13
www/package.json Normal file
View file

@ -0,0 +1,13 @@
{
"name": "tutor",
"description": "Tutor",
"version": "1.0.0",
"author": "Correl Roush <correl@gmail.com>",
"license": "MIT",
"devDependencies": {
"elm": "0.19.1"
},
"dependencies": {
"elm-live": "^4.0.2"
}
}

11
www/public/index.html Normal file
View file

@ -0,0 +1,11 @@
<html>
<head>
<title>Bulk Tagging Dashboard</title>
<script type="text/javascript" src="elm.js"></script>
</head>
<body>
<script type="text/javascript">
var app = Elm.App.init({flags: {}});
</script>
</body>
</html>

176
www/src/App.elm Normal file
View file

@ -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
[]
}