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 ADDED
@@ -0,0 +1,2 @@
1
+ """dictz — a cross-platform multi-format offline dictionary application."""
2
+ __version__ = "0.0.2"
dictz/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow `python -m dictz` to launch the app."""
2
+ from .app import main
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
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
@@ -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()