Repository: beancount/fava
Issue: set bean file title to "Account books" will cause auto-reload failed.

### Subject of the issue
set bean file title to "Account books" will cause auto-reload failed.

### Your environment
Python 3.8.3
beancount-2.3.1-dev
fava (git commint '330d023")
chrome v84

### Steps to reproduce
set the bean file title as
`option "title" "Account books"`

### Expected behaviour
every thing good.

### Actual behaviour
the auto-reload function failed, and lot's of console log in browser
`GET http://localhost:5000/account-books/api/changed/ 404 (NOT FOUND)`

### Workaround
change bean file title.


Changed files:
src/fava/application.py, src/fava/util/__init__.py, src/fava/core/file.py, tests/test_util.py, tests/test_core_file.py

diff --git a/src/fava/application.py b/src/fava/application.py
index 1c0d49d1..0e7641c6 100644
--- a/src/fava/application.py
+++ b/src/fava/application.py
@@ -18,6 +18,7 @@ from io import BytesIO
 from typing import Any
 from typing import Dict
 from typing import Optional
+from typing import List
 
 import flask
 import markdown2  # type: ignore
@@ -45,6 +46,7 @@ from fava.help import HELP_PAGES
 from fava.helpers import FavaAPIException
 from fava.json_api import json_api
 from fava.serialisation import serialise
+from fava.util import next_key
 from fava.util import resource_path
 from fava.util import send_file_inline
 from fava.util import setup_logging
@@ -91,19 +93,31 @@ REPORTS = [
 LOAD_FILE_LOCK = threading.Lock()
 
 
-def _load_file():
+def ledger_slug(ledger: FavaLedger) -> str:
+    """Generate URL slug for a ledger."""
+    title_slug = slugify(ledger.options["title"])
+    return title_slug or slugify(ledger.beancount_file_path)
+
+
+def update_ledger_slugs(ledgers: List[FavaLedger]) -> None:
+    """Update the dictionary mapping URL slugs to ledgers."""
+    ledgers_by_slug: Dict[str, FavaLedger] = {}
+    for ledger in ledgers:
+        slug = ledger_slug(ledger)
+        unique_key = next_key(slug, ledgers_by_slug)
+        ledgers_by_slug[unique_key] = ledger
+    app.config["LEDGERS"] = ledgers_by_slug
+
+
+def _load_file() -> None:
     """Load Beancount files.
 
     This is run automatically on the first request.
     """
-    app.config["LEDGERS"] = {}
-    for filepath in app.config["BEANCOUNT_FILES"]:
-        ledger = FavaLedger(filepath)
-        slug = slugify(ledger.options["title"])
-        if not slug:
-            slug = slugify(filepath)
-        app.config["LEDGERS"][slug] = ledger
-    app.config["FILE_SLUGS"] = list(app.config["LEDGERS"].keys())
+    ledgers = [
+        FavaLedger(filepath) for filepath in app.config["BEANCOUNT_FILES"]
+    ]
+    update_ledger_slugs(ledgers)
 
 
 BABEL = Babel(app)
@@ -229,8 +243,14 @@ def _pull_beancount_file(_, values) -> None:
         if not app.config.get("LEDGERS"):
             _load_file()
     if g.beancount_file_slug:
-        if g.beancount_file_slug not in app.config["FILE_SLUGS"]:
-            abort(404)
+        if g.beancount_file_slug not in app.config["LEDGERS"]:
+            if not any(
+                g.beancount_file_slug == ledger_slug(ledger)
+                for ledger in app.config["LEDGERS"].values()
+            ):
+                abort(404)
+            # one of the file slugs changed, update the mapping
+            update_ledger_slugs(app.config["LEDGERS"].values())
         g.ledger = app.config["LEDGERS"][g.beancount_file_slug]
         g.conversion = request.args.get(
             "conversion", g.ledger.fava_options["conversion"]
@@ -253,7 +273,7 @@ def fava_api_exception(error: FavaAPIException):
 def index():
     """Redirect to the Income Statement (of the given or first file)."""
     if not g.beancount_file_slug:
-        g.beancount_file_slug = app.config["FILE_SLUGS"][0]
+        g.beancount_file_slug = next(iter(app.config["LEDGERS"]))
     return redirect(url_for("report", report_name="income_statement"))
 
 
diff --git a/src/fava/core/file.py b/src/fava/core/file.py
index aa927039..98f1447a 100644
--- a/src/fava/core/file.py
+++ b/src/fava/core/file.py
@@ -6,8 +6,6 @@ from codecs import decode
 from codecs import encode
 from hashlib import sha256
 from operator import attrgetter
-from typing import Any
-from typing import Dict
 from typing import Generator
 from typing import List
 from typing import Optional
@@ -26,6 +24,7 @@ from fava.core.filters import get_entry_accounts
 from fava.core.misc import align
 from fava.core.module_base import FavaModule
 from fava.helpers import FavaAPIException
+from fava.util import next_key
 
 
 #: The flags to exclude when rendering entries entries.
@@ -215,20 +214,6 @@ def incomplete_sortkey(entry: Directive) -> Tuple[datetime.date, int]:
     return (entry.date, SORT_ORDER.get(type(entry), 0))
 
 
-def next_key(basekey: str, keys: Dict[str, Any]) -> str:
-    """Returns the next unused key for basekey in the supplied array.
-
-    The first try is `basekey`, followed by `basekey-2`, `basekey-3`, etc
-    until a free one is found.
-    """
-    if basekey not in keys:
-        return basekey
-    i = 2
-    while f"{basekey}-{i}" in keys:
-        i = i + 1
-    return f"{basekey}-{i}"
-
-
 def insert_metadata_in_file(
     filename: str, lineno: int, indent: int, key: str, value: str
 ) -> None:
diff --git a/src/fava/util/__init__.py b/src/fava/util/__init__.py
index 382d48a2..3b95b31b 100644
--- a/src/fava/util/__init__.py
+++ b/src/fava/util/__init__.py
@@ -7,6 +7,8 @@ import re
 import time
 import unicodedata
 from pathlib import Path
+from typing import Any
+from typing import Dict
 
 from flask import abort
 from flask import send_file
@@ -62,7 +64,21 @@ def pairwise(iterable):
     return zip(left, right)
 
 
-def slugify(string):
+def next_key(basekey: str, keys: Dict[str, Any]) -> str:
+    """Returns the next unused key for basekey in the supplied dictionary.
+
+    The first try is `basekey`, followed by `basekey-2`, `basekey-3`, etc
+    until a free one is found.
+    """
+    if basekey not in keys:
+        return basekey
+    i = 2
+    while f"{basekey}-{i}" in keys:
+        i = i + 1
+    return f"{basekey}-{i}"
+
+
+def slugify(string: str) -> str:
     """Slugify a string.
 
     Args:
diff --git a/tests/test_core_file.py b/tests/test_core_file.py
index a1e8cd39..f98fbcc6 100644
--- a/tests/test_core_file.py
+++ b/tests/test_core_file.py
@@ -15,7 +15,6 @@ from fava.core.file import find_entry_lines
 from fava.core.file import get_entry_slice
 from fava.core.file import insert_entry
 from fava.core.file import insert_metadata_in_file
-from fava.core.file import next_key
 from fava.core.file import save_entry_slice
 from fava.helpers import FavaAPIException
 
@@ -59,16 +58,6 @@ def test_save_entry_slice(example_ledger) -> None:
     assert filename.read_text("utf-8") == contents
 
 
-def test_next_key() -> None:
-    assert next_key("statement", {}) == "statement"
-    assert next_key("statement", {"foo": 1}) == "statement"
-    assert next_key("statement", {"foo": 1, "statement": 1}) == "statement-2"
-    assert (
-        next_key("statement", {"statement": 1, "statement-2": 1})
-        == "statement-3"
-    )
-
-
 def test_insert_metadata_in_file(tmp_path) -> None:
     file_content = dedent(
         """\\
diff --git a/tests/test_util.py b/tests/test_util.py
index 1c48a02d..c1dda95c 100644
--- a/tests/test_util.py
+++ b/tests/test_util.py
@@ -1,9 +1,13 @@
 # pylint: disable=missing-docstring
-
 from werkzeug.test import Client
 from werkzeug.wrappers import BaseResponse
 
-from fava.util import simple_wsgi, slugify, pairwise, listify, send_file_inline
+from fava.util import listify
+from fava.util import next_key
+from fava.util import pairwise
+from fava.util import send_file_inline
+from fava.util import simple_wsgi
+from fava.util import slugify
 
 from .conftest import data_file
 
@@ -29,6 +33,16 @@ def test_simple_wsgi():
     assert resp.data == b""
 
 
+def test_next_key() -> None:
+    assert next_key("statement", {}) == "statement"
+    assert next_key("statement", {"foo": 1}) == "statement"
+    assert next_key("statement", {"foo": 1, "statement": 1}) == "statement-2"
+    assert (
+        next_key("statement", {"statement": 1, "statement-2": 1})
+        == "statement-3"
+    )
+
+
 def test_slugify():
     assert slugify("Example Beancount File") == "example-beancount-file"
     assert slugify("    Example Beancount File  ") == "example-beancount-file"