Compare commits
68 Commits
@ -1 +1,13 @@ |
||||
web |
||||
* |
||||
|
||||
# NOTE: docker-compose doesn't allow spaces after the !'s :-/ |
||||
|
||||
!setup.py |
||||
!requirements*.txt |
||||
|
||||
!asl_articles/ |
||||
!run_server.py |
||||
!LICENSE.txt |
||||
|
||||
!alembic/ |
||||
!docker/ |
||||
|
@ -0,0 +1,28 @@ |
||||
"""Added article ratings. |
||||
|
||||
Revision ID: 21ec84874208 |
||||
Revises: 3d58e8ebf8c6 |
||||
Create Date: 2020-03-19 01:10:12.194485 |
||||
|
||||
""" |
||||
from alembic import op |
||||
import sqlalchemy as sa |
||||
|
||||
|
||||
# revision identifiers, used by Alembic. |
||||
revision = '21ec84874208' |
||||
down_revision = '3d58e8ebf8c6' |
||||
branch_labels = None |
||||
depends_on = None |
||||
|
||||
|
||||
def upgrade(): |
||||
# ### commands auto generated by Alembic - please adjust! ### |
||||
op.add_column('article', sa.Column('article_rating', sa.Integer(), nullable=True)) |
||||
# ### end Alembic commands ### |
||||
|
||||
|
||||
def downgrade(): |
||||
# ### commands auto generated by Alembic - please adjust! ### |
||||
op.drop_column('article', 'article_rating') |
||||
# ### end Alembic commands ### |
@ -0,0 +1,28 @@ |
||||
"""Added a seq# for publications. |
||||
|
||||
Revision ID: 3d58e8ebf8c6 |
||||
Revises: 41cfc117c809 |
||||
Create Date: 2020-03-18 10:26:25.801673 |
||||
|
||||
""" |
||||
from alembic import op |
||||
import sqlalchemy as sa |
||||
|
||||
|
||||
# revision identifiers, used by Alembic. |
||||
revision = '3d58e8ebf8c6' |
||||
down_revision = '41cfc117c809' |
||||
branch_labels = None |
||||
depends_on = None |
||||
|
||||
|
||||
def upgrade(): |
||||
# ### commands auto generated by Alembic - please adjust! ### |
||||
op.add_column('publication', sa.Column('pub_seqno', sa.Integer(), nullable=True)) |
||||
# ### end Alembic commands ### |
||||
|
||||
|
||||
def downgrade(): |
||||
# ### commands auto generated by Alembic - please adjust! ### |
||||
op.drop_column('publication', 'pub_seqno') |
||||
# ### end Alembic commands ### |
@ -0,0 +1,28 @@ |
||||
"""Allow articles to have a publication date. |
||||
|
||||
Revision ID: 702eeb219037 |
||||
Revises: a33edb7272a2 |
||||
Create Date: 2021-11-16 20:41:37.454305 |
||||
|
||||
""" |
||||
from alembic import op |
||||
import sqlalchemy as sa |
||||
|
||||
|
||||
# revision identifiers, used by Alembic. |
||||
revision = '702eeb219037' |
||||
down_revision = 'a33edb7272a2' |
||||
branch_labels = None |
||||
depends_on = None |
||||
|
||||
|
||||
def upgrade(): |
||||
# ### commands auto generated by Alembic - please adjust! ### |
||||
op.add_column('article', sa.Column('article_date', sa.String(length=100), nullable=True)) |
||||
# ### end Alembic commands ### |
||||
|
||||
|
||||
def downgrade(): |
||||
# ### commands auto generated by Alembic - please adjust! ### |
||||
op.drop_column('article', 'article_date') |
||||
# ### end Alembic commands ### |
@ -0,0 +1,40 @@ |
||||
"""Allow articles to be associated with a publisher. |
||||
|
||||
Revision ID: a33edb7272a2 |
||||
Revises: 21ec84874208 |
||||
Create Date: 2021-10-22 20:10:50.440849 |
||||
|
||||
""" |
||||
from alembic import op |
||||
import sqlalchemy as sa |
||||
|
||||
|
||||
# revision identifiers, used by Alembic. |
||||
revision = 'a33edb7272a2' |
||||
down_revision = '21ec84874208' |
||||
branch_labels = None |
||||
depends_on = None |
||||
|
||||
|
||||
from alembic import context |
||||
is_sqlite = context.config.get_main_option( "sqlalchemy.url" ).startswith( "sqlite://" ) |
||||
|
||||
|
||||
def upgrade(): |
||||
# ### commands auto generated by Alembic - please adjust! ### |
||||
op.add_column('article', sa.Column('publ_id', sa.Integer(), nullable=True)) |
||||
if is_sqlite: |
||||
op.execute( "PRAGMA foreign_keys = off" ) # nb: stop cascading deletes |
||||
with op.batch_alter_table('article') as batch_op: |
||||
batch_op.create_foreign_key('fk_article_publisher', 'publisher', ['publ_id'], ['publ_id'], ondelete='CASCADE') |
||||
# ### end Alembic commands ### |
||||
|
||||
|
||||
def downgrade(): |
||||
# ### commands auto generated by Alembic - please adjust! ### |
||||
if is_sqlite: |
||||
op.execute( "PRAGMA foreign_keys = off" ) # nb: stop cascading deletes |
||||
with op.batch_alter_table('article') as batch_op: |
||||
batch_op.drop_constraint('fk_article_publisher', type_='foreignkey') |
||||
op.drop_column('article', 'publ_id') |
||||
# ### end Alembic commands ### |
@ -1,61 +1,8 @@ |
||||
[System] |
||||
|
||||
; Allowed HTML tags and attributes. If not specified, the lxml defaults will be used: |
||||
; https://github.com/lxml/lxml/blob/master/src/lxml/html/defs.py |
||||
;HTML_ATTR_WHITELIST = |
||||
; Allowed HTML tags (default = allow all). |
||||
;HTML_TAG_WHITELIST = |
||||
|
||||
[Search aliases] |
||||
; This section defines search term aliases. |
||||
; For example, the entry "a = b ; c" means that searching for "a" will become "( a OR b OR c )". |
||||
; NOTE: Searching for "b" or "c" will be unaffected. |
||||
latw = atmm ; atr ; baz ; mol-p ; mol-projector ; piat ; pf ; pfk ; psk |
||||
sw = support weapon ; lmg ; mmg ; hmg ; mtr ; mortar ; dc ; ft ; radio ; phone ; rcl ; atmm ; atr ; baz ; mol-p ; mol-projector ; piat ; pf ; pfk ; psk |
||||
fortifications = cave ; a-t ditch ; foxhole ; sangar ; trench ; bunker ; minefield ; mines ; booby trap ; panji ; pillbox ; roadblock ; tetrahedron ; wire |
||||
vehicles = tank ; halftrack ; half-track ; jeep ; carrier |
||||
illumination = starshell ; illuminating round ; trip flare |
||||
|
||||
[Search aliases 2] |
||||
; This section defines search term aliases. |
||||
; For example, the entry "a = b = c" means that searching for any of "a" or "b" or "c" will all become "( a OR b OR c )". |
||||
asl = Advanced Squad Leader |
||||
mmp = Multi-Man Publishing = Multiman Publishing |
||||
ah = Avalon Hill |
||||
vftt = View From The Trenches |
||||
dftb = Dispatches From The Bunker |
||||
ch = Critical Hit |
||||
aslj = ASL Journal |
||||
|
||||
rb = red barricades |
||||
votg = valor of the guards |
||||
kgp = kampfgrupper peiper |
||||
kgs = kampfgrupper scherer |
||||
brt = br:t = blood reef tarawa |
||||
pb = pegasus bridge |
||||
|
||||
dc = demo charge |
||||
ft = flamethrower |
||||
baz = bazooka |
||||
pf = panzerfaust |
||||
psk = panzershreck |
||||
wp = white phosphorous |
||||
mol = molotov cocktail |
||||
ovr = overrun |
||||
cc = close combat |
||||
thh = tank-hunter hero |
||||
scw = shaped-charge weapon |
||||
; NOTE: We can't define "sw" here since we've defined it above. |
||||
; sw = support weapon |
||||
mg = machinegun = machine gun |
||||
ammo = ammunition |
||||
lc = landing craft |
||||
ht = halftrack |
||||
wa = wall advantage |
||||
hob = heat of battle |
||||
cg = campaign game |
||||
|
||||
firelane = fire-lane = fire lane |
||||
firegroup = fire-group = fire group |
||||
|
||||
armor = armour |
||||
humor = humour |
||||
; Allowed HTML attributes. If not specified, the lxml defaults will be used: |
||||
; https://github.com/lxml/lxml/blob/master/src/lxml/html/defs.py |
||||
HTML_ATTR_WHITELIST = style |
||||
|
@ -0,0 +1,42 @@ |
||||
[Author aliases] |
||||
|
||||
Andrew Hershey = Andrew H. Hershey |
||||
Andy Goldin = CPT Andy Goldin |
||||
Bob Medrow = Robert Medrow |
||||
Bruce Bakken = Bruce E. Bakken |
||||
Carl Fago = Carl D. Fago |
||||
Charlie Kibler = Charles Kibler |
||||
Chas Smith = Captain Chas Smith |
||||
Chris Doary = Chris "Clouseaux" Doary |
||||
Derek Tocher = Derek A. Tocher |
||||
Ed Beekman = Edward Beekman |
||||
Jeff Shields = Jeffrey Shields |
||||
Joe Suchar = Joseph Suchar |
||||
John Slotwinski = Dr. John Slotwinski |
||||
Jon Mishcon = M. J. Mishcon = M. Johnathon Mishcon |
||||
JR Van Mechelen = Jonathan Van Mechelen |
||||
Mark Nixon = Mark C. Nixon |
||||
Mark Walz = Mark S. Walz |
||||
Matt Cicero = Matthew Cicero |
||||
Matt Shostak = Matthew Shostak |
||||
Michael Dorosh = Michael A. Dorosh |
||||
Mike Clay = Dr. Michael Clay |
||||
Mike Conklin = Michael Conklin = Michael "6+3" Conklin |
||||
Mike Licari = Michael Licari = Michael J. Licari |
||||
Paul Venard = Paul J. Venard |
||||
Ray Tapio = Raymond J. Tapio |
||||
Rex Martin = Rex A. Martin |
||||
Robert Seulowitz = Dr. Rob Seulowitz |
||||
Robert Walden = Bob Walden |
||||
Rob Modarelli = Robert Modarelli = Captain Robert Modarelli III |
||||
Roy Connelly = Roy W. Connelly |
||||
Russ Bunten = Russell Bunten |
||||
Sam Rockwell = Samuel Rockwell |
||||
Scott Jackson = Scott "Stonewall" Jackson |
||||
Scott Thompson = Scott E. Thompson |
||||
Seth Fancher = Seth W. Fancher |
||||
Steve Linton = Steven Linton |
||||
Steve Pleva = Steven J. Pleva = Steve "Gor Gor" Pleva |
||||
Steve Swann = Steve C. Swann = Steven Swann = Steven C. Swann |
||||
Tom Huntington = Thomas Huntington |
||||
Trevor Edwards = Trev Edwards |
@ -0,0 +1,67 @@ |
||||
[Search weights] |
||||
; This section defines the relative weights of the searchable fields (see _SEARCHABLE_COL_NAMES). |
||||
; Each hit in a field scores 1 point, unless otherwise specified otherwise here. |
||||
|
||||
tags = 10 |
||||
name = 5 |
||||
name2 = 3 |
||||
authors = 5 |
||||
|
||||
[Search aliases] |
||||
; This section defines search term aliases. |
||||
; For example, the entry "a = b ; c" means that searching for "a" will become "( a OR b OR c )". |
||||
; NOTE: Searching for "b" or "c" will be unaffected. |
||||
|
||||
latw = atmm ; atr ; baz ; mol-p ; mol-projector ; piat ; pf ; pfk ; psk |
||||
sw = support weapon ; lmg ; mmg ; hmg ; mtr ; mortar ; dc ; ft ; radio ; phone ; rcl ; atmm ; atr ; baz ; mol-p ; mol-projector ; piat ; pf ; pfk ; psk |
||||
fortifications = cave ; a-t ditch ; foxhole ; sangar ; trench ; bunker ; minefield ; mines ; booby trap ; panji ; pillbox ; roadblock ; tetrahedron ; wire |
||||
entrenchments = foxhole ; trench ; ditch |
||||
vehicles = tank ; halftrack ; half-track ; jeep ; carrier |
||||
illumination = starshell ; illuminating round ; trip flare |
||||
|
||||
[Search aliases 2] |
||||
; This section defines search term aliases. |
||||
; For example, the entry "a = b = c" means that searching for any of "a" or "b" or "c" will all become "( a OR b OR c )". |
||||
|
||||
asl = Advanced Squad Leader |
||||
mmp = Multi-Man Publishing = Multiman Publishing |
||||
ah = Avalon Hill |
||||
vftt = View From The Trenches |
||||
dftb = Dispatches From The Bunker |
||||
ch = Critical Hit |
||||
aslj = ASL Journal |
||||
|
||||
rb = red barricades |
||||
votg = valor of the guards |
||||
kgp = kampfgrupper peiper |
||||
kgs = kampfgrupper scherer |
||||
brt = br:t = blood reef tarawa |
||||
pb = pegasus bridge |
||||
|
||||
dc = demo charge |
||||
ft = flamethrower |
||||
baz = bazooka |
||||
pf = panzerfaust |
||||
psk = panzershreck |
||||
wp = white phosphorous |
||||
mol = molotov cocktail |
||||
ovr = overrun |
||||
cc = close combat |
||||
thh = tank-hunter hero |
||||
scw = shaped-charge weapon |
||||
; NOTE: We can't define "sw" here since we've defined it above. |
||||
; sw = support weapon |
||||
mg = machinegun = machine gun |
||||
ammo = ammunition |
||||
lc = landing craft |
||||
ht = halftrack |
||||
wa = wall advantage |
||||
hob = heat of battle |
||||
cg = campaign game |
||||
pbm = pbem |
||||
|
||||
firelane = fire-lane = fire lane |
||||
firegroup = fire-group = fire group |
||||
|
||||
armor = armour |
||||
humor = humour |
@ -0,0 +1,149 @@ |
||||
""" Generate the database report. """ |
||||
|
||||
import urllib.request |
||||
import urllib.error |
||||
import hashlib |
||||
from collections import defaultdict |
||||
|
||||
from flask import request, jsonify, abort |
||||
|
||||
from asl_articles import app, db |
||||
|
||||
# --------------------------------------------------------------------- |
||||
|
||||
@app.route( "/db-report/row-counts" ) |
||||
def get_db_row_counts(): |
||||
"""Get the database row counts.""" |
||||
results = {} |
||||
for table_name in [ |
||||
"publisher", "publication", "article", "author", |
||||
"publisher_image", "publication_image", "article_image", |
||||
"scenario" |
||||
]: |
||||
query = db.engine.execute( "SELECT count(*) FROM {}".format( table_name ) ) |
||||
results[ table_name ] = query.scalar() |
||||
return jsonify( results ) |
||||
|
||||
# --------------------------------------------------------------------- |
||||
|
||||
@app.route( "/db-report/links" ) |
||||
def get_db_links(): |
||||
"""Get all links in the database.""" |
||||
|
||||
# initialize |
||||
results = {} |
||||
|
||||
def find_db_links( table_name, col_names ): |
||||
links = [] |
||||
query = db.engine.execute( "SELECT * FROM {}".format( table_name ) ) |
||||
for row in query: |
||||
url = row[ col_names[1] ] |
||||
if not url: |
||||
continue |
||||
obj_id = row[ col_names[0] ] |
||||
name = col_names[2]( row ) if callable( col_names[2] ) else row[ col_names[2] ] |
||||
links.append( [ obj_id, name, url ] ) |
||||
results[ table_name ] = links |
||||
|
||||
# find all links |
||||
find_db_links( "publisher", [ |
||||
"publ_id", "publ_url", "publ_name" |
||||
] ) |
||||
find_db_links( "publication", [ |
||||
"pub_id", "pub_url", _get_pub_name |
||||
] ) |
||||
find_db_links( "article", [ |
||||
"article_id", "article_url", "article_title" |
||||
] ) |
||||
|
||||
return jsonify( results ) |
||||
|
||||
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - |
||||
|
||||
@app.route( "/db-report/check-link", methods=["POST"] ) |
||||
def check_db_link(): |
||||
"""Check if a link appears to be working.""" |
||||
url = request.args.get( "url" ) |
||||
try: |
||||
req = urllib.request.Request( url, method="HEAD" ) |
||||
with urllib.request.urlopen( req ) as resp: |
||||
resp_code = resp.code |
||||
except urllib.error.URLError as ex: |
||||
resp_code = getattr( ex, "code", None ) |
||||
if not resp_code: |
||||
resp_code = 400 |
||||
if resp_code != 200: |
||||
abort( resp_code ) |
||||
return "ok" |
||||
|
||||
# --------------------------------------------------------------------- |
||||
|
||||
@app.route( "/db-report/images" ) |
||||
def get_db_images(): |
||||
"""Analyze the images stored in the database.""" |
||||
|
||||
# initialize |
||||
results = {} |
||||
image_hashes = defaultdict( list ) |
||||
|
||||
def find_images( table_name, col_names, get_name ): |
||||
|
||||
# find rows in the specified table that have images |
||||
sql = "SELECT {cols}, image_data" \ |
||||
" FROM {table}_image LEFT JOIN {table}" \ |
||||
" ON {table}_image.{id_col} = {table}.{id_col}".format( |
||||
cols = ",".join( "{}.{}".format( table_name, c ) for c in col_names ), |
||||
table = table_name, |
||||
id_col = col_names[0] |
||||
) |
||||
rows = [ |
||||
dict( row ) |
||||
for row in db.engine.execute( sql ) |
||||
] |
||||
|
||||
# save the image hashes |
||||
for row in rows: |
||||
image_hash = hashlib.md5( row["image_data"] ).hexdigest() |
||||
image_hashes[ image_hash ].append( [ |
||||
table_name, row[col_names[0]], get_name(row) |
||||
] ) |
||||
|
||||
# save the image sizes |
||||
image_sizes = [ |
||||
[ len(row["image_data"]), row[col_names[0]], get_name(row) ] |
||||
for row in rows |
||||
] |
||||
image_sizes.sort( key = lambda r: r[0], reverse=True ) |
||||
results[ table_name ] = image_sizes |
||||
|
||||
# look for images in each table |
||||
find_images( "publisher", |
||||
[ "publ_id", "publ_name" ], |
||||
lambda row: row["publ_name"] |
||||
) |
||||
find_images( "publication", |
||||
[ "pub_id", "pub_name", "pub_edition" ], |
||||
_get_pub_name |
||||
) |
||||
find_images( "article", |
||||
[ "article_id", "article_title" ], |
||||
lambda row: row["article_title"] |
||||
) |
||||
|
||||
# look for duplicate images |
||||
results["duplicates"] = {} |
||||
for image_hash, images in image_hashes.items(): |
||||
if len(images) == 1: |
||||
continue |
||||
results["duplicates"][ image_hash ] = images |
||||
|
||||
return results |
||||
|
||||
# --------------------------------------------------------------------- |
||||
|
||||
def _get_pub_name( row ): |
||||
"""Get a publication's display name.""" |
||||
name = row["pub_name"] |
||||
if row["pub_edition"]: |
||||
name += " ({})".format( row["pub_edition"] ) |
||||
return name |
@ -0,0 +1,3 @@ |
||||
""" Module definitions. """ |
||||
|
||||
pytest_options = None |
@ -0,0 +1,3 @@ |
||||
[Author aliases] |
||||
Chuck Jones = Charles M. Jones = Charles Martin Jones |
||||
Joe Blow = Joseph Blow |
@ -0,0 +1,31 @@ |
||||
{ |
||||
|
||||
"article": [ |
||||
{ "article_id": 101, "article_title": "By Charles M. Jones" }, |
||||
{ "article_id": 102, "article_title": "By Chuck Jones" }, |
||||
{ "article_id": 103, "article_title": "By Charles Martin Jones" }, |
||||
{ "article_id": 104, "article_title": "By Joseph Blow" }, |
||||
{ "article_id": 105, "article_title": "By Joe Blow" }, |
||||
{ "article_id": 106, "article_title": "By John Doe" } |
||||
], |
||||
|
||||
"author": [ |
||||
{ "author_id": 1, "author_name": "Charles M. Jones" }, |
||||
{ "author_id": 2, "author_name": "Joseph Blow" }, |
||||
{ "author_id": 3, "author_name": "Chuck Jones" }, |
||||
{ "author_id": 4, "author_name": "Joe Blow" }, |
||||
{ "author_id": 5, "author_name": "Charles Martin Jones" }, |
||||
{ "author_id": 6, "author_name": "John Doe" } |
||||
], |
||||
|
||||
"article_author": [ |
||||
{ "seq_no": 1, "article_id": 101, "author_id": 1 }, |
||||
{ "seq_no": 1, "article_id": 102, "author_id": 3 }, |
||||
{ "seq_no": 1, "article_id": 103, "author_id": 5 }, |
||||
{ "seq_no": 1, "article_id": 104, "author_id": 2 }, |
||||
{ "seq_no": 1, "article_id": 105, "author_id": 4 }, |
||||
{ "seq_no": 1, "article_id": 106, "author_id": 6 } |
||||
] |
||||
|
||||
} |
||||
|
@ -0,0 +1,42 @@ |
||||
{ |
||||
|
||||
"publisher": [ |
||||
{ "publ_id": 1, "publ_name": "Avalon Hill", "publ_url": "http://{FLASK}/ping" }, |
||||
{ "publ_id": 2, "publ_name": "Multiman Publishing", "publ_url": "http://{FLASK}/unknown" } |
||||
], |
||||
|
||||
"publication": [ |
||||
{ "pub_id": 10, "pub_name": "ASL Journal", "pub_edition": "1", "publ_id": 1, "pub_url": "/aslj-1.html" }, |
||||
{ "pub_id": 11, "pub_name": "ASL Journal", "pub_edition": "2", "publ_id": 1, "pub_url": "/aslj-2.html" }, |
||||
{ "pub_id": 20, "pub_name": "MMP News", "publ_id": 2 } |
||||
], |
||||
|
||||
"article": [ |
||||
{ "article_id": 100, "article_title": "ASLJ article 1", "pub_id": 10 }, |
||||
{ "article_id": 101, "article_title": "ASLJ article 2", "pub_id": 10 }, |
||||
{ "article_id": 110, "article_title": "ASLJ article 3", "pub_id": 11 }, |
||||
{ "article_id": 200, "article_title": "MMP article", "pub_id": 20, "article_url": "/mmp.html" }, |
||||
{ "article_id": 299, "article_title": "MMP publisher article", "publ_id": 2, "article_url": "/unknown" } |
||||
], |
||||
|
||||
"article_author": [ |
||||
{ "seq_no": 1, "article_id": 100, "author_id": 1000 }, |
||||
{ "seq_no": 2, "article_id": 100, "author_id": 1001 }, |
||||
{ "seq_no": 1, "article_id": 299, "author_id": 1000 } |
||||
], |
||||
"author": [ |
||||
{ "author_id": 1000, "author_name": "Joe Blow" }, |
||||
{ "author_id": 1001, "author_name": "Fred Nerk" }, |
||||
{ "author_id": 1999, "author_name": "Alan Smithee" } |
||||
], |
||||
|
||||
"article_scenario": [ |
||||
{ "seq_no": 1, "article_id": 100, "scenario_id": 2000 }, |
||||
{ "seq_no": 1, "article_id": 299, "scenario_id": 2001 } |
||||
], |
||||
"scenario": [ |
||||
{ "scenario_id": 2000, "scenario_display_id": "ASL 1", "scenario_name": "The Guards Counterattack" }, |
||||
{ "scenario_id": 2001, "scenario_display_id": "ASL 5", "scenario_name": "Hill 621" } |
||||
] |
||||
|
||||
} |
@ -0,0 +1 @@ |
||||
ASL Journal #1. |
@ -0,0 +1 @@ |
||||
ASL Journal #2. |
@ -0,0 +1 @@ |
||||
Multiman Publishing. |
@ -0,0 +1,11 @@ |
||||
{ |
||||
|
||||
"publisher": [ |
||||
{ "publ_id": 1, "publ_name": "Avalon Hill" } |
||||
], |
||||
|
||||
"publication": [ |
||||
{ "pub_id": 20, "pub_name": "ASL Journal", "publ_id": 1 } |
||||
] |
||||
|
||||
} |
@ -0,0 +1,17 @@ |
||||
{ |
||||
|
||||
"publisher": [ |
||||
{ "publ_id": 1, "publ_name": "Avalon Hill" }, |
||||
{ "publ_id": 2, "publ_name": "Multiman Publishing" }, |
||||
{ "publ_id": 3, "publ_name": "Le Franc Tireur" } |
||||
], |
||||
|
||||
"publication": [ |
||||
{ "pub_id": 20, "pub_name": "MMP News", "publ_id": 2 } |
||||
], |
||||
|
||||
"article": [ |
||||
{ "article_id": 200, "article_title": "MMP Today", "pub_id": 20 } |
||||
] |
||||
|
||||
} |
@ -0,0 +1,235 @@ |
||||
""" Test the database reports. """ |
||||
|
||||
import os |
||||
import itertools |
||||
import re |
||||
|
||||
import pytest |
||||
|
||||
from asl_articles.search import SEARCH_ALL |
||||
from asl_articles.tests.test_publishers import edit_publisher |
||||
from asl_articles.tests.test_publications import edit_publication |
||||
from asl_articles.tests.test_articles import edit_article |
||||
from asl_articles.tests.utils import init_tests, \ |
||||
select_main_menu_option, select_sr_menu_option, check_ask_dialog, \ |
||||
do_search, find_search_result, get_search_results, \ |
||||
wait_for, wait_for_elem, find_child, find_children |
||||
from asl_articles.tests import pytest_options |
||||
|
||||
# --------------------------------------------------------------------- |
||||
|
||||
def test_db_report( webdriver, flask_app, dbconn ): |
||||
"""Test the database report.""" |
||||
|
||||
# initialize |
||||
init_tests( webdriver, flask_app, dbconn, fixtures="db-report.json" ) |
||||
|
||||
# check the initial report |
||||
row_counts, links, dupe_images, image_sizes = _get_db_report() |
||||
assert row_counts == { |
||||
"publishers": 2, "publications": 3, "articles": 5, |
||||
"authors": 3, "scenarios": 2 |
||||
} |
||||
assert links == { |
||||
"publishers": [ 2, [] ], |
||||
"publications": [ 2, [] ], |
||||
"articles": [ 2, [] ], |
||||
} |
||||
assert dupe_images == [] |
||||
assert image_sizes == {} |
||||
|
||||
# add some images |
||||
do_search( SEARCH_ALL ) |
||||
publ_sr = find_search_result( "Avalon Hill", wait=2 ) |
||||
fname = os.path.join( os.path.split(__file__)[0], "fixtures/images/1.gif" ) |
||||
edit_publisher( publ_sr, { "image": fname } ) |
||||
results = get_search_results() |
||||
pub_sr = find_search_result( "ASL Journal (1)", results ) |
||||
fname = os.path.join( os.path.split(__file__)[0], "fixtures/images/2.gif" ) |
||||
edit_publication( pub_sr, { "image": fname } ) |
||||
article_sr = find_search_result( "ASLJ article 1", results ) |
||||
fname = os.path.join( os.path.split(__file__)[0], "fixtures/images/3.gif" ) |
||||
edit_article( article_sr, { "image": fname } ) |
||||
article_sr = find_search_result( "ASLJ article 2", results ) |
||||
fname = os.path.join( os.path.split(__file__)[0], "fixtures/images/3.gif" ) |
||||
edit_article( article_sr, { "image": fname } ) |
||||
|
||||
# check the updated report |
||||
row_counts, _, dupe_images, image_sizes = _get_db_report() |
||||
assert row_counts == { |
||||
"publishers": 2, "publisher_images": 1, |
||||
"publications": 3, "publication_images": 1, |
||||
"articles": 5, "article_images": 2, |
||||
"authors": 3, "scenarios": 2 |
||||
} |
||||
assert dupe_images == [ |
||||
[ "f0457ea742376e76ff276ce62c7a8540", "/images/article/100", |
||||
( "ASLJ article 1", "/article/100" ), |
||||
( "ASLJ article 2", "/article/101" ), |
||||
] |
||||
] |
||||
assert image_sizes == { |
||||
"publishers": [ |
||||
( "Avalon Hill", "/publisher/1", "/images/publisher/1" ), |
||||
], |
||||
"publications": [ |
||||
( "ASL Journal (1)", "/publication/10", "/images/publication/10" ), |
||||
], |
||||
"articles": [ |
||||
( "ASLJ article 1", "/article/100", "/images/article/100" ), |
||||
( "ASLJ article 2", "/article/101", "/images/article/101" ), |
||||
] |
||||
} |
||||
|
||||
# delete all the publishers (and associated objects), then check the updated report |
||||
do_search( SEARCH_ALL ) |
||||
publ_sr = find_search_result( "Avalon Hill", wait=2 ) |
||||
select_sr_menu_option( publ_sr, "delete" ) |
||||
check_ask_dialog( "Delete this publisher?", "ok" ) |
||||
results = get_search_results() |
||||
publ_sr = find_search_result( "Multiman Publishing", results ) |
||||
select_sr_menu_option( publ_sr, "delete" ) |
||||
check_ask_dialog( "Delete this publisher?", "ok" ) |
||||
row_counts, links, dupe_images, image_sizes = _get_db_report() |
||||
assert row_counts == { |
||||
"publishers": 0, "publications": 0, "articles": 0, |
||||
"authors": 3, "scenarios": 2 |
||||
} |
||||
assert links == { |
||||
"publishers": [ 0, [] ], |
||||
"publications": [ 0, [] ], |
||||
"articles": [ 0, [] ], |
||||
} |
||||
assert dupe_images == [] |
||||
assert image_sizes == {} |
||||
|
||||
# --------------------------------------------------------------------- |
||||
|
||||
# NOTE: This test may not work if we are running against Docker containers, because: |
||||
# - external URL's are created that point to the back-end's $/ping endpoint. |
||||
# - the front-end container realizes that these URL's need to be checked by the backend, |
||||
# so it sends them to the $/db-report/check-link endpoint. |
||||
# - these URL's may not resolve because they were generated using gAppRef.makeFlaskUrl(), |
||||
# which will work if the front-end container is sending a request to the back-end |
||||
# container, but may not work from inside the back-end container, because the port number |
||||
# being used by Flask *inside* the container may not be the same as *outside* the container. |
||||
# The problem is generating a URL that can be used as an external URL that will work everywhere. |
||||
# We could specify it as a parameter, but that's more trouble than it's worth. |
||||
@pytest.mark.skipif( pytest_options.flask_url is not None, reason="Testing against a remote Flask server." ) |
||||
def test_check_db_links( webdriver, flask_app, dbconn ): |
||||
"""Test checking links in the database.""" |
||||
|
||||
# initialize |
||||
init_tests( webdriver, flask_app, dbconn, docs="docs/", fixtures="db-report.json" ) |
||||
|
||||
# check the initial report |
||||
_, links, _, _ = _get_db_report() |
||||
assert links == { |
||||
"publishers": [ 2, [] ], |
||||
"publications": [ 2, [] ], |
||||
"articles": [ 2, [] ], |
||||
} |
||||
|
||||
# check the links |
||||
btn = find_child( "#db-report button.check-links" ) |
||||
btn.click() |
||||
status = find_child( "#db-report .db-links .status-msg" ) |
||||
wait_for( 10, lambda: status.text == "Checked 6 links." ) |
||||
|
||||
# check the updated report |
||||
_, links, _, _ = _get_db_report() |
||||
assert links == { |
||||
"publishers": [ 2, [ |
||||
( "Multiman Publishing", "/publisher/2", "HTTP 404: http://{FLASK}/unknown" ) |
||||
] ], |
||||
"publications": [ 2, [] ], |
||||
"articles": [ 2, [ |
||||
( "MMP publisher article", "/article/299", "HTTP 404: /unknown" ) |
||||
] ], |
||||
} |
||||
|
||||
# --------------------------------------------------------------------- |
||||
|
||||
def _get_db_report(): #pylint: disable=too-many-locals |
||||
"""Generate the database report.""" |
||||
|
||||
# generate the report |
||||
select_main_menu_option( "db-report" ) |
||||
wait_for_elem( 2, "#db-report .db-images" ) |
||||
|
||||
# unload the row counts |
||||
row_counts = {} |
||||
table = find_child( "#db-report .db-row-counts" ) |
||||
for row in find_children( "tr", table ): |
||||
cells = find_children( "td", row ) |
||||
mo = re.search( r"^(\d+)( \((\d+) images?\))?$", cells[1].text ) |
||||
key = cells[0].text.lower()[:-1] |
||||
row_counts[ key ] = int( mo.group(1) ) |
||||
if mo.group( 3 ): |
||||
row_counts[ key[:-1] + "_images" ] = int( mo.group(3) ) |
||||
|
||||
# unload the links |
||||
links = {} |
||||
table = find_child( "#db-report .db-links" ) |
||||
last_key = None |
||||
for row in find_children( "tr", table ): |
||||
cells = find_children( "td", row ) |
||||
if len(cells) == 2: |
||||
last_key = cells[0].text.lower()[:-1] |
||||
links[ last_key ] = [ int( cells[1].text ) , [] ] |
||||
else: |
||||
mo = re.search( r"^(.+) \((.+)\)$", cells[0].text ) |
||||
tags = find_children( "a", cells[0] ) |
||||
url = _fixup_url( tags[0].get_attribute( "href" ) ) |
||||
links[ last_key ][1].append( ( mo.group(1), url, mo.group(2) ) ) |
||||
|
||||
# unload duplicate images |
||||
dupe_images = [] |
||||
for row in find_children( "#db-report .dupe-analysis .dupe-image" ): |
||||
elem = find_child( ".caption .hash", row ) |
||||
mo = re.search( r"^\(md5:(.+)\)$", elem.text ) |
||||
image_hash = mo.group(1) |
||||
image_url = _fixup_url( find_child( "img", row ).get_attribute( "src" ) ) |
||||
parents = [] |
||||
for entry in find_children( ".collapsible li", row ): |
||||
url = _fixup_url( find_child( "a", entry ).get_attribute( "href" ) ) |
||||
parents.append( ( entry.text, url ) ) |
||||
dupe_images.append( list( itertools.chain( |
||||
[ image_hash, image_url ], parents |
||||
) ) ) |
||||
|
||||
# unload the image sizes |
||||
tab_ctrl = find_child( "#db-report .db-images .react-tabs" ) |
||||
image_sizes = {} |
||||
for tab in find_children( ".react-tabs__tab", tab_ctrl ): |
||||
key = tab.text.lower() |
||||
tab_id = tab.get_attribute( "id" ) |
||||
tab.click() |
||||
sel = ".react-tabs__tab-panel[aria-labelledby='{}'].react-tabs__tab-panel--selected".format( tab_id ) |
||||
tab_page = wait_for( 2, |
||||
lambda: find_child( sel, tab_ctrl ) #pylint: disable=cell-var-from-loop |
||||
) |
||||
parents = [] |
||||
for row_no, row in enumerate( find_children( "table.image-sizes tr", tab_page ) ): |
||||
if row_no == 0: |
||||
continue |
||||
cells = find_children( "td", row ) |
||||
image_url = _fixup_url( find_child( "img", cells[0] ).get_attribute( "src" ) ) |
||||
url = _fixup_url( find_child( "a", cells[2] ).get_attribute( "href" ) ) |
||||
parents.append( ( cells[2].text, url, image_url ) ) |
||||
if parents: |
||||
image_sizes[ key ] = parents |
||||
else: |
||||
assert tab_page.text == "No images found." |
||||
|
||||
return row_counts, links, dupe_images, image_sizes |
||||
|
||||
# --------------------------------------------------------------------- |
||||
|
||||
def _fixup_url( url ): |
||||
"""Fixup a URL to make it independent of its server.""" |
||||
url = re.sub( r"^http://[^/]+", "", url ) |
||||
pos = url.find( "?" ) |
||||
if pos >= 0: |
||||
url = url[:pos] |
||||
return url |
@ -0,0 +1,74 @@ |
||||
""" Test previewing images. """ |
||||
|
||||
import os |
||||
|
||||
from selenium.common.exceptions import ElementClickInterceptedException |
||||
|
||||
from asl_articles.search import SEARCH_ALL_PUBLISHERS, SEARCH_ALL_PUBLICATIONS, SEARCH_ALL_ARTICLES |
||||
from asl_articles.tests.test_publishers import create_publisher, edit_publisher |
||||
from asl_articles.tests.test_publications import create_publication, edit_publication |
||||
from asl_articles.tests.test_articles import create_article, edit_article |
||||
from asl_articles.tests.utils import init_tests, find_child, find_children, wait_for, \ |
||||
do_search, get_search_results, call_with_retry |
||||
|
||||
# --------------------------------------------------------------------- |
||||
|
||||
def test_image_preview( webdriver, flask_app, dbconn ): |
||||
"""Test previewing images.""" |
||||
|
||||
# initialize |
||||
init_tests( webdriver, flask_app, dbconn ) |
||||
|
||||
def do_test( create, edit, refresh ): |
||||
|
||||
# create a new object |
||||
webdriver.refresh() |
||||
create() |
||||
results = get_search_results() |
||||
assert len(results) == 1 |
||||
sr = results[0] |
||||
|
||||
# add images to the object |
||||
# NOTE: We're testing that images in an object already on-screen is updated correctly. |
||||
fname = os.path.join( os.path.split(__file__)[0], "fixtures/images/1.gif" ) |
||||
description = 'foo <img src="/images/app.png" style="height:2em;" class="preview"> bar' |
||||
edit( sr, fname, description ) |
||||
_check_previewable_images( sr ) |
||||
|
||||
# refresh the object |
||||
# NOTE: We're testing that images in an object loaded afresh is set up correctly. |
||||
webdriver.refresh() |
||||
wait_for( 2, lambda: find_child( "#search-form" ) ) |
||||
results = refresh() |
||||
assert len(results) == 1 |
||||
_check_previewable_images( results[0] ) |
||||
|
||||
# do the tests |
||||
do_test( |
||||
lambda: create_publisher( { "name": "Test publisher" } ), |
||||
lambda sr, fname, description: edit_publisher( sr, { "image": fname, "description": description } ), |
||||
lambda: do_search( SEARCH_ALL_PUBLISHERS ) |
||||
) |
||||
do_test( |
||||
lambda: create_publication( { "name": "Test publication" } ), |
||||
lambda sr, fname, description: edit_publication( sr, { "image": fname, "description": description } ), |
||||
lambda: do_search( SEARCH_ALL_PUBLICATIONS ) |
||||
) |
||||
do_test( |
||||
lambda: create_article( { "title": "Test article" } ), |
||||
lambda sr, fname, description: edit_article( sr, { "image": fname, "snippet": description } ), |
||||
lambda: do_search( SEARCH_ALL_ARTICLES ) |
||||
) |
||||
|
||||
# --------------------------------------------------------------------- |
||||
|
||||
def _check_previewable_images( sr ): |
||||
"""Check that previewable images are working correctly.""" |
||||
images = list( find_children( "a.preview img", sr ) ) |
||||
assert len(images) == 2 |
||||
for img in images: |
||||
assert find_child( ".jquery-image-zoom" ) is None |
||||
img.click() |
||||
preview = wait_for( 2, lambda: find_child( ".jquery-image-zoom" ) ) |
||||
call_with_retry( preview.click, [ElementClickInterceptedException] ) |
||||
wait_for( 2, lambda: find_child( ".jquery-image-zoom" ) is None ) |
After Width: | Height: | Size: 122 KiB |
After Width: | Height: | Size: 124 KiB |
After Width: | Height: | Size: 116 KiB |
After Width: | Height: | Size: 109 KiB |
@ -1,38 +1,31 @@ |
||||
# Set the TAG environment variable to set the image tag e.g. |
||||
# TAG=testing docker-compose build ... |
||||
# |
||||
# Set the ENABLE_TESTS build argument to support test functionality in the containers e.g. |
||||
# docker-compose build --build-arg ENABLE_TESTS=1 |
||||
# |
||||
# DBCONN is a database connection string that is passed straight through to the Flask container. |
||||
# However, if we're talking to a SQLite database, we also need to mount the file into the container. |
||||
# This is done via the SQLITE variable, but since Docker doesn't allow any way to do things conditionally, |
||||
# it needs to be set even if it's not being used :-/ |
||||
# |
||||
# Similarly, EXTERNAL_DOCS_BASEDIR is the base directory for external documents that we want to link to, |
||||
# but it needs to be set even if it's not being used :-/ |
||||
# |
||||
# See the run-containers.sh script that manages all of this. |
||||
# IMPORTANT: Use run-containers.sh to set up the necessary environment variables. |
||||
|
||||
version: "3" |
||||
version: "3.4" |
||||
|
||||
services: |
||||
web: |
||||
image: asl-articles-web:$TAG |
||||
build: web |
||||
build: |
||||
context: web |
||||
network: $BUILD_NETWORK |
||||
ports: |
||||
- "3002:80" |
||||
- $WEB_PORTNO:80 |
||||
flask: |
||||
image: asl-articles-flask:$TAG |
||||
build: |
||||
context: . |
||||
network: $BUILD_NETWORK |
||||
args: |
||||
ENABLE_TESTS: $ENABLE_TESTS |
||||
ports: |
||||
- "5002:5000" |
||||
- $FLASK_PORTNO:5000 |
||||
volumes: |
||||
- $SQLITE:/data/sqlite.db |
||||
- $EXTERNAL_DOCS_BASEDIR:/data/docs/ |
||||
- $USER_FILES_BASEDIR:/data/user-files/ |
||||
- $AUTHOR_ALIASES:/app/asl_articles/config/author-aliases.cfg |
||||
environment: |
||||
- DBCONN |
||||
- EXTERNAL_DOCS_BASEDIR |
||||
- USER_FILES_BASEDIR |
||||
- ASLRB_BASE_URL |
||||
|
@ -1,5 +1,5 @@ |
||||
pytest==5.2.2 |
||||
selenium==3.141.0 |
||||
pylint==2.4.3 |
||||
pylint-flask-sqlalchemy==0.1.0 |
||||
pytest-pylint==0.14.1 |
||||
pytest==7.1.2 |
||||
selenium==4.2.0 |
||||
pylint==2.14.1 |
||||
pylint-flask-sqlalchemy==0.2.0 |
||||
pytest-pylint==0.18.0 |
||||
|
@ -1,8 +1,9 @@ |
||||
# python 3.7.5 |
||||
# python 3.10.4 |
||||
|
||||
flask==1.1.1 |
||||
flask-sqlalchemy==2.4.1 |
||||
psycopg2-binary==2.8.4 |
||||
alembic==1.3.1 |
||||
pyyaml==5.1.2 |
||||
lxml==4.4.2 |
||||
flask==2.1.2 |
||||
flask-sqlalchemy==2.5.1 |
||||
psycopg2-binary==2.9.3 |
||||
alembic==1.8.0 |
||||
pyyaml==6.0 |
||||
lxml==4.9.0 |
||||
waitress==2.1.2 |
||||
|
@ -1,60 +1,170 @@ |
||||
#!/usr/bin/env bash |
||||
# Helper script that builds and launches the Docker containers. |
||||
|
||||
# parse the command-line arguments |
||||
if [ -z "$1" ]; then |
||||
echo "Usage: `basename "$0"` <db-conn> <external-docs>" |
||||
echo " Build and launch the \"asl-articles\" containers, using the specified database e.g." |
||||
echo " ~/asl-articles.db (path to a SQLite database)" |
||||
echo " postgresql://USER:PASS@host/dbname (database connection string)" |
||||
echo " Note that the database server address is relative to the container i.e. NOT \"localhost\"." |
||||
echo |
||||
echo " If you want link articles to their original documents, specify a base directory for the documents." |
||||
# --------------------------------------------------------------------- |
||||
|
||||
function print_help { |
||||
echo "`basename "$0"` {options}" |
||||
echo " Build and launch the \"asl-articles\" containers." |
||||
echo |
||||
echo " The TAG env variable can also be set to specify which containers to run e.g." |
||||
echo " TAG=testing ./run.sh /tmp/asl-articles.db" |
||||
echo " -t --tag Docker container tag e.g. \"testing\" or \"latest\"." |
||||
echo " -d --dbconn Database connection string e.g." |
||||
echo " ~/asl-articles.db (path to a SQLite database)" |
||||
echo " postgresql://USER:PASS@host/dbname (database connection string)" |
||||
echo " Note that the database server address is relative to the container i.e. NOT \"localhost\"." |
||||
echo " --web-portno Webapp port number." |
||||
echo " --flask-portno Flask backend server port number." |
||||
echo " -e --extdocs Base directory for external documents (to allow articles to link to them)." |
||||
echo " -u --user-files Base directory for user files." |
||||
echo " -r --aslrb Base URL for an eASLRB." |
||||
echo " -a --author-aliases Author aliases config file (see config/author-aliases.cfg.example)." |
||||
echo " --no-build Launch the containers as they are (i.e. without rebuilding them first)." |
||||
echo " --build-network Docker network to use when building the container." |
||||
} |
||||
|
||||
# --------------------------------------------------------------------- |
||||
|
||||
# initialize |
||||
cd `dirname "$0"` |
||||
export TAG= |
||||
export DBCONN= |
||||
export SQLITE= |
||||
export WEB_PORTNO=3002 |
||||
export FLASK_PORTNO=5002 |
||||
export EXTERNAL_DOCS_BASEDIR= |
||||
export USER_FILES_BASEDIR= |
||||
export ASLRB_BASE_URL= |
||||
export AUTHOR_ALIASES= |
||||
export ENABLE_TESTS= |
||||
NO_BUILD= |
||||
export BUILD_NETWORK= |
||||
export DOCKER_UID=$(id -u) |
||||
export DOCKER_GID=$(id -g) |
||||
|
||||
# parse the command-line arguments |
||||
if [ $# -eq 0 ]; then |
||||
print_help |
||||
exit 0 |
||||
fi |
||||
if [ -f "$1" ]; then |
||||
params="$(getopt -o t:d:e:u:r:a:h -l tag:,dbconn:,web-portno:,flask-portno:,extdocs:,user-files:,aslrb:,author-aliases:,no-build,build-network:,help --name "$0" -- "$@")" |
||||
if [ $? -ne 0 ]; then exit 1; fi |
||||
eval set -- "$params" |
||||
while true; do |
||||
case "$1" in |
||||
-t | --tag ) |
||||
TAG=$2 |
||||
shift 2 ;; |
||||
-d | --dbconn ) |
||||
DBCONN=$2 |
||||
shift 2 ;; |
||||
--web-portno ) |
||||
WEB_PORTNO=$2 |
||||
shift 2 ;; |
||||
--flask-portno ) |
||||
FLASK_PORTNO=$2 |
||||
shift 2 ;; |
||||
-e | --extdocs ) |
||||
EXTERNAL_DOCS_BASEDIR=$2 |
||||
shift 2 ;; |
||||
-u | --user-files ) |
||||
USER_FILES_BASEDIR=$2 |
||||
shift 2 ;; |
||||
-r | --aslrb ) |
||||
ASLRB_BASE_URL=$2 |
||||
shift 2 ;; |
||||
-a | --author-aliases ) |
||||
AUTHOR_ALIASES=$2 |
||||
shift 2 ;; |
||||
--no-build ) |
||||
NO_BUILD=1 |
||||
shift 1 ;; |
||||
--build-network ) |
||||
# FUDGE! We sometimes can't get out to the internet from the container (DNS problems) using the default |
||||
# "bridge" network, so we offer the option of using an alternate network (e.g. "host"). |
||||
BUILD_NETWORK=$2 |
||||
shift 2 ;; |
||||
-h | --help ) |
||||
print_help |
||||
exit 0 ;; |
||||
-- ) shift ; break ;; |
||||
* ) |
||||
echo "Unknown option: $1" >&2 |
||||
exit 1 ;; |
||||
esac |
||||
done |
||||
|
||||
# prepare the database connection string |
||||
if [ -z "$DBCONN" ]; then |
||||
echo "No database was specified." |
||||
exit 3 |
||||
fi |
||||
if [ -f "$DBCONN" ]; then |
||||
# connect to a SQLite database |
||||
export SQLITE=$1 |
||||
export DBCONN=sqlite:////data/sqlite.db |
||||
SQLITE=$DBCONN |
||||
DBCONN=sqlite:////data/sqlite.db |
||||
else |
||||
# FUDGE! We pass the database connection string (DBCONN) through to the container, |
||||
# but this needs to be set, even if it's not being used :-/ |
||||
SQLITE=/dev/null |
||||
fi |
||||
|
||||
# initialize for testing |
||||
if [ "$TAG" == "testing" ]; then |
||||
echo -e "*** WARNING! Test mode is enabled! ***\n" |
||||
ENABLE_TESTS=1 |
||||
else |
||||
# pass the database connection string through to the container |
||||
export SQLITE=/dev/null |
||||
export DBCONN=$1 |
||||
if [ -z "$TAG" ]; then |
||||
TAG=latest |
||||
fi |
||||
fi |
||||
if [ ! -z "$2" ]; then |
||||
# set the base directory for external documents |
||||
export EXTERNAL_DOCS_BASEDIR=$2 |
||||
|
||||
# check the external documents directory |
||||
if [ -n "$EXTERNAL_DOCS_BASEDIR" ]; then |
||||
if [ ! -d "$EXTERNAL_DOCS_BASEDIR" ]; then |
||||
echo "Invalid document base directory: $EXTERNAL_DOCS_BASEDIR" |
||||
echo "Can't find the external documents base directory: $EXTERNAL_DOCS_BASEDIR" |
||||
exit 1 |
||||
fi |
||||
else |
||||
# FUDGE! This needs to be set, even if it's not being used :-/ |
||||
export EXTERNAL_DOCS_BASEDIR=/dev/null |
||||
EXTERNAL_DOCS_BASEDIR=/dev/null |
||||
fi |
||||
|
||||
# initialize |
||||
if [ "$TAG" == "testing" ]; then |
||||
echo "*** WARNING! Special test functionality is enabled." |
||||
export ENABLE_TESTS=1 |
||||
elif [ "$TAG" == "prod" ]; then |
||||
export ENABLE_TESTS= |
||||
# check the user files directory |
||||
if [ -n "$USER_FILES_BASEDIR" ]; then |
||||
if [ ! -d "$USER_FILES_BASEDIR" ]; then |
||||
echo "Can't find the user files base directory: $USER_FILES_BASEDIR" |
||||
exit 1 |
||||
fi |
||||
else |
||||
export ENABLE_TESTS= |
||||
export TAG=latest |
||||
# FUDGE! This needs to be set, even if it's not being used :-/ |
||||
USER_FILES_BASEDIR=/dev/null |
||||
fi |
||||
|
||||
# check the author aliases |
||||
if [ -n "$AUTHOR_ALIASES" ]; then |
||||
if [ ! -f "$AUTHOR_ALIASES" ]; then |
||||
echo "Can't find the author aliases config file: $AUTHOR_ALIASES" |
||||
exit 1 |
||||
fi |
||||
else |
||||
# FUDGE! This needs to be set, even if it's not being used :-/ |
||||
AUTHOR_ALIASES=/dev/null |
||||
fi |
||||
|
||||
# build the containers |
||||
echo Building the \"$TAG\" containers... |
||||
docker-compose build --build-arg ENABLE_TESTS=$ENABLE_TESTS 2>&1 \ |
||||
| sed -e 's/^/ /' |
||||
if [ $? -ne 0 ]; then exit 10 ; fi |
||||
echo |
||||
if [ -z "$NO_BUILD" ]; then |
||||
echo Building the \"$TAG\" containers... |
||||
docker-compose build --build-arg ENABLE_TESTS=$ENABLE_TESTS 2>&1 \ |
||||
| sed -e 's/^/ /' |
||||
if [ ${PIPESTATUS[0]} -ne 0 ]; then exit 10 ; fi |
||||
echo |
||||
fi |
||||
|
||||
# launch the containers |
||||
echo Launching the \"$TAG\" containers... |
||||
if [ -n "$ENABLE_TESTS" ]; then |
||||
echo " *** TEST MODE ***" |
||||
fi |
||||
docker-compose up --detach 2>&1 \ |
||||
| sed -e 's/^/ /' |
||||
exit ${PIPESTATUS[0]} |
||||
|
@ -1 +1,10 @@ |
||||
node_modules |
||||
* |
||||
|
||||
# NOTE: docker-compose doesn't allow spaces after the !'s :-/ |
||||
|
||||
!package.json |
||||
|
||||
!src/ |
||||
!public/ |
||||
|
||||
!docker/ |
||||
|
@ -0,0 +1,3 @@ |
||||
# This file will be copied into the container as the React .env file. |
||||
|
||||
REACT_APP_FLASK_URL = http://localhost:5000 |
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 9.4 KiB |
Before Width: | Height: | Size: 372 B After Width: | Height: | Size: 372 B |
After Width: | Height: | Size: 1.7 KiB |
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 3.0 KiB |
Before Width: | Height: | Size: 7.7 KiB |
After Width: | Height: | Size: 584 B |
After Width: | Height: | Size: 1.2 KiB |
After Width: | Height: | Size: 2.1 KiB |
After Width: | Height: | Size: 2.5 KiB |
After Width: | Height: | Size: 1.8 KiB |
After Width: | Height: | Size: 2.9 KiB |
After Width: | Height: | Size: 2.3 KiB |
After Width: | Height: | Size: 8.1 KiB |
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 1.3 KiB |
After Width: | Height: | Size: 6.0 KiB |
After Width: | Height: | Size: 6.0 KiB |
After Width: | Height: | Size: 50 KiB |
@ -0,0 +1,48 @@ |
||||
div.jquery-image-zoom { |
||||
line-height: 0; |
||||
font-size: 0; |
||||
|
||||
z-index: 10; |
||||
|
||||
border: 5px solid #fff; |
||||
background: #eee; /* TM 25jan15: Added this to make it easier to see images with transparent backgrounds. */ |
||||
margin: -5px; |
||||
|
||||
-webkit-box-shadow: 0 0 10px rgba(0, 0, 0, 0.3); |
||||
-moz-box-shadow: 0 0 10px rgba(0, 0, 0, 0.3); |
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.3); |
||||
} |
||||
|
||||
div.jquery-image-zoom a { |
||||
background: url(/jQuery/imageZoom/jquery.imageZoom.png) no-repeat; |
||||
|
||||
display: block; |
||||
width: 25px; |
||||
height: 25px; |
||||
|
||||
position: absolute; |
||||
left: -17px; |
||||
top: -17px; |
||||
/* IE-users are prolly used to close-link in right-hand corner */ |
||||
*left: auto; |
||||
*right: -17px; |
||||
|
||||
text-decoration: none; |
||||
text-indent: -100000px; |
||||
outline: 0; |
||||
|
||||
z-index: 11; |
||||
} |
||||
|
||||
div.jquery-image-zoom a:hover { |
||||
background-position: left -25px; |
||||
} |
||||
|
||||
div.jquery-image-zoom img, |
||||
div.jquery-image-zoom embed, |
||||
div.jquery-image-zoom object, |
||||
div.jquery-image-zoom div { |
||||
width: 100%; |
||||
height: 100%; |
||||
margin: 0; |
||||
} |
@ -0,0 +1,195 @@ |
||||
/*** |
||||
@title: |
||||
Image Zoom |
||||
|
||||
@version: |
||||
2.0 |
||||
|
||||
@author: |
||||
Andreas Lagerkvist |
||||
|
||||
@date: |
||||
2008-08-31 |
||||
|
||||
@url: |
||||
http://andreaslagerkvist.com/jquery/image-zoom/
|
||||
|
||||
@license: |
||||
http://creativecommons.org/licenses/by/3.0/
|
||||
|
||||
@copyright: |
||||
2008 Andreas Lagerkvist (andreaslagerkvist.com) |
||||
|
||||
@requires: |
||||
jquery, jquery.imageZoom.css, jquery.imageZoom.png |
||||
|
||||
@does: |
||||
This plug-in makes links pointing to images open in the "Image Zoom". Clicking a link will zoom out the clicked image to its target-image. Click anywhere on the image or the close-button to zoom the image back in. Only ~3k minified. |
||||
|
||||
@howto: |
||||
jQuery(document.body).imageZoom(); Would make every link pointing to an image in the document open in the zoom. |
||||
|
||||
@exampleHTML: |
||||
<ul> |
||||
<li><a href="http://exscale.se/__files/3d/bloodcells.jpg">Bloodcells</a></li> |
||||
<li><a href="http://exscale.se/__files/3d/x-wing.jpg">X-Wing</a></li> |
||||
<li><a href="http://exscale.se/__files/3d/weve-moved.jpg">We've moved</a></li> |
||||
</ul> |
||||
|
||||
<ul> |
||||
<li><a href="http://exscale.se/__files/3d/lamp-and-mates/lamp-and-mates-01.jpg"><img src="http://exscale.se/__files/3d/lamp-and-mates/lamp-and-mates-01_small.jpg" alt="Lamp and Mates" /></a></li> |
||||
<li><a href="http://exscale.se/__files/3d/stugan-winter.jpg"><img src="http://exscale.se/__files/3d/stugan-winter_small.jpg" alt="The Cottage - Winter time" /></a></li> |
||||
<li><a href="http://exscale.se/__files/3d/ps2.jpg"><img src="http://exscale.se/__files/3d/ps2_small.jpg" alt="PS2" /></a></li> |
||||
</ul> |
||||
|
||||
@exampleJS: |
||||
// I don't run it because my site already uses imgZoom
|
||||
// jQuery(document.body).imageZoom();
|
||||
***/ |
||||
jQuery.fn.imageZoom = function (conf) { |
||||
// Some config. If you set dontFadeIn: 0 and hideClicked: 0 imgzoom will act exactly like fancyzoom
|
||||
var config = jQuery.extend({ |
||||
speed: 200, // Animation-speed of zoom
|
||||
dontFadeIn: 1, // 1 = Do not fade in, 0 = Do fade in
|
||||
hideClicked: 1, // Whether to hide the image that was clicked to bring up the imgzoom
|
||||
imageMargin: 30, // Margin from image-edge to window-edge if image is larger than screen
|
||||
className: 'jquery-image-zoom', |
||||
loading: 'Loading...' |
||||
}, conf); |
||||
config.doubleSpeed = config.speed / 4; // Used for fading in the close-button
|
||||
|
||||
return this.click(function(e) { |
||||
// Make sure the target-element is a link (or an element inside a link)
|
||||
var clickedElement = jQuery(e.target); // The element that was actually clicked
|
||||
var clickedLink = clickedElement.is('a') ? clickedElement : clickedElement.parents('a'); // If it's not an a, check if any of its parents is
|
||||
// TM MAR/20: Removed the check on the filename extension (it was looking for an image-type extension).
|
||||
clickedLink = (clickedLink && clickedLink.is('a')) ? clickedLink : false; // If it was an a or child of an a, make sure it points to an image
|
||||
var clickedImg = (clickedLink && clickedLink.find('img').length) ? clickedLink.find('img') : false; // See if the clicked link contains and image
|
||||
|
||||
// Only continue if a link pointing to an image was clicked
|
||||
if (clickedLink) { |
||||
// These functions are used when the imaeg starts and stops loading (displays either 'loading..' or fades out the clicked img slightly)
|
||||
clickedLink.oldText = clickedLink.text(); |
||||
|
||||
clickedLink.setLoadingImg = function () { |
||||
if (clickedImg) { |
||||
clickedImg.css({opacity: '0.5'}); |
||||
} |
||||
else { |
||||
clickedLink.text(config.loading); |
||||
} |
||||
}; |
||||
|
||||
clickedLink.setNotLoadingImg = function () { |
||||
if (clickedImg) { |
||||
clickedImg.css({opacity: '1'}); |
||||
} |
||||
else { |
||||
clickedLink.text(clickedLink.oldText); |
||||
} |
||||
}; |
||||
|
||||
// The URI to the image we are going to display
|
||||
var displayImgSrc = clickedLink.attr('href'); |
||||
|
||||
// If an imgzoom wiv this image is already open dont do nathin
|
||||
if (jQuery('div.' + config.className + ' img[src="' + displayImgSrc + '"]').length) { |
||||
return false; |
||||
} |
||||
|
||||
// This function is run once the displayImgSrc-img has loaded (below)
|
||||
var preloadOnload = function (pload) { |
||||
// The clicked-link is faded out during loading, fade it back in
|
||||
clickedLink.setNotLoadingImg(); |
||||
|
||||
// Now set some vars we need
|
||||
var dimElement = clickedImg ? clickedImg : clickedLink; // The element used to retrieve dimensions of imgzoom before zoom (either clicked link or img inside)
|
||||
var hideClicked = clickedImg ? config.hideClicked : 0; // Whether to hide clicked link (set in config but always true for non-image-links)
|
||||
var offset = dimElement.offset(); // Offset of clicked link (or image inside)
|
||||
var imgzoomBefore = { // The dimensions of the imgzoom _before_ it is zoomed out
|
||||
width: dimElement.outerWidth(), |
||||
height: dimElement.outerHeight(), |
||||
left: offset.left, |
||||
top: offset.top/*, |
||||
opacity: config.dontFadeIn*/ |
||||
}; |
||||
var imgzoom = jQuery('<div><img src="' + displayImgSrc + '" alt=""/></div>').css('position', 'absolute').appendTo(document.body); // We don't want any class-name or any other contents part from the image when we calculate the new dimensions of the imgzoom
|
||||
var imgzoomAfter = { // The dimensions of the imgzoom _after_ it is zoomed out
|
||||
width: pload.width, |
||||
height: pload.height/*, |
||||
opacity: 1*/ |
||||
}; |
||||
var windowDim = { |
||||
width: jQuery(window).width(), |
||||
height: jQuery(window).height() |
||||
}; |
||||
// Make sure imgzoom isn't wider than screen
|
||||
if (imgzoomAfter.width > (windowDim.width - config.imageMargin * 2)) { |
||||
var nWidth = windowDim.width - config.imageMargin * 2; |
||||
imgzoomAfter.height = (nWidth / imgzoomAfter.width) * imgzoomAfter.height; |
||||
imgzoomAfter.width = nWidth; |
||||
} |
||||
// Now make sure it isn't taller
|
||||
if (imgzoomAfter.height > (windowDim.height - config.imageMargin * 2)) { |
||||
var nHeight = windowDim.height - config.imageMargin * 2; |
||||
imgzoomAfter.width = (nHeight / imgzoomAfter.height) * imgzoomAfter.width; |
||||
imgzoomAfter.height = nHeight; |
||||
} |
||||
// Center imgzoom
|
||||
imgzoomAfter.left = (windowDim.width - imgzoomAfter.width) / 2 + jQuery(window).scrollLeft(); |
||||
imgzoomAfter.top = (windowDim.height - imgzoomAfter.height) / 2 + jQuery(window).scrollTop(); |
||||
var closeButton = jQuery('<a href="#">Close</a>').appendTo(imgzoom).hide(); // The button that closes the imgzoom (we're adding this after the calculation of the dimensions)
|
||||
|
||||
// Hide the clicked link if set so in config
|
||||
if (hideClicked) { |
||||
clickedLink.css('visibility', 'hidden'); |
||||
} |
||||
|
||||
// Now animate the imgzoom from its small size to its large size, and then fade in the close-button
|
||||
imgzoom.addClass(config.className).css(imgzoomBefore).animate(imgzoomAfter, config.speed, function () { |
||||
closeButton.fadeIn(config.doubleSpeed); |
||||
}); |
||||
|
||||
// This function closes the imgzoom
|
||||
var hideImgzoom = function () { |
||||
closeButton.fadeOut(config.doubleSpeed, function () { |
||||
imgzoom.animate(imgzoomBefore, config.speed, function () { |
||||
clickedLink.css('visibility', 'visible'); |
||||
imgzoom.remove(); |
||||
}); |
||||
}); |
||||
|
||||
return false; |
||||
}; |
||||
|
||||
// Close imgzoom when you click the closeButton or the imgzoom
|
||||
imgzoom.click(hideImgzoom); |
||||
closeButton.click(hideImgzoom); |
||||
}; |
||||
|
||||
// Preload image
|
||||
var preload = new Image(); |
||||
|
||||
preload.src = displayImgSrc; |
||||
|
||||
if (preload.complete) { |
||||
preloadOnload(preload); |
||||
} |
||||
else { |
||||
clickedLink.setLoadingImg(); |
||||
|
||||
preload.onload = function () { |
||||
preloadOnload(preload); |
||||
}; |
||||
} |
||||
|
||||
// Finally return false from the click so the browser doesn't actually follow the link...
|
||||
return false; |
||||
} |
||||
}); |
||||
}; |
||||
|
||||
// NOTE: We used to close up on ESC, but we want to do this on *any* keypress (e.g. if the user
|
||||
// starts typing in the search query box) or click (e.g. if the user clicks on a menu).
|
||||
$(document).keydown( () => { $("div.jquery-image-zoom a").click() ; } ) ; |
||||
$(document).click( () => { $("div.jquery-image-zoom a").click() ; } ) ; |
After Width: | Height: | Size: 1.7 KiB |
@ -0,0 +1,59 @@ |
||||
import React from "react" ; |
||||
import { gAppRef } from "./App.js" ; |
||||
|
||||
const axios = require( "axios" ) ; |
||||
|
||||
// --------------------------------------------------------------------
|
||||
|
||||
export class DataCache |
||||
{ |
||||
|
||||
constructor() { |
||||
// initialize
|
||||
this.data = {} ; |
||||
} |
||||
|
||||
get( keys, onOK ) { |
||||
|
||||
// initialize
|
||||
if ( onOK === undefined ) |
||||
onOK = () => {} ; |
||||
|
||||
let nOK = 0 ; |
||||
function onPartialOK() { |
||||
if ( ++nOK === keys.length ) { |
||||
onOK() ; |
||||
} |
||||
} |
||||
|
||||
// refresh each key
|
||||
for ( let key of keys ) { |
||||
// check if we already have the data in the cache
|
||||
if ( this.data[ key ] !== undefined ) { |
||||
onPartialOK() ; |
||||
} else { |
||||
// nope - get the specified data from the backend
|
||||
axios.get( |
||||
gAppRef.makeFlaskUrl( "/" + key ) |
||||
).then( resp => { |
||||
// got it - update the cache
|
||||
this.data[ key ] = resp.data ; |
||||
onPartialOK() ; |
||||
} ).catch( err => { |
||||
gAppRef.showErrorToast( |
||||
<div> Couldn't load the {key}: <div className="monospace"> {err.toString()} </div> </div> |
||||
) ; |
||||
} ) ; |
||||
} |
||||
} |
||||
|
||||
} |
||||
|
||||
refresh( keys, onOK ) { |
||||
// refresh the specified keys
|
||||
for ( let key of keys ) |
||||
delete this.data[ key ] ; |
||||
this.get( keys, onOK ) ; |
||||
} |
||||
|
||||
} |
@ -0,0 +1,24 @@ |
||||
#db-report { |
||||
border: 1px solid #ccc ; border-radius: 8px ; |
||||
padding: 0.5em ; |
||||
} |
||||
#db-report .section { margin-top: 1em ; } |
||||
#db-report .section:first-of-type { margin-top: 0 ; } |
||||
|
||||
#db-report h2 { border: 1px solid #ccc ; padding: 0.1em 0.2em ; background: #eee ; margin-bottom: 0.25em ; font-size: 125% ; } |
||||
#db-report h2 img.loading { height: 0.75em ; margin-left: 0.25em ; } |
||||
|
||||
#db-report .db-row-counts .images { font-size: 80% ; font-style: italic ; } |
||||
|
||||
#db-report .db-links .check-links-frame { display: inline-block ; position: absolute ; right: 1em ; text-align: center ; } |
||||
#db-report .db-links button.check-links { margin-bottom: 0.2em ; padding: 0.25em 0.5em ; } |
||||
#db-report .db-links .check-links-frame .status-msg { font-size: 60% ; font-style: italic ; } |
||||
#db-report .db-links .link-errors { font-size: 80% ; list-style-image: url("/public/images/link-error-bullet.png") ; } |
||||
#db-report .db-links .link-errors .status { font-family: monospace ; font-style: italic ; } |
||||
|
||||
#db-report .db-images .dupe-analysis .collapsible { margin-bottom: 0.5em ; } |
||||
#db-report .db-images .dupe-analysis .hash { font-family: monospace ; font-size: 80% ; font-style: italic ; } |
||||
#db-report .db-images .image-sizes th { text-align: left ; font-weight: normal ; font-style: italic ; } |
||||
#db-report .db-images .image-sizes img { height: 0.9em ; } |
||||
#db-report .db-images .react-tabs__tab-list { margin-bottom: 0 ; font-weight: bold ; } |
||||
#db-report .db-images .react-tabs__tab-panel { border: 1px solid #aaa ; border-top-width: 0 ; padding: 0.25em 0.5em ; } |
@ -0,0 +1,387 @@ |
||||
import React from "react" ; |
||||
import { Link } from "react-router-dom" ; |
||||
import { Tabs, TabList, TabPanel, Tab } from 'react-tabs'; |
||||
import 'react-tabs/style/react-tabs.css'; |
||||
import "./DbReport.css" ; |
||||
import { PreviewableImage } from "./PreviewableImage" ; |
||||
import { gAppRef } from "./App.js" ; |
||||
import { makeCollapsibleList, pluralString, isLink } from "./utils.js" ; |
||||
|
||||
const axios = require( "axios" ) ; |
||||
|
||||
// --------------------------------------------------------------------
|
||||
|
||||
export class DbReport extends React.Component |
||||
{ |
||||
// render the component
|
||||
render() { |
||||
return ( <div id="db-report"> |
||||
<div className="section"> <DbRowCounts /> </div> |
||||
<div className="section"> <DbLinks /> </div> |
||||
<div className="section"> <DbImages /> </div> |
||||
</div> |
||||
) ; |
||||
} |
||||
} |
||||
|
||||
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
class DbRowCounts extends React.Component |
||||
{ |
||||
|
||||
constructor( props ) { |
||||
|
||||
// initialize
|
||||
super( props ) ; |
||||
this.state = { |
||||
dbRowCounts: null, |
||||
} ; |
||||
|
||||
// get the database row counts
|
||||
axios.get( |
||||
gAppRef.makeFlaskUrl( "/db-report/row-counts" ) |
||||
).then( resp => { |
||||
this.setState( { dbRowCounts: resp.data } ) ; |
||||
} ).catch( err => { |
||||
gAppRef.showErrorResponse( "Can't get the database row counts", err ) ; |
||||
} ) ; |
||||
|
||||
} |
||||
|
||||
render() { |
||||
|
||||
// initialize
|
||||
const dbRowCounts = this.state.dbRowCounts ; |
||||
|
||||
// render the table rows
|
||||
function makeRowCountRow( tableName ) { |
||||
const tableName2 = tableName[0].toUpperCase() + tableName.substring(1) ; |
||||
let nRows ; |
||||
if ( dbRowCounts ) { |
||||
nRows = dbRowCounts[ tableName ] ; |
||||
const nImages = dbRowCounts[ tableName+"_image" ] ; |
||||
if ( nImages > 0 ) |
||||
nRows = ( <span> |
||||
{nRows} <span className="images">({pluralString(nImages,"image")})</span> |
||||
</span> |
||||
) ; |
||||
} |
||||
return ( <tr key={tableName}> |
||||
<td style={{paddingRight:"0.5em",fontWeight:"bold"}}> {tableName2}s: </td> |
||||
<td> {nRows} </td> |
||||
</tr> |
||||
) ; |
||||
} |
||||
let tableRows = [ "publisher", "publication", "article", "author", "scenario" ].map( |
||||
(tableName) => makeRowCountRow( tableName ) |
||||
) ; |
||||
|
||||
// render the component
|
||||
return ( <div className="db-row-counts"> |
||||
<h2> Content { !dbRowCounts && <img src="/images/loading.gif" className="loading" alt="Loading..." /> } </h2> |
||||
<table><tbody>{tableRows}</tbody></table> |
||||
</div> |
||||
) ; |
||||
} |
||||
|
||||
} |
||||
|
||||
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
class DbLinks extends React.Component |
||||
{ |
||||
|
||||
constructor( props ) { |
||||
|
||||
// initialize
|
||||
super( props ) ; |
||||
this.state = { |
||||
dbLinks: null, |
||||
linksToCheck: null, currLinkToCheck: null, isFirstLinkCheck: true, |
||||
checkLinksInProgress: false, checkLinksStatusMsg: null, |
||||
linkErrors: {}, |
||||
} ; |
||||
|
||||
// initialize
|
||||
this._getLinksToCheck() ; |
||||
|
||||
} |
||||
|
||||
render() { |
||||
|
||||
// initialize
|
||||
const dbLinks = this.state.dbLinks ; |
||||
|
||||
// render the table rows
|
||||
let tableRows = [] ; |
||||
for ( let key of [ "publisher", "publication", "article" ] ) { |
||||
const nDbLinks = dbLinks && dbLinks[key] ? dbLinks[key].length : null ; |
||||
const key2 = key[0].toUpperCase() + key.substring(1) + "s" ; |
||||
tableRows.push( <tr key={key}> |
||||
<td style={{paddingRight:"0.5em",fontWeight:"bold"}}> {key2}: </td> |
||||
<td style={{width:"100%"}}> {nDbLinks} </td> |
||||
</tr> |
||||
) ; |
||||
if ( this.state.linkErrors[ key ] ) { |
||||
// NOTE: Showing all the errors at once (e.g. not as a collapsible list) will be unwieldy
|
||||
// if there are a lot of them, but this shouldn't happen often, and if it does, the user
|
||||
// is likely to stop the check, fix the problem, then try again.
|
||||
let rows = [] ; |
||||
for ( let linkError of this.state.linkErrors[ key ] ) { |
||||
const url = gAppRef.makeAppUrl( "/" + linkError[0][0] + "/" + linkError[0][1] ) ; |
||||
const targetUrl = linkError[0][3] ; |
||||
const target = isLink( targetUrl ) |
||||
? <a href={targetUrl}>{targetUrl}</a> |
||||
: targetUrl ; |
||||
let errorMsg = linkError[1] && linkError[1] + ": " ; |
||||
rows.push( <li key={linkError[0]}> |
||||
<Link to={url} dangerouslySetInnerHTML={{__html:linkError[0][2]}} /> |
||||
<span className="status"> ({errorMsg}{target}) </span> |
||||
</li> |
||||
) ; |
||||
} |
||||
tableRows.push( <tr key={key+"-errors"}> |
||||
<td colSpan="2"> |
||||
<ul className="link-errors"> {rows} </ul> |
||||
</td> |
||||
</tr> |
||||
) ; |
||||
} |
||||
} |
||||
|
||||
// render the component
|
||||
const nLinksToCheck = this.state.linksToCheck ? this.state.linksToCheck.length - this.state.currLinkToCheck : null ; |
||||
const imageUrl = this.state.checkLinksInProgress ? "/images/loading.gif" : "/images/check-db-links.png" ; |
||||
return ( <div className="db-links"> |
||||
<h2> Links { !dbLinks && <img src="/images/loading.gif" className="loading" alt="Loading..." /> } </h2> |
||||
{ this.state.linksToCheck && this.state.linksToCheck.length > 0 && ( |
||||
<div className="check-links-frame"> |
||||
<button className="check-links" style={{display:"flex"}} onClick={() => this.checkDbLinks()} > |
||||
<img src={imageUrl} style={{height:"1em",marginTop:"0.15em",marginRight:"0.5em"}} alt="Check database links." /> |
||||
{ this.state.checkLinksInProgress ? "Stop checking" : "Check links (" + nLinksToCheck + ")" } |
||||
</button> |
||||
<div className="status-msg"> {this.state.checkLinksStatusMsg} </div> |
||||
</div> |
||||
) } |
||||
<table className="db-links" style={{width:"100%"}}><tbody>{tableRows}</tbody></table> |
||||
</div> |
||||
) ; |
||||
} |
||||
|
||||
checkDbLinks() { |
||||
// start/stop checking links
|
||||
const inProgress = ! this.state.checkLinksInProgress ; |
||||
this.setState( { checkLinksInProgress: inProgress } ) ; |
||||
if ( inProgress ) |
||||
this._checkNextLink() ; |
||||
} |
||||
|
||||
_checkNextLink( force ) { |
||||
|
||||
// check if this is the start of a new run
|
||||
if ( this.state.currLinkToCheck === 0 && !force ) { |
||||
// yup - reset the UI
|
||||
this.setState( { linkErrors: {} } ) ; |
||||
// NOTE: If the user is checking the links *again*, it could be because some links were flagged
|
||||
// during the first run, they've fixed them up, and want to check everything again. In this case,
|
||||
// we need to re-fetch the links from the database.
|
||||
if ( ! this.state.isFirstLinkCheck ) { |
||||
this._getLinksToCheck( |
||||
() => { this._checkNextLink( true ) ; }, |
||||
() => { this.setState( { checkLinksInProgress: false } ) ; } |
||||
) ; |
||||
return ; |
||||
} |
||||
} |
||||
|
||||
// check if this is the end of a run
|
||||
if ( this.state.currLinkToCheck >= this.state.linksToCheck.length ) { |
||||
// yup - reset the UI
|
||||
this.setState( { |
||||
checkLinksStatusMsg: "Checked " + pluralString( this.state.linksToCheck.length, "link" ) + ".", |
||||
currLinkToCheck: 0, // nb: to allow the user to check again
|
||||
checkLinksInProgress: false, |
||||
isFirstLinkCheck: false, |
||||
} ) ; |
||||
return ; |
||||
} |
||||
|
||||
// get the next link to check
|
||||
const linkToCheck = this.state.linksToCheck[ this.state.currLinkToCheck ] ; |
||||
this.setState( { currLinkToCheck: this.state.currLinkToCheck + 1 } ) ; |
||||
|
||||
let continueCheckLinks = () => { |
||||
// update the UI
|
||||
this.setState( { checkLinksStatusMsg: |
||||
"Checked " + this.state.currLinkToCheck + " of " + pluralString( this.state.linksToCheck.length, "link" ) + "..." |
||||
} ) ; |
||||
// check the next link
|
||||
if ( this.state.checkLinksInProgress ) |
||||
this._checkNextLink() ; |
||||
} |
||||
|
||||
// check the next link
|
||||
let url = linkToCheck[3] ; |
||||
if ( url.substr( 0, 14 ) === "http://{FLASK}" ) |
||||
url = gAppRef.makeFlaskUrl( url.substr( 14 ) ) ; |
||||
// NOTE: Because of CORS, we have to proxy URL's that don't belong to us via the backend :-/
|
||||
let req = isLink( url ) |
||||
? axios.post( gAppRef.makeFlaskUrl( "/db-report/check-link", {url:url} ) ) |
||||
: axios.head( gAppRef.makeExternalDocUrl( url ) ) ; |
||||
req.then( resp => { |
||||
// the link worked - continue checking links
|
||||
continueCheckLinks() ; |
||||
} ).catch( err => { |
||||
// the link failed - record the error
|
||||
let newLinkErrors = this.state.linkErrors ; |
||||
if ( newLinkErrors[ linkToCheck[0] ] === undefined ) |
||||
newLinkErrors[ linkToCheck[0] ] = [] ; |
||||
const errorMsg = err.response ? "HTTP " + err.response.status : null ; |
||||
newLinkErrors[ linkToCheck[0] ].push( [ linkToCheck, errorMsg ] ) ; |
||||
this.setState( { linkErrors: newLinkErrors } ) ; |
||||
// continue checking links
|
||||
continueCheckLinks() ; |
||||
} ) ; |
||||
|
||||
} |
||||
|
||||
_getLinksToCheck( onOK, onError ) { |
||||
// get the links in the database
|
||||
axios.get( |
||||
gAppRef.makeFlaskUrl( "/db-report/links" ) |
||||
).then( resp => { |
||||
const dbLinks = resp.data ; |
||||
// flatten the links to a list
|
||||
let linksToCheck = [] ; |
||||
for ( let key of [ "publisher", "publication", "article" ] ) { |
||||
for ( let row of dbLinks[key] ) { |
||||
linksToCheck.push( [ |
||||
key, row[0], row[1], row[2] |
||||
] ) ; |
||||
} |
||||
} |
||||
this.setState( { |
||||
dbLinks: resp.data, |
||||
linksToCheck: linksToCheck, |
||||
currLinkToCheck: 0, |
||||
} ) ; |
||||
if ( onOK ) |
||||
onOK() ; |
||||
} ).catch( err => { |
||||
gAppRef.showErrorResponse( "Can't get the database links", err ) ; |
||||
if ( onError ) |
||||
onError() ; |
||||
} ) ; |
||||
} |
||||
|
||||
} |
||||
|
||||
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
class DbImages extends React.Component |
||||
{ |
||||
|
||||
constructor( props ) { |
||||
|
||||
// initialize
|
||||
super( props ) ; |
||||
this.state = { |
||||
dbImages: null, |
||||
} ; |
||||
|
||||
// get the database images
|
||||
axios.get( |
||||
gAppRef.makeFlaskUrl( "/db-report/images" ) |
||||
).then( resp => { |
||||
this.setState( { dbImages: resp.data } ) ; |
||||
} ).catch( err => { |
||||
gAppRef.showErrorResponse( "Can't get the database images", err ) ; |
||||
} ) ; |
||||
|
||||
} |
||||
|
||||
render() { |
||||
|
||||
// initialize
|
||||
const dbImages = this.state.dbImages ; |
||||
|
||||
// render any duplicate images
|
||||
let dupeImages = [] ; |
||||
if ( dbImages ) { |
||||
for ( let hash in dbImages.duplicates ) { |
||||
let parents = [] ; |
||||
for ( let row of dbImages.duplicates[hash] ) { |
||||
const url = gAppRef.makeAppUrl( "/" + row[0] + "/" + row[1] ) ; |
||||
parents.push( |
||||
<Link to={url} dangerouslySetInnerHTML={{__html:row[2]}} /> |
||||
) ; |
||||
} |
||||
// NOTE: We just use the first row's image since, presumably, they will all be the same.
|
||||
const row = dbImages.duplicates[hash][ 0 ] ; |
||||
const imageUrl = gAppRef.makeFlaskImageUrl( row[0], row[1] ) ; |
||||
const caption = ( <span> |
||||
Found a duplicate image <span className="hash">(md5:{hash})</span> |
||||
</span> |
||||
) ; |
||||
dupeImages.push( <div className="dupe-image" style={{display:"flex"}} key={hash} > |
||||
<PreviewableImage url={imageUrl} style={{width:"3em",marginTop:"0.1em",marginRight:"0.5em"}} /> |
||||
{ makeCollapsibleList( caption, parents, 5, {flexGrow:1}, hash ) } |
||||
</div> |
||||
) ; |
||||
} |
||||
} |
||||
|
||||
// render the image sizes
|
||||
let tabList = [] ; |
||||
let tabPanels = [] ; |
||||
if ( dbImages ) { |
||||
function toKB( n ) { return ( n / 1024 ).toFixed( 1 ) ; } |
||||
for ( let key of [ "publisher", "publication", "article" ] ) { |
||||
const tableName2 = key[0].toUpperCase() + key.substring(1) ; |
||||
tabList.push( |
||||
<Tab key={key}> {tableName2+"s"} </Tab> |
||||
) ; |
||||
let rows = [] ; |
||||
for ( let row of dbImages[key] ) { |
||||
const url = gAppRef.makeAppUrl( "/" + key + "/" + row[1] ) ; |
||||
// NOTE: Loading every image will be expensive, but we assume we're talking to a local server.
|
||||
// Otherwise, we could use a generic "preview" image, and expand it out to the real image
|
||||
// when the user clicks on it.
|
||||
const imageUrl = gAppRef.makeFlaskImageUrl( key, row[1] ) ; |
||||
rows.push( <tr key={row}> |
||||
<td> <PreviewableImage url={imageUrl} /> </td> |
||||
<td> {toKB(row[0])} </td> |
||||
<td> <Link to={url} dangerouslySetInnerHTML={{__html:row[2]}} /> </td> |
||||
</tr> |
||||
) ; |
||||
} |
||||
tabPanels.push( <TabPanel key={key}> |
||||
{ rows.length === 0 ? "No images found." : |
||||
<table className="image-sizes"><tbody> |
||||
<tr><th style={{width:"1.25em"}}/><th style={{paddingRight:"0.5em"}}> Size (KB) </th><th> {tableName2} </th></tr> |
||||
{rows} |
||||
</tbody></table> |
||||
} |
||||
</TabPanel> |
||||
) ; |
||||
} |
||||
} |
||||
const imageSizes = tabList.length > 0 && ( <Tabs> |
||||
<TabList> {tabList} </TabList> |
||||
{tabPanels} |
||||
</Tabs> |
||||
) ; |
||||
|
||||
// render the component
|
||||
return ( <div className="db-images"> |
||||
<h2> Images { !dbImages && <img src="/images/loading.gif" className="loading" alt="Loading..." /> } </h2> |
||||
{ dupeImages.length > 0 && |
||||
<div className="dupe-analysis"> {dupeImages} </div> |
||||
} |
||||
{imageSizes} |
||||
</div> |
||||
) ; |
||||
|
||||
} |
||||
|
||||
} |
@ -0,0 +1,90 @@ |
||||
import React from "react" ; |
||||
import ReactDOM from "react-dom" ; |
||||
import $ from "jquery" ; |
||||
|
||||
// --------------------------------------------------------------------
|
||||
|
||||
export class PreviewableImage extends React.Component |
||||
{ |
||||
// NOTE: While the "react-modal-image" component seems to work nicely, how can we use it
|
||||
// on arbitrary images in user-defined content?
|
||||
// This class is a wrapper around the jQuery-based imageZoom plugin.
|
||||
|
||||
render() { |
||||
return ( <a href={this.props.url} className="preview" target="_blank" rel="noopener noreferrer"> |
||||
<img src={this.props.url} className={this.props.className} style={this.props.style} alt={this.props.altText} /> |
||||
</a> ) ; |
||||
} |
||||
|
||||
static initPreviewableImages() { |
||||
// load the imageZoom script
|
||||
$.getScript( { |
||||
url: "/jQuery/imageZoom/jquery.imageZoom.js", |
||||
cache: true, |
||||
} ) ; |
||||
// load the imageZoom CSS
|
||||
let cssNode = document.createElement( "link" ) ; |
||||
cssNode.type = "text/css" ; |
||||
cssNode.rel = "stylesheet" ; |
||||
cssNode.href = "/jQuery/imageZoom/jquery.imageZoom.css" ; |
||||
let headNode = document.getElementsByTagName( "head" )[0] ; |
||||
headNode.appendChild( cssNode ) ; |
||||
} |
||||
|
||||
static adjustHtmlForPreviewableImages( html ) { |
||||
// FUDGE! The imageZoom plugin requires images to be wrapped with a <a class="preview"> tag.
|
||||
// I was hoping to be able to let the user enable the preview functionality for images
|
||||
// by simply adding a "preview" attribute to their <img> tags, then locating them after render
|
||||
// and dynamically wrapping them with the necessary <a class="preview"> tag, but React doesn't
|
||||
// seem to like that :-/
|
||||
// We instead look for such images in the HTML returned to us by the backend server, and fix it up
|
||||
// before rendering it.
|
||||
|
||||
// initialize
|
||||
if ( ! html ) |
||||
return "" ; |
||||
|
||||
// locate <img> tags with a class of "preview", and wrap them in a <a class="preview">.
|
||||
let buf=[], pos=0 ; |
||||
const img_regex = /<img [^>]*class\s*=\s*["']preview["'][^>]*>/g ; |
||||
const url_regex = /src\s*=\s*["'](.*?)['"]/ |
||||
for ( const match of html.matchAll( img_regex ) ) { |
||||
buf.push( html.substr( pos, match.index-pos ) ) ; |
||||
const match2 = url_regex.exec( match[0] ) ; |
||||
if ( match2 ) { |
||||
buf.push( |
||||
"<a href='" + match2[1] + "' class='preview'>", |
||||
match[0], |
||||
"</a>" |
||||
) ; |
||||
} else |
||||
buf.push( match[0] ) ; |
||||
pos = match.index + match[0].length ; |
||||
} |
||||
buf.push( html.substr( pos ) ) ; |
||||
|
||||
return buf.join( "" ) ; |
||||
} |
||||
|
||||
componentDidMount() { |
||||
if ( this.props.manualActivate ) { |
||||
// NOTE: We normally want PreviewableImage's to automatically activate themselves, but there is
|
||||
// a common case where we don't want this to happen: when raw HTML is received from the backend
|
||||
// and inserted like that into the page.
|
||||
// In this case, <img> tags are fixed up by adjustHtmlForPreviewableImages() as raw HTML (i.e. not
|
||||
// as a PreviewableImage instance), and so the page still needs to call activatePreviewableImages()
|
||||
// to activate these. Since it's probably not a good idea to activate an image twice, in this case
|
||||
// PreviewableImage instances should be created as "manually activated".
|
||||
return ; |
||||
} |
||||
let $elem = $( ReactDOM.findDOMNode( this ) ) ; |
||||
$elem.imageZoom() ; |
||||
} |
||||
|
||||
static activatePreviewableImages( rootNode ) { |
||||
// locate images marked as previewable and activate them
|
||||
let $elems = $( ReactDOM.findDOMNode( rootNode ) ).find( "a.preview" ) ; |
||||
$elems.imageZoom() ; |
||||
} |
||||
|
||||
} |