def manuscript_full_json (passage_or_id, hs_hsnr_id): """Endpoint. Serve information about a manuscript. :param string hs_hsnr_id: The hs, hsnr or id of the manuscript. """ auth () hs_hsnr_id = request.args.get ('ms_id') or hs_hsnr_id chapter = request.args.get ('range') or 'All' with current_app.config.dba.engine.begin () as conn: passage = Passage (conn, passage_or_id) ms = Manuscript (conn, hs_hsnr_id) rg_id = passage.range_id (chapter) json = ms.to_json () json['length'] = ms.get_length (passage, chapter) # Get the attestation(s) of the manuscript (may be uncertain eg. a/b/c) res = execute (conn, """ SELECT labez, clique, labez_clique FROM apparatus_view_agg WHERE ms_id = :ms_id AND pass_id = :pass_id """, dict (parameters, ms_id = ms.ms_id, pass_id = passage.pass_id)) json['labez'], json['clique'], json['labez_clique'] = res.fetchone () # Get the affinity of the manuscript to all manuscripts res = execute (conn, """ SELECT avg (a.affinity) as aa, percentile_cont(0.5) WITHIN GROUP (ORDER BY a.affinity) as ma FROM affinity a WHERE a.ms_id1 = :ms_id1 AND a.rg_id = :rg_id """, dict (parameters, ms_id1 = ms.ms_id, rg_id = rg_id)) json['aa'], json['ma'] = res.fetchone () # Get the affinity of the manuscript to MT # # For a description of mt and mtp see the comment in # ActsMsListValPh3.pl and # http://intf.uni-muenster.de/cbgm/actsPh3/guide_en.html#Ancestors json['mt'], json['mtp'] = 0.0, 0.0 res = execute (conn, """ SELECT a.affinity as mt, a.equal::float / c.length as mtp FROM affinity a JOIN ms_ranges c ON (a.ms_id1, a.rg_id) = (c.ms_id, c.rg_id) WHERE a.ms_id1 = :ms_id1 AND a.ms_id2 = 2 AND a.rg_id = :rg_id """, dict (parameters, ms_id1 = ms.ms_id, rg_id = rg_id)) if res.rowcount > 0: json['mt'], json['mtp'] = res.fetchone () return make_json_response (json)
def cliques_json(passage_or_id): """ Endpoint. Serve all cliques found in a passage. :param string passage_or_id: The passage id. """ auth() with current_app.config.dba.engine.begin() as conn: passage = Passage(conn, passage_or_id) return make_json_response(passage.cliques())
def cliques_json (passage_or_id): """ Endpoint. Serve all cliques found in a passage. :param string passage_or_id: The passage id. """ auth () with current_app.config.dba.engine.begin () as conn: passage = Passage (conn, passage_or_id) return make_json_response (passage.cliques ())
def notes_json(range_id): """Endpoint. Get a list of all editor notes.""" private_auth() with current_app.config.dba.engine.begin() as conn: res = execute( conn, """ SELECT pass_id, begadr, endadr, note FROM passages_view p JOIN ranges rg ON (rg.passage @> p.passage) JOIN notes USING (pass_id) WHERE rg.rg_id = :range_id ORDER BY pass_id """, dict(parameters, range_id=range_id)) Notes = collections.namedtuple('Notes', 'pass_id, begadr, endadr, note') notes = [] for r in res: note = Notes._make(r)._asdict() note['hr'] = Passage.static_to_hr(note['begadr'], note['endadr']) notes.append(note) return make_json_response(notes)
def stemma(passage_or_id): """Serve a local stemma in dot format. A local stemma is a DAG (directed acyclic graph). The layout of the DAG is precomputed on the server using GraphViz. GraphViz adds a precomputed position to each node and a precomputed bezier path to each edge. N.B. I also considered client-side layout of DAGs, but found only 2 viable libraries: - dagre. Javascript clone of GraphViz. Unmaintained. Buggy. Does not work well with require.js. - viz.js. GraphViz cross-compiled to Javascript with Emscripten. Huge. Promising but still early days. Both libraries have their drawbacks so the easiest way out was to precompute the layout on the server. """ width = float(request.args.get('width') or 0.0) fontsize = float(request.args.get('fontsize') or 10.0) with current_app.config.dba.engine.begin() as conn: passage = Passage(conn, passage_or_id) graph = db_tools.local_stemma_to_nx(conn, passage.pass_id, user_can_write(current_app)) dot = helpers.nx_to_dot(graph, width, fontsize, nodesep=0.2) return dot
def leitzeile_json(passage_or_id): """Endpoint. Serve the leitzeile for the verse containing passage_or_id. """ auth() with current_app.config.dba.engine.begin() as conn: passage = Passage(conn, passage_or_id) verse_start = (passage.start // 1000) * 1000 verse_end = verse_start + 999 res = execute( conn, """ SELECT l.begadr, l.endadr, l.lemma, ARRAY_AGG (p.pass_id) FROM nestle l LEFT JOIN passages p ON (p.passage @> l.passage) WHERE int4range (:start, :end + 1) @> l.passage GROUP BY l.begadr, l.endadr, l.lemma UNION -- get the insertions SELECT p.begadr, p.endadr, '', ARRAY_AGG (p.pass_id) FROM passages_view p WHERE int4range (:start, :end + 1) @> p.passage AND (begadr % 2) = 1 GROUP BY p.begadr, p.endadr ORDER BY begadr, endadr DESC """, dict(parameters, start=verse_start, end=verse_end)) Leitzeile = collections.namedtuple('Leitzeile', 'begadr, endadr, lemma, pass_ids') leitzeile = [Leitzeile._make(r)._asdict() for r in res] return make_json_response(leitzeile)
def notes_txt(passage_or_id): """Read or write the editor notes for a passage """ private_auth() with current_app.config.dba.engine.begin() as conn: passage = Passage(conn, passage_or_id) if request.method == 'PUT': edit_auth() json = request.get_json() res = execute( conn, """ SET LOCAL ntg.user_id = :user_id; """, dict(parameters, user_id=flask_login.current_user.id)) # check for edit conflicts res = execute( conn, """ SELECT * FROM notes WHERE pass_id = :pass_id AND note != :old_note """, dict(parameters, pass_id=passage.pass_id, old_note=json['original'])) for row in res: return make_json_response( status=409, message='Cannot save. The note was edited by another user.' ) # save res = execute( conn, """ INSERT INTO notes AS n (pass_id, note) VALUES (:pass_id, :note) ON CONFLICT (pass_id) DO UPDATE SET note = :note WHERE n.pass_id = EXCLUDED.pass_id """, dict(parameters, pass_id=passage.pass_id, note=json['note'])) return make_json_response(message='Note saved.') res = execute( conn, """ SELECT note FROM notes WHERE pass_id = :pass_id """, dict(parameters, pass_id=passage.pass_id)) if res.rowcount > 0: return make_text_response(res.fetchone()[0]) return make_text_response('')
def attesting_csv(passage_or_id, labez): """ Serve all relatives of all mss. attesting labez at passage. """ auth() with current_app.config.dba.engine.begin() as conn: passage = Passage(conn, passage_or_id) res = execute( conn, """ SELECT ms_id, hs, hsnr FROM apparatus_view WHERE pass_id = :pass_id AND labez = :labez ORDER BY hsnr """, dict(parameters, pass_id=passage.pass_id, labez=labez)) Attesting = collections.namedtuple('Attesting', 'ms_id hs hsnr') return csvify(Attesting._fields, list(map(Attesting._make, res)))
def apparatus_json(passage_or_id): """ The contents of the apparatus table. """ auth() with current_app.config.dba.engine.begin() as conn: passage = Passage(conn, passage_or_id) # list of labez => lesart res = execute( conn, """ SELECT labez, reading (labez, lesart) FROM readings WHERE pass_id = :pass_id ORDER BY labez """, dict(parameters, pass_id=passage.pass_id)) Readings = collections.namedtuple('Readings', 'labez lesart') readings = [Readings._make(r)._asdict() for r in res] # list of labez_clique => manuscripts res = execute( conn, """ SELECT labez, clique, labez_clique, labezsuf, reading (labez, lesart), ms_id, hs, hsnr, certainty FROM apparatus_view_agg WHERE pass_id = :pass_id ORDER BY hsnr, labez, clique """, dict(parameters, pass_id=passage.pass_id)) Manuscripts = collections.namedtuple( 'Manuscripts', 'labez clique labez_clique labezsuf lesart ms_id hs hsnr certainty' ) manuscripts = [Manuscripts._make(r)._asdict() for r in res] return make_json_response({ 'readings': readings, 'manuscripts': manuscripts, }) return 'Error'
def passage_json (passage_or_id = None): """Endpoint. Serve information about a passage. Return information about a passage or navigate to it. :param string passage_or_id: The passage id. :param string siglum: The siglum of the book to navigate to. :param string chapter: The chapter to navigate to. :param string verse: The verse to navigate to. :param string word: The word (range) to navigate to. :param string button: The button pressed. """ auth () passage_or_id = request.args.get ('pass_id') or passage_or_id or '0' siglum = request.args.get ('siglum') chapter = request.args.get ('chapter') verse = request.args.get ('verse') word = request.args.get ('word') button = request.args.get ('button') with current_app.config.dba.engine.begin () as conn: if siglum and chapter and verse and word and button == 'Go': parsed_passage = Passage.parse ("%s %s:%s/%s" % (siglum, chapter, verse, word)) # log (logging.INFO, parsed_passage) passage = Passage (conn, parsed_passage) return make_json_response (passage.to_json ()) if button in ('-1', '1'): passage = Passage (conn, passage_or_id) passage = Passage (conn, int (passage.pass_id) + int (button)) return make_json_response (passage.to_json ()) passage = Passage (conn, passage_or_id) return make_json_response (passage.to_json ())
def notes_json (): """Endpoint. Get a list of all editor notes.""" if not flask_login.current_user.has_role ('editor'): raise PrivilegeError ('You don\'t have editor privilege.') with current_app.config.dba.engine.begin () as conn: res = execute (conn, """ SELECT pass_id, begadr, endadr, note FROM passages_view JOIN notes USING (pass_id) """, dict (parameters)) Notes = collections.namedtuple ('Notes', 'pass_id, begadr, endadr, note') notes = [] for r in res: note = Notes._make (r)._asdict () note['hr'] = Passage.static_to_hr (note['begadr'], note['endadr']) notes.append (note) return make_json_response (notes)
def attestation_json(passage_or_id): """Answer with a list of the attestations of all manuscripts at one specified passage.""" auth() with current_app.config.dba.engine.begin() as conn: passage = Passage(conn, passage_or_id) res = execute( conn, """ SELECT ms_id, labez FROM apparatus WHERE pass_id = :pass_id ORDER BY ms_id """, dict(parameters, pass_id=passage.pass_id)) attestations = {} for row in res: ms_id, labez = row attestations[str(ms_id)] = labez return make_json_response({'attestations': attestations})
def relatives_csv(passage_or_id, hs_hsnr_id): """Output a table of the nearest relatives of a manuscript. Output a table of the nearest relatives/ancestors/descendants of a manuscript and what they attest. """ auth() type_ = request.args.get('type') or 'rel' limit = int(request.args.get('limit') or 0) labez = request.args.get('labez') or 'all' mode = request.args.get('mode') or 'sim' include = request.args.getlist('include[]') or [] fragments = request.args.getlist('fragments[]') or [] view = 'affinity_view' if mode == 'rec' else 'affinity_p_view' where = '' if type_ == 'anc': where = ' AND older < newer' if type_ == 'des': where = ' AND older >= newer' if labez == 'all': where += " AND labez !~ '^z'" elif labez == 'all+lac': pass else: where += " AND labez = '%s'" % labez if 'fragments' in fragments: frag_where = '' else: frag_where = 'AND aff.common > aff.ms1_length / 2' limit = '' if limit == 0 else ' LIMIT %d' % limit with current_app.config.dba.engine.begin() as conn: passage = Passage(conn, passage_or_id) ms = Manuscript(conn, hs_hsnr_id) rg_id = passage.request_rg_id(request) exclude = get_excluded_ms_ids(conn, include) # Get the X most similar manuscripts and their attestations res = execute( conn, """ /* get the LIMIT closest ancestors for this node */ WITH ranks AS ( SELECT ms_id1, ms_id2, rank () OVER (ORDER BY affinity DESC, common, older, newer DESC, ms_id2) AS rank, affinity FROM {view} aff WHERE ms_id1 = :ms_id1 AND aff.rg_id = :rg_id AND ms_id2 NOT IN :exclude AND newer > older {frag_where} ORDER BY affinity DESC ) SELECT r.rank, aff.ms_id2 as ms_id, ms.hs, ms.hsnr, aff.ms2_length, aff.common, aff.equal, aff.older, aff.newer, aff.unclear, aff.common - aff.equal - aff.older - aff.newer - aff.unclear as norel, CASE WHEN aff.newer < aff.older THEN '' WHEN aff.newer = aff.older THEN '-' ELSE '>' END as direction, aff.affinity, a.labez, a.certainty FROM {view} aff JOIN apparatus_view_agg a ON aff.ms_id2 = a.ms_id JOIN manuscripts ms ON aff.ms_id2 = ms.ms_id LEFT JOIN ranks r ON r.ms_id2 = aff.ms_id2 WHERE aff.ms_id2 NOT IN :exclude AND aff.ms_id1 = :ms_id1 AND aff.rg_id = :rg_id AND aff.common > 0 AND a.pass_id = :pass_id {where} {frag_where} ORDER BY affinity DESC, r.rank, newer DESC, older DESC, hsnr {limit} """, dict(parameters, where=where, frag_where=frag_where, ms_id1=ms.ms_id, hsnr=ms.hsnr, pass_id=passage.pass_id, rg_id=rg_id, limit=limit, view=view, exclude=exclude)) Relatives = collections.namedtuple( 'Relatives', 'rank ms_id hs hsnr length common equal older newer unclear norel direction affinity labez certainty' ) return csvify(Relatives._fields, list(map(Relatives._make, res)))
def manuscript_full_json(passage_or_id, hs_hsnr_id): """Endpoint. Serve information about a manuscript. :param string hs_hsnr_id: The hs, hsnr or id of the manuscript. """ auth() hs_hsnr_id = request.args.get('ms_id') or hs_hsnr_id with current_app.config.dba.engine.begin() as conn: passage = Passage(conn, passage_or_id) ms = Manuscript(conn, hs_hsnr_id) rg_id = passage.request_rg_id(request) if ms.ms_id is None: return cache( make_json_response(None, 400, 'Bad request: No such manuscript.')) json = ms.to_json() json['length'] = ms.get_length(rg_id) # Get the attestation(s) of the manuscript (may be uncertain eg. a/b/c) res = execute( conn, """ SELECT labez, clique, labez_clique, certainty FROM apparatus_view_agg WHERE ms_id = :ms_id AND pass_id = :pass_id """, dict(parameters, ms_id=ms.ms_id, pass_id=passage.pass_id)) row = res.fetchone() if row is not None: json['labez'], json['clique'], json['labez_clique'], json[ 'certainty'] = row # Get the affinity of the manuscript to all manuscripts res = execute( conn, """ SELECT avg (a.affinity) as aa, percentile_cont(0.5) WITHIN GROUP (ORDER BY a.affinity) as ma FROM affinity a WHERE a.ms_id1 = :ms_id1 AND a.rg_id = :rg_id """, dict(parameters, ms_id1=ms.ms_id, rg_id=rg_id)) json['aa'], json['ma'] = 0.0, 0.0 row = res.fetchone() if row is not None: json['aa'], json['ma'] = row # Get the affinity of the manuscript to MT # # For a description of mt and mtp see the comment in # ActsMsListValPh3.pl and # http://intf.uni-muenster.de/cbgm/actsPh3/guide_en.html#Ancestors res = execute( conn, """ SELECT a.affinity as mt, a.equal::float / c.length as mtp FROM affinity a JOIN ms_ranges c ON (a.ms_id1, a.rg_id) = (c.ms_id, c.rg_id) WHERE a.ms_id1 = :ms_id1 AND a.ms_id2 = 2 AND a.rg_id = :rg_id """, dict(parameters, ms_id1=ms.ms_id, rg_id=rg_id)) json['mt'], json['mtp'] = 0.0, 0.0 row = res.fetchone() if row is not None: json['mt'], json['mtp'] = row return cache(make_json_response(json))
def pass_hr (self): return Passage.static_to_hr (self.begadr, self.endadr)
def congruence_list(conn, passage, range_id): """Check the congruence. "Das Prüfprogramm soll eine Inkongruenz anzeigen, wenn der Zeuge einer Lesart x, die im lokalen Stemma von y abhängt UND (bei x keinen pV mit Conn <= 5 hat ODER bei y keinen pV mit höherem Rang hat als ein weiterer pV bei einer anderen Variante), nicht mit x ODER x(n) der Quelle "?" zugeordnet wird." -- email K. Wachtel 16.01.2020 Wenn Lesart x im lokalen Stemma von y != ? abhängt, muß jeder Zeuge der Lesart x: 1. einen pV(conn=5) der Lesart x haben, oder 2. der höchste pV(!= zz) die Lesart y haben. Wenn Lesart x im lokalen Stemma von ? abhängt, ist keine Aussage möglich. """ res = execute( conn, """ -- get the closest ancestors ms1 for every manuscript ms2 WITH ranked AS ( SELECT ms_id1, ms_id2, rank () OVER (PARTITION BY ms_id2 ORDER BY affinity DESC, common, older, newer DESC, ms_id1) AS rank, affinity FROM affinity_p_view aff WHERE ms_id1 NOT IN :exclude AND ms_id2 NOT IN :exclude AND aff.rg_id = :rg_id AND aff.newer < aff.older AND aff.common > aff.ms2_length / 2 ORDER BY ms_id2, affinity DESC ), -- get readings readings AS ( SELECT p.pass_id, p.begadr, p.endadr, r.ms_id1, r.ms_id2, r.rank, q1.labez AS labez1, q2.labez AS labez2, q1.clique AS clique1, q2.clique AS clique2, labez_clique (q1.labez, q1.clique) as lq1, labez_clique (q2.labez, q2.clique) as lq2, l.source_labez as source_l2, l.source_clique as source_q2, labez_clique (l.source_labez, l.source_clique) as source_lq2, row_number () OVER (PARTITION BY p.pass_id, r.ms_id2 ORDER BY r.rank) as row_no, count (*) FILTER (WHERE q1.labez !~ '^z') OVER ( PARTITION BY p.pass_id, r.ms_id2 ORDER BY r.rank ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) as row_no_no_zz FROM passages p JOIN ranges rg ON (rg.rg_id = :range_id AND rg.passage @> p.passage) CROSS JOIN ranked r JOIN apparatus_cliques_view q1 ON q1.ms_id = r.ms_id1 AND q1.pass_id = p.pass_id JOIN apparatus_cliques_view q2 ON q2.ms_id = r.ms_id2 AND q2.pass_id = p.pass_id JOIN locstem l ON (l.pass_id, l.labez, l.clique) = (q2.pass_id, q2.labez, q2.clique) WHERE q1.certainty = 1.0 AND q2.certainty = 1.0 AND r.rank <= 2 * :connectivity -- speed things up ORDER BY pass_id, ms_id2, rank ) -- output mss that fail both rules SELECT r.pass_id, r.begadr, r.endadr, ms1.hs AS hs1, ms2.hs AS hs2, r.ms_id1, r.ms_id2, r.lq1, r.lq2, r.rank FROM readings r JOIN manuscripts ms1 ON ms1.ms_id = r.ms_id1 JOIN manuscripts ms2 ON ms2.ms_id = r.ms_id2 WHERE r.lq2 != lq1 AND r.labez1 !~ '^z' AND r.labez2 !~ '^z' AND r.source_l2 != '?' AND r.row_no = 1 -- ancestor ms1 reads different from descendant ms2 AND -- ms2 fails rule 1 (muß einen pV(conn=5) der Lesart x haben) NOT EXISTS ( SELECT 1 FROM readings c WHERE c.ms_id2 = r.ms_id2 AND c.pass_id = r.pass_id AND c.lq1 = r.lq2 AND c.row_no <= :connectivity ) AND -- ms2 fails rule 2 (der höchste pV(!= zz) muß die Lesart y haben) NOT EXISTS ( SELECT 1 FROM readings c WHERE c.ms_id2 = r.ms_id2 AND c.pass_id = r.pass_id AND c.lq1 = r.source_lq2 AND c.row_no_no_zz = 1 ) ORDER BY pass_id, ms1.hsnr """, dict( rg_id=passage.range_id('All'), range_id=range_id, connectivity=5, exclude=(1, 2), )) Ranks = collections.namedtuple( 'Ranks', 'pass_id begadr endadr ms1 ms2 ms_id1 ms_id2 labez1 labez2 rank') ranks = [] for r in res: rank = Ranks._make(r)._asdict() rank['hr'] = Passage.static_to_hr(rank['begadr'], rank['endadr']) ranks.append(rank) return ranks
def textflow(passage_or_id): """ Output a stemma of manuscripts. """ labez = request.args.get('labez') or '' hyp_a = request.args.get('hyp_a') or 'A' connectivity = int(request.args.get('connectivity') or 10) width = float(request.args.get('width') or 0.0) fontsize = float(request.args.get('fontsize') or 10.0) mode = request.args.get('mode') or 'sim' include = request.args.getlist('include[]') or [] fragments = request.args.getlist('fragments[]') or [] checks = request.args.getlist('checks[]') or [] var_only = request.args.getlist('var_only[]') or [] cliques = request.args.getlist('cliques[]') or [] fragments = 'fragments' in fragments checks = 'checks' in checks var_only = 'var_only' in var_only # Panel: Coherence at Variant Passages (GraphViz) cliques = 'cliques' in cliques # consider or ignore cliques leaf_z = 'Z' in include # show leaf z nodes in global textflow? view = 'affinity_view' if mode == 'rec' else 'affinity_p_view' global_textflow = not ((labez != '') or var_only) rank_z = False # include z nodes in ranking? if global_textflow: connectivity = 1 rank_z = True if connectivity == 21: connectivity = 9999 labez_where = '' frag_where = '' z_where = '' if labez != '': labez_where = 'AND app.cbgm AND app.labez = :labez' if hyp_a != 'A': labez_where = 'AND app.cbgm AND (app.labez = :labez OR (app.ms_id = 1 AND :hyp_a = :labez))' if not fragments: frag_where = 'AND a.common > a.ms1_length / 2' if not rank_z: z_where = "AND app.labez !~ '^z' AND app.certainty = 1.0" group_field = 'labez_clique' if cliques else 'labez' with current_app.config.dba.engine.begin() as conn: passage = Passage(conn, passage_or_id) rg_id = passage.request_rg_id(request) exclude = get_excluded_ms_ids(conn, include) # nodes query # # get all nodes or all nodes (hypothetically) attesting labez res = execute( conn, """ SELECT ms_id FROM apparatus app WHERE pass_id = :pass_id AND ms_id NOT IN :exclude {labez_where} {z_where} """, dict(parameters, exclude=exclude, pass_id=passage.pass_id, labez=labez, hyp_a=hyp_a, labez_where=labez_where, z_where=z_where)) nodes = {row[0] for row in res} if not nodes: nodes = {-1} # avoid SQL syntax error # rank query # # query to get the closest ancestors for every node with rank <= connectivity query = """ SELECT ms_id1, ms_id2, rank FROM ( SELECT ms_id1, ms_id2, rank () OVER (PARTITION BY ms_id1 ORDER BY affinity DESC, common, older, newer DESC, ms_id2) AS rank FROM {view} a WHERE ms_id1 IN :nodes AND a.rg_id = :rg_id AND ms_id2 NOT IN :exclude AND newer > older {frag_where} ) AS r WHERE rank <= :connectivity ORDER BY rank """ res = execute( conn, query, dict(parameters, nodes=tuple(nodes), exclude=exclude, rg_id=rg_id, pass_id=passage.pass_id, view=view, labez=labez, connectivity=connectivity, frag_where=frag_where, hyp_a=hyp_a)) Ranks = collections.namedtuple('Ranks', 'ms_id1 ms_id2 rank') ranks = list(map(Ranks._make, res)) # Initially build an unconnected graph with one node for each # manuscript. We will connect the nodes later. Finally we will remove # unconnected nodes. graph = nx.DiGraph() dest_nodes = {r.ms_id1 for r in ranks} src_nodes = {r.ms_id2 for r in ranks} res = execute( conn, """ SELECT ms.ms_id, ms.hs, ms.hsnr, a.labez, a.clique, a.labez_clique, a.certainty FROM apparatus_view_agg a JOIN manuscripts ms USING (ms_id) WHERE pass_id = :pass_id AND ms_id IN :ms_ids """, dict(parameters, ms_ids=tuple(src_nodes | dest_nodes | nodes), pass_id=passage.pass_id)) Mss = collections.namedtuple( 'Mss', 'ms_id hs hsnr labez clique labez_clique certainty') mss = list(map(Mss._make, res)) for ms in mss: attrs = {} attrs['hs'] = ms.hs attrs['hsnr'] = ms.hsnr attrs[ 'labez'] = ms.labez if ms.certainty == 1.0 else 'zw ' + ms.labez attrs['clique'] = ms.clique attrs[ 'labez_clique'] = ms.labez_clique if ms.certainty == 1.0 else 'zw ' + ms.labez_clique attrs['ms_id'] = ms.ms_id attrs['label'] = ms.hs attrs['certainty'] = ms.certainty attrs['clickable'] = '1' if ms.ms_id == 1 and hyp_a != 'A': attrs['labez'] = hyp_a[0] attrs['clique'] = '' attrs['labez_clique'] = hyp_a[0] # FIXME: attrs['shape'] = SHAPES.get (attrs['labez'], SHAPES['a']) graph.add_node(ms.ms_id, **attrs) # Connect the nodes # # Step 1: If the node has internal parents, keep only the top-ranked # internal parent. # # Step 2: If the node has no internal parents, keep the top-ranked # parents for each external attestation. # # Assumption: ranks are sorted top-ranked first def is_z_node(n): labez = n['labez'] cert = n['certainty'] return (labez[0] == 'z') or (cert < 1.0) tags = set() for step in (1, 2): for r in ranks: a1 = graph.nodes[r.ms_id1] if not r.ms_id2 in graph.nodes: continue a2 = graph.nodes[r.ms_id2] if not (global_textflow) and is_z_node(a2): # disregard zz / zw continue if step == 1 and a1[group_field] != a2[group_field]: # differing attestations are handled in step 2 continue if r.ms_id1 in tags: # an ancestor of this node that lays within the node's # attestation was already seen. we need not look into other # attestations continue if str(r.ms_id1) + a2[group_field] in tags: # an ancestor of this node that lays within this attestation # was already seen. we need not look into further nodes continue # add a new parent if r.rank > 1: graph.add_edge(r.ms_id2, r.ms_id1, rank=r.rank, headlabel=r.rank) else: graph.add_edge(r.ms_id2, r.ms_id1) if a1[group_field] == a2[group_field]: # tag: has ancestor node within the same attestation tags.add(r.ms_id1) else: # tag: has ancestor node with this other attestation tags.add(str(r.ms_id1) + a2[group_field]) if not leaf_z: remove_z_leaves(graph) # the if clause fixes #83 graph.remove_nodes_from([ n for n in nx.isolates(graph) if graph.nodes[n]['labez'] != labez ]) if var_only: # Panel: Coherence at Variant Passages (GraphViz) # # if one predecessor is within the same attestation then remove all # other predecessors that are not within the same attestation for n in graph: within = False attestation_n = graph.nodes[n][group_field] for p in graph.predecessors(n): if graph.nodes[p][group_field] == attestation_n: within = True break if within: for p in graph.predecessors(n): if graph.nodes[p][group_field] != attestation_n: graph.remove_edge(p, n) # remove edges between nodes within the same attestation for u, v in list(graph.edges()): if graph.nodes[u][group_field] == graph.nodes[v][group_field]: graph.remove_edge(u, v) # remove now isolated nodes graph.remove_nodes_from(list(nx.isolates(graph))) # unconstrain backward edges (yields a better GraphViz layout) for u, v in graph.edges(): if graph.nodes[u][group_field] > graph.nodes[v][group_field]: graph.adj[u][v]['constraint'] = 'false' else: for n in graph: # Use a different label if the parent's labez_clique differs from this # node's labez_clique. pred = list(graph.predecessors(n)) attrs = graph.nodes[n] if not pred: attrs['label'] = "%s: %s" % (attrs['labez_clique'], attrs['hs']) for p in pred: if attrs['labez_clique'] != graph.nodes[p]['labez_clique']: attrs['label'] = "%s: %s" % (attrs['labez_clique'], attrs['hs']) graph.adj[p][n]['style'] = 'dashed' if checks: for rank in congruence(conn, passage): try: graph.adj[rank.ms_id1][rank.ms_id2]['style'] = 'bold' except KeyError: pass if var_only: dot = helpers.nx_to_dot_subgraphs(graph, group_field, width, fontsize) else: dot = helpers.nx_to_dot(graph, width, fontsize) return dot
def stemma_edit (passage_or_id): """Edit a local stemma. Called from local-stemma.js (split, merge, move) and textflow.js (move-manuscripts). """ if not flask_login.current_user.has_role ('editor'): raise PrivilegeError ('You don\'t have editor privilege.') args = request.get_json () action = args.get ('action') if action not in ('split', 'merge', 'move', 'move-manuscripts'): raise EditError ('Bad request') params = { 'original_new' : args.get ('labez_new') == '*' } for n in 'labez_old labez_new'.split (): params[n] = args.get (n) if not RE_VALID_LABEZ.match (params[n]): raise EditError ('Bad request') if params[n] in ('*', '?'): params[n] = None for n in 'clique_old clique_new'.split (): params[n] = args.get (n) if not RE_VALID_CLIQUE.match (params[n]): raise EditError ('Bad request') if params[n] == '0': params[n] = None with current_app.config.dba.engine.begin () as conn: passage = Passage (conn, passage_or_id) params['pass_id'] = passage.pass_id params['user_id'] = flask_login.current_user.id res = execute (conn, """ SET LOCAL ntg.user_id = :user_id; """, dict (parameters, **params)) if action == 'move': try: res = execute (conn, """ UPDATE locstem SET source_labez = :labez_new, source_clique = :clique_new, original = :original_new WHERE pass_id = :pass_id AND labez = :labez_old AND clique = :clique_old """, dict (parameters, **params)) except sqlalchemy.exc.IntegrityError as e: if 'unique constraint' in str (e): raise EditError ( '''Only one original reading allowed. If you want to change the original reading, first remove the old original reading.<br/><br/>''' + str (e) ) raise EditError (str (e)) except sqlalchemy.exc.DatabaseError as e: raise EditError (str (e)) # test the still uncommited changes graph = db_tools.local_stemma_to_nx (conn, passage.pass_id) # test: not a DAG if not nx.is_directed_acyclic_graph (graph): raise EditError ('The graph is not a DAG anymore.') # test: not connected graph.add_edge ('*', '?') if not nx.is_weakly_connected (graph): raise EditError ('The graph is not connected anymore.') # test: x derived from x for e in graph.edges: m0 = RE_EXTRACT_LABEZ.match (e[0]) m1 = RE_EXTRACT_LABEZ.match (e[1]) if m0 and m1 and m0.group (1) == m1.group (1): raise EditError ( '''A reading cannot be derived from the same reading. If you want to <b>merge</b> instead, use shift + drag.''' ) elif action == 'split': # get the next free clique res = execute (conn, """ SELECT max (clique) FROM cliques WHERE pass_id = :pass_id AND labez = :labez_old """, dict (parameters, **params)) params['clique_next'] = str (int (res.fetchone ()[0]) + 1) # insert into cliques table res = execute (conn, """ INSERT INTO cliques (pass_id, labez, clique) VALUES (:pass_id, :labez_old, :clique_next) """, dict (parameters, **params)) # insert into locstem table with source = '?' res = execute (conn, """ INSERT INTO locstem (pass_id, labez, clique, source_labez, source_clique, original) VALUES (:pass_id, :labez_old, :clique_next, NULL, NULL, false) """, dict (parameters, **params)) elif action == 'merge': # reassign manuscripts to merged clique res = execute (conn, """ UPDATE ms_cliques SET clique = :clique_new WHERE (pass_id, labez, clique) = (:pass_id, :labez_old, :clique_old) """, dict (parameters, **params)) # reassign sources to merged clique res = execute (conn, """ UPDATE locstem SET source_clique = :clique_new WHERE (pass_id, source_labez, source_clique) = (:pass_id, :labez_old, :clique_old) """, dict (parameters, **params)) # remove clique from locstem res = execute (conn, """ DELETE FROM locstem WHERE (pass_id, labez, clique) = (:pass_id, :labez_old, :clique_old) """, dict (parameters, **params)) # remove clique from cliques res = execute (conn, """ DELETE FROM cliques WHERE (pass_id, labez, clique) = (:pass_id, :labez_old, :clique_old) """, dict (parameters, **params)) elif action == 'move-manuscripts': ms_ids = set (args.get ('ms_ids') or []) # reassign manuscripts to new clique res = execute (conn, """ UPDATE apparatus_cliques_view SET clique = :clique_new WHERE (pass_id, labez, clique) = (:pass_id, :labez_old, :clique_old) AND ms_id IN :ms_ids """, dict (parameters, ms_ids = tuple (ms_ids), **params)) tools.log (logging.INFO, 'Moved ms_ids: ' + str (ms_ids)) # return the changed passage passage = Passage (conn, passage_or_id) return make_json_response (passage.to_json ()) raise EditError ('Could not edit local stemma.')
def stemma_edit(passage_or_id): """Edit a local stemma. Called from local-stemma.js (split, merge, move) and textflow.js (move-manuscripts). """ edit_auth() args = request.get_json() action = args.get('action') if action not in ('add', 'del', 'split', 'merge', 'move', 'move-manuscripts'): raise EditError('Bad request') params = {} for n in 'labez_old labez_new source_labez'.split(): if n in args: params[n] = args.get(n) if not RE_VALID_LABEZ.match(params[n]): raise EditError('Bad request') for n in 'clique_old clique_new source_clique'.split(): if n in args: params[n] = args.get(n) if not RE_VALID_CLIQUE.match(params[n]): raise EditError('Bad request') def integrity_error(e): if 'ix_locstem_unique_original' in str(e): raise EditError( '''Only one original reading allowed. If you want to change the original reading, first remove the old original reading.<br/><br/>''' + str(e)) if 'locstem_pkey' in str(e): raise EditError( '''This readings already dependes on that reading.<br/><br/>''' + str(e)) if 'same_source' in str(e): raise EditError( '''A reading cannot be derived from the same reading. If you want to <b>merge two readings</b>, use shift + drag.''') raise EditError(str(e)) with current_app.config.dba.engine.begin() as conn: passage = Passage(conn, passage_or_id) params['pass_id'] = passage.pass_id params['user_id'] = flask_login.current_user.id res = execute( conn, """ SET LOCAL ntg.user_id = :user_id; """, dict(parameters, **params)) if action == 'move': # reassign a source reading # there may be multiple existent assignments, there'll be only one left try: res = execute( conn, """ DELETE FROM locstem WHERE (pass_id, labez, clique) = (:pass_id, :labez_old, :clique_old); INSERT INTO locstem (pass_id, labez, clique, source_labez, source_clique) VALUES (:pass_id, :labez_old, :clique_old, :labez_new, :clique_new) """, dict(parameters, **params)) except sqlalchemy.exc.IntegrityError as e: integrity_error(e) except sqlalchemy.exc.DatabaseError as e: raise EditError(str(e)) if action == 'del': # remove a source reading try: # check if we are asked to remove the only link, # in that case reassign to 'unknown' res = execute( conn, """ SELECT pass_id FROM locstem WHERE (pass_id, labez, clique) = (:pass_id, :labez_old, :clique_old); """, dict(parameters, **params)) tools.log(logging.INFO, 'Deleting: ' + str(params)) if res.rowcount > 1: res = execute( conn, """ DELETE FROM locstem WHERE (pass_id, labez, clique) = (:pass_id, :labez_old, :clique_old) AND (source_labez, source_clique) = (:source_labez, :source_clique) """, dict(parameters, **params)) else: res = execute( conn, """ UPDATE locstem SET (source_labez, source_clique) = ('?', '1') WHERE (pass_id, labez, clique) = (:pass_id, :labez_old, :clique_old); """, dict(parameters, **params)) except sqlalchemy.exc.IntegrityError as e: integrity_error(e) except sqlalchemy.exc.DatabaseError as e: raise EditError(str(e)) if action == 'add': # add a source reading try: res = execute( conn, """ INSERT INTO locstem (pass_id, labez, clique, source_labez, source_clique) VALUES (:pass_id, :labez_old, :clique_old, :labez_new, :clique_new) """, dict(parameters, **params)) except sqlalchemy.exc.IntegrityError as e: integrity_error(e) except sqlalchemy.exc.DatabaseError as e: raise EditError(str(e)) if action in ('add', 'del', 'move'): # test the still uncommitted changes graph = db_tools.local_stemma_to_nx(conn, passage.pass_id) # test: not a DAG if not nx.is_directed_acyclic_graph(graph): raise EditError('The new graph contains cycles.') # test: not connected graph.add_edge('*', '?') if not nx.is_weakly_connected(graph): raise EditError('The new graph is not connected.') elif action == 'split': # Get the lowest free integer for the new clique. See: #122 res = execute( conn, """ SELECT clique FROM cliques WHERE pass_id = :pass_id AND labez = :labez_old """, dict(parameters, **params)) taken = set([int(r[0]) for r in res]) n = 1 while n in taken: n += 1 params['clique_next'] = str(n) # insert into cliques table res = execute( conn, """ INSERT INTO cliques (pass_id, labez, clique) VALUES (:pass_id, :labez_old, :clique_next) """, dict(parameters, **params)) # insert into locstem table with source = '?' res = execute( conn, """ INSERT INTO locstem (pass_id, labez, clique, source_labez, source_clique) VALUES (:pass_id, :labez_old, :clique_next, '?', '1') """, dict(parameters, **params)) elif action == 'merge': # merge two cliques (eg. b1, b2) into one clique (eg. b1) # # reassign manuscripts to merged clique res = execute( conn, """ UPDATE ms_cliques SET clique = :clique_new WHERE (pass_id, labez, clique) = (:pass_id, :labez_old, :clique_old) """, dict(parameters, **params)) # reassign sources to merged clique res = execute( conn, """ UPDATE locstem SET source_clique = :clique_new WHERE (pass_id, source_labez, source_clique) = (:pass_id, :labez_old, :clique_old) """, dict(parameters, **params)) # remove clique from locstem res = execute( conn, """ DELETE FROM locstem WHERE (pass_id, labez, clique) = (:pass_id, :labez_old, :clique_old) """, dict(parameters, **params)) # remove clique from cliques res = execute( conn, """ DELETE FROM cliques WHERE (pass_id, labez, clique) = (:pass_id, :labez_old, :clique_old) """, dict(parameters, **params)) elif action == 'move-manuscripts': # reassign a set of manuscripts to a new clique ms_ids = set(args.get('ms_ids') or []) res = execute( conn, """ UPDATE apparatus_cliques_view SET clique = :clique_new WHERE (pass_id, labez, clique) = (:pass_id, :labez_old, :clique_old) AND ms_id IN :ms_ids """, dict(parameters, ms_ids=tuple(ms_ids), **params)) tools.log(logging.INFO, 'Moved ms_ids: ' + str(ms_ids)) # return the changed passage passage = Passage(conn, passage_or_id) return make_json_response(passage.to_json()) raise EditError('Could not edit local stemma.')
def congruence_list_json(range_id): """ Endpoint: check the congruence """ with current_app.config.dba.engine.begin() as conn: passage = Passage(conn, 1) return make_json_response(congruence_list(conn, passage, range_id))
def relatives_csv (passage_or_id, hs_hsnr_id): """Output a table of the nearest relatives of a manuscript. Output a table of the nearest relatives/ancestors/descendants of a manuscript and what they attest. """ auth () type_ = request.args.get ('type') or 'rel' chapter = request.args.get ('range') or 'All' limit = int (request.args.get ('limit') or 0) labez = request.args.get ('labez') or 'all' mode = request.args.get ('mode') or 'sim' include = request.args.getlist ('include[]') or [] fragments = request.args.getlist ('fragments[]') or [] view = 'affinity_view' if mode == 'rec' else 'affinity_p_view' where = '' if type_ == 'anc': where = ' AND older < newer' if type_ == 'des': where = ' AND older >= newer' if labez == 'all': where += " AND labez !~ '^z'" elif labez == 'all+lac': pass else: where += " AND labez = '%s'" % labez if 'fragments' in fragments: frag_where = '' else: frag_where = 'AND aff.common > aff.ms1_length / 2' limit = '' if limit == 0 else ' LIMIT %d' % limit with current_app.config.dba.engine.begin () as conn: passage = Passage (conn, passage_or_id) ms = Manuscript (conn, hs_hsnr_id) rg_id = passage.range_id (chapter) exclude = get_excluded_ms_ids (conn, include) # Get the X most similar manuscripts and their attestations res = execute (conn, """ /* get the LIMIT closest ancestors for this node */ WITH ranks AS ( SELECT ms_id1, ms_id2, rank () OVER (ORDER BY affinity DESC, common, older, newer DESC, ms_id2) AS rank, affinity FROM {view} aff WHERE ms_id1 = :ms_id1 AND aff.rg_id = :rg_id AND ms_id2 NOT IN :exclude AND newer > older {frag_where} ORDER BY affinity DESC ) SELECT r.rank, aff.ms_id2 as ms_id, ms.hs, ms.hsnr, aff.ms2_length, aff.common, aff.equal, aff.older, aff.newer, aff.unclear, aff.common - aff.equal - aff.older - aff.newer - aff.unclear as norel, CASE WHEN aff.newer < aff.older THEN '' WHEN aff.newer = aff.older THEN '-' ELSE '>' END as direction, aff.affinity, a.labez FROM {view} aff JOIN apparatus_view_agg a ON aff.ms_id2 = a.ms_id JOIN manuscripts ms ON aff.ms_id2 = ms.ms_id LEFT JOIN ranks r ON r.ms_id2 = aff.ms_id2 WHERE aff.ms_id2 NOT IN :exclude AND aff.ms_id1 = :ms_id1 AND aff.rg_id = :rg_id AND aff.common > 0 AND a.pass_id = :pass_id {where} {frag_where} ORDER BY affinity DESC, r.rank, newer DESC, older DESC, hsnr {limit} """, dict (parameters, where = where, frag_where = frag_where, ms_id1 = ms.ms_id, hsnr = ms.hsnr, pass_id = passage.pass_id, rg_id = rg_id, limit = limit, view = view, exclude = exclude)) Relatives = collections.namedtuple ( 'Relatives', 'rank ms_id hs hsnr length common equal older newer unclear norel direction affinity labez' ) return csvify (Relatives._fields, list (map (Relatives._make, res)))
def passage_json(passage_or_id=None): """Endpoint. Serve information about a passage. Return information about a passage or navigate to it. :param string passage_or_id: The passage id. :param string siglum: The siglum of the book to navigate to. :param string chapter: The chapter to navigate to. :param string verse: The verse to navigate to. :param string word: The word (range) to navigate to. :param string button: The button pressed. """ auth() passage_or_id = request.args.get('pass_id') or passage_or_id or '0' siglum = request.args.get('siglum') chapter = request.args.get('chapter') verse = request.args.get('verse') word = request.args.get('word') button = request.args.get('button') with current_app.config.dba.engine.begin() as conn: if siglum and chapter and verse and word and button == 'Go': parsed_passage = Passage.parse("%s %s:%s/%s" % (siglum, chapter, verse, word)) passage = Passage(conn, parsed_passage) return make_json_response(passage.to_json()) if button in ('-1', '1'): passage = Passage(conn, passage_or_id) passage = Passage(conn, int(passage.pass_id) + int(button)) return make_json_response(passage.to_json()) passage = Passage(conn, passage_or_id) return cache(make_json_response(passage.to_json()))
def pass_hr (self): """ Add a field with a human-readable passage id. """ return Passage.static_to_hr (self.begadr, self.endadr)
def pass_hr(self): """ Add a field with a human-readable passage id. """ return Passage.static_to_hr(self.begadr, self.endadr)