-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathOffline Survival.py
More file actions
executable file
·3084 lines (2851 loc) · 302 KB
/
Copy pathOffline Survival.py
File metadata and controls
executable file
·3084 lines (2851 loc) · 302 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
# MAINTENANCE: Keep the CLI standard-library only, preserve bilingual parity, and keep database reads tolerant of optional narrative fields.
"""Offline Survival Project terminal browser.
A dependency-free, bilingual terminal application for searching, finding,
browsing, validating, and reading the consolidated English/Greek JSON database
that ships with this repository.
"""
from __future__ import annotations
import difflib
import json
import random
import re
import shutil
import sys
import textwrap
import unicodedata
from collections import Counter, defaultdict
from datetime import date
from pathlib import Path
from typing import Any, Callable, Optional, Sequence
from urllib.parse import urlparse
APP_NAME = "Offline Survival Project"
PROJECT_ROOT = Path(__file__).resolve().parent
DATABASE_FILE = PROJECT_ROOT / "Offline Survival Database.json"
SETTINGS_DIR = Path.home() / ".offline_survival_project"
SETTINGS_FILE = SETTINGS_DIR / "settings.json"
LANGUAGES = {
"en": {"name": "English", "record_language": "English"},
"el": {"name": "Ελληνικά", "record_language": "Ελληνικά"},
}
DEFAULT_SETTINGS: dict[str, Any] = {
"language": "en",
"page_size": 10,
"clear_screen": True,
}
BACK_COMMANDS = {
"0",
"q",
"quit",
"exit",
"b",
"back",
"πισω",
"εξοδος",
}
NEXT_COMMANDS = {"", "n", "next", "ε", "επομενη"}
PREVIOUS_COMMANDS = {"p", "prev", "previous", "π", "προηγουμενη"}
YES_COMMANDS = {"y", "yes", "ν", "ναι"}
NO_COMMANDS = {"n", "no", "ο", "οχι", "0", "q"}
TEXT: dict[str, dict[str, str]] = {
"en": {
"header_language": "Language",
"main_menu": "Main menu",
"search": "Search the knowledge base",
"browse": "Browse categories",
"find_file": "Find a source group",
"open_id": "Open a record by ID",
"random": "Read a random topic",
"verified_essentials": "Verified emergency essentials",
"food_growing": "Food growing and safe preservation",
"verified_food_guides": "verified food-growing and preservation guides",
"settings": "Settings",
"help": "Help and controls",
"integrity": "Check database integrity",
"exit": "Exit",
"goodbye": "Goodbye.",
"choice": "Choose an option",
"invalid": "Invalid option. Please try again.",
"press_enter": "Press Enter to continue",
"search_prompt": "Search naturally or use filters (category:, tag:, priority:, -exclude) — 0 to go back",
"file_prompt": "Source group/path words; Enter shows every group (or 0 to go back)",
"id_prompt": "Complete or partial record ID (or 0 to go back)",
"no_results": "No matching results were found.",
"results": "results",
"records": "records",
"files": "source groups",
"categories": "categories",
"loading": "Loading the selected database...",
"loaded": "Database loaded",
"load_error": "The database could not be loaded",
"missing_folder": "The consolidated database or selected language is missing",
"select_item": "Choose a visible number | n/Enter: next | p: previous | 0/q: back",
"reader_controls": "Enter/n: next page | p: previous page | 0/q: back",
"reader_return": "Press Enter or 0/q to return",
"back": "Back",
"page": "Page",
"of": "of",
"category_filter": "Category words; Enter shows every category (or 0 to go back)",
"records_in_category": "Records in category",
"file_actions": "File options",
"browse_file_records": "Browse records in this file",
"view_raw_json": "Read this group as JSON",
"file_path": "Source group",
"source_file": "Source group",
"raw_json": "Grouped JSON",
"change_language": "Change language",
"page_size": "Results per list page",
"clear_screen": "Clear the screen between views",
"enabled": "Enabled",
"disabled": "Disabled",
"reset_settings": "Reset all settings",
"settings_saved": "Settings saved.",
"settings_file": "Settings file",
"settings_save_error": "Settings could not be saved. Check storage permissions.",
"choose_language": "Choose interface and database language",
"choose_page_size": "Choose results per list page",
"settings_reset": "Settings reset to their defaults.",
"confirm_reset": "Reset language, page size, and screen setting? (y/n)",
"first_run": "First launch: choose a language",
"about_title": "Help and controls",
"about_text": (
"The application works fully offline and uses only Python's standard library. "
"Use numbers to open menu items. In lists, use n or Enter for the next page, p for "
"the previous page, and 0 or q to go back. Record and raw-file views are paged so "
"long information remains readable on small Termux screens. Search ranks every "
"record field and understands natural questions, bilingual aliases, related concepts, typos, "
"quoted phrases, exclusions, and structured filters such as category:, tag:, and priority:."
),
"safety_title": "Safety",
"safety_text": (
"This is a preparation and reference aid, not a replacement for emergency services "
"or qualified medical, engineering, electrical, utility, fire, police, coast guard, "
"veterinary, agricultural, or civil-protection guidance."
),
"search_results": "Search results",
"empty_query": "Enter at least one search word.",
"raw_read_error": "The raw file could not be read.",
"unexpected_error": "An unexpected error occurred",
"another_random": "Open another random topic? (y/n)",
"integrity_running": "Checking the consolidated bilingual database...",
"integrity_title": "Database integrity report",
"integrity_ok": "All integrity checks passed.",
"integrity_failed": "One or more integrity checks failed.",
"language_report": "Language database",
"json_files": "source groups",
"category_folders": "category folders",
"duplicate_ids": "duplicate IDs",
"duplicate_titles": "duplicate titles",
"invalid_files": "invalid files",
"missing_fields": "missing required fields",
"empty_fields": "empty required fields",
"field_type_errors": "field type errors",
"invalid_source_urls": "invalid source URLs",
"unapproved_source_domains": "unapproved source domains",
"records_without_sources": "records without sources",
"invalid_dates": "invalid or future update dates",
"language_mismatches": "record language mismatches",
"source_domains": "approved source domains used",
"mirrored_paths": "Mirrored file paths",
"mirrored_ids": "Mirrored record IDs",
"mirrored_file_ids": "IDs inside corresponding files",
"database_folder": "Database folder present",
"matching": "matching",
"not_matching": "not matching",
"yes": "Yes",
"no": "No",
"untitled": "Untitled",
"uncategorized": "Uncategorized",
},
"el": {
"header_language": "Γλώσσα",
"main_menu": "Κεντρικό μενού",
"search": "Αναζήτηση στη βάση γνώσεων",
"browse": "Περιήγηση στις κατηγορίες",
"find_file": "Εύρεση ομάδας περιεχομένου",
"open_id": "Άνοιγμα εγγραφής με ID",
"random": "Ανάγνωση τυχαίου θέματος",
"verified_essentials": "Επαληθευμένα βασικά έκτακτης ανάγκης",
"food_growing": "Καλλιέργεια και ασφαλής διατήρηση τροφίμων",
"verified_food_guides": "επαληθευμένοι οδηγοί καλλιέργειας και διατήρησης τροφίμων",
"settings": "Ρυθμίσεις",
"help": "Βοήθεια και χειρισμός",
"integrity": "Έλεγχος ακεραιότητας βάσης",
"exit": "Έξοδος",
"goodbye": "Έξοδος από την εφαρμογή.",
"choice": "Επίλεξε μία επιλογή",
"invalid": "Μη έγκυρη επιλογή. Δοκίμασε ξανά.",
"press_enter": "Πάτησε Enter για συνέχεια",
"search_prompt": "Φυσική αναζήτηση ή φίλτρα (category:, tag:, priority:, -εξαίρεση) — 0 για επιστροφή",
"file_prompt": "Λέξεις ομάδας/διαδρομής· Enter για όλες τις ομάδες (ή 0 για επιστροφή)",
"id_prompt": "Ολόκληρο ή μέρος του ID εγγραφής (ή 0 για επιστροφή)",
"no_results": "Δεν βρέθηκαν αποτελέσματα.",
"results": "αποτελέσματα",
"records": "εγγραφές",
"files": "ομάδες περιεχομένου",
"categories": "κατηγορίες",
"loading": "Φόρτωση της επιλεγμένης βάσης...",
"loaded": "Η βάση φορτώθηκε",
"load_error": "Δεν ήταν δυνατή η φόρτωση της βάσης",
"missing_folder": "Λείπει η ενοποιημένη βάση ή η επιλεγμένη γλώσσα",
"select_item": "Επίλεξε ορατό αριθμό | n/Enter: επόμενη | p: προηγούμενη | 0/q: επιστροφή",
"reader_controls": "Enter/n: επόμενη σελίδα | p: προηγούμενη | 0/q: επιστροφή",
"reader_return": "Πάτησε Enter ή 0/q για επιστροφή",
"back": "Επιστροφή",
"page": "Σελίδα",
"of": "από",
"category_filter": "Λέξεις κατηγορίας· Enter για όλες (ή 0 για επιστροφή)",
"records_in_category": "Εγγραφές στην κατηγορία",
"file_actions": "Επιλογές αρχείου",
"browse_file_records": "Περιήγηση στις εγγραφές του αρχείου",
"view_raw_json": "Ανάγνωση της ομάδας ως JSON",
"file_path": "Ομάδα περιεχομένου",
"source_file": "Ομάδα περιεχομένου",
"raw_json": "Ομαδοποιημένο JSON",
"change_language": "Αλλαγή γλώσσας",
"page_size": "Αποτελέσματα ανά σελίδα λίστας",
"clear_screen": "Καθαρισμός οθόνης μεταξύ προβολών",
"enabled": "Ενεργός",
"disabled": "Ανενεργός",
"reset_settings": "Επαναφορά όλων των ρυθμίσεων",
"settings_saved": "Οι ρυθμίσεις αποθηκεύτηκαν.",
"settings_file": "Αρχείο ρυθμίσεων",
"settings_save_error": "Δεν αποθηκεύτηκαν οι ρυθμίσεις. Έλεγξε τα δικαιώματα αποθήκευσης.",
"choose_language": "Επίλεξε γλώσσα περιβάλλοντος και βάσης",
"choose_page_size": "Επίλεξε αποτελέσματα ανά σελίδα λίστας",
"settings_reset": "Οι ρυθμίσεις επανήλθαν στις προεπιλογές.",
"confirm_reset": "Επαναφορά γλώσσας, μεγέθους σελίδας και οθόνης; (ν/ο)",
"first_run": "Πρώτη εκκίνηση: επίλεξε γλώσσα",
"about_title": "Βοήθεια και χειρισμός",
"about_text": (
"Η εφαρμογή λειτουργεί πλήρως εκτός σύνδεσης και χρησιμοποιεί μόνο την τυπική "
"βιβλιοθήκη της Python. Χρησιμοποίησε αριθμούς για να ανοίξεις επιλογές. Στις λίστες, "
"χρησιμοποίησε n ή Enter για την επόμενη σελίδα, p για την προηγούμενη και 0 ή q για "
"επιστροφή. Οι εγγραφές και τα αρχικά αρχεία εμφανίζονται σε σελίδες ώστε να "
"διαβάζονται εύκολα σε μικρές οθόνες Termux. Η αναζήτηση ελέγχει όλα τα πεδία, "
"μαζί με υλικά, βήματα, προειδοποιήσεις, ετικέτες, IDs και διαδρομές αρχείων."
),
"safety_title": "Ασφάλεια",
"safety_text": (
"Το έργο είναι βοήθημα προετοιμασίας και αναφοράς και δεν αντικαθιστά τις υπηρεσίες "
"έκτακτης ανάγκης ούτε την καθοδήγηση αρμόδιων επαγγελματιών υγείας, μηχανικών, "
"ηλεκτρολόγων, τεχνικών δικτύων, πυροσβεστικής, αστυνομίας, λιμενικού, κτηνιάτρων, "
"γεωπόνων ή πολιτικής προστασίας."
),
"search_results": "Αποτελέσματα αναζήτησης",
"empty_query": "Γράψε τουλάχιστον μία λέξη αναζήτησης.",
"raw_read_error": "Δεν ήταν δυνατή η ανάγνωση του αρχείου.",
"unexpected_error": "Παρουσιάστηκε απρόσμενο σφάλμα",
"another_random": "Άνοιγμα άλλου τυχαίου θέματος; (ν/ο)",
"integrity_running": "Έλεγχος της ενοποιημένης δίγλωσσης βάσης...",
"integrity_title": "Αναφορά ακεραιότητας βάσης",
"integrity_ok": "Όλοι οι έλεγχοι ακεραιότητας ολοκληρώθηκαν επιτυχώς.",
"integrity_failed": "Ένας ή περισσότεροι έλεγχοι ακεραιότητας απέτυχαν.",
"language_report": "Βάση γλώσσας",
"json_files": "ομάδες περιεχομένου",
"category_folders": "φάκελοι κατηγοριών",
"duplicate_ids": "διπλότυπα IDs",
"duplicate_titles": "διπλότυποι τίτλοι",
"invalid_files": "μη έγκυρα αρχεία",
"missing_fields": "απόντα υποχρεωτικά πεδία",
"empty_fields": "κενά υποχρεωτικά πεδία",
"field_type_errors": "σφάλματα τύπου πεδίων",
"invalid_source_urls": "μη έγκυρα URLs πηγών",
"unapproved_source_domains": "μη εγκεκριμένοι τομείς πηγών",
"records_without_sources": "εγγραφές χωρίς πηγές",
"invalid_dates": "μη έγκυρες ή μελλοντικές ημερομηνίες",
"language_mismatches": "ασυμφωνίες γλώσσας εγγραφών",
"source_domains": "εγκεκριμένοι τομείς πηγών",
"mirrored_paths": "Κατοπτρισμένες διαδρομές αρχείων",
"mirrored_ids": "Κατοπτρισμένα IDs εγγραφών",
"mirrored_file_ids": "IDs μέσα στα αντίστοιχα αρχεία",
"database_folder": "Υπάρχει ο φάκελος βάσης",
"matching": "ταιριάζουν",
"not_matching": "δεν ταιριάζουν",
"yes": "Ναι",
"no": "Όχι",
"untitled": "Χωρίς τίτλο",
"uncategorized": "Χωρίς κατηγορία",
},
}
FIELD_LABELS: dict[str, dict[str, str]] = {
"en": {
"id": "ID",
"language": "Language",
"title": "Title",
"category": "Category",
"subcategory": "Subcategory",
"summary": "Summary",
"content": "Full guidance",
"difficulty": "Difficulty",
"urgency": "Urgency",
"priority": "Priority",
"tags": "Tags",
"materials": "Materials",
"steps": "Steps",
"warnings": "Warnings",
"common_mistakes": "Common mistakes",
"alternatives": "Alternatives",
"failure_signs": "Failure signs",
"when_not_to_use": "When not to use",
"short_term": "Short-term actions",
"long_term": "Long-term actions",
"if_method_fails": "If the method fails",
"environment_notes": "Environment notes",
"related_topics": "Related topics",
"sources": "Sources",
"last_updated": "Last updated",
},
"el": {
"id": "ID",
"language": "Γλώσσα",
"title": "Τίτλος",
"category": "Κατηγορία",
"subcategory": "Υποκατηγορία",
"summary": "Σύνοψη",
"content": "Πλήρεις οδηγίες",
"difficulty": "Δυσκολία",
"urgency": "Επείγον",
"priority": "Προτεραιότητα",
"tags": "Ετικέτες",
"materials": "Υλικά",
"steps": "Βήματα",
"warnings": "Προειδοποιήσεις",
"common_mistakes": "Συνηθισμένα λάθη",
"alternatives": "Εναλλακτικές",
"failure_signs": "Ενδείξεις αποτυχίας",
"when_not_to_use": "Πότε να μη χρησιμοποιηθεί",
"short_term": "Βραχυπρόθεσμες ενέργειες",
"long_term": "Μακροπρόθεσμες ενέργειες",
"if_method_fails": "Αν η μέθοδος αποτύχει",
"environment_notes": "Σημειώσεις περιβάλλοντος",
"related_topics": "Σχετικά θέματα",
"sources": "Πηγές",
"last_updated": "Τελευταία ενημέρωση",
},
}
DISPLAY_ORDER = [
"id",
"language",
"category",
"subcategory",
"summary",
"content",
"difficulty",
"urgency",
"priority",
"materials",
"steps",
"warnings",
"common_mistakes",
"alternatives",
"failure_signs",
"when_not_to_use",
"short_term",
"long_term",
"if_method_fails",
"environment_notes",
"related_topics",
"sources",
"last_updated",
]
REQUIRED_FIELDS = ("title", "id", "language", "category", "sources", "last_updated")
# Optional display sections are validated when present, but are no longer mandatory.
# This prevents generic filler from being inserted merely to satisfy the schema.
FLEXIBLE_TEXT_FIELDS = {"short_term", "long_term", "if_method_fails", "environment_notes"}
LIST_FIELDS = {
"tags",
"materials",
"steps",
"warnings",
"common_mistakes",
"alternatives",
"failure_signs",
"when_not_to_use",
"related_topics",
"sources",
}
OFFICIAL_SOURCE_DOMAINS = {
"civilprotection.gov.gr",
"www.avma.org",
"www.cdc.gov",
"www.cisa.gov",
"www.cpsc.gov",
"www.epa.gov",
"www.faa.gov",
"www.fao.org",
"www.fcc.gov",
"www.ars.usda.gov",
"www.minagric.gr",
"www.nal.usda.gov",
"www.nrcs.usda.gov",
"www.usda.gov",
"nchfp.uga.edu",
"www.moh.gov.gr",
"www.nhs.uk",
"www.osha.gov",
"www.ready.gov",
"www.redcross.org",
"www.sarsat.noaa.gov",
"www.usgs.gov",
"www.who.int",
"cdn.who.int",
}
DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$")
def normalize(value: Any) -> str:
"""Return lowercase, accent-insensitive text for English and Greek matching."""
text = unicodedata.normalize("NFKD", str(value))
return "".join(character for character in text if not unicodedata.combining(character)).casefold()
SEARCH_STOP_WORDS = frozenset({
"a", "an", "and", "are", "at", "can", "do", "for", "from", "how", "i", "in",
"is", "it", "me", "my", "of", "on", "or", "the", "to", "what", "when", "where",
"which", "with", "without", "would", "you", "your",
"και", "να", "το", "τα", "τη", "την", "της", "τις", "των", "ο", "η", "οι", "σε",
"στο", "στη", "στην", "για", "με", "απο", "πως", "πώς", "τι", "ποτε", "πού", "που",
})
# Small bilingual concept groups improve natural-language retrieval without any network dependency.
_SEARCH_CONCEPT_GROUPS = (
("water", "drinking", "hydration", "νερο", "ποσιμο", "ενυδατωση"),
("purify", "purification", "disinfect", "disinfection", "καθαρισμος", "απολυμανση"),
("boil", "boiling", "βρασιμο", "βραζω"),
("power", "electricity", "electric", "ρευμα", "ηλεκτρικο", "ηλεκτρισμος"),
("outage", "blackout", "διακοπη", "μπλακαουτ"),
("battery", "batteries", "μπαταρια", "μπαταριες"),
("fire", "flame", "φωτια", "φλογα"),
("wildfire", "bushfire", "δασικη", "πυρκαγια"),
("smoke", "καπνος"),
("flood", "flooding", "πλημμυρα", "πλημμυρισμενο"),
("earthquake", "quake", "σεισμος"),
("evacuation", "evacuate", "escape", "εκκενωση", "διαφυγη"),
("route", "path", "δρομος", "διαδρομη"),
("shelter", "refuge", "καταφυγιο"),
("medical", "medicine", "health", "ιατρικο", "υγεια"),
("first", "aid", "firstaid", "πρωτες", "βοηθειες"),
("injury", "wound", "trauma", "τραυμα", "τραυματισμος"),
("bleeding", "blood", "αιμορραγια", "αιμα"),
("food", "foods", "τροφιμα", "φαγητο"),
("cook", "cooking", "μαγειρεμα"),
("preserve", "preservation", "storage", "διατηρηση", "αποθηκευση"),
("sanitation", "hygiene", "υγιεινη", "αποχετευση"),
("communication", "communications", "comms", "επικοινωνια", "επικοινωνιες"),
("radio", "ραδιοφωνο", "ασυρματος"),
("phone", "mobile", "telephone", "τηλεφωνο", "κινητο"),
("heat", "hot", "καυσωνας", "ζεστη"),
("cold", "freezing", "κρυο", "παγωνια"),
("hypothermia", "υποθερμια"),
("heatstroke", "sunstroke", "θερμοπληξια"),
("document", "documents", "id", "identification", "εγγραφο", "εγγραφα", "ταυτοτητα"),
("navigation", "navigate", "map", "gps", "πλοηγηση", "χαρτης"),
("generator", "γεννητρια"),
("gas", "fuel", "καυσιμο", "αεριο"),
("pet", "pets", "animal", "ζωο", "κατοικιδιο"),
("medication", "medications", "drug", "φαρμακο", "φαρμακα"),
)
_SEARCH_SYNONYMS: dict[str, frozenset[str]] = {}
for _group in _SEARCH_CONCEPT_GROUPS:
_normalized_group = frozenset(normalize(value) for value in _group)
for _term in _normalized_group:
_SEARCH_SYNONYMS[_term] = _normalized_group
_GREEK_LATIN = str.maketrans({
"α": "a", "β": "v", "γ": "g", "δ": "d", "ε": "e", "ζ": "z", "η": "i",
"θ": "th", "ι": "i", "κ": "k", "λ": "l", "μ": "m", "ν": "n", "ξ": "x",
"ο": "o", "π": "p", "ρ": "r", "σ": "s", "ς": "s", "τ": "t", "υ": "y",
"φ": "f", "χ": "ch", "ψ": "ps", "ω": "o",
})
def greek_to_latin(value: Any) -> str:
"""Return a lightweight Greek-to-Latin search alias (e.g. nero -> νερό content)."""
return normalize(value).translate(_GREEK_LATIN)
def search_tokens(value: Any, *, remove_stop_words: bool = True) -> list[str]:
"""Tokenize normalized text while preserving English, Greek, digits, IDs, and hyphenated concepts."""
cleaned = re.sub(r"[^0-9a-zα-ω_-]+", " ", normalize(value))
tokens = [token.strip("_-") for token in cleaned.split() if token.strip("_-")]
if remove_stop_words:
useful = [token for token in tokens if token not in SEARCH_STOP_WORDS]
return useful or tokens
return tokens
def _query_parts(query: str) -> dict[str, Any]:
raw = str(query or "").strip()[:300]
filters: dict[str, list[str]] = defaultdict(list)
filter_re = re.compile(r'(?i)\b(category|tag|priority|urgency|difficulty|id):(?:"([^"]+)"|(\S+))')
for match in filter_re.finditer(raw):
filters[match.group(1).casefold()].append(normalize(match.group(2) or match.group(3)))
without_filters = filter_re.sub(" ", raw)
quoted = [normalize(value) for value in re.findall(r'"([^"]{2,120})"', without_filters)]
without_quotes = re.sub(r'"[^"]*"', " ", without_filters)
negatives = [normalize(token[1:]) for token in without_quotes.split() if token.startswith("-") and len(token) > 1]
positive_text = " ".join(token for token in without_quotes.split() if not token.startswith("-"))
tokens = list(dict.fromkeys(search_tokens(positive_text)))
phrase = normalize(positive_text).strip()
return {"raw": raw, "phrase": phrase, "tokens": tokens, "quoted": quoted, "negatives": negatives, "filters": dict(filters)}
def _fuzzy_quality(query_token: str, candidate: str) -> float:
if len(query_token) < 4 or len(candidate) < 4:
return 0.0
if abs(len(query_token) - len(candidate)) > max(2, len(query_token) // 3):
return 0.0
if query_token[0] != candidate[0]:
return 0.0
ratio = difflib.SequenceMatcher(None, query_token, candidate).ratio()
threshold = 0.84 if len(query_token) <= 5 else 0.78
return ratio if ratio >= threshold else 0.0
def rank_search_fields(query: str, fields: dict[str, Any], *, aliases: Any = "") -> dict[str, Any] | None:
"""Rank a text object against a natural-language query with synonyms, typo tolerance and filters."""
parsed = _query_parts(query)
tokens: list[str] = parsed["tokens"]
if not tokens and not parsed["quoted"] and not parsed["filters"]:
return None
normalized_fields = {name: normalize(flatten(value)) for name, value in fields.items()}
field_tokens = {
name: set(search_tokens(value, remove_stop_words=False)) | set(search_tokens(greek_to_latin(value), remove_stop_words=False))
for name, value in fields.items()
}
alias_text = normalize(flatten(aliases))
alias_text += " " + greek_to_latin(flatten(aliases))
alias_tokens = set(search_tokens(alias_text, remove_stop_words=False))
all_text = " ".join(normalized_fields.values()) + " " + alias_text
# Structured filters are strict; they are useful on both CLI and browser searches.
filter_map = {
"category": normalized_fields.get("category", ""),
"tag": normalized_fields.get("tags", ""),
"priority": normalized_fields.get("priority", ""),
"urgency": normalized_fields.get("urgency", ""),
"difficulty": normalized_fields.get("difficulty", ""),
"id": normalized_fields.get("id", ""),
}
for name, wanted in parsed["filters"].items():
hay = filter_map.get(name, "")
if any(value not in hay for value in wanted):
return None
if any(negative and negative in all_text for negative in parsed["negatives"]):
return None
for quoted in parsed["quoted"]:
if quoted not in all_text:
return None
weights = {
"id": 180.0, "title": 95.0, "category": 60.0, "subcategory": 55.0, "tags": 52.0,
"summary": 40.0, "content": 22.0, "steps": 25.0, "warnings": 26.0,
"common_mistakes": 20.0, "alternatives": 18.0, "failure_signs": 24.0,
"when_not_to_use": 24.0, "short_term": 20.0, "long_term": 18.0,
"if_method_fails": 20.0, "environment_notes": 16.0, "related_topics": 18.0, "path": 20.0,
"name": 80.0, "body": 10.0,
}
score = 0.0
matched_terms: list[str] = []
matched_fields: list[str] = []
fuzzy_terms: list[str] = []
synonym_terms: list[str] = []
alias_matches: list[str] = []
for token in tokens:
variants = _SEARCH_SYNONYMS.get(token, frozenset({token}))
best_quality = 0.0
best_field = ""
best_variant = token
fuzzy_used = False
for field, words in field_tokens.items():
if not words:
continue
quality = 0.0
variant_used = token
if token in words:
quality = 1.0
else:
for variant in variants:
if variant in words:
quality = max(quality, 0.92 if variant != token else 1.0)
variant_used = variant
if quality == 0.0 and len(token) >= 3:
if any(word.startswith(token) or token.startswith(word) for word in words if abs(len(word) - len(token)) <= 5):
quality = 0.82
if quality == 0.0:
for word in words:
q = _fuzzy_quality(token, word)
if q > quality:
quality = q * 0.78
variant_used = word
fuzzy_used = q > 0
weighted = quality * weights.get(field, 12.0)
if weighted > best_quality:
best_quality = weighted
best_field = field
best_variant = variant_used
alias_quality = 0.0
if token in alias_tokens:
alias_quality = 16.0
else:
for variant in variants:
if variant in alias_tokens:
alias_quality = 14.0
best_variant = variant
break
if alias_quality == 0.0:
for word in alias_tokens:
q = _fuzzy_quality(token, word)
if q:
alias_quality = q * 10.0
fuzzy_used = True
best_variant = word
break
if alias_quality > best_quality:
best_quality = alias_quality
best_field = "alias"
alias_matches.append(token)
if best_quality > 0:
score += best_quality
matched_terms.append(token)
if best_field and best_field not in matched_fields:
matched_fields.append(best_field)
if fuzzy_used:
fuzzy_terms.append(token)
if best_variant != token and token not in fuzzy_terms:
synonym_terms.append(token)
total_tokens = max(1, len(tokens))
coverage = len(set(matched_terms)) / total_tokens if tokens else 1.0
if tokens and not matched_terms:
return None
if len(tokens) >= 3 and coverage < 0.34:
return None
phrase = parsed["phrase"]
title = normalized_fields.get("title", normalized_fields.get("name", ""))
record_id = normalized_fields.get("id", "")
if phrase:
if phrase == record_id:
score += 650
if phrase == title:
score += 520
elif title.startswith(phrase):
score += 300
elif phrase in title:
score += 220
elif phrase in normalized_fields.get("summary", ""):
score += 95
elif phrase in normalized_fields.get("path", ""):
score += 70
elif phrase in all_text:
score += 45
score += coverage * 35
if coverage == 1.0 and len(tokens) > 1:
score += 25
score += len(parsed["quoted"]) * 120 + sum(len(v) for v in parsed["filters"].values()) * 45
return {
"score": round(score, 2),
"coverage": round(coverage, 3),
"matched_terms": list(dict.fromkeys(matched_terms)),
"matched_fields": matched_fields,
"fuzzy_terms": list(dict.fromkeys(fuzzy_terms)),
"synonym_terms": list(dict.fromkeys(synonym_terms)),
"cross_language_terms": list(dict.fromkeys(alias_matches)),
}
def record_detail_profile(record: dict[str, Any]) -> dict[str, Any]:
"""Build a richer view solely from facts already present in the record."""
list_fields = ("materials", "steps", "warnings", "common_mistakes", "alternatives", "failure_signs", "when_not_to_use", "related_topics", "sources")
counts = {field: len(record.get(field, [])) for field in list_fields if isinstance(record.get(field), list) and record.get(field)}
available = [field for field in ("summary", "content", "materials", "steps", "warnings", "common_mistakes", "alternatives", "failure_signs", "when_not_to_use", "short_term", "long_term", "if_method_fails", "environment_notes") if record.get(field) not in (None, "", [])]
key_points: list[str] = []
if isinstance(record.get("steps"), list):
key_points.extend(str(value) for value in record["steps"][:8] if str(value).strip())
if not key_points:
base = str(record.get("content") or record.get("summary") or "")
key_points.extend(part.strip() for part in re.split(r"(?<=[.!?])\s+", base) if len(part.strip()) >= 25)
key_points = key_points[:6]
risk_points: list[str] = []
for field in ("warnings", "failure_signs", "when_not_to_use"):
value = record.get(field)
if isinstance(value, list):
risk_points.extend(str(item) for item in value if str(item).strip())
fallbacks: list[str] = []
if isinstance(record.get("alternatives"), list):
fallbacks.extend(str(item) for item in record["alternatives"] if str(item).strip())
if record.get("if_method_fails"):
fallbacks.append(str(record["if_method_fails"]))
source_domains: list[str] = []
for source in record.get("sources", []) if isinstance(record.get("sources"), list) else []:
domain = urlparse(str(source)).netloc.casefold()
if domain and domain not in source_domains:
source_domains.append(domain)
concept_source = " ".join([str(record.get("title", "")), str(record.get("category", "")), str(record.get("subcategory", "")), flatten(record.get("tags", [])), str(record.get("summary", ""))])
concepts = []
for token in search_tokens(concept_source):
if len(token) >= 3 and token not in concepts and not token.startswith("pack"):
concepts.append(token)
if len(concepts) >= 14:
break
return {
"operational_profile": {
"difficulty": record.get("difficulty", ""),
"urgency": record.get("urgency", ""),
"priority": record.get("priority", ""),
},
"available_guidance_sections": available,
"section_item_counts": counts,
"key_points_from_record": key_points,
"risk_and_stop_signals_from_record": risk_points[:12],
"fallbacks_from_record": fallbacks[:10],
"source_domains": source_domains,
"key_concepts": concepts,
}
def flatten(value: Any) -> str:
"""Convert nested JSON-compatible data to searchable text."""
if isinstance(value, dict):
return " ".join(f"{flatten(key)} {flatten(item)}" for key, item in value.items())
if isinstance(value, (list, tuple, set)):
return " ".join(flatten(item) for item in value)
return str(value)
def terminal_size() -> tuple[int, int]:
size = shutil.get_terminal_size((80, 24))
return max(20, min(size.columns, 110)), max(12, size.lines)
def terminal_width() -> int:
return terminal_size()[0]
def divider(character: str = "─") -> str:
return character * terminal_width()
def wrap_lines(text: Any, initial: str = "", subsequent: str = "") -> list[str]:
"""Wrap text safely for narrow mobile terminals while preserving paragraphs."""
width = terminal_width()
paragraphs = str(text).splitlines() or [""]
output: list[str] = []
for paragraph in paragraphs:
if not paragraph.strip():
output.append("")
continue
output.extend(
textwrap.wrap(
paragraph.strip(),
width=max(12, width),
initial_indent=initial,
subsequent_indent=subsequent,
replace_whitespace=False,
break_long_words=True,
break_on_hyphens=False,
)
or [initial.rstrip()]
)
return output
def is_rule_line(line: str) -> bool:
stripped = line.strip()
return bool(stripped) and len(set(stripped)) == 1 and stripped[0] in {"·", "─", "═"}
def paginate_lines(lines: Sequence[str], page_height: int) -> list[list[str]]:
"""Split lines into pages without leaving a section heading above its value."""
content = list(lines) or [""]
pages: list[list[str]] = []
start = 0
while start < len(content):
end = min(len(content), start + page_height)
if end < len(content):
while end > start + 1 and not content[end - 1].strip():
end -= 1
if end > start + 2 and is_rule_line(content[end - 1]):
end -= 2
if end <= start:
end = min(len(content), start + page_height)
pages.append(content[start:end])
start = end
return pages
def load_settings() -> tuple[dict[str, Any], bool]:
settings = dict(DEFAULT_SETTINGS)
existed = SETTINGS_FILE.is_file()
if existed:
try:
saved = json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
if isinstance(saved, dict):
settings.update(saved)
else:
existed = False
except (OSError, UnicodeError, json.JSONDecodeError):
existed = False
if settings.get("language") not in LANGUAGES:
settings["language"] = DEFAULT_SETTINGS["language"]
if settings.get("page_size") not in {5, 10, 15, 20}:
settings["page_size"] = DEFAULT_SETTINGS["page_size"]
if not isinstance(settings.get("clear_screen"), bool):
settings["clear_screen"] = DEFAULT_SETTINGS["clear_screen"]
return settings, existed
def save_settings(settings: dict[str, Any]) -> bool:
"""Atomically save local preferences outside the repository."""
try:
SETTINGS_DIR.mkdir(parents=True, exist_ok=True)
temporary = SETTINGS_FILE.with_suffix(".tmp")
temporary.write_text(
json.dumps(settings, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
temporary.replace(SETTINGS_FILE)
return True
except OSError:
return False
class OfflineDatabase:
"""Load, index, search, and validate the consolidated bilingual database."""
def __init__(self) -> None:
self._document_cache: dict[str, Any] | None = None
self._records: dict[str, list[dict[str, Any]]] = {}
self._files: dict[str, dict[str, list[dict[str, Any]]]] = {}
self._counterpart_aliases: dict[str, dict[str, str]] = {}
@property
def database_file(self) -> Path:
return PROJECT_ROOT / "Offline Survival Database.json"
def language_root(self, language: str) -> Path:
"""Compatibility path for diagnostics; records live in one database file."""
self.language_payload(language)
return self.database_file.parent
def _document(self) -> dict[str, Any]:
if self._document_cache is not None:
return self._document_cache
path = self.database_file
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as error:
raise RuntimeError(f"{path}: {error}") from error
if not isinstance(payload, dict):
raise RuntimeError(f"{path}: top-level JSON value must be an object")
languages = payload.get("languages")
if not isinstance(languages, dict):
raise RuntimeError(f"{path}: missing languages object")
self._document_cache = payload
return payload
def language_payload(self, language: str) -> dict[str, Any]:
document = self._document()
languages = document.get("languages", {})
payload = languages.get(language) if isinstance(languages, dict) else None
if not isinstance(payload, dict):
raise RuntimeError(f"Missing language database: {language}")
return payload
def _aliases_for_language(self, language: str) -> dict[str, str]:
if language in self._counterpart_aliases:
return self._counterpart_aliases[language]
other = "el" if language == "en" else "en"
aliases: dict[str, str] = {}
try:
groups = self.language_payload(other).get("source_groups", [])
except RuntimeError:
groups = []
if isinstance(groups, list):
for group in groups:
if not isinstance(group, dict):
continue
for record in group.get("records", []) if isinstance(group.get("records"), list) else []:
if not isinstance(record, dict):
continue
rid = str(record.get("id", "")).strip()
if not rid:
continue
aliases[rid] = " ".join(str(record.get(field, "")) for field in ("title", "category", "subcategory", "summary", "tags"))
self._counterpart_aliases[language] = aliases
return aliases
def load(self, language: str) -> list[dict[str, Any]]:
if language in self._records:
return self._records[language]
language_payload = self.language_payload(language)
groups = language_payload.get("source_groups", [])
if not isinstance(groups, list):
raise RuntimeError(f"Invalid source_groups for language: {language}")
records: list[dict[str, Any]] = []
files: dict[str, list[dict[str, Any]]] = {}
for group_position, group in enumerate(groups, start=1):
if not isinstance(group, dict):
raise RuntimeError(f"Source group {group_position} for {language} must be an object")
relative_path = str(group.get("path", "")).strip().replace("\\", "/")
payload = group.get("records", [])
if not relative_path or relative_path.startswith("/") or ".." in Path(relative_path).parts:
raise RuntimeError(f"Invalid source group path for {language}: {relative_path!r}")
if not isinstance(payload, list):
raise RuntimeError(f"Source group {relative_path}: records must be a list")
file_records: list[dict[str, Any]] = []
for position, record in enumerate(payload, start=1):
if not isinstance(record, dict):
raise RuntimeError(f"Source group {relative_path}: record {position} must be a JSON object")
primary_fields = {
key: record.get(key, "")
for key in ("id", "title", "category", "subcategory", "summary", "tags")
}
counterpart = self._aliases_for_language(language).get(str(record.get("id", "")), "")
item = {
"record": record,
"relative_path": relative_path,
"absolute_path": self.database_file,
"primary_search_text": normalize(f"{relative_path} {flatten(primary_fields)}"),
"search_text": normalize(f"{relative_path} {flatten(record)}"),
"cross_language_aliases": counterpart,
}
records.append(item)
file_records.append(item)
files[relative_path] = file_records
records.sort(key=lambda item: normalize(item["record"].get("title", "")))
self._records[language] = records
self._files[language] = files
return records
def files(self, language: str) -> dict[str, list[dict[str, Any]]]:
self.load(language)
return self._files[language]
def raw_file_json(self, language: str, relative_path: str) -> str:
records = self.files(language).get(relative_path)
if records is None:
raise FileNotFoundError(relative_path)
payload = [item["record"] for item in records]
return json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
def search(self, language: str, query: str) -> list[dict[str, Any]]:
"""Natural-language ranked search with bilingual aliases, filters, synonyms and typo tolerance."""
ranked: list[tuple[float, str, dict[str, Any]]] = []
for item in self.load(language):
record = item["record"]
fields = dict(record)
fields["path"] = item["relative_path"]
meta = rank_search_fields(query, fields, aliases=item.get("cross_language_aliases", ""))
if meta is None:
continue
result = dict(item)
result["search_meta"] = meta
ranked.append((float(meta["score"]), normalize(record.get("title", "")), result))
ranked.sort(key=lambda row: (-row[0], row[1]))
if not ranked:
return []
# Suppress the long tail of weak matches while keeping broad one-word searches useful.
best_score = ranked[0][0]
floor = max(28.0, best_score * (0.30 if len(_query_parts(query)["tokens"]) <= 1 else 0.38))
return [row[2] for row in ranked if row[0] >= floor][:250]
def find_files(self, language: str, query: str) -> list[tuple[str, list[dict[str, Any]]]]:
ranked: list[tuple[float, str, str, list[dict[str, Any]]]] = []
for relative_path, records in self.files(language).items():
summary = " ".join(str(item["record"].get("title", "")) + " " + str(item["record"].get("summary", "")) for item in records)
meta = rank_search_fields(query, {"name": Path(relative_path).stem, "path": relative_path, "summary": summary})
if meta is None:
continue
ranked.append((float(meta["score"]), normalize(relative_path), relative_path, records))
ranked.sort(key=lambda row: (-row[0], row[1]))
return [(relative_path, records) for _, _, relative_path, records in ranked]
def find_by_id(self, language: str, query: str) -> list[dict[str, Any]]:
phrase = normalize(query).strip()
if not phrase:
return []
exact: list[dict[str, Any]] = []
starts: list[dict[str, Any]] = []
partial: list[dict[str, Any]] = []
for item in self.load(language):
record_id = normalize(item["record"].get("id", ""))
if record_id == phrase:
exact.append(item)
elif record_id.startswith(phrase):
starts.append(item)
elif phrase in record_id:
partial.append(item)
return exact + starts + partial