Split classes.py into separate modules.
authorchangeling <cklarson@gmail.com>
Wed, 9 Feb 2022 08:28:58 +0000 (02:28 -0600)
committerchangeling <cklarson@gmail.com>
Wed, 9 Feb 2022 08:28:58 +0000 (02:28 -0600)
Create gui.py, session.py, tree.py, gedcom.py.
Update version to 1.0.2-rc.2.
Add classes directory to setup.cfg.

13 files changed:
getmyancestors/__init__.py
getmyancestors/classes/__init__.py
getmyancestors/classes/classes.py [deleted file]
getmyancestors/classes/constants.py [moved from getmyancestors/constants.py with 100% similarity]
getmyancestors/classes/gedcom.py [new file with mode: 0644]
getmyancestors/classes/gui.py [new file with mode: 0644]
getmyancestors/classes/session.py [new file with mode: 0644]
getmyancestors/classes/translation.py [moved from getmyancestors/translation.py with 100% similarity]
getmyancestors/classes/tree.py [new file with mode: 0644]
getmyancestors/fstogedcom.py
getmyancestors/getmyancestors.py
getmyancestors/mergemyancestors.py
setup.cfg

index b196a544cd0037511bace1aa70a88a078947117e..8a4f5067ba872681cddbbc381dc86e0f3e118fed 100644 (file)
@@ -3,4 +3,4 @@
 from . import getmyancestors
 from . import mergemyancestors
 
-__version__ = "1.0.2-rc.1"
+__version__ = "1.0.2-rc.2"
index 3117685a03dee0f98ffd5c6562492c6e7d30fccc..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 100644 (file)
@@ -1,2 +0,0 @@
-# coding: utf-8
-
diff --git a/getmyancestors/classes/classes.py b/getmyancestors/classes/classes.py
deleted file mode 100644 (file)
index cdb7d48..0000000
+++ /dev/null
@@ -1,1993 +0,0 @@
-import sys
-import os
-import re
-import time
-import tempfile
-import asyncio
-from urllib.parse import unquote
-
-# global imports
-from tkinter import (
-    Tk,
-    StringVar,
-    IntVar,
-    filedialog,
-    messagebox,
-    Menu,
-    TclError,
-    PhotoImage,
-)
-from tkinter.ttk import Frame, Label, Entry, Button, Checkbutton, Treeview, Notebook
-
-from threading import Thread
-from diskcache import Cache
-import requests
-import babelfish
-
-# local imports
-import getmyancestors
-from getmyancestors.translation import translations
-from getmyancestors.constants import (
-    MAX_PERSONS,
-    FACT_EVEN,
-    FACT_TAGS,
-    FACT_TYPES,
-    ORDINANCES,
-    ORDINANCES_STATUS,
-)
-
-tmp_dir = os.path.join(tempfile.gettempdir(), "fstogedcom")
-cache = Cache(tmp_dir)
-lang = cache.get("lang")
-
-# getmyancestors classes and functions
-def cont(string):
-    """parse a GEDCOM line adding CONT and CONT tags if necessary"""
-    level = int(string[:1]) + 1
-    lines = string.splitlines()
-    res = list()
-    max_len = 255
-    for line in lines:
-        c_line = line
-        to_conc = list()
-        while len(c_line.encode("utf-8")) > max_len:
-            index = min(max_len, len(c_line) - 2)
-            while (
-                len(c_line[:index].encode("utf-8")) > max_len
-                or re.search(r"[ \t\v]", c_line[index - 1 : index + 1])
-            ) and index > 1:
-                index -= 1
-            to_conc.append(c_line[:index])
-            c_line = c_line[index:]
-            max_len = 248
-        to_conc.append(c_line)
-        res.append(("\n%s CONC " % level).join(to_conc))
-        max_len = 248
-    return ("\n%s CONT " % level).join(res) + "\n"
-
-
-class Session:
-    """Create a FamilySearch session
-    :param username and password: valid FamilySearch credentials
-    :param verbose: True to active verbose mode
-    :param logfile: a file object or similar
-    :param timeout: time before retry a request
-    """
-
-    def __init__(self, username, password, verbose=False, logfile=False, timeout=60):
-        self.username = username
-        self.password = password
-        self.verbose = verbose
-        self.logfile = logfile
-        self.timeout = timeout
-        self.fid = self.lang = self.display_name = None
-        self.counter = 0
-        self.logged = self.login()
-
-    def write_log(self, text):
-        """write text in the log file"""
-        log = "[%s]: %s\n" % (time.strftime("%Y-%m-%d %H:%M:%S"), text)
-        if self.verbose:
-            sys.stderr.write(log)
-        if self.logfile:
-            self.logfile.write(log)
-
-    def login(self):
-        """retrieve FamilySearch session ID
-        (https://familysearch.org/developers/docs/guides/oauth2)
-        """
-        while True:
-            try:
-                url = "https://www.familysearch.org/auth/familysearch/login"
-                self.write_log("Downloading: " + url)
-                r = requests.get(url, params={"ldsauth": False}, allow_redirects=False)
-                url = r.headers["Location"]
-                self.write_log("Downloading: " + url)
-                r = requests.get(url, allow_redirects=False)
-                idx = r.text.index('name="params" value="')
-                span = r.text[idx + 21 :].index('"')
-                params = r.text[idx + 21 : idx + 21 + span]
-
-                url = "https://ident.familysearch.org/cis-web/oauth2/v3/authorization"
-                self.write_log("Downloading: " + url)
-                r = requests.post(
-                    url,
-                    data={
-                        "params": params,
-                        "userName": self.username,
-                        "password": self.password,
-                    },
-                    allow_redirects=False,
-                )
-
-                if "The username or password was incorrect" in r.text:
-                    self.write_log("The username or password was incorrect")
-                    return False
-
-                if "Invalid Oauth2 Request" in r.text:
-                    self.write_log("Invalid Oauth2 Request")
-                    time.sleep(self.timeout)
-                    continue
-
-                url = r.headers["Location"]
-                self.write_log("Downloading: " + url)
-                r = requests.get(url, allow_redirects=False)
-                self.fssessionid = r.cookies["fssessionid"]
-            except requests.exceptions.ReadTimeout:
-                self.write_log("Read timed out")
-                continue
-            except requests.exceptions.ConnectionError:
-                self.write_log("Connection aborted")
-                time.sleep(self.timeout)
-                continue
-            except requests.exceptions.HTTPError:
-                self.write_log("HTTPError")
-                time.sleep(self.timeout)
-                continue
-            except KeyError:
-                self.write_log("KeyError")
-                time.sleep(self.timeout)
-                continue
-            except ValueError:
-                self.write_log("ValueError")
-                time.sleep(self.timeout)
-                continue
-            self.write_log("FamilySearch session id: " + self.fssessionid)
-            self.set_current()
-            return True
-
-    def get_url(self, url, headers=None):
-        """retrieve JSON structure from a FamilySearch URL"""
-        self.counter += 1
-        if headers is None:
-            headers = {"Accept": "application/x-gedcomx-v1+json"}
-        while True:
-            try:
-                self.write_log("Downloading: " + url)
-                r = requests.get(
-                    "https://familysearch.org" + url,
-                    cookies={"fssessionid": self.fssessionid},
-                    timeout=self.timeout,
-                    headers=headers,
-                )
-            except requests.exceptions.ReadTimeout:
-                self.write_log("Read timed out")
-                continue
-            except requests.exceptions.ConnectionError:
-                self.write_log("Connection aborted")
-                time.sleep(self.timeout)
-                continue
-            self.write_log("Status code: %s" % r.status_code)
-            if r.status_code == 204:
-                return None
-            if r.status_code in {404, 405, 410, 500}:
-                self.write_log("WARNING: " + url)
-                return None
-            if r.status_code == 401:
-                self.login()
-                continue
-            try:
-                r.raise_for_status()
-            except requests.exceptions.HTTPError:
-                self.write_log("HTTPError")
-                if r.status_code == 403:
-                    if (
-                        "message" in r.json()["errors"][0]
-                        and r.json()["errors"][0]["message"]
-                        == "Unable to get ordinances."
-                    ):
-                        self.write_log(
-                            "Unable to get ordinances. "
-                            "Try with an LDS account or without option -c."
-                        )
-                        return "error"
-                    self.write_log(
-                        "WARNING: code 403 from %s %s"
-                        % (url, r.json()["errors"][0]["message"] or "")
-                    )
-                    return None
-                time.sleep(self.timeout)
-                continue
-            try:
-                return r.json()
-            except Exception as e:
-                self.write_log("WARNING: corrupted file from %s, error: %s" % (url, e))
-                return None
-
-    def set_current(self):
-        """retrieve FamilySearch current user ID, name and language"""
-        url = "/platform/users/current"
-        data = self.get_url(url)
-        if data:
-            self.fid = data["users"][0]["personId"]
-            self.lang = data["users"][0]["preferredLanguage"]
-            self.display_name = data["users"][0]["displayName"]
-
-    def _(self, string):
-        """translate a string into user's language
-        TODO replace translation file for gettext format
-        """
-        if string in translations and self.lang in translations[string]:
-            return translations[string][self.lang]
-        return string
-
-
-class Note:
-    """GEDCOM Note class
-    :param text: the Note content
-    :param tree: a Tree object
-    :param num: the GEDCOM identifier
-    """
-
-    counter = 0
-
-    def __init__(self, text="", tree=None, num=None):
-        if num:
-            self.num = num
-        else:
-            Note.counter += 1
-            self.num = Note.counter
-        self.text = text.strip()
-
-        if tree:
-            tree.notes.append(self)
-
-    def print(self, file=sys.stdout):
-        """print Note in GEDCOM format"""
-        file.write(cont("0 @N%s@ NOTE %s" % (self.num, self.text)))
-
-    def link(self, file=sys.stdout, level=1):
-        """print the reference in GEDCOM format"""
-        file.write("%s NOTE @N%s@\n" % (level, self.num))
-
-
-class Source:
-    """GEDCOM Source class
-    :param data: FS Source data
-    :param tree: a Tree object
-    :param num: the GEDCOM identifier
-    """
-
-    counter = 0
-
-    def __init__(self, data=None, tree=None, num=None):
-        if num:
-            self.num = num
-        else:
-            Source.counter += 1
-            self.num = Source.counter
-
-        self.tree = tree
-        self.url = self.citation = self.title = self.fid = None
-        self.notes = set()
-        if data:
-            self.fid = data["id"]
-            if "about" in data:
-                self.url = data["about"].replace(
-                    "familysearch.org/platform/memories/memories",
-                    "www.familysearch.org/photos/artifacts",
-                )
-            if "citations" in data:
-                self.citation = data["citations"][0]["value"]
-            if "titles" in data:
-                self.title = data["titles"][0]["value"]
-            if "notes" in data:
-                for n in data["notes"]:
-                    if n["text"]:
-                        self.notes.add(Note(n["text"], self.tree))
-
-    def print(self, file=sys.stdout):
-        """print Source in GEDCOM format"""
-        file.write("0 @S%s@ SOUR \n" % self.num)
-        if self.title:
-            file.write(cont("1 TITL " + self.title))
-        if self.citation:
-            file.write(cont("1 AUTH " + self.citation))
-        if self.url:
-            file.write(cont("1 PUBL " + self.url))
-        for n in self.notes:
-            n.link(file, 1)
-        file.write("1 REFN %s\n" % self.fid)
-
-    def link(self, file=sys.stdout, level=1):
-        """print the reference in GEDCOM format"""
-        file.write("%s SOUR @S%s@\n" % (level, self.num))
-
-
-class Fact:
-    """GEDCOM Fact class
-    :param data: FS Fact data
-    :param tree: a tree object
-    """
-
-    def __init__(self, data=None, tree=None):
-        self.value = self.type = self.date = self.place = self.note = self.map = None
-        if data:
-            if "value" in data:
-                self.value = data["value"]
-            if "type" in data:
-                self.type = data["type"]
-                if self.type in FACT_EVEN:
-                    self.type = tree.fs._(FACT_EVEN[self.type])
-                elif self.type[:6] == "data:,":
-                    self.type = unquote(self.type[6:])
-                elif self.type not in FACT_TAGS:
-                    self.type = None
-            if "date" in data:
-                self.date = data["date"]["original"]
-            if "place" in data:
-                place = data["place"]
-                self.place = place["original"]
-                if "description" in place and place["description"][1:] in tree.places:
-                    self.map = tree.places[place["description"][1:]]
-            if "changeMessage" in data["attribution"]:
-                self.note = Note(data["attribution"]["changeMessage"], tree)
-            if self.type == "http://gedcomx.org/Death" and not (
-                self.date or self.place
-            ):
-                self.value = "Y"
-
-    def print(self, file=sys.stdout):
-        """print Fact in GEDCOM format
-        the GEDCOM TAG depends on the type, defined in FACT_TAGS
-        """
-        if self.type in FACT_TAGS:
-            tmp = "1 " + FACT_TAGS[self.type]
-            if self.value:
-                tmp += " " + self.value
-            file.write(cont(tmp))
-        elif self.type:
-            file.write("1 EVEN\n2 TYPE %s\n" % self.type)
-            if self.value:
-                file.write(cont("2 NOTE Description: " + self.value))
-        else:
-            return
-        if self.date:
-            file.write(cont("2 DATE " + self.date))
-        if self.place:
-            file.write(cont("2 PLAC " + self.place))
-        if self.map:
-            latitude, longitude = self.map
-            file.write("3 MAP\n4 LATI %s\n4 LONG %s\n" % (latitude, longitude))
-        if self.note:
-            self.note.link(file, 2)
-
-
-class Memorie:
-    """GEDCOM Memorie class
-    :param data: FS Memorie data
-    """
-
-    def __init__(self, data=None):
-        self.description = self.url = None
-        if data and "links" in data:
-            self.url = data["about"]
-            if "titles" in data:
-                self.description = data["titles"][0]["value"]
-            if "descriptions" in data:
-                self.description = (
-                    "" if not self.description else self.description + "\n"
-                ) + data["descriptions"][0]["value"]
-
-    def print(self, file=sys.stdout):
-        """print Memorie in GEDCOM format"""
-        file.write("1 OBJE\n2 FORM URL\n")
-        if self.description:
-            file.write(cont("2 TITL " + self.description))
-        if self.url:
-            file.write(cont("2 FILE " + self.url))
-
-
-class Name:
-    """GEDCOM Name class
-    :param data: FS Name data
-    :param tree: a Tree object
-    """
-
-    def __init__(self, data=None, tree=None):
-        self.given = ""
-        self.surname = ""
-        self.prefix = None
-        self.suffix = None
-        self.note = None
-        if data:
-            if "parts" in data["nameForms"][0]:
-                for z in data["nameForms"][0]["parts"]:
-                    if z["type"] == "http://gedcomx.org/Given":
-                        self.given = z["value"]
-                    if z["type"] == "http://gedcomx.org/Surname":
-                        self.surname = z["value"]
-                    if z["type"] == "http://gedcomx.org/Prefix":
-                        self.prefix = z["value"]
-                    if z["type"] == "http://gedcomx.org/Suffix":
-                        self.suffix = z["value"]
-            if "changeMessage" in data["attribution"]:
-                self.note = Note(data["attribution"]["changeMessage"], tree)
-
-    def print(self, file=sys.stdout, typ=None):
-        """print Name in GEDCOM format
-        :param typ: type for additional names
-        """
-        tmp = "1 NAME %s /%s/" % (self.given, self.surname)
-        if self.suffix:
-            tmp += " " + self.suffix
-        file.write(cont(tmp))
-        if typ:
-            file.write("2 TYPE %s\n" % typ)
-        if self.prefix:
-            file.write("2 NPFX %s\n" % self.prefix)
-        if self.note:
-            self.note.link(file, 2)
-
-
-class Ordinance:
-    """GEDCOM Ordinance class
-    :param data: FS Ordinance data
-    """
-
-    def __init__(self, data=None):
-        self.date = self.temple_code = self.status = self.famc = None
-        if data:
-            if "completedDate" in data:
-                self.date = data["completedDate"]
-            if "completedTemple" in data:
-                self.temple_code = data["completedTemple"]["code"]
-            self.status = data["status"]
-
-    def print(self, file=sys.stdout):
-        """print Ordinance in Gecom format"""
-        if self.date:
-            file.write(cont("2 DATE " + self.date))
-        if self.temple_code:
-            file.write("2 TEMP %s\n" % self.temple_code)
-        if self.status in ORDINANCES_STATUS:
-            file.write("2 STAT %s\n" % ORDINANCES_STATUS[self.status])
-        if self.famc:
-            file.write("2 FAMC @F%s@\n" % self.famc.num)
-
-
-class Indi:
-    """GEDCOM individual class
-    :param fid' FamilySearch id
-    :param tree: a tree object
-    :param num: the GEDCOM identifier
-    """
-
-    counter = 0
-
-    def __init__(self, fid=None, tree=None, num=None):
-        if num:
-            self.num = num
-        else:
-            Indi.counter += 1
-            self.num = Indi.counter
-        self.fid = fid
-        self.tree = tree
-        self.famc_fid = set()
-        self.fams_fid = set()
-        self.famc_num = set()
-        self.fams_num = set()
-        self.name = None
-        self.gender = None
-        self.living = None
-        self.parents = set()
-        self.spouses = set()
-        self.children = set()
-        self.baptism = self.confirmation = self.initiatory = None
-        self.endowment = self.sealing_child = None
-        self.nicknames = set()
-        self.facts = set()
-        self.birthnames = set()
-        self.married = set()
-        self.aka = set()
-        self.notes = set()
-        self.sources = set()
-        self.memories = set()
-
-    def add_data(self, data):
-        """add FS individual data"""
-        if data:
-            self.living = data["living"]
-            for x in data["names"]:
-                if x["preferred"]:
-                    self.name = Name(x, self.tree)
-                else:
-                    if x["type"] == "http://gedcomx.org/Nickname":
-                        self.nicknames.add(Name(x, self.tree))
-                    if x["type"] == "http://gedcomx.org/BirthName":
-                        self.birthnames.add(Name(x, self.tree))
-                    if x["type"] == "http://gedcomx.org/AlsoKnownAs":
-                        self.aka.add(Name(x, self.tree))
-                    if x["type"] == "http://gedcomx.org/MarriedName":
-                        self.married.add(Name(x, self.tree))
-            if "gender" in data:
-                if data["gender"]["type"] == "http://gedcomx.org/Male":
-                    self.gender = "M"
-                elif data["gender"]["type"] == "http://gedcomx.org/Female":
-                    self.gender = "F"
-                elif data["gender"]["type"] == "http://gedcomx.org/Unknown":
-                    self.gender = "U"
-            if "facts" in data:
-                for x in data["facts"]:
-                    if x["type"] == "http://familysearch.org/v1/LifeSketch":
-                        self.notes.add(
-                            Note(
-                                "=== %s ===\n%s"
-                                % (self.tree.fs._("Life Sketch"), x.get("value", "")),
-                                self.tree,
-                            )
-                        )
-                    else:
-                        self.facts.add(Fact(x, self.tree))
-            if "sources" in data:
-                sources = self.tree.fs.get_url(
-                    "/platform/tree/persons/%s/sources" % self.fid
-                )
-                if sources:
-                    quotes = dict()
-                    for quote in sources["persons"][0]["sources"]:
-                        quotes[quote["descriptionId"]] = (
-                            quote["attribution"]["changeMessage"]
-                            if "changeMessage" in quote["attribution"]
-                            else None
-                        )
-                    for source in sources["sourceDescriptions"]:
-                        if source["id"] not in self.tree.sources:
-                            self.tree.sources[source["id"]] = Source(source, self.tree)
-                        self.sources.add(
-                            (self.tree.sources[source["id"]], quotes[source["id"]])
-                        )
-            if "evidence" in data:
-                url = "/platform/tree/persons/%s/memories" % self.fid
-                memorie = self.tree.fs.get_url(url)
-                if memorie and "sourceDescriptions" in memorie:
-                    for x in memorie["sourceDescriptions"]:
-                        if x["mediaType"] == "text/plain":
-                            text = "\n".join(
-                                val.get("value", "")
-                                for val in x.get("titles", [])
-                                + x.get("descriptions", [])
-                            )
-                            self.notes.add(Note(text, self.tree))
-                        else:
-                            self.memories.add(Memorie(x))
-
-    def add_fams(self, fams):
-        """add family fid (for spouse or parent)"""
-        self.fams_fid.add(fams)
-
-    def add_famc(self, famc):
-        """add family fid (for child)"""
-        self.famc_fid.add(famc)
-
-    def get_notes(self):
-        """retrieve individual notes"""
-        notes = self.tree.fs.get_url("/platform/tree/persons/%s/notes" % self.fid)
-        if notes:
-            for n in notes["persons"][0]["notes"]:
-                text_note = "=== %s ===\n" % n["subject"] if "subject" in n else ""
-                text_note += n["text"] + "\n" if "text" in n else ""
-                self.notes.add(Note(text_note, self.tree))
-
-    def get_ordinances(self):
-        """retrieve LDS ordinances
-        need a LDS account
-        """
-        res = []
-        famc = False
-        if self.living:
-            return res, famc
-        url = "/service/tree/tree-data/reservations/person/%s/ordinances" % self.fid
-        data = self.tree.fs.get_url(url, {})
-        if data:
-            for key, o in data["data"].items():
-                if key == "baptism":
-                    self.baptism = Ordinance(o)
-                elif key == "confirmation":
-                    self.confirmation = Ordinance(o)
-                elif key == "initiatory":
-                    self.initiatory = Ordinance(o)
-                elif key == "endowment":
-                    self.endowment = Ordinance(o)
-                elif key == "sealingsToParents":
-                    for subo in o:
-                        self.sealing_child = Ordinance(subo)
-                        relationships = subo.get("relationships", {})
-                        father = relationships.get("parent1Id")
-                        mother = relationships.get("parent2Id")
-                        if father and mother:
-                            famc = father, mother
-                elif key == "sealingsToSpouses":
-                    res += o
-        return res, famc
-
-    def get_contributors(self):
-        """retrieve contributors"""
-        temp = set()
-        url = "/platform/tree/persons/%s/changes" % self.fid
-        data = self.tree.fs.get_url(url, {"Accept": "application/x-gedcomx-atom+json"})
-        if data:
-            for entries in data["entries"]:
-                for contributors in entries["contributors"]:
-                    temp.add(contributors["name"])
-        if temp:
-            text = "=== %s ===\n%s" % (
-                self.tree.fs._("Contributors"),
-                "\n".join(sorted(temp)),
-            )
-            for n in self.tree.notes:
-                if n.text == text:
-                    self.notes.add(n)
-                    return
-            self.notes.add(Note(text, self.tree))
-
-    def print(self, file=sys.stdout):
-        """print individual in GEDCOM format"""
-        file.write("0 @I%s@ INDI\n" % self.num)
-        if self.name:
-            self.name.print(file)
-        for o in self.nicknames:
-            file.write(cont("2 NICK %s %s" % (o.given, o.surname)))
-        for o in self.birthnames:
-            o.print(file)
-        for o in self.aka:
-            o.print(file, "aka")
-        for o in self.married:
-            o.print(file, "married")
-        if self.gender:
-            file.write("1 SEX %s\n" % self.gender)
-        for o in self.facts:
-            o.print(file)
-        for o in self.memories:
-            o.print(file)
-        if self.baptism:
-            file.write("1 BAPL\n")
-            self.baptism.print(file)
-        if self.confirmation:
-            file.write("1 CONL\n")
-            self.confirmation.print(file)
-        if self.initiatory:
-            file.write("1 WAC\n")
-            self.initiatory.print(file)
-        if self.endowment:
-            file.write("1 ENDL\n")
-            self.endowment.print(file)
-        if self.sealing_child:
-            file.write("1 SLGC\n")
-            self.sealing_child.print(file)
-        for num in self.fams_num:
-            file.write("1 FAMS @F%s@\n" % num)
-        for num in self.famc_num:
-            file.write("1 FAMC @F%s@\n" % num)
-        file.write("1 _FSFTID %s\n" % self.fid)
-        for o in self.notes:
-            o.link(file)
-        for source, quote in self.sources:
-            source.link(file, 1)
-            if quote:
-                file.write(cont("2 PAGE " + quote))
-
-
-class Fam:
-    """GEDCOM family class
-    :param husb: husbant fid
-    :param wife: wife fid
-    :param tree: a Tree object
-    :param num: a GEDCOM identifier
-    """
-
-    counter = 0
-
-    def __init__(self, husb=None, wife=None, tree=None, num=None):
-        if num:
-            self.num = num
-        else:
-            Fam.counter += 1
-            self.num = Fam.counter
-        self.husb_fid = husb if husb else None
-        self.wife_fid = wife if wife else None
-        self.tree = tree
-        self.husb_num = self.wife_num = self.fid = None
-        self.facts = set()
-        self.sealing_spouse = None
-        self.chil_fid = set()
-        self.chil_num = set()
-        self.notes = set()
-        self.sources = set()
-
-    def add_child(self, child):
-        """add a child fid to the family"""
-        if child not in self.chil_fid:
-            self.chil_fid.add(child)
-
-    def add_marriage(self, fid):
-        """retrieve and add marriage information
-        :param fid: the marriage fid
-        """
-        if not self.fid:
-            self.fid = fid
-            url = "/platform/tree/couple-relationships/%s" % self.fid
-            data = self.tree.fs.get_url(url)
-            if data:
-                if "facts" in data["relationships"][0]:
-                    for x in data["relationships"][0]["facts"]:
-                        self.facts.add(Fact(x, self.tree))
-                if "sources" in data["relationships"][0]:
-                    quotes = dict()
-                    for x in data["relationships"][0]["sources"]:
-                        quotes[x["descriptionId"]] = (
-                            x["attribution"]["changeMessage"]
-                            if "changeMessage" in x["attribution"]
-                            else None
-                        )
-                    new_sources = quotes.keys() - self.tree.sources.keys()
-                    if new_sources:
-                        sources = self.tree.fs.get_url(
-                            "/platform/tree/couple-relationships/%s/sources" % self.fid
-                        )
-                        for source in sources["sourceDescriptions"]:
-                            if (
-                                source["id"] in new_sources
-                                and source["id"] not in self.tree.sources
-                            ):
-                                self.tree.sources[source["id"]] = Source(
-                                    source, self.tree
-                                )
-                    for source_fid in quotes:
-                        self.sources.add(
-                            (self.tree.sources[source_fid], quotes[source_fid])
-                        )
-
-    def get_notes(self):
-        """retrieve marriage notes"""
-        if self.fid:
-            notes = self.tree.fs.get_url(
-                "/platform/tree/couple-relationships/%s/notes" % self.fid
-            )
-            if notes:
-                for n in notes["relationships"][0]["notes"]:
-                    text_note = "=== %s ===\n" % n["subject"] if "subject" in n else ""
-                    text_note += n["text"] + "\n" if "text" in n else ""
-                    self.notes.add(Note(text_note, self.tree))
-
-    def get_contributors(self):
-        """retrieve contributors"""
-        if self.fid:
-            temp = set()
-            url = "/platform/tree/couple-relationships/%s/changes" % self.fid
-            data = self.tree.fs.get_url(
-                url, {"Accept": "application/x-gedcomx-atom+json"}
-            )
-            if data:
-                for entries in data["entries"]:
-                    for contributors in entries["contributors"]:
-                        temp.add(contributors["name"])
-            if temp:
-                text = "=== %s ===\n%s" % (
-                    self.tree.fs._("Contributors"),
-                    "\n".join(sorted(temp)),
-                )
-                for n in self.tree.notes:
-                    if n.text == text:
-                        self.notes.add(n)
-                        return
-                self.notes.add(Note(text, self.tree))
-
-    def print(self, file=sys.stdout):
-        """print family information in GEDCOM format"""
-        file.write("0 @F%s@ FAM\n" % self.num)
-        if self.husb_num:
-            file.write("1 HUSB @I%s@\n" % self.husb_num)
-        if self.wife_num:
-            file.write("1 WIFE @I%s@\n" % self.wife_num)
-        for num in self.chil_num:
-            file.write("1 CHIL @I%s@\n" % num)
-        for o in self.facts:
-            o.print(file)
-        if self.sealing_spouse:
-            file.write("1 SLGS\n")
-            self.sealing_spouse.print(file)
-        if self.fid:
-            file.write("1 _FSFTID %s\n" % self.fid)
-        for o in self.notes:
-            o.link(file)
-        for source, quote in self.sources:
-            source.link(file, 1)
-            if quote:
-                file.write(cont("2 PAGE " + quote))
-
-
-class Tree:
-    """family tree class
-    :param fs: a Session object
-    """
-
-    def __init__(self, fs=None):
-        self.fs = fs
-        self.indi = dict()
-        self.fam = dict()
-        self.notes = list()
-        self.sources = dict()
-        self.places = dict()
-        self.display_name = self.lang = None
-        if fs:
-            self.display_name = fs.display_name
-            self.lang = babelfish.Language.fromalpha2(fs.lang).name
-
-    def add_indis(self, fids):
-        """add individuals to the family tree
-        :param fids: an iterable of fid
-        """
-
-        async def add_datas(loop, data):
-            futures = set()
-            for person in data["persons"]:
-                self.indi[person["id"]] = Indi(person["id"], self)
-                futures.add(
-                    loop.run_in_executor(None, self.indi[person["id"]].add_data, person)
-                )
-            for future in futures:
-                await future
-
-        new_fids = [fid for fid in fids if fid and fid not in self.indi]
-        loop = asyncio.new_event_loop()
-        asyncio.set_event_loop(loop)
-        while new_fids:
-            data = self.fs.get_url(
-                "/platform/tree/persons?pids=" + ",".join(new_fids[:MAX_PERSONS])
-            )
-            if data:
-                if "places" in data:
-                    for place in data["places"]:
-                        if place["id"] not in self.places:
-                            self.places[place["id"]] = (
-                                str(place["latitude"]),
-                                str(place["longitude"]),
-                            )
-                loop.run_until_complete(add_datas(loop, data))
-                if "childAndParentsRelationships" in data:
-                    for rel in data["childAndParentsRelationships"]:
-                        father = (
-                            rel["parent1"]["resourceId"] if "parent1" in rel else None
-                        )
-                        mother = (
-                            rel["parent2"]["resourceId"] if "parent2" in rel else None
-                        )
-                        child = rel["child"]["resourceId"] if "child" in rel else None
-                        if child in self.indi:
-                            self.indi[child].parents.add((father, mother))
-                        if father in self.indi:
-                            self.indi[father].children.add((father, mother, child))
-                        if mother in self.indi:
-                            self.indi[mother].children.add((father, mother, child))
-                if "relationships" in data:
-                    for rel in data["relationships"]:
-                        if rel["type"] == "http://gedcomx.org/Couple":
-                            person1 = rel["person1"]["resourceId"]
-                            person2 = rel["person2"]["resourceId"]
-                            relfid = rel["id"]
-                            if person1 in self.indi:
-                                self.indi[person1].spouses.add(
-                                    (person1, person2, relfid)
-                                )
-                            if person2 in self.indi:
-                                self.indi[person2].spouses.add(
-                                    (person1, person2, relfid)
-                                )
-            new_fids = new_fids[MAX_PERSONS:]
-
-    def add_fam(self, father, mother):
-        """add a family to the family tree
-        :param father: the father fid or None
-        :param mother: the mother fid or None
-        """
-        if (father, mother) not in self.fam:
-            self.fam[(father, mother)] = Fam(father, mother, self)
-
-    def add_trio(self, father, mother, child):
-        """add a children relationship to the family tree
-        :param father: the father fid or None
-        :param mother: the mother fid or None
-        :param child: the child fid or None
-        """
-        if father in self.indi:
-            self.indi[father].add_fams((father, mother))
-        if mother in self.indi:
-            self.indi[mother].add_fams((father, mother))
-        if child in self.indi and (father in self.indi or mother in self.indi):
-            self.indi[child].add_famc((father, mother))
-            self.add_fam(father, mother)
-            self.fam[(father, mother)].add_child(child)
-
-    def add_parents(self, fids):
-        """add parents relationships
-        :param fids: a set of fids
-        """
-        parents = set()
-        for fid in fids & self.indi.keys():
-            for couple in self.indi[fid].parents:
-                parents |= set(couple)
-        if parents:
-            self.add_indis(parents)
-        for fid in fids & self.indi.keys():
-            for father, mother in self.indi[fid].parents:
-                if (
-                    mother in self.indi
-                    and father in self.indi
-                    or not father
-                    and mother in self.indi
-                    or not mother
-                    and father in self.indi
-                ):
-                    self.add_trio(father, mother, fid)
-        return set(filter(None, parents))
-
-    def add_spouses(self, fids):
-        """add spouse relationships
-        :param fids: a set of fid
-        """
-
-        async def add(loop, rels):
-            futures = set()
-            for father, mother, relfid in rels:
-                if (father, mother) in self.fam:
-                    futures.add(
-                        loop.run_in_executor(
-                            None, self.fam[(father, mother)].add_marriage, relfid
-                        )
-                    )
-            for future in futures:
-                await future
-
-        rels = set()
-        for fid in fids & self.indi.keys():
-            rels |= self.indi[fid].spouses
-        loop = asyncio.get_event_loop()
-        if rels:
-            self.add_indis(
-                set.union(*({father, mother} for father, mother, relfid in rels))
-            )
-            for father, mother, _ in rels:
-                if father in self.indi and mother in self.indi:
-                    self.indi[father].add_fams((father, mother))
-                    self.indi[mother].add_fams((father, mother))
-                    self.add_fam(father, mother)
-            loop.run_until_complete(add(loop, rels))
-
-    def add_children(self, fids):
-        """add children relationships
-        :param fids: a set of fid
-        """
-        rels = set()
-        for fid in fids & self.indi.keys():
-            rels |= self.indi[fid].children if fid in self.indi else set()
-        children = set()
-        if rels:
-            self.add_indis(set.union(*(set(rel) for rel in rels)))
-            for father, mother, child in rels:
-                if child in self.indi and (
-                    mother in self.indi
-                    and father in self.indi
-                    or not father
-                    and mother in self.indi
-                    or not mother
-                    and father in self.indi
-                ):
-                    self.add_trio(father, mother, child)
-                    children.add(child)
-        return children
-
-    def add_ordinances(self, fid):
-        """retrieve ordinances
-        :param fid: an individual fid
-        """
-        if fid in self.indi:
-            ret, famc = self.indi[fid].get_ordinances()
-            if famc and famc in self.fam:
-                self.indi[fid].sealing_child.famc = self.fam[famc]
-            for o in ret:
-                spouse_id = o["relationships"]["spouseId"]
-                if (fid, spouse_id) in self.fam:
-                    self.fam[fid, spouse_id].sealing_spouse = Ordinance(o)
-                elif (spouse_id, fid) in self.fam:
-                    self.fam[spouse_id, fid].sealing_spouse = Ordinance(o)
-
-    def reset_num(self):
-        """reset all GEDCOM identifiers"""
-        for husb, wife in self.fam:
-            self.fam[(husb, wife)].husb_num = self.indi[husb].num if husb else None
-            self.fam[(husb, wife)].wife_num = self.indi[wife].num if wife else None
-            self.fam[(husb, wife)].chil_num = set(
-                self.indi[chil].num for chil in self.fam[(husb, wife)].chil_fid
-            )
-        for fid in self.indi:
-            self.indi[fid].famc_num = set(
-                self.fam[(husb, wife)].num for husb, wife in self.indi[fid].famc_fid
-            )
-            self.indi[fid].fams_num = set(
-                self.fam[(husb, wife)].num for husb, wife in self.indi[fid].fams_fid
-            )
-
-    def print(self, file=sys.stdout):
-        """print family tree in GEDCOM format"""
-        file.write("0 HEAD\n")
-        file.write("1 CHAR UTF-8\n")
-        file.write("1 GEDC\n")
-        file.write("2 VERS 5.1.1\n")
-        file.write("2 FORM LINEAGE-LINKED\n")
-        file.write("1 SOUR getmyancestors\n")
-        file.write("2 VERS %s\n" % getmyancestors.__version__)
-        file.write("2 NAME getmyancestors\n")
-        file.write("1 DATE %s\n" % time.strftime("%d %b %Y"))
-        file.write("2 TIME %s\n" % time.strftime("%H:%M:%S"))
-        file.write("1 SUBM @SUBM@\n")
-        file.write("0 @SUBM@ SUBM\n")
-        file.write("1 NAME %s\n" % self.display_name)
-        file.write("1 LANG %s\n" % self.lang)
-
-        for fid in sorted(self.indi, key=lambda x: self.indi.__getitem__(x).num):
-            self.indi[fid].print(file)
-        for husb, wife in sorted(self.fam, key=lambda x: self.fam.__getitem__(x).num):
-            self.fam[(husb, wife)].print(file)
-        sources = sorted(self.sources.values(), key=lambda x: x.num)
-        for s in sources:
-            s.print(file)
-        notes = sorted(self.notes, key=lambda x: x.num)
-        for i, n in enumerate(notes):
-            if i > 0:
-                if n.num == notes[i - 1].num:
-                    continue
-            n.print(file)
-        file.write("0 TRLR\n")
-
-
-# mergemyancestors classes
-class Gedcom:
-    """Parse a GEDCOM file into a Tree"""
-
-    def __init__(self, file, tree):
-        self.f = file
-        self.num = None
-        self.tree = tree
-        self.level = 0
-        self.pointer = None
-        self.tag = None
-        self.data = None
-        self.flag = False
-        self.indi = dict()
-        self.fam = dict()
-        self.note = dict()
-        self.sour = dict()
-        self.__parse()
-        self.__add_id()
-
-    def __parse(self):
-        """Parse the GEDCOM file into self.tree"""
-        while self.__get_line():
-            if self.tag == "INDI":
-                self.num = int(self.pointer[2 : len(self.pointer) - 1])
-                self.indi[self.num] = Indi(tree=self.tree, num=self.num)
-                self.__get_indi()
-            elif self.tag == "FAM":
-                self.num = int(self.pointer[2 : len(self.pointer) - 1])
-                if self.num not in self.fam:
-                    self.fam[self.num] = Fam(tree=self.tree, num=self.num)
-                self.__get_fam()
-            elif self.tag == "NOTE":
-                self.num = int(self.pointer[2 : len(self.pointer) - 1])
-                if self.num not in self.note:
-                    self.note[self.num] = Note(tree=self.tree, num=self.num)
-                self.__get_note()
-            elif self.tag == "SOUR" and self.pointer:
-                self.num = int(self.pointer[2 : len(self.pointer) - 1])
-                if self.num not in self.sour:
-                    self.sour[self.num] = Source(num=self.num)
-                self.__get_source()
-            elif self.tag == "SUBM" and self.pointer:
-                self.__get_subm()
-
-    def __get_subm(self):
-        while self.__get_line() and self.level > 0:
-            if not self.tree.display_name or not self.tree.lang:
-                if self.tag == "NAME":
-                    self.tree.display_name = self.data
-                elif self.tag == "LANG":
-                    self.tree.lang = self.data
-        self.flag = True
-
-    def __get_line(self):
-        """Parse a new line
-        If the flag is set, skip reading a newline
-        """
-        if self.flag:
-            self.flag = False
-            return True
-        words = self.f.readline().split()
-
-        if not words:
-            return False
-        self.level = int(words[0])
-        if words[1][0] == "@":
-            self.pointer = words[1]
-            self.tag = words[2]
-            self.data = " ".join(words[3:])
-        else:
-            self.pointer = None
-            self.tag = words[1]
-            self.data = " ".join(words[2:])
-        return True
-
-    def __get_indi(self):
-        """Parse an individual"""
-        while self.f and self.__get_line() and self.level > 0:
-            if self.tag == "NAME":
-                self.__get_name()
-            elif self.tag == "SEX":
-                self.indi[self.num].gender = self.data
-            elif self.tag in FACT_TYPES or self.tag == "EVEN":
-                self.indi[self.num].facts.add(self.__get_fact())
-            elif self.tag == "BAPL":
-                self.indi[self.num].baptism = self.__get_ordinance()
-            elif self.tag == "CONL":
-                self.indi[self.num].confirmation = self.__get_ordinance()
-            elif self.tag == "ENDL":
-                self.indi[self.num].endowment = self.__get_ordinance()
-            elif self.tag == "SLGC":
-                self.indi[self.num].sealing_child = self.__get_ordinance()
-            elif self.tag == "FAMS":
-                self.indi[self.num].fams_num.add(int(self.data[2 : len(self.data) - 1]))
-            elif self.tag == "FAMC":
-                self.indi[self.num].famc_num.add(int(self.data[2 : len(self.data) - 1]))
-            elif self.tag == "_FSFTID":
-                self.indi[self.num].fid = self.data
-            elif self.tag == "NOTE":
-                num = int(self.data[2 : len(self.data) - 1])
-                if num not in self.note:
-                    self.note[num] = Note(tree=self.tree, num=num)
-                self.indi[self.num].notes.add(self.note[num])
-            elif self.tag == "SOUR":
-                self.indi[self.num].sources.add(self.__get_link_source())
-            elif self.tag == "OBJE":
-                self.indi[self.num].memories.add(self.__get_memorie())
-        self.flag = True
-
-    def __get_fam(self):
-        """Parse a family"""
-        while self.__get_line() and self.level > 0:
-            if self.tag == "HUSB":
-                self.fam[self.num].husb_num = int(self.data[2 : len(self.data) - 1])
-            elif self.tag == "WIFE":
-                self.fam[self.num].wife_num = int(self.data[2 : len(self.data) - 1])
-            elif self.tag == "CHIL":
-                self.fam[self.num].chil_num.add(int(self.data[2 : len(self.data) - 1]))
-            elif self.tag in FACT_TYPES:
-                self.fam[self.num].facts.add(self.__get_fact())
-            elif self.tag == "SLGS":
-                self.fam[self.num].sealing_spouse = self.__get_ordinance()
-            elif self.tag == "_FSFTID":
-                self.fam[self.num].fid = self.data
-            elif self.tag == "NOTE":
-                num = int(self.data[2 : len(self.data) - 1])
-                if num not in self.note:
-                    self.note[num] = Note(tree=self.tree, num=num)
-                self.fam[self.num].notes.add(self.note[num])
-            elif self.tag == "SOUR":
-                self.fam[self.num].sources.add(self.__get_link_source())
-        self.flag = True
-
-    def __get_name(self):
-        """Parse a name"""
-        parts = self.__get_text().split("/")
-        name = Name()
-        added = False
-        name.given = parts[0].strip()
-        name.surname = parts[1].strip()
-        if parts[2]:
-            name.suffix = parts[2]
-        if not self.indi[self.num].name:
-            self.indi[self.num].name = name
-            added = True
-        while self.__get_line() and self.level > 1:
-            if self.tag == "NPFX":
-                name.prefix = self.data
-            elif self.tag == "TYPE":
-                if self.data == "aka":
-                    self.indi[self.num].aka.add(name)
-                    added = True
-                elif self.data == "married":
-                    self.indi[self.num].married.add(name)
-                    added = True
-            elif self.tag == "NICK":
-                nick = Name()
-                nick.given = self.data
-                self.indi[self.num].nicknames.add(nick)
-            elif self.tag == "NOTE":
-                num = int(self.data[2 : len(self.data) - 1])
-                if num not in self.note:
-                    self.note[num] = Note(tree=self.tree, num=num)
-                name.note = self.note[num]
-        if not added:
-            self.indi[self.num].birthnames.add(name)
-        self.flag = True
-
-    def __get_fact(self):
-        """Parse a fact"""
-        fact = Fact()
-        if self.tag != "EVEN":
-            fact.type = FACT_TYPES[self.tag]
-            fact.value = self.data
-        while self.__get_line() and self.level > 1:
-            if self.tag == "TYPE":
-                fact.type = self.data
-            if self.tag == "DATE":
-                fact.date = self.__get_text()
-            elif self.tag == "PLAC":
-                fact.place = self.__get_text()
-            elif self.tag == "MAP":
-                fact.map = self.__get_map()
-            elif self.tag == "NOTE":
-                if self.data[:12] == "Description:":
-                    fact.value = self.data[13:]
-                    continue
-                num = int(self.data[2 : len(self.data) - 1])
-                if num not in self.note:
-                    self.note[num] = Note(tree=self.tree, num=num)
-                fact.note = self.note[num]
-            elif self.tag == "CONT":
-                fact.value += "\n" + self.data
-            elif self.tag == "CONC":
-                fact.value += self.data
-        self.flag = True
-        return fact
-
-    def __get_map(self):
-        """Parse map coordinates"""
-        latitude = None
-        longitude = None
-        while self.__get_line() and self.level > 3:
-            if self.tag == "LATI":
-                latitude = self.data
-            elif self.tag == "LONG":
-                longitude = self.data
-        self.flag = True
-        return (latitude, longitude)
-
-    def __get_text(self):
-        """Parse a multiline text"""
-        text = self.data
-        while self.__get_line():
-            if self.tag == "CONT":
-                text += "\n" + self.data
-            elif self.tag == "CONC":
-                text += self.data
-            else:
-                break
-        self.flag = True
-        return text
-
-    def __get_source(self):
-        """Parse a source"""
-        while self.__get_line() and self.level > 0:
-            if self.tag == "TITL":
-                self.sour[self.num].title = self.__get_text()
-            elif self.tag == "AUTH":
-                self.sour[self.num].citation = self.__get_text()
-            elif self.tag == "PUBL":
-                self.sour[self.num].url = self.__get_text()
-            elif self.tag == "REFN":
-                self.sour[self.num].fid = self.data
-                if self.data in self.tree.sources:
-                    self.sour[self.num] = self.tree.sources[self.data]
-                else:
-                    self.tree.sources[self.data] = self.sour[self.num]
-            elif self.tag == "NOTE":
-                num = int(self.data[2 : len(self.data) - 1])
-                if num not in self.note:
-                    self.note[num] = Note(tree=self.tree, num=num)
-                self.sour[self.num].notes.add(self.note[num])
-        self.flag = True
-
-    def __get_link_source(self):
-        """Parse a link to a source"""
-        num = int(self.data[2 : len(self.data) - 1])
-        if num not in self.sour:
-            self.sour[num] = Source(num=num)
-        page = None
-        while self.__get_line() and self.level > 1:
-            if self.tag == "PAGE":
-                page = self.__get_text()
-        self.flag = True
-        return (self.sour[num], page)
-
-    def __get_memorie(self):
-        """Parse a memorie"""
-        memorie = Memorie()
-        while self.__get_line() and self.level > 1:
-            if self.tag == "TITL":
-                memorie.description = self.__get_text()
-            elif self.tag == "FILE":
-                memorie.url = self.__get_text()
-        self.flag = True
-        return memorie
-
-    def __get_note(self):
-        """Parse a note"""
-        self.note[self.num].text = self.__get_text()
-        self.flag = True
-
-    def __get_ordinance(self):
-        """Parse an ordinance"""
-        ordinance = Ordinance()
-        while self.__get_line() and self.level > 1:
-            if self.tag == "DATE":
-                ordinance.date = self.__get_text()
-            elif self.tag == "TEMP":
-                ordinance.temple_code = self.data
-            elif self.tag == "STAT":
-                ordinance.status = ORDINANCES[self.data]
-            elif self.tag == "FAMC":
-                num = int(self.data[2 : len(self.data) - 1])
-                if num not in self.fam:
-                    self.fam[num] = Fam(tree=self.tree, num=num)
-                ordinance.famc = self.fam[num]
-        self.flag = True
-        return ordinance
-
-    def __add_id(self):
-        """Reset GEDCOM identifiers"""
-        for num in self.fam:
-            if self.fam[num].husb_num:
-                self.fam[num].husb_fid = self.indi[self.fam[num].husb_num].fid
-            if self.fam[num].wife_num:
-                self.fam[num].wife_fid = self.indi[self.fam[num].wife_num].fid
-            for chil in self.fam[num].chil_num:
-                self.fam[num].chil_fid.add(self.indi[chil].fid)
-        for num in self.indi:
-            for famc in self.indi[num].famc_num:
-                self.indi[num].famc_fid.add(
-                    (self.fam[famc].husb_fid, self.fam[famc].wife_fid)
-                )
-            for fams in self.indi[num].fams_num:
-                self.indi[num].fams_fid.add(
-                    (self.fam[fams].husb_fid, self.fam[fams].wife_fid)
-                )
-
-
-# fstogedcom classes and functions
-def _(string):
-    if string in translations and lang in translations[string]:
-        return translations[string][lang]
-    return string
-
-
-class EntryWithMenu(Entry):
-    """Entry widget with right-clic menu to copy/cut/paste"""
-
-    def __init__(self, master, **kw):
-        super().__init__(master, **kw)
-        self.bind("<Button-3>", self.click_right)
-
-    def click_right(self, event):
-        """open menu"""
-        menu = Menu(self, tearoff=0)
-        try:
-            self.selection_get()
-            state = "normal"
-        except TclError:
-            state = "disabled"
-        menu.add_command(label=_("Copy"), command=self.copy, state=state)
-        menu.add_command(label=_("Cut"), command=self.cut, state=state)
-        menu.add_command(label=_("Paste"), command=self.paste)
-        menu.post(event.x_root, event.y_root)
-
-    def copy(self):
-        """copy in clipboard"""
-        self.clipboard_clear()
-        text = self.selection_get()
-        self.clipboard_append(text)
-
-    def cut(self):
-        """move in clipboard"""
-        self.copy()
-        self.delete("sel.first", "sel.last")
-
-    def paste(self):
-        """paste from clipboard"""
-        try:
-            text = self.selection_get(selection="CLIPBOARD")
-            self.insert("insert", text)
-        except TclError:
-            pass
-
-
-class FilesToMerge(Treeview):
-    """List of GEDCOM files to merge"""
-
-    def __init__(self, master, **kwargs):
-        super().__init__(master, selectmode="extended", height=5, **kwargs)
-        self.heading("#0", text=_("Files"))
-        self.column("#0", width=300)
-        self.files = dict()
-        self.bind("<Button-3>", self.popup)
-
-    def add_file(self, filename):
-        """add a GEDCOM file"""
-        if any(f.name == filename for f in self.files.values()):
-            messagebox.showinfo(
-                _("Error"),
-                message=_("File already exist: ") + os.path.basename(filename),
-            )
-            return
-        if not os.path.exists(filename):
-            messagebox.showinfo(
-                _("Error"), message=_("File not found: ") + os.path.basename(filename)
-            )
-            return
-        file = open(filename, "r", encoding="utf-8")
-        new_id = self.insert("", 0, text=os.path.basename(filename))
-        self.files[new_id] = file
-
-    def popup(self, event):
-        """open menu to remove item"""
-        item = self.identify_row(event.y)
-        if item:
-            menu = Menu(self, tearoff=0)
-            menu.add_command(label=_("Remove"), command=self.delete_item(item))
-            menu.post(event.x_root, event.y_root)
-
-    def delete_item(self, item):
-        """return a function to remove a file"""
-
-        def delete():
-            self.files[item].close()
-            self.files.pop(item)
-            self.delete(item)
-
-        return delete
-
-
-class Merge(Frame):
-    """Merge GEDCOM widget"""
-
-    def __init__(self, master, **kwargs):
-        super().__init__(master, **kwargs)
-        warning = Label(
-            self,
-            font=("a", 7),
-            wraplength=300,
-            justify="center",
-            text=_(
-                "Warning: This tool should only be used to merge GEDCOM files from this software. "
-                "If you use other GEDCOM files, the result is not guaranteed."
-            ),
-        )
-        self.files_to_merge = FilesToMerge(self)
-        self.btn_add_file = Button(self, text=_("Add files"), command=self.add_files)
-        buttons = Frame(self, borderwidth=20)
-        self.btn_quit = Button(buttons, text=_("Quit"), command=self.quit)
-        self.btn_save = Button(buttons, text=_("Merge"), command=self.save)
-        warning.pack()
-        self.files_to_merge.pack()
-        self.btn_add_file.pack()
-        self.btn_quit.pack(side="left", padx=(0, 40))
-        self.btn_save.pack(side="right", padx=(40, 0))
-        buttons.pack(side="bottom")
-
-    def add_files(self):
-        """open file explorer to pick a file"""
-        for filename in filedialog.askopenfilenames(
-            title=_("Open"),
-            defaultextension=".ged",
-            filetypes=(("GEDCOM", ".ged"), (_("All files"), "*.*")),
-        ):
-            self.files_to_merge.add_file(filename)
-
-    def save(self):
-        """merge GEDCOM files"""
-        if not self.files_to_merge.files:
-            messagebox.showinfo(_("Error"), message=_("Please add GEDCOM files"))
-            return
-
-        filename = filedialog.asksaveasfilename(
-            title=_("Save as"),
-            defaultextension=".ged",
-            filetypes=(("GEDCOM", ".ged"), (_("All files"), "*.*")),
-        )
-        tree = Tree()
-
-        indi_counter = 0
-        fam_counter = 0
-
-        # read the GEDCOM data
-        for file in self.files_to_merge.files.values():
-            ged = Gedcom(file, tree)
-
-            # add informations about individuals
-            for num in ged.indi:
-                fid = ged.indi[num].fid
-                if fid not in tree.indi:
-                    indi_counter += 1
-                    tree.indi[fid] = Indi(tree=tree, num=indi_counter)
-                    tree.indi[fid].tree = tree
-                    tree.indi[fid].fid = ged.indi[num].fid
-                tree.indi[fid].fams_fid |= ged.indi[num].fams_fid
-                tree.indi[fid].famc_fid |= ged.indi[num].famc_fid
-                tree.indi[fid].name = ged.indi[num].name
-                tree.indi[fid].birthnames = ged.indi[num].birthnames
-                tree.indi[fid].nicknames = ged.indi[num].nicknames
-                tree.indi[fid].aka = ged.indi[num].aka
-                tree.indi[fid].married = ged.indi[num].married
-                tree.indi[fid].gender = ged.indi[num].gender
-                tree.indi[fid].facts = ged.indi[num].facts
-                tree.indi[fid].notes = ged.indi[num].notes
-                tree.indi[fid].sources = ged.indi[num].sources
-                tree.indi[fid].memories = ged.indi[num].memories
-                tree.indi[fid].baptism = ged.indi[num].baptism
-                tree.indi[fid].confirmation = ged.indi[num].confirmation
-                tree.indi[fid].endowment = ged.indi[num].endowment
-                if not (
-                    tree.indi[fid].sealing_child and tree.indi[fid].sealing_child.famc
-                ):
-                    tree.indi[fid].sealing_child = ged.indi[num].sealing_child
-
-            # add informations about families
-            for num in ged.fam:
-                husb, wife = (ged.fam[num].husb_fid, ged.fam[num].wife_fid)
-                if (husb, wife) not in tree.fam:
-                    fam_counter += 1
-                    tree.fam[(husb, wife)] = Fam(husb, wife, tree, fam_counter)
-                    tree.fam[(husb, wife)].tree = tree
-                tree.fam[(husb, wife)].chil_fid |= ged.fam[num].chil_fid
-                tree.fam[(husb, wife)].fid = ged.fam[num].fid
-                tree.fam[(husb, wife)].facts = ged.fam[num].facts
-                tree.fam[(husb, wife)].notes = ged.fam[num].notes
-                tree.fam[(husb, wife)].sources = ged.fam[num].sources
-                tree.fam[(husb, wife)].sealing_spouse = ged.fam[num].sealing_spouse
-
-        # merge notes by text
-        tree.notes = sorted(tree.notes, key=lambda x: x.text)
-        for i, n in enumerate(tree.notes):
-            if i == 0:
-                n.num = 1
-                continue
-            if n.text == tree.notes[i - 1].text:
-                n.num = tree.notes[i - 1].num
-            else:
-                n.num = tree.notes[i - 1].num + 1
-
-        # compute number for family relationships and print GEDCOM file
-        tree.reset_num()
-        with open(filename, "w", encoding="utf-8") as file:
-            tree.print(file)
-        messagebox.showinfo(_("Info"), message=_("Files successfully merged"))
-
-    def quit(self):
-        """prevent exception on quit during download"""
-        super().quit()
-        os._exit(1)
-
-
-class SignIn(Frame):
-    """Sign In widget"""
-
-    def __init__(self, master, **kwargs):
-        super().__init__(master, **kwargs)
-        self.username = StringVar()
-        self.username.set(cache.get("username") or "")
-        self.password = StringVar()
-        label_username = Label(self, text=_("Username:"))
-        entry_username = EntryWithMenu(self, textvariable=self.username, width=30)
-        label_password = Label(self, text=_("Password:"))
-        entry_password = EntryWithMenu(
-            self, show="●", textvariable=self.password, width=30
-        )
-        label_username.grid(row=0, column=0, pady=15, padx=(0, 5))
-        entry_username.grid(row=0, column=1)
-        label_password.grid(row=1, column=0, padx=(0, 5))
-        entry_password.grid(row=1, column=1)
-        entry_username.focus_set()
-        entry_username.bind("<Key>", self.enter)
-        entry_password.bind("<Key>", self.enter)
-
-    def enter(self, evt):
-        """enter event"""
-        if evt.keysym in {"Return", "KP_Enter"}:
-            self.master.master.command_in_thread(self.master.master.login)()
-
-
-class StartIndis(Treeview):
-    """List of starting individuals"""
-
-    def __init__(self, master, **kwargs):
-        super().__init__(
-            master, selectmode="extended", height=5, columns=("fid",), **kwargs
-        )
-        self.heading("#0", text=_("Name"))
-        self.column("#0", width=250)
-        self.column("fid", width=80)
-        self.indis = dict()
-        self.heading("fid", text="Id")
-        self.bind("<Button-3>", self.popup)
-
-    def add_indi(self, fid):
-        """add an individual fid"""
-        if not fid:
-            return None
-        if fid in self.indis.values():
-            messagebox.showinfo(_("Error"), message=_("ID already exist"))
-            return None
-        if not re.match(r"[A-Z0-9]{4}-[A-Z0-9]{3}", fid):
-            messagebox.showinfo(
-                _("Error"), message=_("Invalid FamilySearch ID: ") + fid
-            )
-            return None
-        fs = self.master.master.master.fs
-        data = fs.get_url("/platform/tree/persons/%s" % fid)
-        if data and "persons" in data:
-            if "names" in data["persons"][0]:
-                for name in data["persons"][0]["names"]:
-                    if name["preferred"]:
-                        self.indis[
-                            self.insert(
-                                "", 0, text=name["nameForms"][0]["fullText"], values=fid
-                            )
-                        ] = fid
-                        return True
-        messagebox.showinfo(_("Error"), message=_("Individual not found"))
-        return None
-
-    def popup(self, event):
-        """open menu to remove item"""
-        item = self.identify_row(event.y)
-        if item:
-            menu = Menu(self, tearoff=0)
-            menu.add_command(label=_("Remove"), command=self.delete_item(item))
-            menu.post(event.x_root, event.y_root)
-
-    def delete_item(self, item):
-        """return a function to remove a fid"""
-
-        def delete():
-            self.indis.pop(item)
-            self.delete(item)
-
-        return delete
-
-
-class Options(Frame):
-    """Options form"""
-
-    def __init__(self, master, ordinances=False, **kwargs):
-        super().__init__(master, **kwargs)
-        self.ancestors = IntVar()
-        self.ancestors.set(4)
-        self.descendants = IntVar()
-        self.spouses = IntVar()
-        self.ordinances = IntVar()
-        self.contributors = IntVar()
-        self.start_indis = StartIndis(self)
-        self.fid = StringVar()
-        btn = Frame(self)
-        entry_fid = EntryWithMenu(btn, textvariable=self.fid, width=16)
-        entry_fid.bind("<Key>", self.enter)
-        label_ancestors = Label(self, text=_("Number of generations to ascend"))
-        entry_ancestors = EntryWithMenu(self, textvariable=self.ancestors, width=5)
-        label_descendants = Label(self, text=_("Number of generations to descend"))
-        entry_descendants = EntryWithMenu(self, textvariable=self.descendants, width=5)
-        btn_add_indi = Button(
-            btn, text=_("Add a FamilySearch ID"), command=self.add_indi
-        )
-        btn_spouses = Checkbutton(
-            self,
-            text="\t" + _("Add spouses and couples information"),
-            variable=self.spouses,
-        )
-        btn_ordinances = Checkbutton(
-            self, text="\t" + _("Add Temple information"), variable=self.ordinances
-        )
-        btn_contributors = Checkbutton(
-            self,
-            text="\t" + _("Add list of contributors in notes"),
-            variable=self.contributors,
-        )
-        self.start_indis.grid(row=0, column=0, columnspan=3)
-        entry_fid.grid(row=0, column=0, sticky="w")
-        btn_add_indi.grid(row=0, column=1, sticky="w")
-        btn.grid(row=1, column=0, columnspan=2, sticky="w")
-        entry_ancestors.grid(row=2, column=0, sticky="w")
-        label_ancestors.grid(row=2, column=1, sticky="w")
-        entry_descendants.grid(row=3, column=0, sticky="w")
-        label_descendants.grid(row=3, column=1, sticky="w")
-        btn_spouses.grid(row=4, column=0, columnspan=2, sticky="w")
-        if ordinances:
-            btn_ordinances.grid(row=5, column=0, columnspan=3, sticky="w")
-        btn_contributors.grid(row=6, column=0, columnspan=3, sticky="w")
-        entry_ancestors.focus_set()
-
-    def add_indi(self):
-        """add a fid"""
-        if self.start_indis.add_indi(self.fid.get()):
-            self.fid.set("")
-
-    def enter(self, evt):
-        """enter event"""
-        if evt.keysym in {"Return", "KP_Enter"}:
-            self.add_indi()
-
-
-class Download(Frame):
-    """Main widget"""
-
-    def __init__(self, master, **kwargs):
-        super().__init__(master, borderwidth=20, **kwargs)
-        self.fs = None
-        self.tree = None
-        self.logfile = None
-
-        # User information
-        self.info_tree = False
-        self.start_time = None
-        info = Frame(self, borderwidth=10)
-        self.info_label = Label(
-            info,
-            wraplength=350,
-            borderwidth=20,
-            justify="center",
-            font=("a", 10, "bold"),
-        )
-        self.info_indis = Label(info)
-        self.info_fams = Label(info)
-        self.info_sources = Label(info)
-        self.info_notes = Label(info)
-        self.time = Label(info)
-        self.info_label.grid(row=0, column=0, columnspan=2)
-        self.info_indis.grid(row=1, column=0)
-        self.info_fams.grid(row=1, column=1)
-        self.info_sources.grid(row=2, column=0)
-        self.info_notes.grid(row=2, column=1)
-        self.time.grid(row=3, column=0, columnspan=2)
-
-        self.form = Frame(self)
-        self.sign_in = SignIn(self.form)
-        self.options = None
-        self.title = Label(
-            self, text=_("Sign In to FamilySearch"), font=("a", 12, "bold")
-        )
-        buttons = Frame(self)
-        self.btn_quit = Button(
-            buttons, text=_("Quit"), command=Thread(target=self.quit).start
-        )
-        self.btn_valid = Button(
-            buttons, text=_("Sign In"), command=self.command_in_thread(self.login)
-        )
-        self.title.pack()
-        self.sign_in.pack()
-        self.form.pack()
-        self.btn_quit.pack(side="left", padx=(0, 40))
-        self.btn_valid.pack(side="right", padx=(40, 0))
-        info.pack()
-        buttons.pack(side="bottom")
-        self.pack()
-        self.update_needed = False
-
-    def info(self, text):
-        """dislay informations"""
-        self.info_label.config(text=text)
-
-    def save(self):
-        """save the GEDCOM file"""
-        filename = filedialog.asksaveasfilename(
-            title=_("Save as"),
-            defaultextension=".ged",
-            filetypes=(("GEDCOM", ".ged"), (_("All files"), "*.*")),
-        )
-        if not filename:
-            return
-        with open(filename, "w", encoding="utf-8") as file:
-            self.tree.print(file)
-
-    def login(self):
-        """log in FamilySearch"""
-        global _
-        username = self.sign_in.username.get()
-        password = self.sign_in.password.get()
-        if not (username and password):
-            messagebox.showinfo(
-                message=_("Please enter your FamilySearch username and password.")
-            )
-            return
-        self.btn_valid.config(state="disabled")
-        self.info(_("Login to FamilySearch..."))
-        self.logfile = open("download.log", "w", encoding="utf-8")
-        self.fs = Session(
-            self.sign_in.username.get(),
-            self.sign_in.password.get(),
-            verbose=True,
-            logfile=self.logfile,
-            timeout=1,
-        )
-        if not self.fs.logged:
-            messagebox.showinfo(
-                _("Error"), message=_("The username or password was incorrect")
-            )
-            self.btn_valid.config(state="normal")
-            self.info("")
-            return
-        self.tree = Tree(self.fs)
-        _ = self.fs._
-        self.title.config(text=_("Options"))
-        cache.delete("lang")
-        cache.add("lang", self.fs.lang)
-        cache.delete("username")
-        cache.add("username", username)
-        url = "/service/tree/tree-data/reservations/person/%s/ordinances" % self.fs.fid
-        lds_account = self.fs.get_url(url, {}).get("status") == "OK"
-        self.options = Options(self.form, lds_account)
-        self.info("")
-        self.sign_in.destroy()
-        self.options.pack()
-        self.master.change_lang()
-        self.btn_valid.config(
-            command=self.command_in_thread(self.download),
-            state="normal",
-            text=_("Download"),
-        )
-        self.options.start_indis.add_indi(self.fs.fid)
-        self.update_needed = False
-
-    def quit(self):
-        """prevent exception during download"""
-        self.update_needed = False
-        if self.logfile:
-            self.logfile.close()
-        super().quit()
-        os._exit(1)
-
-    def download(self):
-        """download family tree"""
-        todo = [
-            self.options.start_indis.indis[key]
-            for key in sorted(self.options.start_indis.indis)
-        ]
-        for fid in todo:
-            if not re.match(r"[A-Z0-9]{4}-[A-Z0-9]{3}", fid):
-                messagebox.showinfo(
-                    _("Error"), message=_("Invalid FamilySearch ID: ") + fid
-                )
-                return
-        self.start_time = time.time()
-        self.options.destroy()
-        self.form.destroy()
-        self.title.config(text="FamilySearch to GEDCOM")
-        self.btn_valid.config(state="disabled")
-        self.info(_("Downloading starting individuals..."))
-        self.info_tree = True
-        self.tree.add_indis(todo)
-        todo = set(todo)
-        done = set()
-        for i in range(self.options.ancestors.get()):
-            if not todo:
-                break
-            done |= todo
-            self.info(_("Downloading %s. of generations of ancestors...") % (i + 1))
-            todo = self.tree.add_parents(todo) - done
-
-        todo = set(self.tree.indi.keys())
-        done = set()
-        for i in range(self.options.descendants.get()):
-            if not todo:
-                break
-            done |= todo
-            self.info(_("Downloading %s. of generations of descendants...") % (i + 1))
-            todo = self.tree.add_children(todo) - done
-
-        if self.options.spouses.get():
-            self.info(_("Downloading spouses and marriage information..."))
-            todo = set(self.tree.indi.keys())
-            self.tree.add_spouses(todo)
-        ordi = self.options.ordinances.get()
-        cont = self.options.contributors.get()
-
-        async def download_stuff(loop):
-            futures = set()
-            for fid, indi in self.tree.indi.items():
-                futures.add(loop.run_in_executor(None, indi.get_notes))
-                if ordi:
-                    futures.add(
-                        loop.run_in_executor(None, self.tree.add_ordinances, fid)
-                    )
-                if cont:
-                    futures.add(loop.run_in_executor(None, indi.get_contributors))
-            for fam in self.tree.fam.values():
-                futures.add(loop.run_in_executor(None, fam.get_notes))
-                if cont:
-                    futures.add(loop.run_in_executor(None, fam.get_contributors))
-            for future in futures:
-                await future
-
-        loop = asyncio.get_event_loop()
-        self.info(
-            _("Downloading notes")
-            + ((("," if cont else _(" and")) + _(" ordinances")) if ordi else "")
-            + (_(" and contributors") if cont else "")
-            + "..."
-        )
-        loop.run_until_complete(download_stuff(loop))
-
-        self.tree.reset_num()
-        self.btn_valid.config(command=self.save, state="normal", text=_("Save"))
-        self.info(text=_("Success ! Click below to save your GEDCOM file"))
-        self.update_info_tree()
-        self.update_needed = False
-
-    def command_in_thread(self, func):
-        """command to update widget in a new Thread"""
-
-        def res():
-            self.update_needed = True
-            Thread(target=self.update_gui).start()
-            Thread(target=func).start()
-
-        return res
-
-    def update_info_tree(self):
-        """update informations"""
-        if self.info_tree and self.start_time and self.tree:
-            self.info_indis.config(text=_("Individuals: %s") % len(self.tree.indi))
-            self.info_fams.config(text=_("Families: %s") % len(self.tree.fam))
-            self.info_sources.config(text=_("Sources: %s") % len(self.tree.sources))
-            self.info_notes.config(text=_("Notes: %s") % len(self.tree.notes))
-            t = round(time.time() - self.start_time)
-            minutes = t // 60
-            seconds = t % 60
-            self.time.config(
-                text=_("Elapsed time: %s:%s") % (minutes, str(seconds).zfill(2))
-            )
-
-    def update_gui(self):
-        """update widget"""
-        while self.update_needed:
-            self.update_info_tree()
-            self.master.update()
-            time.sleep(0.1)
-
-
-class FStoGEDCOM(Notebook):
-    """Main notebook"""
-
-    def __init__(self, master, **kwargs):
-        super().__init__(master, width=400, **kwargs)
-        self.download = Download(self)
-        self.merge = Merge(self)
-        self.add(self.download, text=_("Download GEDCOM"))
-        self.add(self.merge, text=_("Merge GEDCOMs"))
-        self.pack()
-
-    def change_lang(self):
-        """update text with user's language"""
-        self.tab(self.index(self.download), text=_("Download GEDCOM"))
-        self.tab(self.index(self.merge), text=_("Merge GEDCOMs"))
-        self.download.btn_quit.config(text=_("Quit"))
-        self.merge.btn_quit.config(text=_("Quit"))
-        self.merge.btn_save.config(text=_("Merge"))
-        self.merge.btn_add_file.config(text=_("Add files"))
diff --git a/getmyancestors/classes/gedcom.py b/getmyancestors/classes/gedcom.py
new file mode 100644 (file)
index 0000000..9d3a3c1
--- /dev/null
@@ -0,0 +1,315 @@
+# mergemyancestors classes
+from getmyancestors.classes.tree import Indi, Fact, Fam, Memorie, Name, Note, Source
+from getmyancestors.classes.constants import FACT_TYPES
+
+class Gedcom:
+    """Parse a GEDCOM file into a Tree"""
+
+    def __init__(self, file, tree):
+        self.f = file
+        self.num = None
+        self.tree = tree
+        self.level = 0
+        self.pointer = None
+        self.tag = None
+        self.data = None
+        self.flag = False
+        self.indi = dict()
+        self.fam = dict()
+        self.note = dict()
+        self.sour = dict()
+        self.__parse()
+        self.__add_id()
+
+    def __parse(self):
+        """Parse the GEDCOM file into self.tree"""
+        while self.__get_line():
+            if self.tag == "INDI":
+                self.num = int(self.pointer[2 : len(self.pointer) - 1])
+                self.indi[self.num] = Indi(tree=self.tree, num=self.num)
+                self.__get_indi()
+            elif self.tag == "FAM":
+                self.num = int(self.pointer[2 : len(self.pointer) - 1])
+                if self.num not in self.fam:
+                    self.fam[self.num] = Fam(tree=self.tree, num=self.num)
+                self.__get_fam()
+            elif self.tag == "NOTE":
+                self.num = int(self.pointer[2 : len(self.pointer) - 1])
+                if self.num not in self.note:
+                    self.note[self.num] = Note(tree=self.tree, num=self.num)
+                self.__get_note()
+            elif self.tag == "SOUR" and self.pointer:
+                self.num = int(self.pointer[2 : len(self.pointer) - 1])
+                if self.num not in self.sour:
+                    self.sour[self.num] = Source(num=self.num)
+                self.__get_source()
+            elif self.tag == "SUBM" and self.pointer:
+                self.__get_subm()
+
+    def __get_subm(self):
+        while self.__get_line() and self.level > 0:
+            if not self.tree.display_name or not self.tree.lang:
+                if self.tag == "NAME":
+                    self.tree.display_name = self.data
+                elif self.tag == "LANG":
+                    self.tree.lang = self.data
+        self.flag = True
+
+    def __get_line(self):
+        """Parse a new line
+        If the flag is set, skip reading a newline
+        """
+        if self.flag:
+            self.flag = False
+            return True
+        words = self.f.readline().split()
+
+        if not words:
+            return False
+        self.level = int(words[0])
+        if words[1][0] == "@":
+            self.pointer = words[1]
+            self.tag = words[2]
+            self.data = " ".join(words[3:])
+        else:
+            self.pointer = None
+            self.tag = words[1]
+            self.data = " ".join(words[2:])
+        return True
+
+    def __get_indi(self):
+        """Parse an individual"""
+        while self.f and self.__get_line() and self.level > 0:
+            if self.tag == "NAME":
+                self.__get_name()
+            elif self.tag == "SEX":
+                self.indi[self.num].gender = self.data
+            elif self.tag in FACT_TYPES or self.tag == "EVEN":
+                self.indi[self.num].facts.add(self.__get_fact())
+            elif self.tag == "BAPL":
+                self.indi[self.num].baptism = self.__get_ordinance()
+            elif self.tag == "CONL":
+                self.indi[self.num].confirmation = self.__get_ordinance()
+            elif self.tag == "ENDL":
+                self.indi[self.num].endowment = self.__get_ordinance()
+            elif self.tag == "SLGC":
+                self.indi[self.num].sealing_child = self.__get_ordinance()
+            elif self.tag == "FAMS":
+                self.indi[self.num].fams_num.add(int(self.data[2 : len(self.data) - 1]))
+            elif self.tag == "FAMC":
+                self.indi[self.num].famc_num.add(int(self.data[2 : len(self.data) - 1]))
+            elif self.tag == "_FSFTID":
+                self.indi[self.num].fid = self.data
+            elif self.tag == "NOTE":
+                num = int(self.data[2 : len(self.data) - 1])
+                if num not in self.note:
+                    self.note[num] = Note(tree=self.tree, num=num)
+                self.indi[self.num].notes.add(self.note[num])
+            elif self.tag == "SOUR":
+                self.indi[self.num].sources.add(self.__get_link_source())
+            elif self.tag == "OBJE":
+                self.indi[self.num].memories.add(self.__get_memorie())
+        self.flag = True
+
+    def __get_fam(self):
+        """Parse a family"""
+        while self.__get_line() and self.level > 0:
+            if self.tag == "HUSB":
+                self.fam[self.num].husb_num = int(self.data[2 : len(self.data) - 1])
+            elif self.tag == "WIFE":
+                self.fam[self.num].wife_num = int(self.data[2 : len(self.data) - 1])
+            elif self.tag == "CHIL":
+                self.fam[self.num].chil_num.add(int(self.data[2 : len(self.data) - 1]))
+            elif self.tag in FACT_TYPES:
+                self.fam[self.num].facts.add(self.__get_fact())
+            elif self.tag == "SLGS":
+                self.fam[self.num].sealing_spouse = self.__get_ordinance()
+            elif self.tag == "_FSFTID":
+                self.fam[self.num].fid = self.data
+            elif self.tag == "NOTE":
+                num = int(self.data[2 : len(self.data) - 1])
+                if num not in self.note:
+                    self.note[num] = Note(tree=self.tree, num=num)
+                self.fam[self.num].notes.add(self.note[num])
+            elif self.tag == "SOUR":
+                self.fam[self.num].sources.add(self.__get_link_source())
+        self.flag = True
+
+    def __get_name(self):
+        """Parse a name"""
+        parts = self.__get_text().split("/")
+        name = Name()
+        added = False
+        name.given = parts[0].strip()
+        name.surname = parts[1].strip()
+        if parts[2]:
+            name.suffix = parts[2]
+        if not self.indi[self.num].name:
+            self.indi[self.num].name = name
+            added = True
+        while self.__get_line() and self.level > 1:
+            if self.tag == "NPFX":
+                name.prefix = self.data
+            elif self.tag == "TYPE":
+                if self.data == "aka":
+                    self.indi[self.num].aka.add(name)
+                    added = True
+                elif self.data == "married":
+                    self.indi[self.num].married.add(name)
+                    added = True
+            elif self.tag == "NICK":
+                nick = Name()
+                nick.given = self.data
+                self.indi[self.num].nicknames.add(nick)
+            elif self.tag == "NOTE":
+                num = int(self.data[2 : len(self.data) - 1])
+                if num not in self.note:
+                    self.note[num] = Note(tree=self.tree, num=num)
+                name.note = self.note[num]
+        if not added:
+            self.indi[self.num].birthnames.add(name)
+        self.flag = True
+
+    def __get_fact(self):
+        """Parse a fact"""
+        fact = Fact()
+        if self.tag != "EVEN":
+            fact.type = FACT_TYPES[self.tag]
+            fact.value = self.data
+        while self.__get_line() and self.level > 1:
+            if self.tag == "TYPE":
+                fact.type = self.data
+            if self.tag == "DATE":
+                fact.date = self.__get_text()
+            elif self.tag == "PLAC":
+                fact.place = self.__get_text()
+            elif self.tag == "MAP":
+                fact.map = self.__get_map()
+            elif self.tag == "NOTE":
+                if self.data[:12] == "Description:":
+                    fact.value = self.data[13:]
+                    continue
+                num = int(self.data[2 : len(self.data) - 1])
+                if num not in self.note:
+                    self.note[num] = Note(tree=self.tree, num=num)
+                fact.note = self.note[num]
+            elif self.tag == "CONT":
+                fact.value += "\n" + self.data
+            elif self.tag == "CONC":
+                fact.value += self.data
+        self.flag = True
+        return fact
+
+    def __get_map(self):
+        """Parse map coordinates"""
+        latitude = None
+        longitude = None
+        while self.__get_line() and self.level > 3:
+            if self.tag == "LATI":
+                latitude = self.data
+            elif self.tag == "LONG":
+                longitude = self.data
+        self.flag = True
+        return (latitude, longitude)
+
+    def __get_text(self):
+        """Parse a multiline text"""
+        text = self.data
+        while self.__get_line():
+            if self.tag == "CONT":
+                text += "\n" + self.data
+            elif self.tag == "CONC":
+                text += self.data
+            else:
+                break
+        self.flag = True
+        return text
+
+    def __get_source(self):
+        """Parse a source"""
+        while self.__get_line() and self.level > 0:
+            if self.tag == "TITL":
+                self.sour[self.num].title = self.__get_text()
+            elif self.tag == "AUTH":
+                self.sour[self.num].citation = self.__get_text()
+            elif self.tag == "PUBL":
+                self.sour[self.num].url = self.__get_text()
+            elif self.tag == "REFN":
+                self.sour[self.num].fid = self.data
+                if self.data in self.tree.sources:
+                    self.sour[self.num] = self.tree.sources[self.data]
+                else:
+                    self.tree.sources[self.data] = self.sour[self.num]
+            elif self.tag == "NOTE":
+                num = int(self.data[2 : len(self.data) - 1])
+                if num not in self.note:
+                    self.note[num] = Note(tree=self.tree, num=num)
+                self.sour[self.num].notes.add(self.note[num])
+        self.flag = True
+
+    def __get_link_source(self):
+        """Parse a link to a source"""
+        num = int(self.data[2 : len(self.data) - 1])
+        if num not in self.sour:
+            self.sour[num] = Source(num=num)
+        page = None
+        while self.__get_line() and self.level > 1:
+            if self.tag == "PAGE":
+                page = self.__get_text()
+        self.flag = True
+        return (self.sour[num], page)
+
+    def __get_memorie(self):
+        """Parse a memorie"""
+        memorie = Memorie()
+        while self.__get_line() and self.level > 1:
+            if self.tag == "TITL":
+                memorie.description = self.__get_text()
+            elif self.tag == "FILE":
+                memorie.url = self.__get_text()
+        self.flag = True
+        return memorie
+
+    def __get_note(self):
+        """Parse a note"""
+        self.note[self.num].text = self.__get_text()
+        self.flag = True
+
+    def __get_ordinance(self):
+        """Parse an ordinance"""
+        ordinance = Ordinance()
+        while self.__get_line() and self.level > 1:
+            if self.tag == "DATE":
+                ordinance.date = self.__get_text()
+            elif self.tag == "TEMP":
+                ordinance.temple_code = self.data
+            elif self.tag == "STAT":
+                ordinance.status = ORDINANCES[self.data]
+            elif self.tag == "FAMC":
+                num = int(self.data[2 : len(self.data) - 1])
+                if num not in self.fam:
+                    self.fam[num] = Fam(tree=self.tree, num=num)
+                ordinance.famc = self.fam[num]
+        self.flag = True
+        return ordinance
+
+    def __add_id(self):
+        """Reset GEDCOM identifiers"""
+        for num in self.fam:
+            if self.fam[num].husb_num:
+                self.fam[num].husb_fid = self.indi[self.fam[num].husb_num].fid
+            if self.fam[num].wife_num:
+                self.fam[num].wife_fid = self.indi[self.fam[num].wife_num].fid
+            for chil in self.fam[num].chil_num:
+                self.fam[num].chil_fid.add(self.indi[chil].fid)
+        for num in self.indi:
+            for famc in self.indi[num].famc_num:
+                self.indi[num].famc_fid.add(
+                    (self.fam[famc].husb_fid, self.fam[famc].wife_fid)
+                )
+            for fams in self.indi[num].fams_num:
+                self.indi[num].fams_fid.add(
+                    (self.fam[fams].husb_fid, self.fam[fams].wife_fid)
+                )
+
+
diff --git a/getmyancestors/classes/gui.py b/getmyancestors/classes/gui.py
new file mode 100644 (file)
index 0000000..239e8a8
--- /dev/null
@@ -0,0 +1,646 @@
+# fstogedcom classes and functions
+import os
+import re
+import time
+import asyncio
+import tempfile
+from threading import Thread
+from diskcache import Cache
+
+from tkinter import (
+    StringVar,
+    IntVar,
+    filedialog,
+    messagebox,
+    Menu,
+    TclError,
+)
+from tkinter.ttk import Frame, Label, Entry, Button, Checkbutton, Treeview, Notebook
+
+from getmyancestors.classes.tree import Indi, Fam, Tree
+from getmyancestors.classes.gedcom import Gedcom
+from getmyancestors.classes.session import Session
+from getmyancestors.classes.translation import translations
+
+tmp_dir = os.path.join(tempfile.gettempdir(), "fstogedcom")
+cache = Cache(tmp_dir)
+lang = cache.get("lang")
+
+
+def _(string):
+    if string in translations and lang in translations[string]:
+        return translations[string][lang]
+    return string
+
+
+class EntryWithMenu(Entry):
+    """Entry widget with right-clic menu to copy/cut/paste"""
+
+    def __init__(self, master, **kw):
+        super().__init__(master, **kw)
+        self.bind("<Button-3>", self.click_right)
+
+    def click_right(self, event):
+        """open menu"""
+        menu = Menu(self, tearoff=0)
+        try:
+            self.selection_get()
+            state = "normal"
+        except TclError:
+            state = "disabled"
+        menu.add_command(label=_("Copy"), command=self.copy, state=state)
+        menu.add_command(label=_("Cut"), command=self.cut, state=state)
+        menu.add_command(label=_("Paste"), command=self.paste)
+        menu.post(event.x_root, event.y_root)
+
+    def copy(self):
+        """copy in clipboard"""
+        self.clipboard_clear()
+        text = self.selection_get()
+        self.clipboard_append(text)
+
+    def cut(self):
+        """move in clipboard"""
+        self.copy()
+        self.delete("sel.first", "sel.last")
+
+    def paste(self):
+        """paste from clipboard"""
+        try:
+            text = self.selection_get(selection="CLIPBOARD")
+            self.insert("insert", text)
+        except TclError:
+            pass
+
+
+class FilesToMerge(Treeview):
+    """List of GEDCOM files to merge"""
+
+    def __init__(self, master, **kwargs):
+        super().__init__(master, selectmode="extended", height=5, **kwargs)
+        self.heading("#0", text=_("Files"))
+        self.column("#0", width=300)
+        self.files = dict()
+        self.bind("<Button-3>", self.popup)
+
+    def add_file(self, filename):
+        """add a GEDCOM file"""
+        if any(f.name == filename for f in self.files.values()):
+            messagebox.showinfo(
+                _("Error"),
+                message=_("File already exist: ") + os.path.basename(filename),
+            )
+            return
+        if not os.path.exists(filename):
+            messagebox.showinfo(
+                _("Error"), message=_("File not found: ") + os.path.basename(filename)
+            )
+            return
+        file = open(filename, "r", encoding="utf-8")
+        new_id = self.insert("", 0, text=os.path.basename(filename))
+        self.files[new_id] = file
+
+    def popup(self, event):
+        """open menu to remove item"""
+        item = self.identify_row(event.y)
+        if item:
+            menu = Menu(self, tearoff=0)
+            menu.add_command(label=_("Remove"), command=self.delete_item(item))
+            menu.post(event.x_root, event.y_root)
+
+    def delete_item(self, item):
+        """return a function to remove a file"""
+
+        def delete():
+            self.files[item].close()
+            self.files.pop(item)
+            self.delete(item)
+
+        return delete
+
+
+class Merge(Frame):
+    """Merge GEDCOM widget"""
+
+    def __init__(self, master, **kwargs):
+        super().__init__(master, **kwargs)
+        warning = Label(
+            self,
+            font=("a", 7),
+            wraplength=300,
+            justify="center",
+            text=_(
+                "Warning: This tool should only be used to merge GEDCOM files from this software. "
+                "If you use other GEDCOM files, the result is not guaranteed."
+            ),
+        )
+        self.files_to_merge = FilesToMerge(self)
+        self.btn_add_file = Button(self, text=_("Add files"), command=self.add_files)
+        buttons = Frame(self, borderwidth=20)
+        self.btn_quit = Button(buttons, text=_("Quit"), command=self.quit)
+        self.btn_save = Button(buttons, text=_("Merge"), command=self.save)
+        warning.pack()
+        self.files_to_merge.pack()
+        self.btn_add_file.pack()
+        self.btn_quit.pack(side="left", padx=(0, 40))
+        self.btn_save.pack(side="right", padx=(40, 0))
+        buttons.pack(side="bottom")
+
+    def add_files(self):
+        """open file explorer to pick a file"""
+        for filename in filedialog.askopenfilenames(
+            title=_("Open"),
+            defaultextension=".ged",
+            filetypes=(("GEDCOM", ".ged"), (_("All files"), "*.*")),
+        ):
+            self.files_to_merge.add_file(filename)
+
+    def save(self):
+        """merge GEDCOM files"""
+        if not self.files_to_merge.files:
+            messagebox.showinfo(_("Error"), message=_("Please add GEDCOM files"))
+            return
+
+        filename = filedialog.asksaveasfilename(
+            title=_("Save as"),
+            defaultextension=".ged",
+            filetypes=(("GEDCOM", ".ged"), (_("All files"), "*.*")),
+        )
+        tree = Tree()
+
+        indi_counter = 0
+        fam_counter = 0
+
+        # read the GEDCOM data
+        for file in self.files_to_merge.files.values():
+            ged = Gedcom(file, tree)
+
+            # add informations about individuals
+            for num in ged.indi:
+                fid = ged.indi[num].fid
+                if fid not in tree.indi:
+                    indi_counter += 1
+                    tree.indi[fid] = Indi(tree=tree, num=indi_counter)
+                    tree.indi[fid].tree = tree
+                    tree.indi[fid].fid = ged.indi[num].fid
+                tree.indi[fid].fams_fid |= ged.indi[num].fams_fid
+                tree.indi[fid].famc_fid |= ged.indi[num].famc_fid
+                tree.indi[fid].name = ged.indi[num].name
+                tree.indi[fid].birthnames = ged.indi[num].birthnames
+                tree.indi[fid].nicknames = ged.indi[num].nicknames
+                tree.indi[fid].aka = ged.indi[num].aka
+                tree.indi[fid].married = ged.indi[num].married
+                tree.indi[fid].gender = ged.indi[num].gender
+                tree.indi[fid].facts = ged.indi[num].facts
+                tree.indi[fid].notes = ged.indi[num].notes
+                tree.indi[fid].sources = ged.indi[num].sources
+                tree.indi[fid].memories = ged.indi[num].memories
+                tree.indi[fid].baptism = ged.indi[num].baptism
+                tree.indi[fid].confirmation = ged.indi[num].confirmation
+                tree.indi[fid].endowment = ged.indi[num].endowment
+                if not (
+                    tree.indi[fid].sealing_child and tree.indi[fid].sealing_child.famc
+                ):
+                    tree.indi[fid].sealing_child = ged.indi[num].sealing_child
+
+            # add informations about families
+            for num in ged.fam:
+                husb, wife = (ged.fam[num].husb_fid, ged.fam[num].wife_fid)
+                if (husb, wife) not in tree.fam:
+                    fam_counter += 1
+                    tree.fam[(husb, wife)] = Fam(husb, wife, tree, fam_counter)
+                    tree.fam[(husb, wife)].tree = tree
+                tree.fam[(husb, wife)].chil_fid |= ged.fam[num].chil_fid
+                tree.fam[(husb, wife)].fid = ged.fam[num].fid
+                tree.fam[(husb, wife)].facts = ged.fam[num].facts
+                tree.fam[(husb, wife)].notes = ged.fam[num].notes
+                tree.fam[(husb, wife)].sources = ged.fam[num].sources
+                tree.fam[(husb, wife)].sealing_spouse = ged.fam[num].sealing_spouse
+
+        # merge notes by text
+        tree.notes = sorted(tree.notes, key=lambda x: x.text)
+        for i, n in enumerate(tree.notes):
+            if i == 0:
+                n.num = 1
+                continue
+            if n.text == tree.notes[i - 1].text:
+                n.num = tree.notes[i - 1].num
+            else:
+                n.num = tree.notes[i - 1].num + 1
+
+        # compute number for family relationships and print GEDCOM file
+        tree.reset_num()
+        with open(filename, "w", encoding="utf-8") as file:
+            tree.print(file)
+        messagebox.showinfo(_("Info"), message=_("Files successfully merged"))
+
+    def quit(self):
+        """prevent exception on quit during download"""
+        super().quit()
+        os._exit(1)
+
+
+class SignIn(Frame):
+    """Sign In widget"""
+
+    def __init__(self, master, **kwargs):
+        super().__init__(master, **kwargs)
+        self.username = StringVar()
+        self.username.set(cache.get("username") or "")
+        self.password = StringVar()
+        label_username = Label(self, text=_("Username:"))
+        entry_username = EntryWithMenu(self, textvariable=self.username, width=30)
+        label_password = Label(self, text=_("Password:"))
+        entry_password = EntryWithMenu(
+            self, show="●", textvariable=self.password, width=30
+        )
+        label_username.grid(row=0, column=0, pady=15, padx=(0, 5))
+        entry_username.grid(row=0, column=1)
+        label_password.grid(row=1, column=0, padx=(0, 5))
+        entry_password.grid(row=1, column=1)
+        entry_username.focus_set()
+        entry_username.bind("<Key>", self.enter)
+        entry_password.bind("<Key>", self.enter)
+
+    def enter(self, evt):
+        """enter event"""
+        if evt.keysym in {"Return", "KP_Enter"}:
+            self.master.master.command_in_thread(self.master.master.login)()
+
+
+class StartIndis(Treeview):
+    """List of starting individuals"""
+
+    def __init__(self, master, **kwargs):
+        super().__init__(
+            master, selectmode="extended", height=5, columns=("fid",), **kwargs
+        )
+        self.heading("#0", text=_("Name"))
+        self.column("#0", width=250)
+        self.column("fid", width=80)
+        self.indis = dict()
+        self.heading("fid", text="Id")
+        self.bind("<Button-3>", self.popup)
+
+    def add_indi(self, fid):
+        """add an individual fid"""
+        if not fid:
+            return None
+        if fid in self.indis.values():
+            messagebox.showinfo(_("Error"), message=_("ID already exist"))
+            return None
+        if not re.match(r"[A-Z0-9]{4}-[A-Z0-9]{3}", fid):
+            messagebox.showinfo(
+                _("Error"), message=_("Invalid FamilySearch ID: ") + fid
+            )
+            return None
+        fs = self.master.master.master.fs
+        data = fs.get_url("/platform/tree/persons/%s" % fid)
+        if data and "persons" in data:
+            if "names" in data["persons"][0]:
+                for name in data["persons"][0]["names"]:
+                    if name["preferred"]:
+                        self.indis[
+                            self.insert(
+                                "", 0, text=name["nameForms"][0]["fullText"], values=fid
+                            )
+                        ] = fid
+                        return True
+        messagebox.showinfo(_("Error"), message=_("Individual not found"))
+        return None
+
+    def popup(self, event):
+        """open menu to remove item"""
+        item = self.identify_row(event.y)
+        if item:
+            menu = Menu(self, tearoff=0)
+            menu.add_command(label=_("Remove"), command=self.delete_item(item))
+            menu.post(event.x_root, event.y_root)
+
+    def delete_item(self, item):
+        """return a function to remove a fid"""
+
+        def delete():
+            self.indis.pop(item)
+            self.delete(item)
+
+        return delete
+
+
+class Options(Frame):
+    """Options form"""
+
+    def __init__(self, master, ordinances=False, **kwargs):
+        super().__init__(master, **kwargs)
+        self.ancestors = IntVar()
+        self.ancestors.set(4)
+        self.descendants = IntVar()
+        self.spouses = IntVar()
+        self.ordinances = IntVar()
+        self.contributors = IntVar()
+        self.start_indis = StartIndis(self)
+        self.fid = StringVar()
+        btn = Frame(self)
+        entry_fid = EntryWithMenu(btn, textvariable=self.fid, width=16)
+        entry_fid.bind("<Key>", self.enter)
+        label_ancestors = Label(self, text=_("Number of generations to ascend"))
+        entry_ancestors = EntryWithMenu(self, textvariable=self.ancestors, width=5)
+        label_descendants = Label(self, text=_("Number of generations to descend"))
+        entry_descendants = EntryWithMenu(self, textvariable=self.descendants, width=5)
+        btn_add_indi = Button(
+            btn, text=_("Add a FamilySearch ID"), command=self.add_indi
+        )
+        btn_spouses = Checkbutton(
+            self,
+            text="\t" + _("Add spouses and couples information"),
+            variable=self.spouses,
+        )
+        btn_ordinances = Checkbutton(
+            self, text="\t" + _("Add Temple information"), variable=self.ordinances
+        )
+        btn_contributors = Checkbutton(
+            self,
+            text="\t" + _("Add list of contributors in notes"),
+            variable=self.contributors,
+        )
+        self.start_indis.grid(row=0, column=0, columnspan=3)
+        entry_fid.grid(row=0, column=0, sticky="w")
+        btn_add_indi.grid(row=0, column=1, sticky="w")
+        btn.grid(row=1, column=0, columnspan=2, sticky="w")
+        entry_ancestors.grid(row=2, column=0, sticky="w")
+        label_ancestors.grid(row=2, column=1, sticky="w")
+        entry_descendants.grid(row=3, column=0, sticky="w")
+        label_descendants.grid(row=3, column=1, sticky="w")
+        btn_spouses.grid(row=4, column=0, columnspan=2, sticky="w")
+        if ordinances:
+            btn_ordinances.grid(row=5, column=0, columnspan=3, sticky="w")
+        btn_contributors.grid(row=6, column=0, columnspan=3, sticky="w")
+        entry_ancestors.focus_set()
+
+    def add_indi(self):
+        """add a fid"""
+        if self.start_indis.add_indi(self.fid.get()):
+            self.fid.set("")
+
+    def enter(self, evt):
+        """enter event"""
+        if evt.keysym in {"Return", "KP_Enter"}:
+            self.add_indi()
+
+
+class Download(Frame):
+    """Main widget"""
+
+    def __init__(self, master, **kwargs):
+        super().__init__(master, borderwidth=20, **kwargs)
+        self.fs = None
+        self.tree = None
+        self.logfile = None
+
+        # User information
+        self.info_tree = False
+        self.start_time = None
+        info = Frame(self, borderwidth=10)
+        self.info_label = Label(
+            info,
+            wraplength=350,
+            borderwidth=20,
+            justify="center",
+            font=("a", 10, "bold"),
+        )
+        self.info_indis = Label(info)
+        self.info_fams = Label(info)
+        self.info_sources = Label(info)
+        self.info_notes = Label(info)
+        self.time = Label(info)
+        self.info_label.grid(row=0, column=0, columnspan=2)
+        self.info_indis.grid(row=1, column=0)
+        self.info_fams.grid(row=1, column=1)
+        self.info_sources.grid(row=2, column=0)
+        self.info_notes.grid(row=2, column=1)
+        self.time.grid(row=3, column=0, columnspan=2)
+
+        self.form = Frame(self)
+        self.sign_in = SignIn(self.form)
+        self.options = None
+        self.title = Label(
+            self, text=_("Sign In to FamilySearch"), font=("a", 12, "bold")
+        )
+        buttons = Frame(self)
+        self.btn_quit = Button(
+            buttons, text=_("Quit"), command=Thread(target=self.quit).start
+        )
+        self.btn_valid = Button(
+            buttons, text=_("Sign In"), command=self.command_in_thread(self.login)
+        )
+        self.title.pack()
+        self.sign_in.pack()
+        self.form.pack()
+        self.btn_quit.pack(side="left", padx=(0, 40))
+        self.btn_valid.pack(side="right", padx=(40, 0))
+        info.pack()
+        buttons.pack(side="bottom")
+        self.pack()
+        self.update_needed = False
+
+    def info(self, text):
+        """dislay informations"""
+        self.info_label.config(text=text)
+
+    def save(self):
+        """save the GEDCOM file"""
+        filename = filedialog.asksaveasfilename(
+            title=_("Save as"),
+            defaultextension=".ged",
+            filetypes=(("GEDCOM", ".ged"), (_("All files"), "*.*")),
+        )
+        if not filename:
+            return
+        with open(filename, "w", encoding="utf-8") as file:
+            self.tree.print(file)
+
+    def login(self):
+        """log in FamilySearch"""
+        global _
+        username = self.sign_in.username.get()
+        password = self.sign_in.password.get()
+        if not (username and password):
+            messagebox.showinfo(
+                message=_("Please enter your FamilySearch username and password.")
+            )
+            return
+        self.btn_valid.config(state="disabled")
+        self.info(_("Login to FamilySearch..."))
+        self.logfile = open("download.log", "w", encoding="utf-8")
+        self.fs = Session(
+            self.sign_in.username.get(),
+            self.sign_in.password.get(),
+            verbose=True,
+            logfile=self.logfile,
+            timeout=1,
+        )
+        if not self.fs.logged:
+            messagebox.showinfo(
+                _("Error"), message=_("The username or password was incorrect")
+            )
+            self.btn_valid.config(state="normal")
+            self.info("")
+            return
+        self.tree = Tree(self.fs)
+        _ = self.fs._
+        self.title.config(text=_("Options"))
+        cache.delete("lang")
+        cache.add("lang", self.fs.lang)
+        cache.delete("username")
+        cache.add("username", username)
+        url = "/service/tree/tree-data/reservations/person/%s/ordinances" % self.fs.fid
+        lds_account = self.fs.get_url(url, {}).get("status") == "OK"
+        self.options = Options(self.form, lds_account)
+        self.info("")
+        self.sign_in.destroy()
+        self.options.pack()
+        self.master.change_lang()
+        self.btn_valid.config(
+            command=self.command_in_thread(self.download),
+            state="normal",
+            text=_("Download"),
+        )
+        self.options.start_indis.add_indi(self.fs.fid)
+        self.update_needed = False
+
+    def quit(self):
+        """prevent exception during download"""
+        self.update_needed = False
+        if self.logfile:
+            self.logfile.close()
+        super().quit()
+        os._exit(1)
+
+    def download(self):
+        """download family tree"""
+        todo = [
+            self.options.start_indis.indis[key]
+            for key in sorted(self.options.start_indis.indis)
+        ]
+        for fid in todo:
+            if not re.match(r"[A-Z0-9]{4}-[A-Z0-9]{3}", fid):
+                messagebox.showinfo(
+                    _("Error"), message=_("Invalid FamilySearch ID: ") + fid
+                )
+                return
+        self.start_time = time.time()
+        self.options.destroy()
+        self.form.destroy()
+        self.title.config(text="FamilySearch to GEDCOM")
+        self.btn_valid.config(state="disabled")
+        self.info(_("Downloading starting individuals..."))
+        self.info_tree = True
+        self.tree.add_indis(todo)
+        todo = set(todo)
+        done = set()
+        for i in range(self.options.ancestors.get()):
+            if not todo:
+                break
+            done |= todo
+            self.info(_("Downloading %s. of generations of ancestors...") % (i + 1))
+            todo = self.tree.add_parents(todo) - done
+
+        todo = set(self.tree.indi.keys())
+        done = set()
+        for i in range(self.options.descendants.get()):
+            if not todo:
+                break
+            done |= todo
+            self.info(_("Downloading %s. of generations of descendants...") % (i + 1))
+            todo = self.tree.add_children(todo) - done
+
+        if self.options.spouses.get():
+            self.info(_("Downloading spouses and marriage information..."))
+            todo = set(self.tree.indi.keys())
+            self.tree.add_spouses(todo)
+        ordi = self.options.ordinances.get()
+        cont = self.options.contributors.get()
+
+        async def download_stuff(loop):
+            futures = set()
+            for fid, indi in self.tree.indi.items():
+                futures.add(loop.run_in_executor(None, indi.get_notes))
+                if ordi:
+                    futures.add(
+                        loop.run_in_executor(None, self.tree.add_ordinances, fid)
+                    )
+                if cont:
+                    futures.add(loop.run_in_executor(None, indi.get_contributors))
+            for fam in self.tree.fam.values():
+                futures.add(loop.run_in_executor(None, fam.get_notes))
+                if cont:
+                    futures.add(loop.run_in_executor(None, fam.get_contributors))
+            for future in futures:
+                await future
+
+        loop = asyncio.get_event_loop()
+        self.info(
+            _("Downloading notes")
+            + ((("," if cont else _(" and")) + _(" ordinances")) if ordi else "")
+            + (_(" and contributors") if cont else "")
+            + "..."
+        )
+        loop.run_until_complete(download_stuff(loop))
+
+        self.tree.reset_num()
+        self.btn_valid.config(command=self.save, state="normal", text=_("Save"))
+        self.info(text=_("Success ! Click below to save your GEDCOM file"))
+        self.update_info_tree()
+        self.update_needed = False
+
+    def command_in_thread(self, func):
+        """command to update widget in a new Thread"""
+
+        def res():
+            self.update_needed = True
+            Thread(target=self.update_gui).start()
+            Thread(target=func).start()
+
+        return res
+
+    def update_info_tree(self):
+        """update informations"""
+        if self.info_tree and self.start_time and self.tree:
+            self.info_indis.config(text=_("Individuals: %s") % len(self.tree.indi))
+            self.info_fams.config(text=_("Families: %s") % len(self.tree.fam))
+            self.info_sources.config(text=_("Sources: %s") % len(self.tree.sources))
+            self.info_notes.config(text=_("Notes: %s") % len(self.tree.notes))
+            t = round(time.time() - self.start_time)
+            minutes = t // 60
+            seconds = t % 60
+            self.time.config(
+                text=_("Elapsed time: %s:%s") % (minutes, str(seconds).zfill(2))
+            )
+
+    def update_gui(self):
+        """update widget"""
+        while self.update_needed:
+            self.update_info_tree()
+            self.master.update()
+            time.sleep(0.1)
+
+
+class FStoGEDCOM(Notebook):
+    """Main notebook"""
+
+    def __init__(self, master, **kwargs):
+        super().__init__(master, width=400, **kwargs)
+        self.download = Download(self)
+        self.merge = Merge(self)
+        self.add(self.download, text=_("Download GEDCOM"))
+        self.add(self.merge, text=_("Merge GEDCOMs"))
+        self.pack()
+
+    def change_lang(self):
+        """update text with user's language"""
+        self.tab(self.index(self.download), text=_("Download GEDCOM"))
+        self.tab(self.index(self.merge), text=_("Merge GEDCOMs"))
+        self.download.btn_quit.config(text=_("Quit"))
+        self.merge.btn_quit.config(text=_("Quit"))
+        self.merge.btn_save.config(text=_("Merge"))
+        self.merge.btn_add_file.config(text=_("Add files"))
diff --git a/getmyancestors/classes/session.py b/getmyancestors/classes/session.py
new file mode 100644 (file)
index 0000000..0cc6099
--- /dev/null
@@ -0,0 +1,174 @@
+# global imports
+import sys
+import time
+
+import requests
+
+# local imports
+from getmyancestors.classes.translation import translations
+
+
+class Session:
+    """Create a FamilySearch session
+    :param username and password: valid FamilySearch credentials
+    :param verbose: True to active verbose mode
+    :param logfile: a file object or similar
+    :param timeout: time before retry a request
+    """
+
+    def __init__(self, username, password, verbose=False, logfile=False, timeout=60):
+        self.username = username
+        self.password = password
+        self.verbose = verbose
+        self.logfile = logfile
+        self.timeout = timeout
+        self.fid = self.lang = self.display_name = None
+        self.counter = 0
+        self.logged = self.login()
+
+    def write_log(self, text):
+        """write text in the log file"""
+        log = "[%s]: %s\n" % (time.strftime("%Y-%m-%d %H:%M:%S"), text)
+        if self.verbose:
+            sys.stderr.write(log)
+        if self.logfile:
+            self.logfile.write(log)
+
+    def login(self):
+        """retrieve FamilySearch session ID
+        (https://familysearch.org/developers/docs/guides/oauth2)
+        """
+        while True:
+            try:
+                url = "https://www.familysearch.org/auth/familysearch/login"
+                self.write_log("Downloading: " + url)
+                r = requests.get(url, params={"ldsauth": False}, allow_redirects=False)
+                url = r.headers["Location"]
+                self.write_log("Downloading: " + url)
+                r = requests.get(url, allow_redirects=False)
+                idx = r.text.index('name="params" value="')
+                span = r.text[idx + 21 :].index('"')
+                params = r.text[idx + 21 : idx + 21 + span]
+
+                url = "https://ident.familysearch.org/cis-web/oauth2/v3/authorization"
+                self.write_log("Downloading: " + url)
+                r = requests.post(
+                    url,
+                    data={
+                        "params": params,
+                        "userName": self.username,
+                        "password": self.password,
+                    },
+                    allow_redirects=False,
+                )
+
+                if "The username or password was incorrect" in r.text:
+                    self.write_log("The username or password was incorrect")
+                    return False
+
+                if "Invalid Oauth2 Request" in r.text:
+                    self.write_log("Invalid Oauth2 Request")
+                    time.sleep(self.timeout)
+                    continue
+
+                url = r.headers["Location"]
+                self.write_log("Downloading: " + url)
+                r = requests.get(url, allow_redirects=False)
+                self.fssessionid = r.cookies["fssessionid"]
+            except requests.exceptions.ReadTimeout:
+                self.write_log("Read timed out")
+                continue
+            except requests.exceptions.ConnectionError:
+                self.write_log("Connection aborted")
+                time.sleep(self.timeout)
+                continue
+            except requests.exceptions.HTTPError:
+                self.write_log("HTTPError")
+                time.sleep(self.timeout)
+                continue
+            except KeyError:
+                self.write_log("KeyError")
+                time.sleep(self.timeout)
+                continue
+            except ValueError:
+                self.write_log("ValueError")
+                time.sleep(self.timeout)
+                continue
+            self.write_log("FamilySearch session id: " + self.fssessionid)
+            self.set_current()
+            return True
+
+    def get_url(self, url, headers=None):
+        """retrieve JSON structure from a FamilySearch URL"""
+        self.counter += 1
+        if headers is None:
+            headers = {"Accept": "application/x-gedcomx-v1+json"}
+        while True:
+            try:
+                self.write_log("Downloading: " + url)
+                r = requests.get(
+                    "https://familysearch.org" + url,
+                    cookies={"fssessionid": self.fssessionid},
+                    timeout=self.timeout,
+                    headers=headers,
+                )
+            except requests.exceptions.ReadTimeout:
+                self.write_log("Read timed out")
+                continue
+            except requests.exceptions.ConnectionError:
+                self.write_log("Connection aborted")
+                time.sleep(self.timeout)
+                continue
+            self.write_log("Status code: %s" % r.status_code)
+            if r.status_code == 204:
+                return None
+            if r.status_code in {404, 405, 410, 500}:
+                self.write_log("WARNING: " + url)
+                return None
+            if r.status_code == 401:
+                self.login()
+                continue
+            try:
+                r.raise_for_status()
+            except requests.exceptions.HTTPError:
+                self.write_log("HTTPError")
+                if r.status_code == 403:
+                    if (
+                        "message" in r.json()["errors"][0]
+                        and r.json()["errors"][0]["message"]
+                        == "Unable to get ordinances."
+                    ):
+                        self.write_log(
+                            "Unable to get ordinances. "
+                            "Try with an LDS account or without option -c."
+                        )
+                        return "error"
+                    self.write_log(
+                        "WARNING: code 403 from %s %s"
+                        % (url, r.json()["errors"][0]["message"] or "")
+                    )
+                    return None
+                time.sleep(self.timeout)
+                continue
+            try:
+                return r.json()
+            except Exception as e:
+                self.write_log("WARNING: corrupted file from %s, error: %s" % (url, e))
+                return None
+
+    def set_current(self):
+        """retrieve FamilySearch current user ID, name and language"""
+        url = "/platform/users/current"
+        data = self.get_url(url)
+        if data:
+            self.fid = data["users"][0]["personId"]
+            self.lang = data["users"][0]["preferredLanguage"]
+            self.display_name = data["users"][0]["displayName"]
+
+    def _(self, string):
+        """translate a string into user's language
+        TODO replace translation file for gettext format
+        """
+        if string in translations and self.lang in translations[string]:
+            return translations[string][self.lang]
+        return string
diff --git a/getmyancestors/classes/tree.py b/getmyancestors/classes/tree.py
new file mode 100644 (file)
index 0000000..4bea414
--- /dev/null
@@ -0,0 +1,871 @@
+import sys
+import re
+import time
+import asyncio
+from urllib.parse import unquote
+
+# global imports
+import babelfish
+
+# local imports
+import getmyancestors
+from getmyancestors.classes.constants import (
+    MAX_PERSONS,
+    FACT_EVEN,
+    FACT_TAGS,
+    ORDINANCES_STATUS,
+)
+
+# getmyancestors classes and functions
+def cont(string):
+    """parse a GEDCOM line adding CONT and CONT tags if necessary"""
+    level = int(string[:1]) + 1
+    lines = string.splitlines()
+    res = list()
+    max_len = 255
+    for line in lines:
+        c_line = line
+        to_conc = list()
+        while len(c_line.encode("utf-8")) > max_len:
+            index = min(max_len, len(c_line) - 2)
+            while (
+                len(c_line[:index].encode("utf-8")) > max_len
+                or re.search(r"[ \t\v]", c_line[index - 1 : index + 1])
+            ) and index > 1:
+                index -= 1
+            to_conc.append(c_line[:index])
+            c_line = c_line[index:]
+            max_len = 248
+        to_conc.append(c_line)
+        res.append(("\n%s CONC " % level).join(to_conc))
+        max_len = 248
+    return ("\n%s CONT " % level).join(res) + "\n"
+
+
+class Note:
+    """GEDCOM Note class
+    :param text: the Note content
+    :param tree: a Tree object
+    :param num: the GEDCOM identifier
+    """
+
+    counter = 0
+
+    def __init__(self, text="", tree=None, num=None):
+        if num:
+            self.num = num
+        else:
+            Note.counter += 1
+            self.num = Note.counter
+        self.text = text.strip()
+
+        if tree:
+            tree.notes.append(self)
+
+    def print(self, file=sys.stdout):
+        """print Note in GEDCOM format"""
+        file.write(cont("0 @N%s@ NOTE %s" % (self.num, self.text)))
+
+    def link(self, file=sys.stdout, level=1):
+        """print the reference in GEDCOM format"""
+        file.write("%s NOTE @N%s@\n" % (level, self.num))
+
+
+class Source:
+    """GEDCOM Source class
+    :param data: FS Source data
+    :param tree: a Tree object
+    :param num: the GEDCOM identifier
+    """
+
+    counter = 0
+
+    def __init__(self, data=None, tree=None, num=None):
+        if num:
+            self.num = num
+        else:
+            Source.counter += 1
+            self.num = Source.counter
+
+        self.tree = tree
+        self.url = self.citation = self.title = self.fid = None
+        self.notes = set()
+        if data:
+            self.fid = data["id"]
+            if "about" in data:
+                self.url = data["about"].replace(
+                    "familysearch.org/platform/memories/memories",
+                    "www.familysearch.org/photos/artifacts",
+                )
+            if "citations" in data:
+                self.citation = data["citations"][0]["value"]
+            if "titles" in data:
+                self.title = data["titles"][0]["value"]
+            if "notes" in data:
+                for n in data["notes"]:
+                    if n["text"]:
+                        self.notes.add(Note(n["text"], self.tree))
+
+    def print(self, file=sys.stdout):
+        """print Source in GEDCOM format"""
+        file.write("0 @S%s@ SOUR \n" % self.num)
+        if self.title:
+            file.write(cont("1 TITL " + self.title))
+        if self.citation:
+            file.write(cont("1 AUTH " + self.citation))
+        if self.url:
+            file.write(cont("1 PUBL " + self.url))
+        for n in self.notes:
+            n.link(file, 1)
+        file.write("1 REFN %s\n" % self.fid)
+
+    def link(self, file=sys.stdout, level=1):
+        """print the reference in GEDCOM format"""
+        file.write("%s SOUR @S%s@\n" % (level, self.num))
+
+
+class Fact:
+    """GEDCOM Fact class
+    :param data: FS Fact data
+    :param tree: a tree object
+    """
+
+    def __init__(self, data=None, tree=None):
+        self.value = self.type = self.date = self.place = self.note = self.map = None
+        if data:
+            if "value" in data:
+                self.value = data["value"]
+            if "type" in data:
+                self.type = data["type"]
+                if self.type in FACT_EVEN:
+                    self.type = tree.fs._(FACT_EVEN[self.type])
+                elif self.type[:6] == "data:,":
+                    self.type = unquote(self.type[6:])
+                elif self.type not in FACT_TAGS:
+                    self.type = None
+            if "date" in data:
+                self.date = data["date"]["original"]
+            if "place" in data:
+                place = data["place"]
+                self.place = place["original"]
+                if "description" in place and place["description"][1:] in tree.places:
+                    self.map = tree.places[place["description"][1:]]
+            if "changeMessage" in data["attribution"]:
+                self.note = Note(data["attribution"]["changeMessage"], tree)
+            if self.type == "http://gedcomx.org/Death" and not (
+                self.date or self.place
+            ):
+                self.value = "Y"
+
+    def print(self, file=sys.stdout):
+        """print Fact in GEDCOM format
+        the GEDCOM TAG depends on the type, defined in FACT_TAGS
+        """
+        if self.type in FACT_TAGS:
+            tmp = "1 " + FACT_TAGS[self.type]
+            if self.value:
+                tmp += " " + self.value
+            file.write(cont(tmp))
+        elif self.type:
+            file.write("1 EVEN\n2 TYPE %s\n" % self.type)
+            if self.value:
+                file.write(cont("2 NOTE Description: " + self.value))
+        else:
+            return
+        if self.date:
+            file.write(cont("2 DATE " + self.date))
+        if self.place:
+            file.write(cont("2 PLAC " + self.place))
+        if self.map:
+            latitude, longitude = self.map
+            file.write("3 MAP\n4 LATI %s\n4 LONG %s\n" % (latitude, longitude))
+        if self.note:
+            self.note.link(file, 2)
+
+
+class Memorie:
+    """GEDCOM Memorie class
+    :param data: FS Memorie data
+    """
+
+    def __init__(self, data=None):
+        self.description = self.url = None
+        if data and "links" in data:
+            self.url = data["about"]
+            if "titles" in data:
+                self.description = data["titles"][0]["value"]
+            if "descriptions" in data:
+                self.description = (
+                    "" if not self.description else self.description + "\n"
+                ) + data["descriptions"][0]["value"]
+
+    def print(self, file=sys.stdout):
+        """print Memorie in GEDCOM format"""
+        file.write("1 OBJE\n2 FORM URL\n")
+        if self.description:
+            file.write(cont("2 TITL " + self.description))
+        if self.url:
+            file.write(cont("2 FILE " + self.url))
+
+
+class Name:
+    """GEDCOM Name class
+    :param data: FS Name data
+    :param tree: a Tree object
+    """
+
+    def __init__(self, data=None, tree=None):
+        self.given = ""
+        self.surname = ""
+        self.prefix = None
+        self.suffix = None
+        self.note = None
+        if data:
+            if "parts" in data["nameForms"][0]:
+                for z in data["nameForms"][0]["parts"]:
+                    if z["type"] == "http://gedcomx.org/Given":
+                        self.given = z["value"]
+                    if z["type"] == "http://gedcomx.org/Surname":
+                        self.surname = z["value"]
+                    if z["type"] == "http://gedcomx.org/Prefix":
+                        self.prefix = z["value"]
+                    if z["type"] == "http://gedcomx.org/Suffix":
+                        self.suffix = z["value"]
+            if "changeMessage" in data["attribution"]:
+                self.note = Note(data["attribution"]["changeMessage"], tree)
+
+    def print(self, file=sys.stdout, typ=None):
+        """print Name in GEDCOM format
+        :param typ: type for additional names
+        """
+        tmp = "1 NAME %s /%s/" % (self.given, self.surname)
+        if self.suffix:
+            tmp += " " + self.suffix
+        file.write(cont(tmp))
+        if typ:
+            file.write("2 TYPE %s\n" % typ)
+        if self.prefix:
+            file.write("2 NPFX %s\n" % self.prefix)
+        if self.note:
+            self.note.link(file, 2)
+
+
+class Ordinance:
+    """GEDCOM Ordinance class
+    :param data: FS Ordinance data
+    """
+
+    def __init__(self, data=None):
+        self.date = self.temple_code = self.status = self.famc = None
+        if data:
+            if "completedDate" in data:
+                self.date = data["completedDate"]
+            if "completedTemple" in data:
+                self.temple_code = data["completedTemple"]["code"]
+            self.status = data["status"]
+
+    def print(self, file=sys.stdout):
+        """print Ordinance in Gecom format"""
+        if self.date:
+            file.write(cont("2 DATE " + self.date))
+        if self.temple_code:
+            file.write("2 TEMP %s\n" % self.temple_code)
+        if self.status in ORDINANCES_STATUS:
+            file.write("2 STAT %s\n" % ORDINANCES_STATUS[self.status])
+        if self.famc:
+            file.write("2 FAMC @F%s@\n" % self.famc.num)
+
+
+class Indi:
+    """GEDCOM individual class
+    :param fid' FamilySearch id
+    :param tree: a tree object
+    :param num: the GEDCOM identifier
+    """
+
+    counter = 0
+
+    def __init__(self, fid=None, tree=None, num=None):
+        if num:
+            self.num = num
+        else:
+            Indi.counter += 1
+            self.num = Indi.counter
+        self.fid = fid
+        self.tree = tree
+        self.famc_fid = set()
+        self.fams_fid = set()
+        self.famc_num = set()
+        self.fams_num = set()
+        self.name = None
+        self.gender = None
+        self.living = None
+        self.parents = set()
+        self.spouses = set()
+        self.children = set()
+        self.baptism = self.confirmation = self.initiatory = None
+        self.endowment = self.sealing_child = None
+        self.nicknames = set()
+        self.facts = set()
+        self.birthnames = set()
+        self.married = set()
+        self.aka = set()
+        self.notes = set()
+        self.sources = set()
+        self.memories = set()
+
+    def add_data(self, data):
+        """add FS individual data"""
+        if data:
+            self.living = data["living"]
+            for x in data["names"]:
+                if x["preferred"]:
+                    self.name = Name(x, self.tree)
+                else:
+                    if x["type"] == "http://gedcomx.org/Nickname":
+                        self.nicknames.add(Name(x, self.tree))
+                    if x["type"] == "http://gedcomx.org/BirthName":
+                        self.birthnames.add(Name(x, self.tree))
+                    if x["type"] == "http://gedcomx.org/AlsoKnownAs":
+                        self.aka.add(Name(x, self.tree))
+                    if x["type"] == "http://gedcomx.org/MarriedName":
+                        self.married.add(Name(x, self.tree))
+            if "gender" in data:
+                if data["gender"]["type"] == "http://gedcomx.org/Male":
+                    self.gender = "M"
+                elif data["gender"]["type"] == "http://gedcomx.org/Female":
+                    self.gender = "F"
+                elif data["gender"]["type"] == "http://gedcomx.org/Unknown":
+                    self.gender = "U"
+            if "facts" in data:
+                for x in data["facts"]:
+                    if x["type"] == "http://familysearch.org/v1/LifeSketch":
+                        self.notes.add(
+                            Note(
+                                "=== %s ===\n%s"
+                                % (self.tree.fs._("Life Sketch"), x.get("value", "")),
+                                self.tree,
+                            )
+                        )
+                    else:
+                        self.facts.add(Fact(x, self.tree))
+            if "sources" in data:
+                sources = self.tree.fs.get_url(
+                    "/platform/tree/persons/%s/sources" % self.fid
+                )
+                if sources:
+                    quotes = dict()
+                    for quote in sources["persons"][0]["sources"]:
+                        quotes[quote["descriptionId"]] = (
+                            quote["attribution"]["changeMessage"]
+                            if "changeMessage" in quote["attribution"]
+                            else None
+                        )
+                    for source in sources["sourceDescriptions"]:
+                        if source["id"] not in self.tree.sources:
+                            self.tree.sources[source["id"]] = Source(source, self.tree)
+                        self.sources.add(
+                            (self.tree.sources[source["id"]], quotes[source["id"]])
+                        )
+            if "evidence" in data:
+                url = "/platform/tree/persons/%s/memories" % self.fid
+                memorie = self.tree.fs.get_url(url)
+                if memorie and "sourceDescriptions" in memorie:
+                    for x in memorie["sourceDescriptions"]:
+                        if x["mediaType"] == "text/plain":
+                            text = "\n".join(
+                                val.get("value", "")
+                                for val in x.get("titles", [])
+                                + x.get("descriptions", [])
+                            )
+                            self.notes.add(Note(text, self.tree))
+                        else:
+                            self.memories.add(Memorie(x))
+
+    def add_fams(self, fams):
+        """add family fid (for spouse or parent)"""
+        self.fams_fid.add(fams)
+
+    def add_famc(self, famc):
+        """add family fid (for child)"""
+        self.famc_fid.add(famc)
+
+    def get_notes(self):
+        """retrieve individual notes"""
+        notes = self.tree.fs.get_url("/platform/tree/persons/%s/notes" % self.fid)
+        if notes:
+            for n in notes["persons"][0]["notes"]:
+                text_note = "=== %s ===\n" % n["subject"] if "subject" in n else ""
+                text_note += n["text"] + "\n" if "text" in n else ""
+                self.notes.add(Note(text_note, self.tree))
+
+    def get_ordinances(self):
+        """retrieve LDS ordinances
+        need a LDS account
+        """
+        res = []
+        famc = False
+        if self.living:
+            return res, famc
+        url = "/service/tree/tree-data/reservations/person/%s/ordinances" % self.fid
+        data = self.tree.fs.get_url(url, {})
+        if data:
+            for key, o in data["data"].items():
+                if key == "baptism":
+                    self.baptism = Ordinance(o)
+                elif key == "confirmation":
+                    self.confirmation = Ordinance(o)
+                elif key == "initiatory":
+                    self.initiatory = Ordinance(o)
+                elif key == "endowment":
+                    self.endowment = Ordinance(o)
+                elif key == "sealingsToParents":
+                    for subo in o:
+                        self.sealing_child = Ordinance(subo)
+                        relationships = subo.get("relationships", {})
+                        father = relationships.get("parent1Id")
+                        mother = relationships.get("parent2Id")
+                        if father and mother:
+                            famc = father, mother
+                elif key == "sealingsToSpouses":
+                    res += o
+        return res, famc
+
+    def get_contributors(self):
+        """retrieve contributors"""
+        temp = set()
+        url = "/platform/tree/persons/%s/changes" % self.fid
+        data = self.tree.fs.get_url(url, {"Accept": "application/x-gedcomx-atom+json"})
+        if data:
+            for entries in data["entries"]:
+                for contributors in entries["contributors"]:
+                    temp.add(contributors["name"])
+        if temp:
+            text = "=== %s ===\n%s" % (
+                self.tree.fs._("Contributors"),
+                "\n".join(sorted(temp)),
+            )
+            for n in self.tree.notes:
+                if n.text == text:
+                    self.notes.add(n)
+                    return
+            self.notes.add(Note(text, self.tree))
+
+    def print(self, file=sys.stdout):
+        """print individual in GEDCOM format"""
+        file.write("0 @I%s@ INDI\n" % self.num)
+        if self.name:
+            self.name.print(file)
+        for o in self.nicknames:
+            file.write(cont("2 NICK %s %s" % (o.given, o.surname)))
+        for o in self.birthnames:
+            o.print(file)
+        for o in self.aka:
+            o.print(file, "aka")
+        for o in self.married:
+            o.print(file, "married")
+        if self.gender:
+            file.write("1 SEX %s\n" % self.gender)
+        for o in self.facts:
+            o.print(file)
+        for o in self.memories:
+            o.print(file)
+        if self.baptism:
+            file.write("1 BAPL\n")
+            self.baptism.print(file)
+        if self.confirmation:
+            file.write("1 CONL\n")
+            self.confirmation.print(file)
+        if self.initiatory:
+            file.write("1 WAC\n")
+            self.initiatory.print(file)
+        if self.endowment:
+            file.write("1 ENDL\n")
+            self.endowment.print(file)
+        if self.sealing_child:
+            file.write("1 SLGC\n")
+            self.sealing_child.print(file)
+        for num in self.fams_num:
+            file.write("1 FAMS @F%s@\n" % num)
+        for num in self.famc_num:
+            file.write("1 FAMC @F%s@\n" % num)
+        file.write("1 _FSFTID %s\n" % self.fid)
+        for o in self.notes:
+            o.link(file)
+        for source, quote in self.sources:
+            source.link(file, 1)
+            if quote:
+                file.write(cont("2 PAGE " + quote))
+
+
+class Fam:
+    """GEDCOM family class
+    :param husb: husbant fid
+    :param wife: wife fid
+    :param tree: a Tree object
+    :param num: a GEDCOM identifier
+    """
+
+    counter = 0
+
+    def __init__(self, husb=None, wife=None, tree=None, num=None):
+        if num:
+            self.num = num
+        else:
+            Fam.counter += 1
+            self.num = Fam.counter
+        self.husb_fid = husb if husb else None
+        self.wife_fid = wife if wife else None
+        self.tree = tree
+        self.husb_num = self.wife_num = self.fid = None
+        self.facts = set()
+        self.sealing_spouse = None
+        self.chil_fid = set()
+        self.chil_num = set()
+        self.notes = set()
+        self.sources = set()
+
+    def add_child(self, child):
+        """add a child fid to the family"""
+        if child not in self.chil_fid:
+            self.chil_fid.add(child)
+
+    def add_marriage(self, fid):
+        """retrieve and add marriage information
+        :param fid: the marriage fid
+        """
+        if not self.fid:
+            self.fid = fid
+            url = "/platform/tree/couple-relationships/%s" % self.fid
+            data = self.tree.fs.get_url(url)
+            if data:
+                if "facts" in data["relationships"][0]:
+                    for x in data["relationships"][0]["facts"]:
+                        self.facts.add(Fact(x, self.tree))
+                if "sources" in data["relationships"][0]:
+                    quotes = dict()
+                    for x in data["relationships"][0]["sources"]:
+                        quotes[x["descriptionId"]] = (
+                            x["attribution"]["changeMessage"]
+                            if "changeMessage" in x["attribution"]
+                            else None
+                        )
+                    new_sources = quotes.keys() - self.tree.sources.keys()
+                    if new_sources:
+                        sources = self.tree.fs.get_url(
+                            "/platform/tree/couple-relationships/%s/sources" % self.fid
+                        )
+                        for source in sources["sourceDescriptions"]:
+                            if (
+                                source["id"] in new_sources
+                                and source["id"] not in self.tree.sources
+                            ):
+                                self.tree.sources[source["id"]] = Source(
+                                    source, self.tree
+                                )
+                    for source_fid in quotes:
+                        self.sources.add(
+                            (self.tree.sources[source_fid], quotes[source_fid])
+                        )
+
+    def get_notes(self):
+        """retrieve marriage notes"""
+        if self.fid:
+            notes = self.tree.fs.get_url(
+                "/platform/tree/couple-relationships/%s/notes" % self.fid
+            )
+            if notes:
+                for n in notes["relationships"][0]["notes"]:
+                    text_note = "=== %s ===\n" % n["subject"] if "subject" in n else ""
+                    text_note += n["text"] + "\n" if "text" in n else ""
+                    self.notes.add(Note(text_note, self.tree))
+
+    def get_contributors(self):
+        """retrieve contributors"""
+        if self.fid:
+            temp = set()
+            url = "/platform/tree/couple-relationships/%s/changes" % self.fid
+            data = self.tree.fs.get_url(
+                url, {"Accept": "application/x-gedcomx-atom+json"}
+            )
+            if data:
+                for entries in data["entries"]:
+                    for contributors in entries["contributors"]:
+                        temp.add(contributors["name"])
+            if temp:
+                text = "=== %s ===\n%s" % (
+                    self.tree.fs._("Contributors"),
+                    "\n".join(sorted(temp)),
+                )
+                for n in self.tree.notes:
+                    if n.text == text:
+                        self.notes.add(n)
+                        return
+                self.notes.add(Note(text, self.tree))
+
+    def print(self, file=sys.stdout):
+        """print family information in GEDCOM format"""
+        file.write("0 @F%s@ FAM\n" % self.num)
+        if self.husb_num:
+            file.write("1 HUSB @I%s@\n" % self.husb_num)
+        if self.wife_num:
+            file.write("1 WIFE @I%s@\n" % self.wife_num)
+        for num in self.chil_num:
+            file.write("1 CHIL @I%s@\n" % num)
+        for o in self.facts:
+            o.print(file)
+        if self.sealing_spouse:
+            file.write("1 SLGS\n")
+            self.sealing_spouse.print(file)
+        if self.fid:
+            file.write("1 _FSFTID %s\n" % self.fid)
+        for o in self.notes:
+            o.link(file)
+        for source, quote in self.sources:
+            source.link(file, 1)
+            if quote:
+                file.write(cont("2 PAGE " + quote))
+
+
+class Tree:
+    """family tree class
+    :param fs: a Session object
+    """
+
+    def __init__(self, fs=None):
+        self.fs = fs
+        self.indi = dict()
+        self.fam = dict()
+        self.notes = list()
+        self.sources = dict()
+        self.places = dict()
+        self.display_name = self.lang = None
+        if fs:
+            self.display_name = fs.display_name
+            self.lang = babelfish.Language.fromalpha2(fs.lang).name
+
+    def add_indis(self, fids):
+        """add individuals to the family tree
+        :param fids: an iterable of fid
+        """
+
+        async def add_datas(loop, data):
+            futures = set()
+            for person in data["persons"]:
+                self.indi[person["id"]] = Indi(person["id"], self)
+                futures.add(
+                    loop.run_in_executor(None, self.indi[person["id"]].add_data, person)
+                )
+            for future in futures:
+                await future
+
+        new_fids = [fid for fid in fids if fid and fid not in self.indi]
+        loop = asyncio.new_event_loop()
+        asyncio.set_event_loop(loop)
+        while new_fids:
+            data = self.fs.get_url(
+                "/platform/tree/persons?pids=" + ",".join(new_fids[:MAX_PERSONS])
+            )
+            if data:
+                if "places" in data:
+                    for place in data["places"]:
+                        if place["id"] not in self.places:
+                            self.places[place["id"]] = (
+                                str(place["latitude"]),
+                                str(place["longitude"]),
+                            )
+                loop.run_until_complete(add_datas(loop, data))
+                if "childAndParentsRelationships" in data:
+                    for rel in data["childAndParentsRelationships"]:
+                        father = (
+                            rel["parent1"]["resourceId"] if "parent1" in rel else None
+                        )
+                        mother = (
+                            rel["parent2"]["resourceId"] if "parent2" in rel else None
+                        )
+                        child = rel["child"]["resourceId"] if "child" in rel else None
+                        if child in self.indi:
+                            self.indi[child].parents.add((father, mother))
+                        if father in self.indi:
+                            self.indi[father].children.add((father, mother, child))
+                        if mother in self.indi:
+                            self.indi[mother].children.add((father, mother, child))
+                if "relationships" in data:
+                    for rel in data["relationships"]:
+                        if rel["type"] == "http://gedcomx.org/Couple":
+                            person1 = rel["person1"]["resourceId"]
+                            person2 = rel["person2"]["resourceId"]
+                            relfid = rel["id"]
+                            if person1 in self.indi:
+                                self.indi[person1].spouses.add(
+                                    (person1, person2, relfid)
+                                )
+                            if person2 in self.indi:
+                                self.indi[person2].spouses.add(
+                                    (person1, person2, relfid)
+                                )
+            new_fids = new_fids[MAX_PERSONS:]
+
+    def add_fam(self, father, mother):
+        """add a family to the family tree
+        :param father: the father fid or None
+        :param mother: the mother fid or None
+        """
+        if (father, mother) not in self.fam:
+            self.fam[(father, mother)] = Fam(father, mother, self)
+
+    def add_trio(self, father, mother, child):
+        """add a children relationship to the family tree
+        :param father: the father fid or None
+        :param mother: the mother fid or None
+        :param child: the child fid or None
+        """
+        if father in self.indi:
+            self.indi[father].add_fams((father, mother))
+        if mother in self.indi:
+            self.indi[mother].add_fams((father, mother))
+        if child in self.indi and (father in self.indi or mother in self.indi):
+            self.indi[child].add_famc((father, mother))
+            self.add_fam(father, mother)
+            self.fam[(father, mother)].add_child(child)
+
+    def add_parents(self, fids):
+        """add parents relationships
+        :param fids: a set of fids
+        """
+        parents = set()
+        for fid in fids & self.indi.keys():
+            for couple in self.indi[fid].parents:
+                parents |= set(couple)
+        if parents:
+            self.add_indis(parents)
+        for fid in fids & self.indi.keys():
+            for father, mother in self.indi[fid].parents:
+                if (
+                    mother in self.indi
+                    and father in self.indi
+                    or not father
+                    and mother in self.indi
+                    or not mother
+                    and father in self.indi
+                ):
+                    self.add_trio(father, mother, fid)
+        return set(filter(None, parents))
+
+    def add_spouses(self, fids):
+        """add spouse relationships
+        :param fids: a set of fid
+        """
+
+        async def add(loop, rels):
+            futures = set()
+            for father, mother, relfid in rels:
+                if (father, mother) in self.fam:
+                    futures.add(
+                        loop.run_in_executor(
+                            None, self.fam[(father, mother)].add_marriage, relfid
+                        )
+                    )
+            for future in futures:
+                await future
+
+        rels = set()
+        for fid in fids & self.indi.keys():
+            rels |= self.indi[fid].spouses
+        loop = asyncio.get_event_loop()
+        if rels:
+            self.add_indis(
+                set.union(*({father, mother} for father, mother, relfid in rels))
+            )
+            for father, mother, _ in rels:
+                if father in self.indi and mother in self.indi:
+                    self.indi[father].add_fams((father, mother))
+                    self.indi[mother].add_fams((father, mother))
+                    self.add_fam(father, mother)
+            loop.run_until_complete(add(loop, rels))
+
+    def add_children(self, fids):
+        """add children relationships
+        :param fids: a set of fid
+        """
+        rels = set()
+        for fid in fids & self.indi.keys():
+            rels |= self.indi[fid].children if fid in self.indi else set()
+        children = set()
+        if rels:
+            self.add_indis(set.union(*(set(rel) for rel in rels)))
+            for father, mother, child in rels:
+                if child in self.indi and (
+                    mother in self.indi
+                    and father in self.indi
+                    or not father
+                    and mother in self.indi
+                    or not mother
+                    and father in self.indi
+                ):
+                    self.add_trio(father, mother, child)
+                    children.add(child)
+        return children
+
+    def add_ordinances(self, fid):
+        """retrieve ordinances
+        :param fid: an individual fid
+        """
+        if fid in self.indi:
+            ret, famc = self.indi[fid].get_ordinances()
+            if famc and famc in self.fam:
+                self.indi[fid].sealing_child.famc = self.fam[famc]
+            for o in ret:
+                spouse_id = o["relationships"]["spouseId"]
+                if (fid, spouse_id) in self.fam:
+                    self.fam[fid, spouse_id].sealing_spouse = Ordinance(o)
+                elif (spouse_id, fid) in self.fam:
+                    self.fam[spouse_id, fid].sealing_spouse = Ordinance(o)
+
+    def reset_num(self):
+        """reset all GEDCOM identifiers"""
+        for husb, wife in self.fam:
+            self.fam[(husb, wife)].husb_num = self.indi[husb].num if husb else None
+            self.fam[(husb, wife)].wife_num = self.indi[wife].num if wife else None
+            self.fam[(husb, wife)].chil_num = set(
+                self.indi[chil].num for chil in self.fam[(husb, wife)].chil_fid
+            )
+        for fid in self.indi:
+            self.indi[fid].famc_num = set(
+                self.fam[(husb, wife)].num for husb, wife in self.indi[fid].famc_fid
+            )
+            self.indi[fid].fams_num = set(
+                self.fam[(husb, wife)].num for husb, wife in self.indi[fid].fams_fid
+            )
+
+    def print(self, file=sys.stdout):
+        """print family tree in GEDCOM format"""
+        file.write("0 HEAD\n")
+        file.write("1 CHAR UTF-8\n")
+        file.write("1 GEDC\n")
+        file.write("2 VERS 5.1.1\n")
+        file.write("2 FORM LINEAGE-LINKED\n")
+        file.write("1 SOUR getmyancestors\n")
+        file.write("2 VERS %s\n" % getmyancestors.__version__)
+        file.write("2 NAME getmyancestors\n")
+        file.write("1 DATE %s\n" % time.strftime("%d %b %Y"))
+        file.write("2 TIME %s\n" % time.strftime("%H:%M:%S"))
+        file.write("1 SUBM @SUBM@\n")
+        file.write("0 @SUBM@ SUBM\n")
+        file.write("1 NAME %s\n" % self.display_name)
+        file.write("1 LANG %s\n" % self.lang)
+
+        for fid in sorted(self.indi, key=lambda x: self.indi.__getitem__(x).num):
+            self.indi[fid].print(file)
+        for husb, wife in sorted(self.fam, key=lambda x: self.fam.__getitem__(x).num):
+            self.fam[(husb, wife)].print(file)
+        sources = sorted(self.sources.values(), key=lambda x: x.num)
+        for s in sources:
+            s.print(file)
+        notes = sorted(self.notes, key=lambda x: x.num)
+        for i, n in enumerate(notes):
+            if i > 0:
+                if n.num == notes[i - 1].num:
+                    continue
+            n.print(file)
+        file.write("0 TRLR\n")
index 3fabf6556cb992e41caae7528c9df05fc1ac526e..db4faef807699eb431f816debf1f1fcbfe140fb2 100644 (file)
@@ -2,45 +2,18 @@
 # coding: utf-8
 
 # global imports
-import re
 import os
 import sys
-import time
-import asyncio
-import tempfile
 from tkinter import (
     Tk,
-    StringVar,
-    IntVar,
-    filedialog,
-    messagebox,
-    Menu,
-    TclError,
     PhotoImage,
 )
-from tkinter.ttk import Frame, Label, Entry, Button, Checkbutton, Treeview, Notebook
-from threading import Thread
-from diskcache import Cache
 
 # local imports
-from getmyancestors.classes.classes import (
-    Session,
-    Tree,
-    Indi,
-    Fam,
-    Gedcom,
-    EntryWithMenu,
-    FilesToMerge,
-    Merge,
-    SignIn,
-    StartIndis,
-    Options,
-    Download,
+from getmyancestors.classes.gui import (
     FStoGEDCOM,
 )
 
-from getmyancestors.translation import translations
-
 
 def main():
     root = Tk()
index ab0891b4d9ae4084b94973631eeb8dff599f6752..c320176491ceac729bfcc744fac339f86a5107f3 100644 (file)
@@ -12,20 +12,13 @@ import argparse
 
 # local imports
 import getmyancestors
-from getmyancestors.translation import translations
-from getmyancestors.classes.classes import (
-    Session,
-    Note,
-    Source,
-    Fact,
-    Memorie,
-    Name,
-    Ordinance,
-    Indi,
-    Fam,
+
+from getmyancestors.classes.tree import (
     Tree,
 )
-from getmyancestors.constants import (
+from getmyancestors.classes.session import Session
+from getmyancestors.classes.translation import translations
+from getmyancestors.classes.constants import (
     FACT_TAGS,
     FACT_EVEN,
     MAX_PERSONS,
index d755a7bf0b8b9d76206c323bba34675259f80fb9..1e452b51dc93d78a9fb4713c10d507aca4595a5a 100644 (file)
@@ -8,14 +8,8 @@ import sys
 import argparse
 
 # local imports
-from getmyancestors.classes.classes import Indi, Fam, Tree, Gedcom
-
-from getmyancestors.constants import (
-    FACT_TAGS,
-    FACT_TYPES,
-    ORDINANCES,
-    ORDINANCES_STATUS,
-)
+from getmyancestors.classes.tree import Indi, Fam, Tree
+from getmyancestors.classes.gedcom import Gedcom
 
 sys.path.append(os.path.dirname(sys.argv[0]))
 
index e87357760ecd7930af18f02105cca9aae5488f6e..098fc989fca163c8a29b22f82db284e8c4ba7ab5 100644 (file)
--- a/setup.cfg
+++ b/setup.cfg
@@ -13,7 +13,7 @@ classifiers =
   Programming Language :: Python :: 3 :: Only
 
 [options]
-packages = getmyancestors
+packages=getmyancestors, getmyancestors.classes
 install_requires =
   babelfish==0.6.0
   diskcache==5.2.1