Compare commits
1 commit
a38465fc77
...
30171c3c4a
Author | SHA1 | Date | |
---|---|---|---|
30171c3c4a |
12 changed files with 366 additions and 14 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -3,7 +3,6 @@
|
||||||
*.log
|
*.log
|
||||||
tmp/
|
tmp/
|
||||||
*.egg-info
|
*.egg-info
|
||||||
poetry.lock
|
|
||||||
|
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
*.egg
|
*.egg
|
||||||
|
|
45
Dockerfile
Normal file
45
Dockerfile
Normal 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
7
docker-entrypoint.sh
Executable file
|
@ -0,0 +1,7 @@
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
. /venv/bin/activate
|
||||||
|
|
||||||
|
sqlite3 ${TUTOR_DATABASE} <./tables.sql
|
||||||
|
exec tutor $@
|
|
@ -15,7 +15,12 @@ import tutor.server
|
||||||
|
|
||||||
|
|
||||||
@click.group()
|
@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(
|
@click.option(
|
||||||
"--log-level",
|
"--log-level",
|
||||||
type=click.Choice(
|
type=click.Choice(
|
||||||
|
@ -39,11 +44,18 @@ def cli(ctx, database, log_level):
|
||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@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.option("--debug", is_flag=True)
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def server(ctx, port, debug):
|
def server(ctx, port, static, debug):
|
||||||
app = tutor.server.make_app({**ctx.obj, "debug": debug})
|
app = tutor.server.make_app(
|
||||||
|
{
|
||||||
|
**ctx.obj,
|
||||||
|
"static": static,
|
||||||
|
"debug": debug,
|
||||||
|
}
|
||||||
|
)
|
||||||
app.listen(port)
|
app.listen(port)
|
||||||
tornado.ioloop.IOLoop.current().start()
|
tornado.ioloop.IOLoop.current().start()
|
||||||
|
|
||||||
|
|
|
@ -19,8 +19,10 @@ async def search(
|
||||||
scryfall_id: typing.Optional[str] = None,
|
scryfall_id: typing.Optional[str] = None,
|
||||||
limit: int = 10,
|
limit: int = 10,
|
||||||
distinct: bool = True,
|
distinct: bool = True,
|
||||||
|
in_collection: typing.Optional[bool] = None,
|
||||||
) -> typing.List[tutor.models.Card]:
|
) -> typing.List[tutor.models.Card]:
|
||||||
db.row_factory = aiosqlite.Row
|
db.row_factory = aiosqlite.Row
|
||||||
|
joins = []
|
||||||
constraints = []
|
constraints = []
|
||||||
params = {}
|
params = {}
|
||||||
if name is not None:
|
if name is not None:
|
||||||
|
@ -44,10 +46,17 @@ async def search(
|
||||||
if scryfall_id is not None:
|
if scryfall_id is not None:
|
||||||
constraints.append("cards.scryfall_id LIKE :scryfall_id")
|
constraints.append("cards.scryfall_id LIKE :scryfall_id")
|
||||||
params["scryfall_id"] = 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(
|
query = " ".join(
|
||||||
[
|
[
|
||||||
"SELECT cards.* FROM cards",
|
"SELECT cards.* FROM cards",
|
||||||
"JOIN sets USING (set_code)",
|
" ".join(joins),
|
||||||
"WHERE" if constraints else "",
|
"WHERE" if constraints else "",
|
||||||
" AND ".join(constraints),
|
" AND ".join(constraints),
|
||||||
f"LIMIT {limit}",
|
f"LIMIT {limit}",
|
||||||
|
|
|
@ -10,10 +10,17 @@ import tutor.models
|
||||||
class SearchHandler(tornado.web.RequestHandler):
|
class SearchHandler(tornado.web.RequestHandler):
|
||||||
async def get(self) -> None:
|
async def get(self) -> None:
|
||||||
async with aiosqlite.connect(self.application.settings["database"]) as db:
|
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(
|
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("Content-Type", "application/json")
|
||||||
|
self.set_header("Access-Control-Allow-Origin", "*")
|
||||||
self.write(
|
self.write(
|
||||||
json.dumps(
|
json.dumps(
|
||||||
[
|
[
|
||||||
|
@ -22,7 +29,7 @@ class SearchHandler(tornado.web.RequestHandler):
|
||||||
"name": card.name,
|
"name": card.name,
|
||||||
"set_code": card.set_code,
|
"set_code": card.set_code,
|
||||||
"collector_number": card.collector_number,
|
"collector_number": card.collector_number,
|
||||||
"rarity": card.rarity,
|
"rarity": str(card.rarity),
|
||||||
"color_identity": tutor.models.Color.to_string(
|
"color_identity": tutor.models.Color.to_string(
|
||||||
card.color_identity
|
card.color_identity
|
||||||
),
|
),
|
||||||
|
@ -34,9 +41,16 @@ class SearchHandler(tornado.web.RequestHandler):
|
||||||
|
|
||||||
|
|
||||||
def make_app(settings) -> tornado.web.Application:
|
def make_app(settings) -> tornado.web.Application:
|
||||||
return tornado.web.Application(
|
paths = [
|
||||||
[
|
(r"/search", SearchHandler),
|
||||||
(r"/", SearchHandler),
|
]
|
||||||
],
|
if static_path := settings.get("static"):
|
||||||
**settings,
|
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)
|
||||||
|
|
4
www/.gitignore
vendored
Normal file
4
www/.gitignore
vendored
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
elm-stuff
|
||||||
|
node_modules
|
||||||
|
package-lock.json
|
||||||
|
public/elm.js
|
33
www/Makefile
Normal file
33
www/Makefile
Normal 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
29
www/elm.json
Normal 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
13
www/package.json
Normal 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
11
www/public/index.html
Normal 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
176
www/src/App.elm
Normal 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
|
||||||
|
[]
|
||||||
|
}
|
Loading…
Reference in a new issue