dictz 0.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dictz/__init__.py +2 -0
- dictz/__main__.py +5 -0
- dictz/app.py +65 -0
- dictz/core/__init__.py +0 -0
- dictz/core/query_manager.py +126 -0
- dictz/parsers/__init__.py +0 -0
- dictz/parsers/babylon.py +16 -0
- dictz/parsers/base.py +104 -0
- dictz/parsers/dsl.py +20 -0
- dictz/parsers/pyglossary_base.py +171 -0
- dictz/parsers/stardict.py +190 -0
- dictz/services/__init__.py +0 -0
- dictz/storage/__init__.py +0 -0
- dictz/storage/indexer.py +183 -0
- dictz/ui/__init__.py +0 -0
- dictz/ui/main_window.py +306 -0
- dictz-0.0.2.dist-info/METADATA +212 -0
- dictz-0.0.2.dist-info/RECORD +22 -0
- dictz-0.0.2.dist-info/WHEEL +5 -0
- dictz-0.0.2.dist-info/entry_points.txt +2 -0
- dictz-0.0.2.dist-info/licenses/LICENSE +55 -0
- dictz-0.0.2.dist-info/top_level.txt +1 -0
dictz/__init__.py
ADDED
dictz/__main__.py
ADDED
dictz/app.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""dictz application entry point.
|
|
2
|
+
|
|
3
|
+
Run with:
|
|
4
|
+
python -m dictz
|
|
5
|
+
# or after `pip install -e .`:
|
|
6
|
+
dictz
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from PySide6.QtWidgets import QApplication
|
|
15
|
+
|
|
16
|
+
from .core.query_manager import QueryManager
|
|
17
|
+
from .storage.indexer import IndexStore
|
|
18
|
+
from .ui.main_window import MainWindow
|
|
19
|
+
|
|
20
|
+
__version__ = "0.0.2"
|
|
21
|
+
APP_NAME = "dictz"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def user_data_dir() -> Path:
|
|
25
|
+
"""Cross-platform per-user data directory.
|
|
26
|
+
|
|
27
|
+
- Windows: %APPDATA%\\dictz
|
|
28
|
+
- macOS: ~/Library/Application Support/dictz
|
|
29
|
+
- Linux: $XDG_DATA_HOME/dictz (fallback ~/.local/share/dictz)
|
|
30
|
+
"""
|
|
31
|
+
if sys.platform == "win32":
|
|
32
|
+
base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
|
|
33
|
+
return base / APP_NAME
|
|
34
|
+
elif sys.platform == "darwin":
|
|
35
|
+
return Path.home() / "Library" / "Application Support" / APP_NAME
|
|
36
|
+
else:
|
|
37
|
+
xdg = os.environ.get("XDG_DATA_HOME")
|
|
38
|
+
base = Path(xdg) if xdg else Path.home() / ".local" / "share"
|
|
39
|
+
return base / APP_NAME.lower()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def main() -> int:
|
|
43
|
+
app = QApplication(sys.argv)
|
|
44
|
+
app.setApplicationName(APP_NAME)
|
|
45
|
+
app.setApplicationVersion(__version__)
|
|
46
|
+
app.setOrganizationName("Bayram Kotan")
|
|
47
|
+
app.setOrganizationDomain("bayramkotan.com")
|
|
48
|
+
|
|
49
|
+
data_dir = user_data_dir()
|
|
50
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
index_path = data_dir / "dictz_index.db"
|
|
52
|
+
|
|
53
|
+
index_store = IndexStore(index_path)
|
|
54
|
+
index_store.open()
|
|
55
|
+
|
|
56
|
+
query_manager = QueryManager(index_store=index_store)
|
|
57
|
+
|
|
58
|
+
window = MainWindow(query_manager)
|
|
59
|
+
window.show()
|
|
60
|
+
|
|
61
|
+
return app.exec()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
if __name__ == "__main__":
|
|
65
|
+
sys.exit(main())
|
dictz/core/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Query manager: orchestrates lookups across all loaded dictionaries.
|
|
2
|
+
|
|
3
|
+
The query manager keeps a list of loaded `Dictionary` instances and dispatches
|
|
4
|
+
a single user query to all of them, collecting and ranking the results.
|
|
5
|
+
|
|
6
|
+
For v0.0.2 we run lookups sequentially. Parallelization with QThreadPool comes
|
|
7
|
+
in a later version when big glossary loading becomes a bottleneck.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from ..parsers.babylon import BabylonDictionary
|
|
14
|
+
from ..parsers.base import Dictionary, DictionaryEntry
|
|
15
|
+
from ..parsers.dsl import DSLDictionary
|
|
16
|
+
from ..parsers.stardict import StarDictDictionary
|
|
17
|
+
from ..storage.indexer import IndexStore
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# File-extension -> parser class. `.dsl.dz` is handled by peeling the .dz
|
|
21
|
+
# compression suffix before the lookup.
|
|
22
|
+
_PARSER_REGISTRY: dict[str, type[Dictionary]] = {
|
|
23
|
+
".ifo": StarDictDictionary,
|
|
24
|
+
".dsl": DSLDictionary,
|
|
25
|
+
".bgl": BabylonDictionary,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parser_for(path: Path | str) -> type[Dictionary]:
|
|
30
|
+
"""Pick a parser class based on file extension.
|
|
31
|
+
|
|
32
|
+
Handles compressed forms (.dsl.dz, .gls.dz) by peeling the .dz suffix.
|
|
33
|
+
"""
|
|
34
|
+
p = Path(path)
|
|
35
|
+
suffix = p.suffix.lower()
|
|
36
|
+
|
|
37
|
+
# Strip dictzip compression layer
|
|
38
|
+
if suffix == ".dz":
|
|
39
|
+
inner_suffix = p.with_suffix("").suffix.lower()
|
|
40
|
+
if inner_suffix in _PARSER_REGISTRY:
|
|
41
|
+
return _PARSER_REGISTRY[inner_suffix]
|
|
42
|
+
|
|
43
|
+
if suffix in _PARSER_REGISTRY:
|
|
44
|
+
return _PARSER_REGISTRY[suffix]
|
|
45
|
+
|
|
46
|
+
raise ValueError(f"No parser registered for file: {p}")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def supported_extensions() -> list[str]:
|
|
50
|
+
"""List of extensions the current build can open."""
|
|
51
|
+
return sorted(_PARSER_REGISTRY.keys())
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class QueryManager:
|
|
55
|
+
"""Orchestrates dictionary loading and multi-dictionary lookups."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, index_store: IndexStore | None = None):
|
|
58
|
+
self.dictionaries: dict[str, Dictionary] = {}
|
|
59
|
+
self.index_store = index_store
|
|
60
|
+
|
|
61
|
+
def add_dictionary(self, path: Path | str, reindex: bool = False) -> Dictionary:
|
|
62
|
+
parser_cls = parser_for(path)
|
|
63
|
+
dictionary = parser_cls(path)
|
|
64
|
+
dictionary.load()
|
|
65
|
+
self.dictionaries[dictionary.metadata.name] = dictionary
|
|
66
|
+
|
|
67
|
+
if self.index_store is not None:
|
|
68
|
+
already_indexed = self.index_store.is_indexed(dictionary.metadata.name)
|
|
69
|
+
if reindex or not already_indexed:
|
|
70
|
+
self.index_store.index_dictionary(dictionary)
|
|
71
|
+
|
|
72
|
+
return dictionary
|
|
73
|
+
|
|
74
|
+
def remove_dictionary(self, name: str) -> None:
|
|
75
|
+
if name in self.dictionaries:
|
|
76
|
+
self.dictionaries[name].close()
|
|
77
|
+
del self.dictionaries[name]
|
|
78
|
+
if self.index_store is not None:
|
|
79
|
+
self.index_store.remove_dictionary(name)
|
|
80
|
+
|
|
81
|
+
def loaded_names(self) -> list[str]:
|
|
82
|
+
return sorted(self.dictionaries.keys())
|
|
83
|
+
|
|
84
|
+
def search(self, word: str, exact: bool = True) -> list[DictionaryEntry]:
|
|
85
|
+
if not word.strip():
|
|
86
|
+
return []
|
|
87
|
+
|
|
88
|
+
results: list[DictionaryEntry] = []
|
|
89
|
+
for name, dictionary in self.dictionaries.items():
|
|
90
|
+
try:
|
|
91
|
+
entries = dictionary.lookup(word, exact=exact)
|
|
92
|
+
except Exception as e:
|
|
93
|
+
print(f"[dictz] lookup failed in {name}: {e}")
|
|
94
|
+
continue
|
|
95
|
+
results.extend(entries)
|
|
96
|
+
return results
|
|
97
|
+
|
|
98
|
+
def suggest(self, prefix: str, limit: int = 30) -> list[str]:
|
|
99
|
+
if not prefix.strip():
|
|
100
|
+
return []
|
|
101
|
+
|
|
102
|
+
if self.index_store is not None:
|
|
103
|
+
rows = self.index_store.search_headword(prefix, limit=limit)
|
|
104
|
+
seen: set[str] = set()
|
|
105
|
+
out: list[str] = []
|
|
106
|
+
for headword, _ in rows:
|
|
107
|
+
if headword not in seen:
|
|
108
|
+
seen.add(headword)
|
|
109
|
+
out.append(headword)
|
|
110
|
+
return out
|
|
111
|
+
|
|
112
|
+
seen = set()
|
|
113
|
+
out = []
|
|
114
|
+
for dictionary in self.dictionaries.values():
|
|
115
|
+
for entry in dictionary.lookup(prefix, exact=False):
|
|
116
|
+
if entry.headword not in seen:
|
|
117
|
+
seen.add(entry.headword)
|
|
118
|
+
out.append(entry.headword)
|
|
119
|
+
if len(out) >= limit:
|
|
120
|
+
return out
|
|
121
|
+
return out
|
|
122
|
+
|
|
123
|
+
def close_all(self) -> None:
|
|
124
|
+
for dictionary in self.dictionaries.values():
|
|
125
|
+
dictionary.close()
|
|
126
|
+
self.dictionaries.clear()
|
|
File without changes
|
dictz/parsers/babylon.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Babylon (.bgl) dictionary parser.
|
|
2
|
+
|
|
3
|
+
Babylon is a proprietary binary format from the Babylon translator. The format
|
|
4
|
+
was reverse-engineered years ago; PyGlossary includes a mature parser that
|
|
5
|
+
handles the binary encoding, embedded images, and multi-byte character sets.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .pyglossary_base import PyGlossaryDictionary
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BabylonDictionary(PyGlossaryDictionary):
|
|
13
|
+
"""Babylon binary dictionary (.bgl)."""
|
|
14
|
+
|
|
15
|
+
_format = "BabylonBgl"
|
|
16
|
+
format_label = "babylon"
|
dictz/parsers/base.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Abstract base class for all dictionary format parsers.
|
|
2
|
+
|
|
3
|
+
Every concrete parser (StarDict, DSL, Babylon) implements this interface,
|
|
4
|
+
so the rest of the application can treat dictionaries uniformly regardless
|
|
5
|
+
of their underlying file format.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Iterator
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class DictionaryEntry:
|
|
17
|
+
"""A single headword + definition pair from a dictionary."""
|
|
18
|
+
|
|
19
|
+
headword: str
|
|
20
|
+
definition: str # HTML or plain text
|
|
21
|
+
dictionary_id: str = "" # filled in by the query manager
|
|
22
|
+
score: float = 1.0 # 1.0 = exact match, <1.0 = fuzzy match similarity
|
|
23
|
+
|
|
24
|
+
def __repr__(self) -> str:
|
|
25
|
+
preview = self.definition[:50].replace("\n", " ")
|
|
26
|
+
return f"DictionaryEntry({self.headword!r}, {preview!r}...)"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class DictionaryMetadata:
|
|
31
|
+
"""Information about a loaded dictionary."""
|
|
32
|
+
|
|
33
|
+
name: str
|
|
34
|
+
source_language: str = ""
|
|
35
|
+
target_language: str = ""
|
|
36
|
+
description: str = ""
|
|
37
|
+
author: str = ""
|
|
38
|
+
word_count: int = 0
|
|
39
|
+
file_path: Path = field(default_factory=Path)
|
|
40
|
+
format: str = "" # "stardict", "dsl", "babylon"
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def display_name(self) -> str:
|
|
44
|
+
if self.source_language and self.target_language:
|
|
45
|
+
return f"{self.name} ({self.source_language} → {self.target_language})"
|
|
46
|
+
return self.name
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Dictionary(ABC):
|
|
50
|
+
"""Abstract base for all dictionary format parsers.
|
|
51
|
+
|
|
52
|
+
Lifecycle:
|
|
53
|
+
1. __init__(path) — store the path, do nothing expensive
|
|
54
|
+
2. load() — read headers, build internal index
|
|
55
|
+
3. lookup(word) / iter_entries() — used by query manager
|
|
56
|
+
4. close() — release file handles
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self, path: Path | str):
|
|
60
|
+
self.path = Path(path)
|
|
61
|
+
self.metadata = DictionaryMetadata(name=self.path.stem, file_path=self.path)
|
|
62
|
+
self._loaded = False
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def loaded(self) -> bool:
|
|
66
|
+
return self._loaded
|
|
67
|
+
|
|
68
|
+
@abstractmethod
|
|
69
|
+
def load(self) -> None:
|
|
70
|
+
"""Load dictionary headers and build an in-memory index.
|
|
71
|
+
|
|
72
|
+
Should populate self.metadata. Heavy I/O is expected here.
|
|
73
|
+
"""
|
|
74
|
+
...
|
|
75
|
+
|
|
76
|
+
@abstractmethod
|
|
77
|
+
def lookup(self, word: str, exact: bool = True) -> list[DictionaryEntry]:
|
|
78
|
+
"""Look up a word. Returns 0 or more matching entries.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
word: The headword to search for (case-insensitive by default).
|
|
82
|
+
exact: If True, only exact matches. If False, prefix matches too.
|
|
83
|
+
"""
|
|
84
|
+
...
|
|
85
|
+
|
|
86
|
+
@abstractmethod
|
|
87
|
+
def iter_entries(self) -> Iterator[DictionaryEntry]:
|
|
88
|
+
"""Yield every entry in the dictionary (used for full indexing)."""
|
|
89
|
+
...
|
|
90
|
+
|
|
91
|
+
def close(self) -> None:
|
|
92
|
+
"""Release any open file handles. Default: no-op."""
|
|
93
|
+
self._loaded = False
|
|
94
|
+
|
|
95
|
+
def __enter__(self):
|
|
96
|
+
if not self._loaded:
|
|
97
|
+
self.load()
|
|
98
|
+
return self
|
|
99
|
+
|
|
100
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
101
|
+
self.close()
|
|
102
|
+
|
|
103
|
+
def __repr__(self) -> str:
|
|
104
|
+
return f"<{self.__class__.__name__} {self.metadata.name!r} ({self.metadata.word_count} words)>"
|
dictz/parsers/dsl.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""DSL (ABBYY Lingvo) dictionary parser.
|
|
2
|
+
|
|
3
|
+
DSL is a text-based markup format used by ABBYY Lingvo. Files end in .dsl
|
|
4
|
+
(or .dsl.dz when dictzip-compressed) and contain headwords followed by
|
|
5
|
+
indented, markup-rich definitions.
|
|
6
|
+
|
|
7
|
+
We delegate the actual parsing to PyGlossary's ABBYYLingvoDSL plugin, which
|
|
8
|
+
handles the markup (phonetic brackets, cross-references, formatting tags)
|
|
9
|
+
and converts it to HTML.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from .pyglossary_base import PyGlossaryDictionary
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DSLDictionary(PyGlossaryDictionary):
|
|
17
|
+
"""ABBYY Lingvo DSL dictionary (.dsl / .dsl.dz)."""
|
|
18
|
+
|
|
19
|
+
_format = "ABBYYLingvoDSL"
|
|
20
|
+
format_label = "dsl"
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""PyGlossary-backed base parser.
|
|
2
|
+
|
|
3
|
+
PyGlossary is a mature multi-format converter library that can read 60+
|
|
4
|
+
dictionary formats. We wrap it behind our own Dictionary interface so the
|
|
5
|
+
rest of dictz doesn't need to know about PyGlossary's quirks.
|
|
6
|
+
|
|
7
|
+
For each supported format (DSL, Babylon, etc.) we subclass PyGlossaryDictionary
|
|
8
|
+
and set `_format` to the PyGlossary plugin name. Everything else is shared:
|
|
9
|
+
headers, iteration, exact and prefix lookup.
|
|
10
|
+
|
|
11
|
+
The PyGlossary Glossary.init() call is global and expensive (it loads every
|
|
12
|
+
plugin module). We call it once per process via _ensure_init().
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Iterator
|
|
18
|
+
|
|
19
|
+
from .base import Dictionary, DictionaryEntry
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
_pyglossary_initialized = False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _ensure_init() -> None:
|
|
26
|
+
"""Call Glossary.init() exactly once per process."""
|
|
27
|
+
global _pyglossary_initialized
|
|
28
|
+
if _pyglossary_initialized:
|
|
29
|
+
return
|
|
30
|
+
try:
|
|
31
|
+
from pyglossary.glossary_v2 import Glossary
|
|
32
|
+
except ImportError as exc:
|
|
33
|
+
raise ImportError(
|
|
34
|
+
"pyglossary is required for DSL/Babylon support. "
|
|
35
|
+
"Install with: pip install pyglossary"
|
|
36
|
+
) from exc
|
|
37
|
+
Glossary.init()
|
|
38
|
+
_pyglossary_initialized = True
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PyGlossaryError(Exception):
|
|
42
|
+
"""Raised when PyGlossary fails to read a dictionary file."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class PyGlossaryDictionary(Dictionary):
|
|
46
|
+
"""Generic PyGlossary-backed dictionary.
|
|
47
|
+
|
|
48
|
+
Subclasses set:
|
|
49
|
+
_format: the PyGlossary plugin name (e.g. "ABBYYLingvoDSL", "Babylon")
|
|
50
|
+
format_label: the human-readable format label stored in metadata
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
_format: str = "" # overridden by subclass
|
|
54
|
+
format_label: str = "" # overridden by subclass
|
|
55
|
+
|
|
56
|
+
def __init__(self, path: Path | str):
|
|
57
|
+
super().__init__(path)
|
|
58
|
+
# word -> definition (loaded eagerly on load() — PyGlossary doesn't
|
|
59
|
+
# offer cheap random access for most formats, so we materialize the
|
|
60
|
+
# whole dictionary in memory on first load, then serve lookups from
|
|
61
|
+
# the dict. For very large glossaries this trades memory for speed.
|
|
62
|
+
self._entries: dict[str, str] = {}
|
|
63
|
+
self._sorted_words: list[str] = []
|
|
64
|
+
self.metadata.format = self.format_label
|
|
65
|
+
|
|
66
|
+
# ------------------------------------------------------------------ load
|
|
67
|
+
|
|
68
|
+
def load(self) -> None:
|
|
69
|
+
if self._loaded:
|
|
70
|
+
return
|
|
71
|
+
if not self._format:
|
|
72
|
+
raise PyGlossaryError(
|
|
73
|
+
f"{self.__class__.__name__} did not set _format"
|
|
74
|
+
)
|
|
75
|
+
if not self.path.exists():
|
|
76
|
+
raise PyGlossaryError(f"File not found: {self.path}")
|
|
77
|
+
|
|
78
|
+
_ensure_init()
|
|
79
|
+
from pyglossary.glossary_v2 import Glossary
|
|
80
|
+
|
|
81
|
+
glos = Glossary()
|
|
82
|
+
try:
|
|
83
|
+
opened = glos.directRead(str(self.path), formatName=self._format)
|
|
84
|
+
except Exception as exc:
|
|
85
|
+
raise PyGlossaryError(
|
|
86
|
+
f"PyGlossary could not read {self.path}: {exc}"
|
|
87
|
+
) from exc
|
|
88
|
+
|
|
89
|
+
if not opened:
|
|
90
|
+
raise PyGlossaryError(
|
|
91
|
+
f"PyGlossary refused to open {self.path} as {self._format}"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# Pull header fields
|
|
95
|
+
self.metadata.name = glos.getInfo("name") or glos.getInfo("title") or self.path.stem
|
|
96
|
+
self.metadata.author = glos.getInfo("author") or ""
|
|
97
|
+
self.metadata.description = glos.getInfo("description") or ""
|
|
98
|
+
self.metadata.source_language = glos.getInfo("sourceLang") or ""
|
|
99
|
+
self.metadata.target_language = glos.getInfo("targetLang") or ""
|
|
100
|
+
|
|
101
|
+
# Materialize all entries. PyGlossary iterates once in direct mode.
|
|
102
|
+
for entry in glos:
|
|
103
|
+
if entry is None:
|
|
104
|
+
continue
|
|
105
|
+
# Skip resource entries (images, sounds, binary blobs)
|
|
106
|
+
if getattr(entry, "isData", lambda: False)():
|
|
107
|
+
continue
|
|
108
|
+
# PyGlossary 5.0+ renamed s_word -> s_term; keep a fallback.
|
|
109
|
+
word = getattr(entry, "s_term", None) or getattr(entry, "s_word", "") or ""
|
|
110
|
+
defi = entry.defi or ""
|
|
111
|
+
if not word:
|
|
112
|
+
continue
|
|
113
|
+
# A headword can have multiple variants; PyGlossary gives them as
|
|
114
|
+
# a single `\n`-separated string in s_word. Register them all.
|
|
115
|
+
variants = [w.strip() for w in word.split("|") if w.strip()]
|
|
116
|
+
if not variants:
|
|
117
|
+
variants = [word]
|
|
118
|
+
for variant in variants:
|
|
119
|
+
if variant not in self._entries:
|
|
120
|
+
self._entries[variant] = defi
|
|
121
|
+
|
|
122
|
+
self.metadata.word_count = len(self._entries)
|
|
123
|
+
self._sorted_words = sorted(self._entries.keys(), key=str.lower)
|
|
124
|
+
self._loaded = True
|
|
125
|
+
|
|
126
|
+
# ---------------------------------------------------------------- lookup
|
|
127
|
+
|
|
128
|
+
def lookup(self, word: str, exact: bool = True) -> list[DictionaryEntry]:
|
|
129
|
+
if not self._loaded:
|
|
130
|
+
self.load()
|
|
131
|
+
|
|
132
|
+
results: list[DictionaryEntry] = []
|
|
133
|
+
|
|
134
|
+
if word in self._entries:
|
|
135
|
+
results.append(self._entry_for(word))
|
|
136
|
+
|
|
137
|
+
if not results:
|
|
138
|
+
word_lower = word.lower()
|
|
139
|
+
for indexed_word in self._sorted_words:
|
|
140
|
+
if indexed_word.lower() == word_lower:
|
|
141
|
+
results.append(self._entry_for(indexed_word))
|
|
142
|
+
break
|
|
143
|
+
|
|
144
|
+
if not exact and not results:
|
|
145
|
+
word_lower = word.lower()
|
|
146
|
+
for indexed_word in self._sorted_words:
|
|
147
|
+
if indexed_word.lower().startswith(word_lower):
|
|
148
|
+
results.append(self._entry_for(indexed_word))
|
|
149
|
+
if len(results) >= 50:
|
|
150
|
+
break
|
|
151
|
+
|
|
152
|
+
return results
|
|
153
|
+
|
|
154
|
+
def _entry_for(self, word: str) -> DictionaryEntry:
|
|
155
|
+
return DictionaryEntry(
|
|
156
|
+
headword=word,
|
|
157
|
+
definition=self._entries[word],
|
|
158
|
+
dictionary_id=self.metadata.name,
|
|
159
|
+
score=1.0,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def iter_entries(self) -> Iterator[DictionaryEntry]:
|
|
163
|
+
if not self._loaded:
|
|
164
|
+
self.load()
|
|
165
|
+
for word in self._sorted_words:
|
|
166
|
+
yield self._entry_for(word)
|
|
167
|
+
|
|
168
|
+
def close(self) -> None:
|
|
169
|
+
self._entries.clear()
|
|
170
|
+
self._sorted_words.clear()
|
|
171
|
+
super().close()
|