├── tests ├── __init__.py ├── recipe_files │ ├── athenos.mx2 │ ├── athenos1.mx2 │ ├── mycookbook.mcb │ ├── special_chars.grmt │ ├── mastercook_text_export.mxp │ └── mealmaster_2_col.mmf ├── old_databases │ ├── gourmet-0.11.2 │ │ └── recipes.db │ └── gourmet-0.13.8 │ │ ├── recipes.db │ │ └── guiprefs ├── old_databases_test.sh ├── broken_readme.md ├── test_mycookbook_exporter.py ├── test_dtd.py ├── test_nutritional_information.py ├── test_pdf_exporter.py ├── test_web_importer.py ├── broken_test_unit_converter_dogtail.py ├── test_prefs.py ├── broken_test_import_manager.py ├── test_gglobals.py ├── test_clipboard_exporter.py ├── broken_test_plugin_loader.py ├── test_reccard_sortby.py ├── test_convert.py ├── test_interactive_importer.py ├── test_time_scanner.py └── test_importer.py ├── src └── gourmand │ ├── plugins │ ├── __init__.py │ ├── import_export │ │ ├── __init__.py │ │ ├── epub_plugin │ │ │ ├── __init__.py │ │ │ └── epub_exporter_plugin.py │ │ ├── html_plugin │ │ │ ├── __init__.py │ │ │ └── html_exporter_plugin.py │ │ ├── krecipe_plugin │ │ │ ├── __init__.py │ │ │ ├── krecipe_importer_plugin.py │ │ │ └── krecipe_importer.py │ │ ├── pdf_plugin │ │ │ ├── __init__.py │ │ │ ├── pdf_exporter_plugin.py │ │ │ └── page_drawer.py │ │ ├── mycookbook_plugin │ │ │ ├── __init__.py │ │ │ ├── mycookbook_exporter_plugin.py │ │ │ └── mycookbook_importer_plugin.py │ │ ├── gxml_plugin │ │ │ ├── __init__.py │ │ │ ├── gxml_exporter_plugin.py │ │ │ ├── gxml2_importer.py │ │ │ └── gxml_importer_plugin.py │ │ ├── mealmaster_plugin │ │ │ ├── __init__.py │ │ │ ├── mealmaster_exporter_plugin.py │ │ │ └── mealmaster_importer_plugin.py │ │ ├── mastercook_import_plugin │ │ │ ├── __init__.py │ │ │ └── mastercook_importer_plugin.py │ │ └── plaintext_plugin │ │ │ ├── __init__.py │ │ │ ├── plaintext_exporter_plugin.py │ │ │ └── plaintext_importer_plugin.py │ ├── browse_recipes │ │ ├── images │ │ │ ├── __init__.py │ │ │ ├── rating.png │ │ │ ├── source.png │ │ │ ├── cooktime.png │ │ │ ├── cuisine.png │ │ │ ├── preptime.png │ │ │ ├── generic_recipe.png │ │ │ ├── generic_category.png │ │ │ ├── cooktime_empty_clock.png │ │ │ └── preptime_empty_clock.png │ │ └── __init__.py │ ├── email_plugin │ │ ├── __init__.py │ │ ├── emailer.py │ │ ├── emailer_plugin.py │ │ └── recipe_emailer.py │ ├── listsaver │ │ ├── __init__.py │ │ └── shoppingSaverPlugin.py │ ├── spellcheck │ │ ├── __init__.py │ │ └── reccard_spellcheck_plugin.py │ ├── shopping_associations │ │ └── __init__.py │ ├── duplicate_finder │ │ ├── __init__.py │ │ └── recipeMergerPlugin.py │ ├── key_editor │ │ ├── __init__.py │ │ ├── keyEditorPlugin.py │ │ └── keyEditorPluggable.py │ ├── check_for_unicode_16 │ │ └── __init__.py │ ├── nutritional_information │ │ ├── __init__.py │ │ ├── main_plugin.py │ │ ├── nutritionModel.py │ │ ├── nutPrefsPlugin.py │ │ ├── enter_nutritional_defaults.py │ │ ├── export_plugin.py │ │ ├── shopping_plugin.py │ │ ├── nutritionGrabberGui.py │ │ └── data_plugin.py │ ├── unit_converter │ │ └── __init__.py │ ├── unit_display_prefs │ │ ├── unit_prefs_dialog.py │ │ └── __init__.py │ └── field_editor │ │ └── __init__.py │ ├── exporters │ ├── __init__.py │ ├── reference_setup │ │ └── recipes.mk │ ├── xml_exporter.py │ ├── printer.py │ ├── clipboard_exporter.py │ └── MarkupString.py │ ├── importers │ ├── __init__.py │ ├── clipboard_importer.py │ ├── xml_importer.py │ └── rezkonv_importer.py │ ├── data │ ├── nutritional_data_sr_version │ ├── FOOD_DES.txt │ ├── images │ │ ├── no_star.png │ │ ├── reccard.png │ │ ├── splash.png │ │ ├── Nutrition.png │ │ ├── blue_star.png │ │ ├── gold_star.png │ │ ├── gourmand.ico │ │ ├── gourmand.png │ │ ├── reccard_edit.png │ │ ├── half_blue_star.png │ │ ├── half_gold_star.png │ │ └── AddToShoppingList.png │ ├── sound │ │ ├── error.opus │ │ ├── phone.opus │ │ └── warning.opus │ ├── style │ │ ├── epubdefault.css │ │ └── default.css │ └── recipe.dtd │ ├── defaults │ ├── __init__.py │ └── defaults.py │ ├── backends │ ├── __init__.py │ └── default.db │ ├── __main__.py │ ├── ui │ └── catalog │ │ ├── README │ │ ├── gourmetwidgets.py │ │ └── gourmetwidgets.xml │ ├── __init__.py │ ├── gtk_extras │ └── __init__.py │ ├── i18n.py │ ├── sound.py │ ├── settings.py │ ├── structure.py │ ├── __version__.py │ ├── optionparser.py │ ├── gdebug.py │ ├── recipeManager.py │ ├── batchEditor.py │ └── prefs.py ├── setup.cfg ├── docs ├── recipe_view.png └── releasing.md ├── development.in ├── MANIFEST.in ├── .github ├── dependabot.yml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── workflows │ ├── flatpak.yml │ ├── pypi.yml │ ├── build.yml │ ├── tests.yml │ ├── codeql.yml │ └── appimage.yml └── PULL_REQUEST_TEMPLATE.md ├── .git-blame-ignore-revs ├── data ├── plugins │ ├── unit_converter.gourmet-plugin.in │ ├── field_editor.gourmet-plugin.in │ ├── listsaver.gourmet-plugin.in │ ├── import_export │ │ ├── epub.gourmet-plugin.in │ │ ├── krecipe_plugin.gourmet-plugin.in │ │ ├── mealmaster.gourmet-plugin.in │ │ ├── mastercook_plugin.gourmet-plugin.in │ │ ├── pdf.gourmet-plugin.in │ │ ├── plaintext.gourmet-plugin.in │ │ ├── gxml.gourmet-plugin.in │ │ └── mycookbook_plugin.gourmet-plugin.in │ ├── browse_plugin.gourmet-plugin.in │ ├── duplicate_finder.gourmet-plugin.in │ ├── nutritional_information.gourmet-plugin.in │ ├── spellcheck.gourmet-plugin.in │ ├── unit_display_prefs.gourmet-plugin.in │ ├── key_editor.gourmet-plugin.in │ ├── shopping_associations.gourmet-plugin.in │ └── utf16.gourmet-plugin.in ├── io.github.GourmandRecipeManager.Gourmand.desktop └── io.github.GourmandRecipeManager.Gourmand.appdata.xml ├── .gitignore ├── README.md ├── pyproject.toml ├── private └── update_i18n.py └── .flatpak └── io.github.GourmandRecipeManager.Gourmand.yml /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/gourmand/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/gourmand/exporters/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/gourmand/importers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/gourmand/plugins/import_export/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [sdist] 2 | owner = root 3 | group = root 4 | -------------------------------------------------------------------------------- /src/gourmand/data/nutritional_data_sr_version: -------------------------------------------------------------------------------- 1 | SR21 2 | -------------------------------------------------------------------------------- /src/gourmand/plugins/browse_recipes/images/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/gourmand/defaults/__init__.py: -------------------------------------------------------------------------------- 1 | from .defaults import lang # noqa: F401 2 | -------------------------------------------------------------------------------- /src/gourmand/backends/__init__.py: -------------------------------------------------------------------------------- 1 | # Database backend stuff should all be in this directory. 2 | # 3 | -------------------------------------------------------------------------------- /docs/recipe_view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GourmandRecipeManager/gourmand/HEAD/docs/recipe_view.png -------------------------------------------------------------------------------- /development.in: -------------------------------------------------------------------------------- 1 | --editable .[epub-export,pdf-export,spellcheck] 2 | dogtail 3 | mypy 4 | ruff==0.8.4 5 | pytest 6 | -------------------------------------------------------------------------------- /src/gourmand/__main__.py: -------------------------------------------------------------------------------- 1 | if __name__ == "__main__": 2 | from gourmand.main import launch_app 3 | 4 | launch_app() 5 | -------------------------------------------------------------------------------- /src/gourmand/data/FOOD_DES.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GourmandRecipeManager/gourmand/HEAD/src/gourmand/data/FOOD_DES.txt -------------------------------------------------------------------------------- /tests/recipe_files/athenos.mx2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GourmandRecipeManager/gourmand/HEAD/tests/recipe_files/athenos.mx2 -------------------------------------------------------------------------------- /tests/recipe_files/athenos1.mx2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GourmandRecipeManager/gourmand/HEAD/tests/recipe_files/athenos1.mx2 -------------------------------------------------------------------------------- /src/gourmand/backends/default.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GourmandRecipeManager/gourmand/HEAD/src/gourmand/backends/default.db -------------------------------------------------------------------------------- /src/gourmand/plugins/email_plugin/__init__.py: -------------------------------------------------------------------------------- 1 | from . import emailer_plugin 2 | 3 | plugins = [emailer_plugin.EmailRecipePlugin] 4 | -------------------------------------------------------------------------------- /tests/recipe_files/mycookbook.mcb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GourmandRecipeManager/gourmand/HEAD/tests/recipe_files/mycookbook.mcb -------------------------------------------------------------------------------- /src/gourmand/data/images/no_star.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GourmandRecipeManager/gourmand/HEAD/src/gourmand/data/images/no_star.png -------------------------------------------------------------------------------- /src/gourmand/data/images/reccard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GourmandRecipeManager/gourmand/HEAD/src/gourmand/data/images/reccard.png -------------------------------------------------------------------------------- /src/gourmand/data/images/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GourmandRecipeManager/gourmand/HEAD/src/gourmand/data/images/splash.png -------------------------------------------------------------------------------- /src/gourmand/data/sound/error.opus: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GourmandRecipeManager/gourmand/HEAD/src/gourmand/data/sound/error.opus -------------------------------------------------------------------------------- /src/gourmand/data/sound/phone.opus: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GourmandRecipeManager/gourmand/HEAD/src/gourmand/data/sound/phone.opus -------------------------------------------------------------------------------- /src/gourmand/data/sound/warning.opus: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GourmandRecipeManager/gourmand/HEAD/src/gourmand/data/sound/warning.opus -------------------------------------------------------------------------------- /src/gourmand/data/images/Nutrition.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GourmandRecipeManager/gourmand/HEAD/src/gourmand/data/images/Nutrition.png -------------------------------------------------------------------------------- /src/gourmand/data/images/blue_star.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GourmandRecipeManager/gourmand/HEAD/src/gourmand/data/images/blue_star.png -------------------------------------------------------------------------------- /src/gourmand/data/images/gold_star.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GourmandRecipeManager/gourmand/HEAD/src/gourmand/data/images/gold_star.png -------------------------------------------------------------------------------- /src/gourmand/data/images/gourmand.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GourmandRecipeManager/gourmand/HEAD/src/gourmand/data/images/gourmand.ico -------------------------------------------------------------------------------- /src/gourmand/data/images/gourmand.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GourmandRecipeManager/gourmand/HEAD/src/gourmand/data/images/gourmand.png -------------------------------------------------------------------------------- /src/gourmand/plugins/listsaver/__init__.py: -------------------------------------------------------------------------------- 1 | from . import shoppingSaverPlugin 2 | 3 | plugins = [shoppingSaverPlugin.ShoppingListSaver] 4 | -------------------------------------------------------------------------------- /src/gourmand/data/images/reccard_edit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GourmandRecipeManager/gourmand/HEAD/src/gourmand/data/images/reccard_edit.png -------------------------------------------------------------------------------- /src/gourmand/plugins/spellcheck/__init__.py: -------------------------------------------------------------------------------- 1 | from . import reccard_spellcheck_plugin 2 | 3 | plugins = [reccard_spellcheck_plugin.SpellPlugin] 4 | -------------------------------------------------------------------------------- /src/gourmand/data/images/half_blue_star.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GourmandRecipeManager/gourmand/HEAD/src/gourmand/data/images/half_blue_star.png -------------------------------------------------------------------------------- /src/gourmand/data/images/half_gold_star.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GourmandRecipeManager/gourmand/HEAD/src/gourmand/data/images/half_gold_star.png -------------------------------------------------------------------------------- /src/gourmand/ui/catalog/README: -------------------------------------------------------------------------------- 1 | This directory contains a catalog with some custom widgets needed to use Gourmand's gtkbuilder *.ui files in glade. 2 | -------------------------------------------------------------------------------- /src/gourmand/data/images/AddToShoppingList.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GourmandRecipeManager/gourmand/HEAD/src/gourmand/data/images/AddToShoppingList.png -------------------------------------------------------------------------------- /src/gourmand/plugins/import_export/epub_plugin/__init__.py: -------------------------------------------------------------------------------- 1 | from . import epub_exporter_plugin 2 | 3 | plugins = [epub_exporter_plugin.EpubExporterPlugin] 4 | -------------------------------------------------------------------------------- /src/gourmand/plugins/import_export/html_plugin/__init__.py: -------------------------------------------------------------------------------- 1 | from . import html_exporter_plugin 2 | 3 | plugins = [html_exporter_plugin.HtmlExporterPlugin] 4 | -------------------------------------------------------------------------------- /tests/old_databases/gourmet-0.11.2/recipes.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GourmandRecipeManager/gourmand/HEAD/tests/old_databases/gourmet-0.11.2/recipes.db -------------------------------------------------------------------------------- /tests/old_databases/gourmet-0.13.8/recipes.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GourmandRecipeManager/gourmand/HEAD/tests/old_databases/gourmet-0.13.8/recipes.db -------------------------------------------------------------------------------- /src/gourmand/plugins/shopping_associations/__init__.py: -------------------------------------------------------------------------------- 1 | from . import shopping_key_editor_plugin 2 | 3 | plugins = [shopping_key_editor_plugin.KeyEditorPlugin] 4 | -------------------------------------------------------------------------------- /src/gourmand/exporters/reference_setup/recipes.mk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GourmandRecipeManager/gourmand/HEAD/src/gourmand/exporters/reference_setup/recipes.mk -------------------------------------------------------------------------------- /src/gourmand/plugins/browse_recipes/images/rating.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GourmandRecipeManager/gourmand/HEAD/src/gourmand/plugins/browse_recipes/images/rating.png -------------------------------------------------------------------------------- /src/gourmand/plugins/browse_recipes/images/source.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GourmandRecipeManager/gourmand/HEAD/src/gourmand/plugins/browse_recipes/images/source.png -------------------------------------------------------------------------------- /src/gourmand/plugins/import_export/krecipe_plugin/__init__.py: -------------------------------------------------------------------------------- 1 | from . import krecipe_importer_plugin 2 | 3 | plugins = [krecipe_importer_plugin.KrecipeImporterPlugin] 4 | -------------------------------------------------------------------------------- /src/gourmand/plugins/browse_recipes/images/cooktime.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GourmandRecipeManager/gourmand/HEAD/src/gourmand/plugins/browse_recipes/images/cooktime.png -------------------------------------------------------------------------------- /src/gourmand/plugins/browse_recipes/images/cuisine.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GourmandRecipeManager/gourmand/HEAD/src/gourmand/plugins/browse_recipes/images/cuisine.png -------------------------------------------------------------------------------- /src/gourmand/plugins/browse_recipes/images/preptime.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GourmandRecipeManager/gourmand/HEAD/src/gourmand/plugins/browse_recipes/images/preptime.png -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft data 2 | graft po 3 | graft src 4 | graft tests 5 | 6 | include LICENSE 7 | include README.md 8 | 9 | global-exclude *.py[cod] __pycache__/* *.so *.dylib 10 | -------------------------------------------------------------------------------- /src/gourmand/plugins/browse_recipes/images/generic_recipe.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GourmandRecipeManager/gourmand/HEAD/src/gourmand/plugins/browse_recipes/images/generic_recipe.png -------------------------------------------------------------------------------- /src/gourmand/plugins/browse_recipes/images/generic_category.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GourmandRecipeManager/gourmand/HEAD/src/gourmand/plugins/browse_recipes/images/generic_category.png -------------------------------------------------------------------------------- /src/gourmand/plugins/duplicate_finder/__init__.py: -------------------------------------------------------------------------------- 1 | from . import recipeMergerPlugin 2 | 3 | plugins = [recipeMergerPlugin.RecipeMergerPlugin, recipeMergerPlugin.RecipeMergerImportManagerPlugin] 4 | -------------------------------------------------------------------------------- /src/gourmand/plugins/import_export/pdf_plugin/__init__.py: -------------------------------------------------------------------------------- 1 | from . import pdf_exporter_plugin, print_plugin 2 | 3 | plugins = [pdf_exporter_plugin.PdfExporterPlugin, print_plugin.PDFPrintPlugin] 4 | -------------------------------------------------------------------------------- /src/gourmand/__init__.py: -------------------------------------------------------------------------------- 1 | from gi import require_version 2 | 3 | require_version("Gdk", "3.0") 4 | require_version("Gst", "1.0") 5 | require_version("Gtk", "3.0") 6 | require_version("Pango", "1.0") 7 | -------------------------------------------------------------------------------- /src/gourmand/plugins/browse_recipes/images/cooktime_empty_clock.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GourmandRecipeManager/gourmand/HEAD/src/gourmand/plugins/browse_recipes/images/cooktime_empty_clock.png -------------------------------------------------------------------------------- /src/gourmand/plugins/browse_recipes/images/preptime_empty_clock.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GourmandRecipeManager/gourmand/HEAD/src/gourmand/plugins/browse_recipes/images/preptime_empty_clock.png -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Set update schedule for GitHub Actions 2 | 3 | version: 2 4 | updates: 5 | 6 | - package-ecosystem: "github-actions" 7 | directory: "/" 8 | schedule: 9 | interval: "weekly" 10 | -------------------------------------------------------------------------------- /src/gourmand/plugins/import_export/mycookbook_plugin/__init__.py: -------------------------------------------------------------------------------- 1 | from . import mycookbook_exporter_plugin, mycookbook_importer_plugin 2 | 3 | plugins = [mycookbook_exporter_plugin.MCBExporterPlugin, mycookbook_importer_plugin.MCBPlugin] 4 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # This file helps us to ignore style / formatting / doc changes 2 | # in git blame. That is useful when we're trying to find the root cause of an 3 | # error. 4 | 5 | # Cleanup code (#177) 6 | 1fd3f17bf120cb2e0456b005344466f6aa36072d 7 | -------------------------------------------------------------------------------- /src/gourmand/plugins/import_export/gxml_plugin/__init__.py: -------------------------------------------------------------------------------- 1 | from . import gxml_exporter_plugin, gxml_importer_plugin 2 | 3 | plugins = [gxml_exporter_plugin.GourmetExporterPlugin, gxml_importer_plugin.GourmetXML2Plugin, gxml_importer_plugin.GourmetXMLPlugin] 4 | -------------------------------------------------------------------------------- /src/gourmand/plugins/import_export/mealmaster_plugin/__init__.py: -------------------------------------------------------------------------------- 1 | from . import mealmaster_exporter_plugin, mealmaster_importer_plugin 2 | 3 | plugins = [mealmaster_exporter_plugin.MealmasterExporterPlugin, mealmaster_importer_plugin.MealmasterImporterPlugin] 4 | -------------------------------------------------------------------------------- /data/plugins/unit_converter.gourmet-plugin.in: -------------------------------------------------------------------------------- 1 | [Gourmet Plugin] 2 | Module=unit_converter 3 | Version=1.0 4 | API_Version=1.0 5 | _Name=Unit Converter 6 | _Comment=Provides a simple unit calculator. 7 | _Category=Tools 8 | Authors=Thomas M. Hinkle 9 | -------------------------------------------------------------------------------- /src/gourmand/plugins/import_export/mastercook_import_plugin/__init__.py: -------------------------------------------------------------------------------- 1 | from . import mastercook_importer_plugin 2 | 3 | plugins = [ 4 | mastercook_importer_plugin.MastercookImporterPlugin, 5 | mastercook_importer_plugin.MastercookTextImporterPlugin, 6 | ] 7 | -------------------------------------------------------------------------------- /data/plugins/field_editor.gourmet-plugin.in: -------------------------------------------------------------------------------- 1 | [Gourmet Plugin] 2 | Module=field_editor 3 | Version=1.0 4 | API_Version=1.0 5 | _Name=Field Editor 6 | _Comment=Edit fields across multiple recipes at a time. 7 | _Category=Tools 8 | Authors=Thomas M. Hinkle 9 | -------------------------------------------------------------------------------- /data/plugins/listsaver.gourmet-plugin.in: -------------------------------------------------------------------------------- 1 | [Gourmet Plugin] 2 | Module=listsaver 3 | Version=1.0 4 | API_Version=1.0 5 | _Name=Shopping List Saver 6 | _Comment=Save shopping lists as recipes for future use. 7 | _Category=Tools 8 | Authors=Thomas M. Hinkle 9 | -------------------------------------------------------------------------------- /src/gourmand/plugins/import_export/plaintext_plugin/__init__.py: -------------------------------------------------------------------------------- 1 | from . import plaintext_exporter_plugin, plaintext_importer_plugin 2 | 3 | plugins = [ 4 | plaintext_importer_plugin.PlainTextImporterPlugin, 5 | plaintext_exporter_plugin.PlainTextExporterPlugin, 6 | ] 7 | -------------------------------------------------------------------------------- /src/gourmand/plugins/key_editor/__init__.py: -------------------------------------------------------------------------------- 1 | from . import keyEditorPlugin, recipeEditorPlugin 2 | 3 | plugins = [ 4 | keyEditorPlugin.KeyEditorPlugin, 5 | recipeEditorPlugin.IngredientKeyEditorPlugin, 6 | recipeEditorPlugin.KeyEditorIngredientControllerPlugin, 7 | ] 8 | -------------------------------------------------------------------------------- /tests/old_databases_test.sh: -------------------------------------------------------------------------------- 1 | cp -R old_databases /tmp/old_databases 2 | for folder in /tmp/old_databases/* 3 | do 4 | echo Testing Gourmet\'s update from $folder 5 | gourmet --gourmet-directory=$folder 6 | echo Done with test. 7 | done 8 | rm -r /tmp/old_databases 9 | -------------------------------------------------------------------------------- /data/plugins/import_export/epub.gourmet-plugin.in: -------------------------------------------------------------------------------- 1 | [Gourmet Plugin] 2 | Module=epub_plugin 3 | Version=1.0 4 | API_Version=1.0 5 | _Name=EPub Export 6 | _Comment=Create an epub book from recipes. 7 | _Category=Importer/Exporter 8 | Authors=Sven Steckmann 9 | -------------------------------------------------------------------------------- /data/plugins/browse_plugin.gourmet-plugin.in: -------------------------------------------------------------------------------- 1 | [Gourmet Plugin] 2 | Module=browse_recipes 3 | Version=1.0 4 | API_Version=1.0 5 | _Name=Browse Recipes 6 | _Comment=Selecting recipes by browsing by category, cuisine, etc. 7 | _Category=Main 8 | Authors=Thomas M. Hinkle 9 | -------------------------------------------------------------------------------- /data/plugins/import_export/krecipe_plugin.gourmet-plugin.in: -------------------------------------------------------------------------------- 1 | [Gourmet Plugin] 2 | Module=krecipe_plugin 3 | Version=1.0 4 | API_Version=1.0 5 | _Name=KRecipe import 6 | _Comment=Import files from the Krecipe program. 7 | _Category=Importer/Exporter 8 | Authors=Thomas M. Hinkle 9 | -------------------------------------------------------------------------------- /data/plugins/duplicate_finder.gourmet-plugin.in: -------------------------------------------------------------------------------- 1 | [Gourmet Plugin] 2 | Module=duplicate_finder 3 | Version=1.0 4 | API_Version=1.0 5 | _Name=Find duplicate recipes 6 | _Comment=A wizard to find and remove or merge duplicate recipes. 7 | _Category=Tools 8 | Authors=Thomas M. Hinkle 9 | -------------------------------------------------------------------------------- /data/plugins/import_export/mealmaster.gourmet-plugin.in: -------------------------------------------------------------------------------- 1 | [Gourmet Plugin] 2 | Module=mealmaster_plugin 3 | Version=1.0 4 | API_Version=1.0 5 | _Name=Mealmaster (tm) Export 6 | _Comment=Export recipes as mealmaster text files 7 | _Category=Importer/Exporter 8 | Authors=Thomas M. Hinkle 9 | -------------------------------------------------------------------------------- /data/plugins/nutritional_information.gourmet-plugin.in: -------------------------------------------------------------------------------- 1 | [Gourmet Plugin] 2 | Module=nutritional_information 3 | Version=1.1 4 | API_Version=1.0 5 | _Name=Nutritional Information 6 | _Comment=Calculate nutritional information for recipes. 7 | _Category=Main 8 | Authors=Thomas M. Hinkle 9 | -------------------------------------------------------------------------------- /data/plugins/import_export/mastercook_plugin.gourmet-plugin.in: -------------------------------------------------------------------------------- 1 | [Gourmet Plugin] 2 | Module=mastercook_import_plugin 3 | Version=1.0 4 | API_Version=1.0 5 | _Name=Mastercook import 6 | _Comment=Import files from the Mastercook program. 7 | _Category=Importer/Exporter 8 | Authors=Thomas M. Hinkle 9 | -------------------------------------------------------------------------------- /data/plugins/spellcheck.gourmet-plugin.in: -------------------------------------------------------------------------------- 1 | [Gourmet Plugin] 2 | Module=spellcheck 3 | Version=1.0 4 | API_Version=1.0 5 | _Name=Spell Checking 6 | _Comment=Adds spell checking to paragraph length text boxes in recipe entry (Instructions, Notes). 7 | _Category=Tools 8 | Authors=Thomas M. Hinkle 9 | -------------------------------------------------------------------------------- /data/plugins/unit_display_prefs.gourmet-plugin.in: -------------------------------------------------------------------------------- 1 | [Gourmet Plugin] 2 | Module=unit_display_prefs 3 | Version=1.0 4 | API_Version=1.0 5 | _Name=Unit Display Preferences 6 | _Comment=Allows you to always use metric (or imperial) units for display. 7 | _Category=Tools 8 | Authors=Thomas M. Hinkle 9 | -------------------------------------------------------------------------------- /data/plugins/import_export/pdf.gourmet-plugin.in: -------------------------------------------------------------------------------- 1 | [Gourmet Plugin] 2 | Module=pdf_plugin 3 | Version=1.0 4 | API_Version=1.0 5 | _Name=Printing & PDF Export 6 | _Comment=Print recipes or export them as PDF; this plugin allows a variety of page layouts. 7 | _Category=Importer/Exporter 8 | Authors=Thomas M. Hinkle 9 | -------------------------------------------------------------------------------- /data/plugins/import_export/plaintext.gourmet-plugin.in: -------------------------------------------------------------------------------- 1 | [Gourmet Plugin] 2 | Module=plaintext_plugin 3 | Version=1.0 4 | API_Version=1.0 5 | _Name=Plain Text Guided Import 6 | _Comment=Help user mark up and import plain text. Also provides plain text export. 7 | _Category=Importer/Exporter 8 | Authors=Thomas M. Hinkle 9 | -------------------------------------------------------------------------------- /data/plugins/key_editor.gourmet-plugin.in: -------------------------------------------------------------------------------- 1 | [Gourmet Plugin] 2 | Module=key_editor 3 | Version=1.0 4 | API_Version=1.0 5 | _Name=Key Editor 6 | _Comment=Assign and edit ingredient keys (unique identifiers for ingredients, used for consolidating items on shopping lists). 7 | _Category=Tools 8 | Authors=Thomas M. Hinkle 9 | -------------------------------------------------------------------------------- /data/plugins/shopping_associations.gourmet-plugin.in: -------------------------------------------------------------------------------- 1 | [Gourmet Plugin] 2 | Module=shopping_associations 3 | Version=1.0 4 | API_Version=1.0 5 | _Name=Shopping Category Editor 6 | _Comment=Assign and edit shopping categories for ingredients from within the recipe editor. 7 | _Category=Tools 8 | Authors=Thomas M. Hinkle 9 | -------------------------------------------------------------------------------- /data/plugins/import_export/gxml.gourmet-plugin.in: -------------------------------------------------------------------------------- 1 | [Gourmet Plugin] 2 | Module=gxml_plugin 3 | Version=1.0 4 | API_Version=1.0 5 | _Name=Gourmet XML Import and Export 6 | _Comment=Import and Export recipes as Gourmet XML files, suitable for backup or exchange with other Gourmet users. 7 | _Category=Importer/Exporter 8 | Authors=Thomas M. Hinkle 9 | -------------------------------------------------------------------------------- /data/plugins/import_export/mycookbook_plugin.gourmet-plugin.in: -------------------------------------------------------------------------------- 1 | [Gourmet Plugin] 2 | Module=mycookbook_plugin 3 | Version=1.0 4 | API_Version=1.0 5 | _Name=My CookBook Import and Export 6 | _Comment=Import and Export recipes as My CookBook XML files, suitable for backup or exchange with other My CookBook users. 7 | _Category=Importer/Exporter 8 | Authors=MaadInfoServices 9 | -------------------------------------------------------------------------------- /tests/broken_readme.md: -------------------------------------------------------------------------------- 1 | The tests prefixed with broken_ were historically part of Gourmet. 2 | 3 | They are broken and haven't been looked at. Some may be fixed, some are deprecated due to heavy changes since they were implemented. 4 | This directory is excluded from the CI runner. 5 | 6 | It does not mean that they can be freely deleted. Each test file should be assayed and fixed, if possible! 7 | -------------------------------------------------------------------------------- /src/gourmand/gtk_extras/__init__.py: -------------------------------------------------------------------------------- 1 | from gi.repository import Gtk 2 | 3 | 4 | def fix_action_group_importance(ag): 5 | for action in ag.list_actions(): 6 | ifact = Gtk.IconFactory() 7 | if not action.get_property("stock-id") or not ifact.lookup(action.get_property("stock-id")): 8 | # print 'No icon found for',action 9 | action.set_property("is-important", True) 10 | -------------------------------------------------------------------------------- /src/gourmand/plugins/check_for_unicode_16/__init__.py: -------------------------------------------------------------------------------- 1 | from gourmand.plugin import ToolPlugin 2 | from gourmand.prefs import Prefs 3 | 4 | 5 | class EnableUTF16Plugin(ToolPlugin): 6 | ui_string = "" 7 | 8 | def activate(self, pluggable): 9 | Prefs.instance()["utf-16"] = True 10 | 11 | def remove(self): 12 | Prefs.instance()["utf-16"] = False 13 | 14 | 15 | plugins = [EnableUTF16Plugin] 16 | -------------------------------------------------------------------------------- /data/io.github.GourmandRecipeManager.Gourmand.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Version=1.0 3 | Type=Application 4 | 5 | Name=Gourmand 6 | GenericName=Recipe Manager 7 | X-GNOME-FullName=Gourmand Recipe Manager 8 | Categories=GTK;Utility; 9 | Comment=Organize recipes, create shopping lists, calculate nutritional information, and more. 10 | Icon=io.github.GourmandRecipeManager.Gourmand 11 | 12 | Exec=gourmand 13 | Terminal=false 14 | StartupNotify=true 15 | -------------------------------------------------------------------------------- /data/plugins/utf16.gourmet-plugin.in: -------------------------------------------------------------------------------- 1 | [Gourmet Plugin] 2 | Module=check_for_unicode_16 3 | Version=1.0 4 | API_Version=1.0 5 | _Name=Check for Unicode-16 in text files 6 | _Comment=Enable this if your utf-16 text files aren't importing cleanly into Gourmet. Disable this if you never use UTF16 and you're constantly being annoyed by the 'Encoding' dialog when you import files. 7 | _Category=Importer/Exporter 8 | Authors=Thomas M. Hinkle 9 | -------------------------------------------------------------------------------- /src/gourmand/plugins/nutritional_information/__init__.py: -------------------------------------------------------------------------------- 1 | from . import data_plugin, export_plugin, main_plugin, nutPrefsPlugin, reccard_plugin, shopping_plugin 2 | 3 | plugins = [ 4 | data_plugin.NutritionDataPlugin, 5 | main_plugin.NutritionMainPlugin, 6 | reccard_plugin.NutritionDisplayPlugin, 7 | export_plugin.NutritionBaseExporterPlugin, 8 | shopping_plugin.ShoppingNutritionalInfoPlugin, 9 | nutPrefsPlugin.NutritionPrefs, 10 | ] 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | 4 | 5 | # Distribution / packaging 6 | *.egg-info/ 7 | *.gourmet-plugin 8 | *.mo 9 | /build/ 10 | /dist/ 11 | gourmet.appdata.xml 12 | gourmet.desktop 13 | /development.txt 14 | 15 | # Test outputs 16 | tests/recipe_files/athenos1.gourmetcleaned.mx2 17 | /Formatted* 18 | /Simple* 19 | /Uni* 20 | 21 | 22 | # Virtual environments 23 | /env/ 24 | /venv/ 25 | 26 | 27 | # Editors 28 | *.swp 29 | .idea/ 30 | -------------------------------------------------------------------------------- /src/gourmand/i18n.py: -------------------------------------------------------------------------------- 1 | """Internationalization for Gourmand. 2 | 3 | This file defines a gettext-like interface with the gourmet domain, and is 4 | later imported in the rest of the application. 5 | """ 6 | 7 | import gettext 8 | import locale 9 | from pathlib import Path 10 | 11 | mo_path = Path(__file__).parent / "data" / "locale" 12 | 13 | locale.bindtextdomain("gourmand", mo_path) 14 | langs = gettext.translation("gourmand", mo_path, fallback=True) 15 | _ = langs.gettext 16 | -------------------------------------------------------------------------------- /tests/test_mycookbook_exporter.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from gourmand.plugins.import_export.mycookbook_plugin.mycookbook_exporter import sanitize_image_name 4 | 5 | 6 | class RectoMCBTest(unittest.TestCase): 7 | def test_sanitize_image_name(self): 8 | input_name = "Brownies w/ caram/el" 9 | result = sanitize_image_name(input_name) 10 | self.assertEqual("Brownies with caramel", result) 11 | 12 | 13 | if __name__ == '__main__': 14 | unittest.main() 15 | -------------------------------------------------------------------------------- /src/gourmand/sound.py: -------------------------------------------------------------------------------- 1 | from urllib.request import pathname2url 2 | 3 | from gi.repository import Gst 4 | 5 | 6 | class Player: 7 | 8 | def __init__(self): 9 | Gst.init() 10 | self.player = Gst.ElementFactory.make("playbin", "player") 11 | 12 | def play_file(self, filepath: str) -> None: 13 | uri = pathname2url(filepath) 14 | self.player.set_state(Gst.State.NULL) 15 | self.player.set_property("uri", f"file://{uri}") 16 | self.player.set_state(Gst.State.PLAYING) 17 | -------------------------------------------------------------------------------- /tests/test_dtd.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from unittest import TestCase 3 | 4 | from lxml import etree 5 | 6 | EXAMPLE_FILE = Path(__file__).parent / "recipe_files" / "test_set.grmt" 7 | DTD_FILE = Path(__file__).parent.parent / "src/gourmand/data/recipe.dtd" 8 | 9 | class TestDtd(TestCase): 10 | 11 | def setUp(self): 12 | self.dtd = etree.DTD(DTD_FILE) 13 | 14 | def test_dtd_validates_example(self): 15 | example = etree.parse(EXAMPLE_FILE) 16 | assert self.dtd.validate(example) 17 | -------------------------------------------------------------------------------- /tests/recipe_files/special_chars.grmt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Yes special characters - Origx2 6 | <b>less than bold &lt;</b> 7 | <i>greater than italic &gt;</i> 8 | <u>ampersand underlined &amp;</u> 9 | All 10 symbols above <u>numbers </u><u><b>!@#</b></u><u><i>$%</i></u><u><b>^&amp;*()</b></u> 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/gourmand/plugins/import_export/krecipe_plugin/krecipe_importer_plugin.py: -------------------------------------------------------------------------------- 1 | from gourmand.i18n import _ 2 | from gourmand.importers.importer import Tester 3 | from gourmand.plugin import ImporterPlugin 4 | 5 | from . import krecipe_importer 6 | 7 | 8 | class KrecipeImporterPlugin(ImporterPlugin): 9 | 10 | name = _("KRecipe XML File") 11 | patterns = ["*.xml", "*.kreml"] 12 | mimetypes = ["text/xml", "application/xml", "text/plain"] 13 | 14 | def test_file(self, filename): 15 | return Tester(".* ]").test(filename) 16 | 17 | def get_importer(self, filename): 18 | return krecipe_importer.Converter(filename) 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Is your feature request related to a problem? Please describe. 11 | 12 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 13 | 14 | ## Describe the solution you'd like 15 | 16 | A clear and concise description of what you want to happen. 17 | 18 | ## Describe alternatives you've considered 19 | A clear and concise description of any alternative solutions or features you've considered. 20 | 21 | ## Additional context 22 | Add any other context or screenshots about the feature request here. 23 | -------------------------------------------------------------------------------- /src/gourmand/ui/catalog/gourmetwidgets.py: -------------------------------------------------------------------------------- 1 | # import os, sys 2 | # execpath = os.path.dirname(__file__) 3 | # sys.path.insert (0, os.path.join(execpath, "../../src/lib")) 4 | # print sys.path 5 | 6 | # from gtk_extras import timeEntry, ratingWidget, timeEntry 7 | # import timeScanner 8 | 9 | from gi.repository import Gtk 10 | 11 | 12 | class TimeEntry(Gtk.Entry): 13 | __gtype_name__ = "TimeEntry" 14 | 15 | 16 | class StarButton(Gtk.Button): 17 | __gtype_name__ = "StarButton" 18 | 19 | 20 | class StarImage(Gtk.Image): 21 | __gtype_name__ = "StarImage" 22 | 23 | 24 | class LinkedTextView(Gtk.TextView): 25 | __gtype_name__ = "LinkedTextView" 26 | 27 | 28 | class LinkedTimeView(LinkedTextView): 29 | __gtype_name__ = "LinkedTimeView" 30 | -------------------------------------------------------------------------------- /src/gourmand/plugins/nutritional_information/main_plugin.py: -------------------------------------------------------------------------------- 1 | from gourmand.gglobals import add_icon as _add_icon 2 | from gourmand.i18n import _ 3 | from gourmand.image_utils import load_pixbuf_from_resource 4 | from gourmand.plugin import MainPlugin 5 | 6 | from . import nutrition, nutritionGrabberGui 7 | 8 | 9 | class NutritionMainPlugin(MainPlugin): 10 | 11 | def activate(self, pluggable): 12 | """Setup nutritional database stuff.""" 13 | pixbuf = load_pixbuf_from_resource("Nutrition.png") 14 | _add_icon(pixbuf, "nutritional-info", _("Nutritional Information")) 15 | nutritionGrabberGui.check_for_db(pluggable.rd) 16 | pluggable.nd = nutrition.NutritionData(pluggable.rd, pluggable.conv) 17 | pluggable.rd.nd = pluggable.nd 18 | -------------------------------------------------------------------------------- /src/gourmand/settings.py: -------------------------------------------------------------------------------- 1 | import os.path as op 2 | import sys 3 | 4 | # The following lines are modified at installation time by setup.py so they 5 | # point to the actual data files installation paths. 6 | 7 | base_dir = op.abspath(op.join(op.dirname(__file__), "..")) 8 | locale_base = op.join(base_dir, "build", "mo") 9 | 10 | # Apologies for the formatting -- something in the build process is 11 | # getting rid of indentations in this file which throws a syntax error 12 | # on install 13 | if getattr(sys, "frozen", False): 14 | base_dir = op.dirname(sys.executable) 15 | data_dir = base_dir 16 | ui_base = op.join(base_dir, "ui") 17 | doc_base = op.join(base_dir, "doc") 18 | locale_base = op.join(base_dir, "locale") 19 | plugin_base = op.join(base_dir) 20 | -------------------------------------------------------------------------------- /tests/test_nutritional_information.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | import pytest 4 | 5 | from gourmand.plugins.nutritional_information.nutrition import NutritionData 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "description, mock_content, expected", 10 | [("", ["", ""], []), ("with", ["", ""], []), ("what a str", ["str", 5], [("str", 5)])], 11 | ) 12 | def test_get_matches(description, mock_content, expected): 13 | content_mock = Mock() 14 | content_mock.desc = mock_content[0] 15 | content_mock.ndbno = mock_content[1] 16 | 17 | mock_db = Mock() 18 | mock_db.search_nutrition = Mock(return_value=(content_mock,)) 19 | 20 | nd = NutritionData(db=mock_db, conv=None) 21 | 22 | ret = nd.get_matches(description) 23 | assert ret == expected 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Describe the bug 11 | 12 | A clear and concise description of what the bug is. 13 | 14 | ## To Reproduce 15 | 16 | Steps to reproduce the behavior: 17 | 1. Go to '...' 18 | 2. Click on '....' 19 | 3. Scroll down to '....' 20 | 4. See error 21 | 22 | ## Expected behavior 23 | A clear and concise description of what you expected to happen. 24 | 25 | ## Screenshots 26 | If applicable, add screenshots to help explain your problem. 27 | 28 | ## Environment 29 | 30 | - Operating system: 31 | - Version or commit ID: 32 | - Installation method: 33 | 34 | ## Additional context 35 | Add any other context about the problem here. 36 | -------------------------------------------------------------------------------- /src/gourmand/structure.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | 3 | # This structure contains information stored in the database, 4 | # as well as other fields that can be found in imports. 5 | Recipe = namedtuple( 6 | "Recipe", 7 | [ 8 | "id", 9 | "title", 10 | "instructions", 11 | "modifications", 12 | "cuisine", 13 | "rating", 14 | "description", 15 | "source", 16 | "totaltime", 17 | "preptime", 18 | "cooktime", 19 | "servings", 20 | "yields", 21 | "yield_unit", 22 | "ingredients", 23 | "image", 24 | "thumb", 25 | "deleted", 26 | "recipe_hash", 27 | "ingredient_hash", 28 | "link", 29 | "last_modified", 30 | "nutrients", 31 | "category", 32 | ], 33 | ) 34 | -------------------------------------------------------------------------------- /src/gourmand/data/style/epubdefault.css: -------------------------------------------------------------------------------- 1 | div.recipe, div.index { font-style: Times, Serif; font-size: 12pt; margin-left: 7%; margin-right: 10%; padding: 1em; margin-top: 1em;} 2 | div.recipe img {float: right; padding: 1em} 3 | body {} 4 | span.label { font-weight: bold;min-width: 10em; display: inline-block;} 5 | p.title { font-size: 120%; text-align: center} 6 | p.title span.label {display: none} 7 | div.header p {margin-top: 0; margin-bottom: 0.2em} 8 | div.ing { padding: 1em; border: solid 1px; background-color: #eef} 9 | ul.ing { padding-left: 0.6em; } 10 | li.ing { list-style: none; border-top: 0.3em } 11 | div.ingamount{ display:inline-block; min-width:3em;} 12 | div.ingunit{ display:inline-block; min-width:3em;} 13 | img{ max-width: 90%; display: block; margin-left: auto; margin-right: auto; } 14 | div.header{margin-top: 1em; margin-bottom: 1em;} 15 | div.ing h2{margin-top: 0} 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gourmand Recipe Manager 2 | 3 | ![Tests](https://github.com/GourmandRecipeManager/gourmand/workflows/Tests/badge.svg) ![Build](https://github.com/GourmandRecipeManager/gourmand/workflows/Build/badge.svg) 4 | 5 | Gourmand is a fork of the Gourmet Recipe Manager: a manager, editor, and organizer for recipes. 6 | 7 | ![recipe view](docs/recipe_view.png) 8 | 9 | ## Requirements and Installation 10 | 11 | Installation instruction are found in the [installation guide](docs/installation.md). 12 | 13 | ## Issues and Contributions 14 | 15 | See the [contribution guide](docs/contributing.md). 16 | 17 | ## Tournant for Android 18 | 19 | If you want to transfer your recipes to your Android device, give [Tournant](https://tournant.zimbelstern.eu) a try. Tournant can parse Gourmand's XML recipe files and serves your recipes wherever you need them – whether in the kitchen or in the grocery store. 20 | -------------------------------------------------------------------------------- /src/gourmand/plugins/key_editor/keyEditorPlugin.py: -------------------------------------------------------------------------------- 1 | from gi.repository import Gtk 2 | 3 | import gourmand.main 4 | import gourmand.plugin 5 | from gourmand.i18n import _ 6 | 7 | from . import keyEditor 8 | 9 | 10 | class KeyEditorPlugin(gourmand.plugin.ToolPlugin): 11 | menu_items = """ 12 | 13 | 14 | 15 | """ 16 | 17 | def setup_action_groups(self): 18 | self.action_group = Gtk.ActionGroup(name="KeyEditorActionGroup") 19 | self.action_group.add_actions([("KeyEditor", None, _("Ingredient _Key Editor"), None, _("Edit ingredient keys en masse"), self.show_key_editor)]) 20 | self.action_groups.append(self.action_group) 21 | 22 | def show_key_editor(self, *args): 23 | gourmand_app = gourmand.main.get_application() 24 | ke = keyEditor.KeyEditor(rd=gourmand_app.rd, rg=gourmand_app) # noqa: F841 25 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools", 4 | ] 5 | build-backend = "setuptools.build_meta" 6 | 7 | [tool.pytest.ini_options] 8 | addopts = "-vv" 9 | testpaths = [ 10 | "tests", 11 | ] 12 | 13 | [tool.black] 14 | line-length = 160 15 | # https://black.readthedocs.io/en/stable/usage_and_configuration/the_basics.html#t-target-version 16 | target-version = ["py38", "py39", "py310", "py311", "py312", "py313"] 17 | 18 | [tool.ruff] 19 | line-length = 160 20 | target-version = "py38" 21 | 22 | [tool.ruff.lint] 23 | extend-select = [ 24 | "E", # All pycodestyle errors 25 | "W", # pycodestyle warnings 26 | "PGH004", # blanket-noqa 27 | "I", # isort 28 | # "N", # pep8-naming # TODO: 338 violations in main API as of 2024-12-30. 29 | # "A", # flake8-builtins # TODO: 112 violations in main API as of 2024-12-30. 30 | # "B", # flake8-bugbear # TODO: 122 violations in main API as of 2024-12-30. 31 | ] 32 | -------------------------------------------------------------------------------- /src/gourmand/plugins/unit_converter/__init__.py: -------------------------------------------------------------------------------- 1 | from gi.repository import Gtk 2 | 3 | from gourmand.i18n import _ 4 | from gourmand.plugin import ToolPlugin 5 | 6 | from . import convertGui 7 | 8 | 9 | class ConverterPlugin(ToolPlugin): 10 | menu_items = """ 11 | 12 | """ 13 | 14 | def setup_action_groups(self): 15 | self.action_group = Gtk.ActionGroup(name="ConverterPluginActionGroup") 16 | self.action_group.add_actions([("UnitConverter", None, _("_Unit Converter"), None, _("Calculate unit conversions"), self.show_unit_converter)]) 17 | self.action_groups.append(self.action_group) 18 | 19 | def show_unit_converter(self, *args): 20 | try: 21 | umodel = self.pluggable.umodel 22 | except AttributeError: 23 | try: 24 | umodel = self.pluggable.rg.umodel 25 | except Exception: 26 | umodel = None 27 | convertGui.ConvGui(unitModel=umodel) 28 | 29 | 30 | plugins = [ConverterPlugin] 31 | -------------------------------------------------------------------------------- /src/gourmand/plugins/nutritional_information/nutritionModel.py: -------------------------------------------------------------------------------- 1 | from gi.repository import GObject, Gtk 2 | 3 | from gourmand.i18n import _ 4 | 5 | 6 | class NutritionModel(Gtk.TreeStore): 7 | """Handed ingredients and a nutritional database, display 8 | our nutritional information thus far.""" 9 | 10 | AMOUNT_COL_HEAD = _("Amount") 11 | UNIT_COL_HEAD = _("Unit") 12 | ING_COL_HEAD = _("Ingredient") 13 | USDA_COL_HEAD = _("USDA Database Equivalent") 14 | UNKNOWN = _("Unknown") 15 | AMT_COL = 1 16 | UNIT_COL = 2 17 | ING_COL = 3 18 | USDA_COL = 4 19 | 20 | def __init__(self, ings, nd): 21 | Gtk.TreeStore.__init__(self, GObject.TYPE_PYOBJECT, str, str, str, str) 22 | self.nd = nd 23 | self.ings = ings 24 | list(map(self.add_ingredient, self.ings)) 25 | 26 | def add_ingredient(self, ing): 27 | r = self.nd.get_key(ing.ingkey) 28 | if r: 29 | desc = r.desc 30 | else: 31 | desc = self.UNKNOWN 32 | self.append(None, [ing, str(ing.amount), ing.unit, str(ing.item), desc]) 33 | -------------------------------------------------------------------------------- /src/gourmand/plugins/spellcheck/reccard_spellcheck_plugin.py: -------------------------------------------------------------------------------- 1 | import locale 2 | 3 | from gi.repository import Gtk 4 | from gtkspellcheck import SpellChecker 5 | 6 | from gourmand.plugin import RecEditorPlugin, UIPlugin 7 | 8 | 9 | def harvest_textviews(widget): 10 | if isinstance(widget, Gtk.TextView): 11 | return [widget] 12 | else: 13 | tvs = [] 14 | if hasattr(widget, "get_children"): 15 | for child in widget.get_children(): 16 | tvs.extend(harvest_textviews(child)) 17 | elif hasattr(widget, "get_child"): 18 | tvs.extend(harvest_textviews(widget.get_child())) 19 | return tvs 20 | 21 | 22 | class SpellPlugin(RecEditorPlugin, UIPlugin): 23 | 24 | main = None 25 | 26 | ui_string = "" 27 | 28 | def activate(self, editor): 29 | UIPlugin.activate(self, editor) 30 | language, _ = locale.getlocale() 31 | for module in self.pluggable.modules: 32 | tvs = harvest_textviews(module.main) 33 | for tv in tvs: 34 | SpellChecker(tv, language=language) 35 | -------------------------------------------------------------------------------- /private/update_i18n.py: -------------------------------------------------------------------------------- 1 | """ 2 | Create/update po/pot translation files. 3 | """ 4 | 5 | import shutil 6 | import subprocess 7 | from pathlib import Path 8 | 9 | PACKAGE = "gourmand" 10 | CURRENT_DIRECTORY = Path(__file__).parent 11 | PACKAGEDIR = CURRENT_DIRECTORY.parent / "src" / PACKAGE 12 | PODIR = CURRENT_DIRECTORY.parent / "po" 13 | LANGS = sorted(f.stem for f in PODIR.glob("*.po")) 14 | 15 | 16 | def main(): 17 | print("Creating POT files") 18 | intltool_update = shutil.which("intltool-update") 19 | cmd = [intltool_update, "--pot", f"--gettext-package={PACKAGE}"] 20 | if not intltool_update: 21 | print(f"intltool-update not found, skipping {cmd!r} call ...") 22 | else: 23 | subprocess.run(cmd, cwd=PODIR) 24 | 25 | for lang in LANGS: 26 | print(f"Updating {lang}.po") 27 | cmd = [intltool_update, "--dist", f"--gettext-package={PACKAGE}", lang] 28 | if not intltool_update: 29 | print(f"intltool-update not found, skipping {cmd!r} call ...") 30 | else: 31 | subprocess.run(cmd, cwd=PODIR) 32 | 33 | 34 | if __name__ == "__main__": 35 | main() 36 | -------------------------------------------------------------------------------- /src/gourmand/plugins/import_export/mycookbook_plugin/mycookbook_exporter_plugin.py: -------------------------------------------------------------------------------- 1 | from gourmand.i18n import _ 2 | from gourmand.plugin import ExporterPlugin 3 | 4 | from . import mycookbook_exporter 5 | 6 | MCB = _("My CookBook MCB File") 7 | 8 | 9 | class MCBExporterPlugin(ExporterPlugin): 10 | 11 | label = _("MCB Export") 12 | sublabel = _("Exporting recipes to My CookBook MCB file %(file)s.") 13 | single_completed_string = (_("Recipe saved in My CookBook MCB file %(file)s."),) 14 | filetype_desc = MCB 15 | saveas_filters = [MCB, ["application/zip"], ["*.mcb", "*.MCB"]] 16 | saveas_single_filters = saveas_filters 17 | 18 | def get_multiple_exporter(self, args): 19 | 20 | return mycookbook_exporter.recipe_table_to_xml( 21 | args["rd"], 22 | args["rv"], 23 | args["file"], 24 | ) 25 | 26 | def do_single_export(self, args): 27 | e = mycookbook_exporter.recipe_table_to_xml(args["rd"], [args["rec"]], args["out"], change_units=args["change_units"], mult=args["mult"]) 28 | e.run() 29 | 30 | def run_extra_prefs_dialog(self): 31 | pass 32 | -------------------------------------------------------------------------------- /data/io.github.GourmandRecipeManager.Gourmand.appdata.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | io.github.GourmandRecipeManager.Gourmand 4 | CC0-1.0 5 | GPL-2.0+ 6 | Gourmand 7 | A recipe manager with the possibilities to import, organize, and tweak recipes 8 | 9 |

Gourmand is a recipe-organizer that allows you to collect, search, organize, and browse your recipes. Gourmand can also generate shopping lists and calculate nutritional information.

10 |
11 | io.github.GourmandRecipeManager.Gourmand.desktop 12 | https://github.com/GourmandRecipeManager/gourmand 13 | 14 | 15 | https://github.com/GourmandRecipeManager/gourmand/raw/main/docs/recipe_view.png 16 | 17 | 18 | 19 | io.github.GourmandRecipeManager.Gourmand.desktop 20 | 21 |
-------------------------------------------------------------------------------- /tests/recipe_files/mastercook_text_export.mxp: -------------------------------------------------------------------------------- 1 | * Exported from MasterCook * 2 | 3 | 4th Of July 4 | 5 | Recipe By : 6 | Serving Size : 1 Preparation Time :0:00 7 | Categories : Mixed Drinks Specialty Drinks 8 | 9 | Amount Measure Ingredient -- Preparation Method 10 | -------- ------------ -------------------------------- 11 | 1 1/2 ounces Vodka 12 | 1/2 ounce Triple sec 13 | 1/2 ounce Sweet and sour mix 14 | 1/2 ounce Curacao 15 | 1 dash Grenadine 16 | 17 | Mix all ingredients except grenadine in shaker and chill. Serve in martini glass. Add grenadine and it will sink to bottom. This is a great specialty drink for Independance Day. 18 | 19 | Recipe Source: 20 | THE ALL DRINKS LIST compiled by Andy Premaza 21 | 22 | 23 | Formatted for MasterCook by Joe Comiskey, aka MR MAD - jpmd44a@prodigy.com -or- MAD-SQUAD@prodigy.net 24 | 25 | 06-17-1998 26 | 27 | - - - - - - - - - - - - - - - - - - 28 | -------------------------------------------------------------------------------- /src/gourmand/plugins/import_export/mastercook_import_plugin/mastercook_importer_plugin.py: -------------------------------------------------------------------------------- 1 | import gourmand.importers.importer as importer 2 | from gourmand.i18n import _ 3 | from gourmand.plugin import ImporterPlugin 4 | 5 | from . import mastercook_importer, mastercook_plaintext_importer 6 | 7 | 8 | class MastercookImporterPlugin(ImporterPlugin): 9 | 10 | name = _("Mastercook XML File") 11 | patterns = ["*.mx2", "*.xml", "*.mxp"] 12 | mimetypes = ["text/plain", "text/xml", "application/xml"] 13 | 14 | def test_file(self, filename): 15 | return importer.Tester(".* ]").test(filename) 16 | 17 | def get_importer(self, filename): 18 | return mastercook_importer.MastercookImporter(filename) 19 | 20 | 21 | class MastercookTextImporterPlugin(ImporterPlugin): 22 | 23 | name = _("Mastercook Text File") 24 | patterns = ["*.mxp", "*.txt"] 25 | mimetypes = ["text/plain", "text/mastercook"] 26 | 27 | def test_file(self, filename): 28 | return mastercook_plaintext_importer.Tester().test(filename) 29 | 30 | def get_importer(self, filename): 31 | return mastercook_plaintext_importer.MastercookPlaintextImporter(filename) 32 | -------------------------------------------------------------------------------- /.github/workflows/flatpak.yml: -------------------------------------------------------------------------------- 1 | name: Create Flatpak 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | branches: 8 | - 'main' 9 | # pull_request: 10 | workflow_dispatch: 11 | 12 | jobs: 13 | flatpak: 14 | 15 | runs-on: ubuntu-24.04 16 | 17 | steps: 18 | - uses: actions/checkout@v6 19 | - name: Install Flatpak 20 | run: | 21 | sudo apt-get update 22 | sudo apt install flatpak flatpak-builder 23 | 24 | - name: Setup Flatpak 25 | run: | 26 | flatpak remote-add --if-not-exists --user flathub https://flathub.org/repo/flathub.flatpakrepo 27 | flatpak install flathub org.gnome.Platform//49 org.gnome.Sdk//49 -y 28 | 29 | - name: Build Flatpak 30 | run: | 31 | flatpak-builder --repo=repo --force-clean build-dir .flatpak/io.github.GourmandRecipeManager.Gourmand.yml 32 | 33 | - name: Build Bundle 34 | run: | 35 | flatpak build-bundle repo gourmand-${GITHUB_SHA::8}.flatpak io.github.GourmandRecipeManager.Gourmand 36 | 37 | - name: Upload Flatpak 38 | uses: actions/upload-artifact@v6 39 | with: 40 | name: gourmand.flatpak 41 | path: ./gourmand-*.flatpak 42 | -------------------------------------------------------------------------------- /src/gourmand/plugins/import_export/mealmaster_plugin/mealmaster_exporter_plugin.py: -------------------------------------------------------------------------------- 1 | import gourmand.exporters.exporter as exporter 2 | from gourmand.i18n import _ 3 | from gourmand.plugin import ExporterPlugin 4 | 5 | from . import mealmaster_exporter 6 | 7 | MMF = _("MealMaster file") 8 | 9 | 10 | class MealmasterExporterPlugin(ExporterPlugin): 11 | 12 | label = _("MealMaster Export") 13 | sublabel = _("Exporting recipes to MealMaster file %(file)s.") 14 | single_completed_string = _("Recipe saved as MealMaster file %(file)s") 15 | filetype_desc = MMF 16 | saveas_filters = [MMF, ["text/mmf", "text/plain"], ["*.mmf", "*.MMF"]] 17 | saveas_single_filters = saveas_filters 18 | 19 | def get_multiple_exporter(self, args): 20 | return exporter.ExporterMultirec(args["rd"], args["rv"], args["file"], one_file=True, ext="mmf", exporter=mealmaster_exporter.mealmaster_exporter) 21 | 22 | def do_single_export(self, args): 23 | e = mealmaster_exporter.mealmaster_exporter( 24 | args["rd"], args["rec"], args["out"], mult=args["mult"], change_units=args["change_units"], conv=args["conv"] 25 | ) 26 | e.run() 27 | 28 | def run_extra_prefs_dialog(self): 29 | pass 30 | -------------------------------------------------------------------------------- /docs/releasing.md: -------------------------------------------------------------------------------- 1 | # Release Tests 2 | 3 | This lists manual tests to do prior to tagging a new release. 4 | 5 | ## Shopping List Generation 6 | 7 | - [ ] From index view 8 | - [ ] From card view 9 | - [ ] With multiplication 10 | - [ ] With optional ingredients 11 | - [ ] With saved optional ingredients 12 | 13 | ## Index View Searching 14 | 15 | - [ ] Limited Searching 16 | 17 | ## Imports 18 | 19 | - [ ] Webpage 20 | - [ ] Mealmaster 21 | - [ ] Mastercook 22 | - [ ] Gourmet 23 | - [ ] Text from file 24 | - [ ] Text from drag on treeview 25 | - [ ] Text from paste on treeview 26 | 27 | ## Exports 28 | 29 | - [ ] Several Recipes 30 | - [ ] HTML 31 | - [ ] Gourmet 32 | - [ ] PDF 33 | - [ ] Print 34 | - [ ] Text: copy from treeview 35 | - [ ] Text: drag from treeview 36 | 37 | ## Customization 38 | 39 | - [ ] Customize columns in Index View 40 | - [ ] Hide recipe widgets 41 | 42 | ## Plugins 43 | 44 | - [ ] Enable/Disable plugins 45 | 46 | ## Recipe Card View 47 | 48 | - [ ] Export from this view 49 | - [ ] Multiply 50 | - [ ] Modify image 51 | 52 | ## Upgrade 53 | 54 | - [ ] Test upgrade from previous stable version 55 | 56 | ## Installation and Launch 57 | 58 | - [ ] From wheel 59 | - [ ] From Flatpak 60 | - [ ] From AppImage 61 | -------------------------------------------------------------------------------- /src/gourmand/plugins/import_export/epub_plugin/epub_exporter_plugin.py: -------------------------------------------------------------------------------- 1 | from gourmand.i18n import _ 2 | from gourmand.plugin import ExporterPlugin 3 | 4 | from . import epub_exporter 5 | 6 | EPUBFILE = _("Epub File") 7 | 8 | 9 | class EpubExporterPlugin(ExporterPlugin): 10 | label = _("Exporting epub") 11 | sublabel = _("Exporting recipes an epub file in directory %(file)s") 12 | single_completed_string = _("Recipe saved as epub file %(file)s") 13 | filetype_desc = EPUBFILE 14 | saveas_filters = [EPUBFILE, ["application/epub+zip"], ["*.epub"]] 15 | saveas_single_filters = [EPUBFILE, ["application/epub+zip"], ["*.epub"]] 16 | mode = "wb" # Epub is a zip file, so we need a binary output file 17 | 18 | def get_multiple_exporter(self, args): 19 | return epub_exporter.website_exporter( 20 | args["rd"], 21 | args["rv"], 22 | args["file"], 23 | # args['conv'], 24 | ) 25 | 26 | def do_single_export(self, args): 27 | e = epub_exporter.website_exporter(args["rd"], [args["rec"]], args["out"], mult=args["mult"], change_units=args["change_units"], conv=args["conv"]) 28 | e.run() 29 | 30 | def run_extra_prefs_dialog(self): 31 | pass 32 | -------------------------------------------------------------------------------- /src/gourmand/ui/catalog/gourmetwidgets.xml: -------------------------------------------------------------------------------- 1 | 3 | glade_python_init 4 | 5 | 6 | 10 | 14 | 18 | 22 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/gourmand/plugins/import_export/plaintext_plugin/plaintext_exporter_plugin.py: -------------------------------------------------------------------------------- 1 | import gourmand.exporters.exporter as exporter 2 | from gourmand.i18n import _ 3 | from gourmand.plugin import ExporterPlugin 4 | 5 | TXT = _("Plain Text file") 6 | 7 | 8 | class PlainTextExporterPlugin(ExporterPlugin): 9 | 10 | label = _("Text Export") 11 | sublabel = _("Exporting recipes to text file %(file)s.") 12 | single_completed_string = _("Recipe saved as plain text file %(file)s") 13 | filetype_desc = TXT 14 | saveas_filters = [TXT, ["text/plain"], ["*.txt", "*.TXT"]] 15 | saveas_single_filters = [TXT, ["text/plain"], ["*.txt", "*.TXT", ""]] 16 | 17 | def get_multiple_exporter(self, args): 18 | return exporter.ExporterMultirec( 19 | args["rd"], 20 | args["rv"], 21 | args["file"], 22 | one_file=True, 23 | ext="txt", 24 | ) 25 | 26 | def do_single_export(self, args): 27 | e = exporter.exporter_mult( 28 | args["rd"], 29 | args["rec"], 30 | args["out"], 31 | mult=args["mult"], 32 | change_units=args["change_units"], 33 | ) 34 | e.run() 35 | 36 | def run_extra_prefs_dialog(self): 37 | pass 38 | -------------------------------------------------------------------------------- /src/gourmand/data/style/default.css: -------------------------------------------------------------------------------- 1 | body {background-color: #3E2723; color: #fff} 2 | div.recipe, div.index { font-family: Times, serif; font-size: 10pt; margin: auto; max-width: 760px; background-color: #FFF8E1; padding: 1em; margin-top: 1em; color: rgba(0,0,0,0.7)} 3 | 4 | div.recipe img {border-radius: 10%; height: 200px; float: right;} 5 | div.recipe img:hover {border: 1px solid black;} 6 | foo {height: initial; position: absolute; top: 10px; left: 10px} 7 | span.label { font-weight: bold } 8 | 9 | h3 {font-family: Calibri, Sans-Serif; font-size: 120%; font-weight: bold; color: #6d4c41; border-bottom: 1px solid #ffa000; margin-top: 0px; margin-bottom: 0px;} 10 | 11 | 12 | div.header p {margin-top: 0; margin-bottom: 0.2em} 13 | div.ing { padding: 1em; background-color: #FFE57F} 14 | ul.ing { margin-top: 0px; padding-left: 0px; color: rgba(0,0,0,1); font-size: 100%;} 15 | li.ing { list-style: none; border-top: 0.3em } 16 | div.header p.title { font-family: Calibri, Sans-Serif; font-size: 35pt; font-weight: bold; color: #3E2723; border-bottom: 8px solid #ff6f00; min-height: 200px; vertical-align: bottom; margin-bottom: 20px; position: relative; padding-top: 30px} 17 | div.header p.title span.label {display: none} 18 | div.recipe div {margin-bottom: 20px;} 19 | div.recipe div p {margin-top: 0} 20 | -------------------------------------------------------------------------------- /src/gourmand/plugins/import_export/html_plugin/html_exporter_plugin.py: -------------------------------------------------------------------------------- 1 | from gourmand.i18n import _ 2 | from gourmand.plugin import ExporterPlugin 3 | 4 | from . import html_exporter 5 | 6 | WEBPAGE = _("HTML Web Page") 7 | 8 | 9 | class HtmlExporterPlugin(ExporterPlugin): 10 | 11 | label = _("Exporting Webpage") 12 | sublabel = _("Exporting recipes to HTML files in directory %(file)s") 13 | single_completed_string = _("Recipe saved as HTML file %(file)s") 14 | filetype_desc = WEBPAGE 15 | saveas_filters = [WEBPAGE, ["text/html"], ["*.html"]] 16 | saveas_single_filters = [WEBPAGE, ["text/html"], ["*.html", "*.htm", "*.HTM", "*.HTML"]] 17 | 18 | def get_multiple_exporter(self, args): 19 | return html_exporter.website_exporter( 20 | args["rd"], 21 | args["rv"], 22 | args["file"], 23 | # args['conv'], 24 | ) 25 | 26 | def do_single_export(self, args): 27 | he = html_exporter.html_exporter( 28 | args["rd"], 29 | args["rec"], 30 | args["out"], 31 | change_units=args["change_units"], 32 | mult=args["mult"], 33 | # conv=args['conv'] 34 | ) 35 | he.run() 36 | 37 | def run_extra_prefs_dialog(self): 38 | pass 39 | -------------------------------------------------------------------------------- /src/gourmand/__version__.py: -------------------------------------------------------------------------------- 1 | from gourmand.i18n import _ 2 | 3 | appname = _("Gourmand Recipe Manager") 4 | copyright = _("Copyright (c) 2004-2021 Thomas M. Hinkle and Contributors.\n" "Copyright (c) 2021 The Gourmand Team and Contributors") 5 | version = "1.2.0" 6 | url = "https://github.com/GourmandRecipeManager/gourmand" 7 | description = "Recipe Organizer and Shopping List Generator" 8 | long_description = _( 9 | """\ 10 | Gourmand is an application to store, organize and search recipes. 11 | 12 | Features: 13 | * Makes it easy to create shopping lists from recipes. 14 | * Imports recipes from a number of sources, including MealMaster and MasterCook 15 | archives and several popular websites. 16 | * Exports recipes as PDF files, plain text, MealMaster files, HTML web pages, 17 | and a custom XML format for exchange with other Gourmet users. 18 | * Supports linking images with recipes. 19 | * Can calculate nutritional information for recipes based on the ingredients. 20 | """ 21 | ) 22 | author = "Gourmand Team and Contributors" 23 | authors = [] # FIXME: get list of contributors 24 | maintainer = "Cyril Danilevski, FriedrichFröbel" 25 | maintainer_email = "gourmand@cyril.wtf" 26 | artists = [_("Nyall Dawson (cookie icon)"), _("Kati Pregartner (splash screen image)")] 27 | license = "GPL-2.0-only" 28 | -------------------------------------------------------------------------------- /tests/test_pdf_exporter.py: -------------------------------------------------------------------------------- 1 | """This test may leave marks in the user preferences file.""" 2 | 3 | from unittest import mock 4 | 5 | from gourmand.plugins.import_export.pdf_plugin.pdf_exporter import PdfPrefGetter 6 | 7 | 8 | def test_get_args_from_opts(tmp_path): 9 | with mock.patch("gourmand.gglobals.gourmanddir", tmp_path): 10 | pref_getter = PdfPrefGetter() 11 | 12 | options = ( 13 | ["Paper _Size:", "Letter"], 14 | ["_Orientation:", "Portrait"], 15 | ["_Font Size:", 42], 16 | ["Page _Layout", "Plain"], 17 | ["Left Margin:", 70.86614173228347], 18 | ["Right Margin:", 70.86614173228347], 19 | ["Top Margin:", 70.86614173228347], 20 | ["Bottom Margin:", 70.86614173228347], 21 | ) 22 | 23 | expected = { 24 | "pagesize": "letter", 25 | "pagemode": "portrait", 26 | "base_font_size": 42, 27 | "mode": ("column", 1), 28 | "left_margin": 70.86614173228347, 29 | "right_margin": 70.86614173228347, 30 | "top_margin": 70.86614173228347, 31 | "bottom_margin": 70.86614173228347, 32 | } 33 | 34 | ret = pref_getter.get_args_from_opts(options) 35 | 36 | assert ret == expected 37 | -------------------------------------------------------------------------------- /src/gourmand/plugins/unit_display_prefs/unit_prefs_dialog.py: -------------------------------------------------------------------------------- 1 | from gourmand.gtk_extras import dialog_extras as de 2 | from gourmand.i18n import _ 3 | from gourmand.prefs import Prefs 4 | 5 | 6 | class UnitPrefsDialog: 7 | 8 | options = [ 9 | (_("Display units as written for each recipe (no change)"), []), 10 | (_("Always display U.S. units"), ["imperial volume", "imperial weight"]), 11 | (_("Always display metric units"), ["metric volume", "metric mass"]), 12 | ] 13 | 14 | def __init__(self, reccards): 15 | self.reccards = reccards 16 | self.prefs = Prefs.instance() 17 | 18 | def run(self): 19 | old_pref = self.prefs.get("preferred_unit_groups", []) 20 | option = de.getRadio( 21 | label=_("Automatically adjust units"), 22 | sublabel=( 23 | "Choose how you would like to adjust units for display and printing. " 24 | "The underlying ingredient data stored in the database will not be affected." 25 | ), 26 | options=self.options, 27 | default=old_pref, 28 | ) 29 | self.prefs["preferred_unit_groups"] = option 30 | if option != old_pref: 31 | for rc in self.reccards: 32 | rc.ingredientDisplay.display_ingredients() 33 | -------------------------------------------------------------------------------- /src/gourmand/plugins/field_editor/__init__.py: -------------------------------------------------------------------------------- 1 | from gi.repository import Gtk 2 | 3 | from gourmand.i18n import _ 4 | from gourmand.plugin import ToolPlugin 5 | 6 | from . import fieldEditor 7 | 8 | 9 | class FieldEditorPlugin(ToolPlugin): 10 | 11 | menu_items = """ 12 | 13 | 14 | """ 15 | 16 | def setup_action_groups(self): 17 | self.action_group = Gtk.ActionGroup(name="FieldEditorPluginActionGroup") 18 | self.action_group.add_actions( 19 | [ 20 | ("FieldEditor", None, _("Field Editor"), None, _("Edit fields across multiple recipes at a time."), self.show_field_editor), 21 | ] 22 | ) 23 | self.action_groups.append(self.action_group) 24 | 25 | def show_field_editor(self, *args): 26 | from gourmand.main import get_application 27 | 28 | self.app = get_application() 29 | self.field_editor = fieldEditor.FieldEditor(self.app.rd, self.app) 30 | self.field_editor.valueDialog.connect("response", self.response_cb) 31 | self.field_editor.show() 32 | 33 | def response_cb(self, d, r): 34 | if r == Gtk.ResponseType.APPLY: 35 | self.app.update_attribute_models() 36 | 37 | 38 | plugins = [FieldEditorPlugin] 39 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Description 4 | 5 | 6 | 7 | 8 | ## How Has This Been Tested? 9 | 10 | 11 | 12 | 13 | ## Screenshots (if appropriate): 14 | 15 | ## Types of changes 16 | 17 | - [ ] Bug fix (non-breaking change which fixes an issue) 18 | - [ ] New feature (non-breaking change which adds functionality) 19 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 20 | 21 | ## Checklist: 22 | 23 | 24 | - [ ] My code follows the code style of this project. 25 | - [ ] My change requires a change to the documentation. 26 | - [ ] I have updated the documentation accordingly. 27 | -------------------------------------------------------------------------------- /src/gourmand/plugins/import_export/pdf_plugin/pdf_exporter_plugin.py: -------------------------------------------------------------------------------- 1 | from gourmand.i18n import _ 2 | from gourmand.plugin import ExporterPlugin 3 | 4 | from . import pdf_exporter 5 | 6 | PDF = _("PDF (Portable Document Format)") 7 | 8 | 9 | class PdfExporterPlugin(ExporterPlugin): 10 | 11 | label = _("PDF Export") 12 | sublabel = _("Exporting recipes to PDF %(file)s.") 13 | single_completed_string = _("Recipe saved as PDF %(file)s") 14 | filetype_desc = PDF 15 | saveas_filters = [PDF, ["application/pdf"], ["*.pdf"]] 16 | saveas_single_filters = [PDF, ["application/pdf"], ["*.pdf"]] 17 | mode = "wb" 18 | 19 | def get_multiple_exporter(self, args): 20 | return pdf_exporter.PdfExporterMultiDoc( 21 | args["rd"], 22 | args["rv"], 23 | args["file"], 24 | pdf_args=args["extra_prefs"], 25 | ) 26 | 27 | def do_single_export(self, args): 28 | exp = pdf_exporter.PdfExporter( 29 | args["rd"], 30 | args["rec"], 31 | args["out"], 32 | change_units=args["change_units"], 33 | mult=args["mult"], 34 | pdf_args=args["extra_prefs"], 35 | ) 36 | exp.run() 37 | 38 | def run_extra_prefs_dialog(self): 39 | return pdf_exporter.get_pdf_prefs() 40 | 41 | def get_default_prefs(self): 42 | return pdf_exporter.DEFAULT_PDF_ARGS 43 | -------------------------------------------------------------------------------- /src/gourmand/data/recipe.dtd: -------------------------------------------------------------------------------- 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 | -------------------------------------------------------------------------------- /src/gourmand/plugins/email_plugin/emailer.py: -------------------------------------------------------------------------------- 1 | import urllib.error 2 | import urllib.parse 3 | import urllib.request 4 | import webbrowser 5 | 6 | from gourmand.gdebug import debug 7 | 8 | 9 | class Emailer: 10 | def __init__(self, emailaddress=None, subject=None, body=None, attachments=[]): 11 | self.emailaddress = None 12 | self.subject = subject 13 | self.body = body 14 | self.attachments = attachments 15 | self.connector_string = "?" 16 | 17 | def send_email(self): 18 | print("send_email()") 19 | self.url = "mailto:" 20 | if self.emailaddress: 21 | self.url += self.emailaddress 22 | if self.subject: 23 | self.url_append("subject", self.subject) 24 | if self.body: 25 | self.url_append("body", self.body) 26 | for a in self.attachments: 27 | print("Adding attachment", a) 28 | self.url_append("attachment", a) 29 | debug("launching URL %s" % self.url, 0) 30 | webbrowser.open(self.url) 31 | 32 | def url_append(self, attr, value): 33 | self.url += "%s%s=%s" % (self.connector(), attr, urllib.parse.quote(value.encode("utf-8", "replace"))) 34 | 35 | def connector(self): 36 | retval = self.connector_string 37 | self.connector_string = "&" 38 | return retval 39 | 40 | 41 | if __name__ == "__main__": 42 | e = Emailer(emailaddress="tmhinkle@gmail.com", subject="Hello", body="hello") 43 | e.send_email() 44 | -------------------------------------------------------------------------------- /tests/test_web_importer.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import Mock, patch 3 | 4 | import gi 5 | import pytest 6 | from recipe_scrapers._exceptions import SchemaOrgException 7 | 8 | from gourmand.importers.web_importer import ( 9 | import_urls, 10 | initialize_recipe, # noqa: E402 11 | ) 12 | 13 | gi.require_version("Gtk", "3.0") 14 | 15 | 16 | @pytest.mark.parametrize( 17 | "urls, expected_pass, expected_fails", 18 | [ 19 | ([], 0, []), 20 | (["https://something.example.com/recipe.html"], 0, ["https://something.example.com/recipe.html"]), 21 | ( 22 | ["https://something.example.com/recipe.html", "https://www.allrecipes.com/recipe/17981/one-bowl-chocolate-cake-iii/"], 23 | 1, 24 | ["https://something.example.com/recipe.html"], 25 | ), 26 | ], 27 | ) 28 | def test_import_urls(tmp_path, urls, expected_pass, expected_fails): 29 | with patch("gourmand.gglobals.gourmanddir", tmp_path): 30 | recipes, failures = import_urls(urls) 31 | 32 | assert len(recipes) == expected_pass 33 | assert failures == expected_fails 34 | 35 | 36 | class TestWebImporter(unittest.TestCase): 37 | 38 | def test_no_rating_gets_set_to_zero(self): 39 | recipe_scrape_mock = Mock() 40 | recipe_scrape_mock.ratings.side_effect = SchemaOrgException("Error") 41 | recipe_scrape_mock.yields.return_value = ('5 muffins') 42 | 43 | recipe = initialize_recipe(recipe_scrape_mock) 44 | self.assertEqual(0, recipe.rating) 45 | -------------------------------------------------------------------------------- /.github/workflows/pypi.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python distribution 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | build: 9 | name: Build distribution 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v6 14 | - name: Set up Python 15 | uses: actions/setup-python@v6 16 | with: 17 | python-version: "3.x" 18 | - name: Install OS packages 19 | run: | 20 | sudo apt-get install --no-install-recommends -y intltool gettext 21 | - name: Install pypa/build 22 | run: >- 23 | python3 -m 24 | pip install 25 | build 26 | --user 27 | - name: Build a binary wheel and a source tarball 28 | run: python3 -m build 29 | - name: Store the distribution packages 30 | uses: actions/upload-artifact@v6 31 | with: 32 | name: python-package-distributions 33 | path: dist/ 34 | 35 | publish-to-pypi: 36 | name: Publish Python distribution to PyPI 37 | needs: 38 | - build 39 | runs-on: ubuntu-latest 40 | environment: 41 | name: pypi 42 | url: https://pypi.org/p/gourmand 43 | permissions: 44 | id-token: write # IMPORTANT: mandatory for trusted publishing 45 | 46 | steps: 47 | - name: Download all the dists 48 | uses: actions/download-artifact@v7 49 | with: 50 | name: python-package-distributions 51 | path: dist/ 52 | - name: Publish distribution to PyPI 53 | uses: pypa/gh-action-pypi-publish@release/v1 54 | -------------------------------------------------------------------------------- /tests/broken_test_unit_converter_dogtail.py: -------------------------------------------------------------------------------- 1 | from dogtail import procedural, tree 2 | from dogtail.utils import run 3 | 4 | 5 | def test_unit_converter(): 6 | """Dogtail integration test: Unit converter plugin behaves as intended.""" 7 | 8 | cmd = "gourmet" 9 | 10 | pid = run(cmd, timeout=3) 11 | gourmet = None 12 | 13 | for app in tree.root.applications(): 14 | if app.get_process_id() == pid: 15 | gourmet = app 16 | break 17 | 18 | assert gourmet is not None, "Could not find Gourmet instance!" 19 | 20 | # Open the unit converter plugin 21 | procedural.keyCombo("T") 22 | procedural.keyCombo("U") 23 | procedural.focus.window("Unit Converter") 24 | 25 | # Enter source amount and unit (5 liters) 26 | procedural.keyCombo("A") 27 | procedural.type("5") 28 | procedural.keyCombo("U") 29 | procedural.keyCombo("") 30 | procedural.click("liter (l)") 31 | 32 | # Enter target unit (ml) 33 | procedural.keyCombo("U") 34 | procedural.keyCombo("") 35 | procedural.keyCombo("Right") 36 | for _ in range(7): 37 | procedural.keyCombo("Down") 38 | procedural.keyCombo("") 39 | 40 | # Check that the result is shown correctly 41 | assert procedural.focus.widget(name="5 l = 5000 ml", roleName="label") 42 | 43 | # There are now two windows, the unit converter, and main window 44 | # Close them successively to quit the application 45 | procedural.keyCombo("") 46 | procedural.keyCombo("") 47 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, and build the app 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Build 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | branches: [ main ] 11 | workflow_dispatch: 12 | 13 | jobs: 14 | build: 15 | 16 | runs-on: ubuntu-22.04 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | python: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] 21 | 22 | steps: 23 | - uses: actions/checkout@v6 24 | 25 | - name: set up Python 26 | uses: actions/setup-python@v6 27 | with: 28 | python-version: ${{ matrix.python }} 29 | 30 | - name: Setup build environment 31 | run: | 32 | sudo apt-get update -q 33 | sudo apt-get install intltool 34 | 35 | - name: Setup build tools 36 | run: 37 | python -m pip install --upgrade pip setuptools wheel testresources build 38 | 39 | - name: Create wheel and source distributions 40 | run: | 41 | python -m build . 42 | mv dist/gourmand-*.tar.gz dist/gourmand-${GITHUB_SHA::8}.tar.gz 43 | mv dist/gourmand-*-py3-none-any.whl dist/gourmand-${GITHUB_SHA::8}-py3-none-any.whl 44 | 45 | - name: Upload artifacts 46 | uses: actions/upload-artifact@v6 47 | with: 48 | name: gourmand_${{ matrix.python }} 49 | path: ./dist/gourmand-* 50 | 51 | - name: Verify artifacts 52 | run: | 53 | python -m pip install twine 54 | python -m twine check dist/* 55 | -------------------------------------------------------------------------------- /tests/test_prefs.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from sys import version_info 3 | 4 | import pytest 5 | 6 | if version_info >= (3, 11): 7 | from tomllib import loads as toml_loads 8 | else: 9 | from tomli import loads as toml_loads 10 | 11 | from tomli_w import dumps as toml_dumps 12 | 13 | from gourmand.prefs import Prefs, update_preferences_file_format 14 | 15 | 16 | def test_singleton(): 17 | prefs = Prefs.instance() 18 | pprefs = Prefs.instance() 19 | assert prefs == pprefs 20 | 21 | 22 | def test_get_sets_default(): 23 | """Test that using get with a default value adds it to the dictionnary""" 24 | prefs = Prefs.instance() 25 | 26 | val = prefs.get("key", "value") 27 | assert val == val 28 | 29 | assert prefs["key"] == val # The value was inserted 30 | 31 | val = prefs.get("anotherkey") 32 | assert val is None 33 | 34 | with pytest.raises(KeyError): 35 | prefs["anotherkey"] 36 | 37 | 38 | def test_update_preferences_file_format(tmpdir): 39 | """Test the update of preferences file format.""" 40 | 41 | filename = tmpdir.join("preferences.toml") 42 | 43 | with open(filename, "w") as fout: 44 | fout.write(toml_dumps({"sort_by": {"column": "title", "ascending": True}})) 45 | 46 | update_preferences_file_format(Path(tmpdir)) 47 | 48 | with open(filename) as fin: 49 | d = toml_loads(fin.read()) 50 | 51 | assert "category" not in d["sort_by"].keys() 52 | assert d["sort_by"]["title"] 53 | 54 | with open(filename, "w") as fout: 55 | fout.write(toml_dumps({})) 56 | 57 | update_preferences_file_format(Path(tmpdir)) 58 | 59 | with open(filename) as fin: 60 | d = toml_loads(fin.read()) 61 | 62 | assert d == {} 63 | -------------------------------------------------------------------------------- /tests/broken_test_import_manager.py: -------------------------------------------------------------------------------- 1 | import os 2 | import os.path 3 | import tempfile 4 | import time 5 | import unittest 6 | 7 | import gourmand.backends.db 8 | import gourmand.gglobals 9 | import gourmand.GourmetRecipeManager 10 | from gourmand.importers import importManager 11 | 12 | tmpdir = tempfile.mktemp() 13 | os.makedirs(tmpdir) 14 | gourmand.gglobals.gourmetdir = tmpdir 15 | 16 | 17 | gourmand.backends.db.RecData.__single = None 18 | gourmand.GourmetRecipeManager.GourmetApplication.__single = None 19 | 20 | 21 | class TestImports(unittest.TestCase): 22 | def setUp(self): 23 | self.im = importManager.get_import_manager() 24 | 25 | def test_plugins(self): 26 | for pi in self.im.importer_plugins: 27 | print("I wonder, is there a test for ", pi) 28 | if hasattr(pi, "get_import_tests"): 29 | for fn, test in pi.get_import_tests(): 30 | print("Testing ", test, fn) 31 | self.__run_importer_test(fn, test) 32 | 33 | def done_callback(self, *args): 34 | print("done!") 35 | self.done = True 36 | 37 | def __run_importer_test(self, fn, test): 38 | self.done = False 39 | importer = self.im.import_filenames([fn])[0] 40 | assert importer, "import_filenames did not return an object" 41 | while not importer.done: 42 | time.sleep(0.2) 43 | print("Done!") 44 | assert importer.added_recs, "Importer did not have any added_recs (%s,%s)" % (fn, test) 45 | try: 46 | test(importer.added_recs, fn) 47 | except Exception: 48 | import traceback 49 | 50 | self.assertEqual(1, 2, "Importer test for %s raised error %s" % ((fn, test), traceback.format_exc())) 51 | -------------------------------------------------------------------------------- /src/gourmand/plugins/nutritional_information/nutPrefsPlugin.py: -------------------------------------------------------------------------------- 1 | from gi.repository import Gtk 2 | 3 | from gourmand.i18n import _ 4 | from gourmand.plugin import PrefsPlugin 5 | from gourmand.prefs import Prefs 6 | 7 | partialp = "include_partial_nutritional_info" 8 | includep = "include_nutritional_info_in_export" 9 | 10 | 11 | class NutritionPrefs(PrefsPlugin): 12 | 13 | label = _("Nutritional Information") 14 | 15 | def __init__(self, *args, **kwargs): 16 | # Create main widget 17 | self.widget = Gtk.VBox() 18 | self.prefs = Prefs.instance() 19 | self.include_tb = Gtk.CheckButton("Include nutritional information in print-outs and exports") 20 | self.partial_tb = Gtk.CheckButton("Include partial nutritional information in print-outs and exports?") 21 | self.include_tb.set_active(self.prefs.get(includep, True)) 22 | self.partial_tb.set_active(self.prefs.get(partialp, False)) 23 | self.include_tb.connect("toggled", self.toggle_cb) 24 | self.partial_tb.connect("toggled", self.toggle_cb) 25 | self.widget.pack_start(self.include_tb, False, False, 0) 26 | self.widget.pack_start(self.partial_tb, False, False, 0) 27 | self.widget.set_border_width(12) 28 | self.widget.set_spacing(6) 29 | self.widget.show_all() 30 | 31 | def toggle_cb(self, tb): 32 | if tb == self.include_tb: 33 | if tb.get_active(): 34 | self.prefs[includep] = True 35 | else: 36 | self.prefs[includep] = False 37 | # Force false... 38 | self.partial_tb.set_active(False) 39 | self.prefs[partialp] = False 40 | if tb == self.partial_tb: 41 | self.prefs[partialp] = tb.get_active() 42 | -------------------------------------------------------------------------------- /src/gourmand/plugins/nutritional_information/enter_nutritional_defaults.py: -------------------------------------------------------------------------------- 1 | from gi.repository import Gtk 2 | 3 | import gourmand.convert 4 | from gourmand.defaults import lang as defaults 5 | from gourmand.recipeManager import RecipeManager, dbargs 6 | 7 | from . import nutritionGrabberGui 8 | from .nutrition import NutritionData 9 | from .nutritionDruid import NutritionInfoDruid 10 | 11 | ingredients_to_check = list(defaults.keydic.keys()) 12 | 13 | # This is intended to be run as a simple script to get nutritional 14 | # equivalents which can then be copied into DEFAULTS for your locale. 15 | 16 | rd = RecipeManager(**dbargs) 17 | 18 | 19 | try: 20 | nutritionGrabberGui.check_for_db(rd) 21 | except nutritionGrabberGui.Terminated: 22 | pass 23 | 24 | c = gourmand.convert.get_converter() 25 | nd = NutritionData(rd, c) 26 | nid = NutritionInfoDruid(nd, {}) 27 | nid.add_ingredients([(k, [(1, "")]) for k in ingredients_to_check]) 28 | 29 | 30 | def quit(*args): 31 | rd.save() 32 | nid.ui.get_object("window1").hide() 33 | Gtk.main_quit() 34 | 35 | 36 | nid.ui.get_object("window1").connect("delete-event", quit) 37 | nid.connect("finish", quit) 38 | nid.show() 39 | Gtk.main() 40 | 41 | rd.changed = True 42 | rd.save() 43 | 44 | ofi = "/tmp/locale_specific_nutritional_info.txt" 45 | print("Writing data to ", ofi) 46 | with open(ofi, "w") as outfi: 47 | outfi.write("{") 48 | for k in ingredients_to_check: 49 | ndbno = nd.get_ndbno(k) 50 | if ndbno: 51 | outfi.write('"%s":(%s,[' % (k, ndbno)) 52 | for conv in nd.db.nutritionconversions_table.select(ingkey=k): 53 | outfi.write('("%s",%s),' % (conv.unit, conv.factor)) 54 | outfi.write("]),\n") 55 | else: 56 | print("No information for ", k) 57 | outfi.write("}") 58 | -------------------------------------------------------------------------------- /src/gourmand/optionparser.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | from . import __version__ 4 | 5 | parser = argparse.ArgumentParser(prog="gourmand", description=__version__.description) 6 | parser.add_argument("--version", action="version", version=__version__.version) 7 | parser.add_argument("--database-url", action="store", dest="db_url", help="Database uri formatted like driver://path/to/db", default="") 8 | parser.add_argument("--use-threads", action="store_const", const=True, dest="threads", help="Enable threading support.", default=False) 9 | parser.add_argument("--disable-threads", action="store_const", const=False, dest="threads", help="Disable threading support.") 10 | parser.add_argument("--gourmand-directory", action="store", dest="gourmanddir", help="Gourmand configuration directory", default="") 11 | parser.add_argument( 12 | "--debug-threading-interval", action="store", type=float, dest="thread_debug_interval", help="Interval for threading debug calls", default=5.0 13 | ) 14 | parser.add_argument("--debug-threading", action="store_true", dest="thread_debug", help="Print debugging information about threading.") 15 | parser.add_argument( 16 | "--debug-file", 17 | action="store", 18 | dest="debug_file", 19 | help=("Regular expression that matches filename(s) " "containing code for which we want to display " "debug messages."), 20 | default="", 21 | ) 22 | parser.add_argument("--showtimes", action="store_true", dest="time", help="Print timestamps on debug statements.") 23 | 24 | group = parser.add_mutually_exclusive_group() 25 | group.add_argument("-q", action="store_const", const=-1, dest="debug", help="Do not print gourmand error messages") 26 | group.add_argument("-v", action="count", dest="debug", help="Be verbose (extra v's increase the verbosity level)") 27 | 28 | args = parser.parse_known_args()[0] 29 | print(f"args = {args}") 30 | -------------------------------------------------------------------------------- /tests/test_gglobals.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import Mock, patch 3 | 4 | from gourmand.gglobals import _get_link_and_star_color 5 | 6 | 7 | class TestLinkColor(unittest.TestCase): 8 | 9 | def setUp(self): 10 | pass 11 | 12 | @patch("gourmand.gglobals.Gtk") 13 | def test_dark_mode_link_color(self, Gtk): 14 | # Closer to 0 is darker. So in dark mode the background 15 | # will be a smaller value than the foreground color. 16 | dark_bgcolor = Mock() 17 | fgcolor = Mock() 18 | Gtk.TextView().get_style_context().get_background_color.return_value = dark_bgcolor 19 | Gtk.TextView().get_style_context().get_color.return_value = fgcolor 20 | fgcolor.red, fgcolor.green, fgcolor.blue = (1, 1, 1) 21 | dark_bgcolor.red, dark_bgcolor.green, dark_bgcolor.blue = (0.18, 0.18, 0.19) 22 | link_color_actual, star_color_actual = _get_link_and_star_color() 23 | self.assertEqual("deeppink", link_color_actual) 24 | self.assertEqual("gold", star_color_actual) 25 | 26 | @patch("gourmand.gglobals.Gtk") 27 | def test_light_mode_link_color(self, Gtk): 28 | # Closer to 0 is darker. So in light mode the background 29 | # will be a larger value than the foreground color. 30 | light_bgcolor = Mock() 31 | fgcolor = Mock() 32 | Gtk.TextView().get_style_context().get_background_color.return_value = light_bgcolor 33 | Gtk.TextView().get_style_context().get_color.return_value = fgcolor 34 | fgcolor.red, fgcolor.green, fgcolor.blue = (0.13, 0.13, 0.13) 35 | light_bgcolor.red, light_bgcolor.green, light_bgcolor.blue = (1, 1, 1) 36 | link_color_actual, star_color_actual = _get_link_and_star_color() 37 | self.assertEqual("blue", link_color_actual) 38 | self.assertEqual("blue", star_color_actual) 39 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Tests 5 | 6 | on: 7 | push: 8 | branches: [main] 9 | pull_request: 10 | branches: [main] 11 | workflow_dispatch: 12 | 13 | jobs: 14 | test: 15 | runs-on: ubuntu-22.04 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | python: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] 20 | 21 | steps: 22 | - uses: actions/checkout@v6 23 | - name: set up Python 24 | uses: actions/setup-python@v6 25 | with: 26 | python-version: ${{ matrix.python }} 27 | # Reference for SVG and Pixbuf on Ubuntu 24.04: https://github.com/dino/dino/issues/1024 28 | - name: Install Ubuntu dependencies 29 | run: | 30 | sudo apt-get update -q 31 | sudo apt-get install --no-install-recommends -y xvfb gir1.2-gtk-3.0 libgirepository1.0-dev libcairo2-dev gir1.2-gstreamer-1.0 intltool enchant-2 librsvg2-common librsvg2-2 32 | 33 | - name: Install build dependencies 34 | run: | 35 | pip install --upgrade pip setuptools wheel 36 | 37 | - name: Install Gourmand 38 | run: | 39 | pip install -r development.in 40 | python setup.py build_i18n 41 | 42 | - name: Test with pytest 43 | run: | 44 | xvfb-run -a python -c "from gourmand import prefs, gglobals; gglobals.gourmanddir.mkdir(parents=True, exist_ok=True); prefs.copy_old_installation_or_initialize(gglobals.gourmanddir)" 45 | LC_ALL=C xvfb-run -a pytest 46 | 47 | - name: Check code style 48 | run: | 49 | ruff check src/ tests/ setup.py 50 | if: always() 51 | -------------------------------------------------------------------------------- /tests/test_clipboard_exporter.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple # noqa: E402 2 | 3 | import gi 4 | import pytest 5 | 6 | gi.require_version("Gtk", "3.0") 7 | gi.require_version("Gdk", "3.0") 8 | from gi.repository import Gdk, Gtk # noqa: E402 9 | 10 | from gourmand.exporters.clipboard_exporter import copy_to_clipboard # noqa: E402 11 | 12 | Recipe = namedtuple("Recipe", ["title", "source", "yields", "yield_unit", "description", "instructions", "link"]) 13 | 14 | recipe1 = Recipe("Title1", "Source1", 700.0, "g.", None, "Make the Dough.", "") 15 | recipe2 = Recipe("Title2", "Source2", 2, "litres", "test", "Directions.", "https://example.com") 16 | 17 | Ingredient = namedtuple("Ingredient", ["amount", "unit", "item"]) 18 | 19 | ingredients1 = (Ingredient(600, "g.", "flour"),) 20 | ingredients2 = (Ingredient(600, "g.", "flour"), Ingredient(2, "l.", "water")) 21 | 22 | recipe_input = [(recipe1, ingredients1)] 23 | recipe_expected_output = """# Title1 24 | 25 | Source1 26 | 27 | 700.0 g. 28 | 29 | 600 g. flour 30 | 31 | Make the Dough. 32 | """ 33 | 34 | two_recipes_input = [(recipe1, ingredients1), (recipe2, ingredients2)] 35 | 36 | two_recipes_expected_output = """# Title1 37 | 38 | Source1 39 | 40 | 700.0 g. 41 | 42 | 600 g. flour 43 | 44 | Make the Dough. 45 | 46 | 47 | # Title2 48 | 49 | Source2 50 | https://example.com 51 | 52 | 2 litres 53 | 54 | test 55 | 56 | 600 g. flour 57 | 2 l. water 58 | 59 | Directions. 60 | """ 61 | 62 | 63 | @pytest.mark.parametrize( 64 | "recipes, expected", 65 | [ 66 | ([], ""), 67 | (recipe_input, recipe_expected_output), 68 | (two_recipes_input, two_recipes_expected_output), 69 | ], 70 | ) 71 | def test_clipboard_exporter(recipes, expected): 72 | copy_to_clipboard(recipes) 73 | 74 | clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) 75 | assert clipboard.wait_for_text() == expected 76 | -------------------------------------------------------------------------------- /tests/recipe_files/mealmaster_2_col.mmf: -------------------------------------------------------------------------------- 1 | MMMMM----- Recipe via Meal-Master (tm) v8.02 2 | 3 | Title: Chiles Rellenos de Queso 4 | Categories: Appetizers, Main dish, Cheese, Mexican 5 | Yield: 2 servings 6 | 7 | 2 Chiles, calif.; roast & peel 1 Eggs; separated 8 | 1 1/3 oz Cheese, monterey jack 3/16 c Flour, all purpose 9 | Oil; for frying 10 | 11 | MMMMM------------------------TOMATO SAUCE----------------------------- 12 | 1 1/3 sm Tomatoes; peeled 3/16 ts Salt 13 | 1/3 sm Onion 2/3 sm Chiles, calif. 14 | 1/3 Garlic clove pn Cinnamon, ground 15 | 1/3 tb Oil, vegetable pn Cloves, ground 16 | 3/16 c Chicken broth 17 | 18 | Prepare tomato sauce; keep warm. 19 | 20 | Cut as small a slit as possible in one side of each chile to remove 21 | seeds. Leave stems on. Pat chiles dry with paper towels. 22 | 23 | Cut cheese into long thin sticks, one for each chile. Place one stick 24 | in each chile, using more if chiles are large. If chiles are loose 25 | and open, wrap around cheese and fasten with wooden picks. 26 | 27 | Pour oil 1/4" deep into large skillet. Heat oil to 365 F. Beat egg 28 | whites in a medium bowl until stiff. Beat egg yolks lightly in a 29 | small bowl and add all at once to beaten egg whites. Fold lightly but 30 | thoroughly. Roll chiles in flour, then dip in egg mixture to coat. 31 | Fry in hot oil until golden brown, turning with a spatula. Drain on 32 | paper towels. Serve immediately topped with tomato sauce. 33 | 34 | Tomato Sauce: Combine tomatoes, onion and garlic in blender or food 35 | pro- cessor; puree. Heat oil in a medium saucepan, add tomato 36 | mixture. Cook 10 minutes, stirring occasionally. Add broth, salt, 37 | chiles, cloves and cinnamon. Simmer gently 15 minutes. 38 | 39 | MMMMM 40 | -------------------------------------------------------------------------------- /tests/old_databases/gourmet-0.13.8/guiprefs: -------------------------------------------------------------------------------- 1 | (dp0 2 | S'rectree_column_order' 3 | p1 4 | (dp2 5 | sS'sautTog' 6 | p3 7 | (dp4 8 | S'active' 9 | p5 10 | I01 11 | ssS'shophpaned1' 12 | p6 13 | (dp7 14 | S'position' 15 | p8 16 | I470 17 | ssS'rectree_hidden_columns' 18 | p9 19 | (lp10 20 | S'Website' 21 | p11 22 | aS'Servings' 23 | p12 24 | aS'Preparation Time' 25 | p13 26 | aS'Cooking Time' 27 | p14 28 | asS'rc1' 29 | p15 30 | (dp16 31 | g8 32 | (I0 33 | I0 34 | tp17 35 | sS'window_size' 36 | p18 37 | (I730 38 | I550 39 | tp19 40 | ssS'rc2' 41 | p20 42 | (dp21 43 | g8 44 | (I0 45 | I0 46 | tp22 47 | sg18 48 | (I730 49 | I550 50 | tp23 51 | ssS'rc3' 52 | p24 53 | (dp25 54 | g8 55 | (I0 56 | I0 57 | tp26 58 | sg18 59 | (I730 60 | I550 61 | tp27 62 | ssS'save_recipes_as' 63 | p28 64 | S'.grmt' 65 | p29 66 | sS'regexpTog' 67 | p30 68 | (dp31 69 | g5 70 | I01 71 | ssS'rec_exp_directory' 72 | p32 73 | S'/home/tom/Projects/grm/src/tests/recipe_files' 74 | p33 75 | sS'shopGuiWin' 76 | p34 77 | (dp35 78 | sS'rc4' 79 | p36 80 | (dp37 81 | S'position' 82 | p38 83 | (I0 84 | I0 85 | tp39 86 | sS'window_size' 87 | p40 88 | (I730 89 | I550 90 | tp41 91 | ssS'rc1_edit' 92 | p42 93 | (dp43 94 | g8 95 | (I0 96 | I0 97 | tp44 98 | sg18 99 | (I800 100 | I500 101 | tp45 102 | ssS'app_window' 103 | p46 104 | (dp47 105 | g8 106 | (I10 107 | I100 108 | tp48 109 | sg18 110 | (I750 111 | I600 112 | tp49 113 | ssS'rc2_edit' 114 | p50 115 | (dp51 116 | g8 117 | (I0 118 | I0 119 | tp52 120 | sg18 121 | (I800 122 | I500 123 | tp53 124 | ssS'rc4_edit' 125 | p54 126 | (dp55 127 | g38 128 | (I0 129 | I0 130 | tp56 131 | sg40 132 | (I800 133 | I500 134 | tp57 135 | ssS'shopvpaned1' 136 | p58 137 | (dp59 138 | g8 139 | I176 140 | ssS'rc3_edit' 141 | p60 142 | (dp61 143 | g8 144 | (I0 145 | I0 146 | tp62 147 | sg18 148 | (I800 149 | I500 150 | tp63 151 | ss. 152 | -------------------------------------------------------------------------------- /src/gourmand/plugins/import_export/mycookbook_plugin/mycookbook_importer_plugin.py: -------------------------------------------------------------------------------- 1 | import os 2 | import os.path 3 | import tempfile 4 | import zipfile 5 | 6 | from lxml import etree 7 | 8 | from gourmand.i18n import _ 9 | from gourmand.plugin import ImporterPlugin 10 | 11 | from . import mycookbook_importer 12 | 13 | 14 | class MCBPlugin(ImporterPlugin): 15 | 16 | name = _("MCB File") 17 | patterns = ["*.mcb"] 18 | mimetypes = ["application/zip", "application/x-gzip", "multipart/x-zip", "multipart/x-gzip"] 19 | 20 | def test_file(self, filename): 21 | return True 22 | 23 | def get_importer(self, filename): 24 | xmlfilename = "" 25 | 26 | # Unzip in a temporary directory 27 | try: 28 | zf = zipfile.ZipFile(filename) 29 | except zipfile.BadZipfile: 30 | raise 31 | tempdir = tempfile.mkdtemp("mcb_zip") 32 | for name in zf.namelist(): 33 | (dirname, filename) = os.path.split(name) 34 | if not filename: 35 | continue 36 | fulldirpath = os.path.join(tempdir, dirname) 37 | # Create the images dir if not exists yet 38 | if not os.path.exists(fulldirpath): 39 | os.mkdir(fulldirpath, 0o775) 40 | outfile = open(os.path.join(tempdir, name), "wb") 41 | outfile.write(zf.read(name)) 42 | outfile.close() 43 | # Get the path to the xml file to import it 44 | if filename.endswith(".xml"): 45 | xmlfilename = os.path.join(tempdir, filename) 46 | 47 | # fix the xml file 48 | parser = etree.XMLParser(recover=True) 49 | tree = etree.parse(xmlfilename, parser) 50 | fixedxmlfilename = xmlfilename + "fixed" 51 | with open(fixedxmlfilename, "wb") as fout: 52 | tree.write(fout, xml_declaration=True, encoding="utf-8", pretty_print=True) 53 | 54 | zf.close() 55 | 56 | return mycookbook_importer.Converter(fixedxmlfilename) 57 | -------------------------------------------------------------------------------- /tests/broken_test_plugin_loader.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import tempfile 4 | import unittest 5 | 6 | from gourmet import gglobals 7 | 8 | 9 | class Test(unittest.TestCase): 10 | @classmethod 11 | def setUpClass(cls): 12 | # preserve the current gourmet working directory 13 | cls.original_gourmetdir = gglobals.gourmetdir 14 | 15 | # create a temporary test directory 16 | cls.tmp_dir = tempfile.mkdtemp() 17 | gglobals.gourmetdir = cls.tmp_dir 18 | 19 | # Continue to import with 'gourmetdir' set to 'tmp_dir', 20 | # Tests need to setup their own test workspace, otherwise 'gourmetdir' is set to '~/.gourmet' which could 21 | # result in the user gourmet database and other files being corrupted. 22 | # This attempt at isolation only really works if you're running this test module alone, not after others. 23 | from gourmet.plugin_loader import get_master_loader # noqa: E402 import not at top of file 24 | 25 | cls.ml = get_master_loader() 26 | 27 | @classmethod 28 | def tearDownClass(cls): 29 | # restore the original gourmet working directory location 30 | gglobals.gourmetdir = cls.original_gourmetdir 31 | 32 | # delete temporary test directory 33 | shutil.rmtree(cls.tmp_dir) 34 | 35 | def test_default_plugins(self): 36 | self.ml.load_active_plugins() 37 | print("active:", self.ml.active_plugins) 38 | print("instantiated:", self.ml.instantiated_plugins) 39 | self.assertEqual(len(self.ml.errors), 0) # there should be 0 plugin errors 40 | 41 | def test_available_plugins(self): 42 | # search module directories for available plugins 43 | for module_name, plugin_set in self.ml.available_plugin_sets.items(): 44 | if module_name not in self.ml.active_plugin_sets: 45 | self.ml.activate_plugin_set(plugin_set) 46 | self.ml.save_active_plugins() 47 | assert os.path.exists(self.ml.active_plugin_filename) 48 | self.assertEqual(len(self.ml.errors), 0) # there should be 0 plugin errors 49 | -------------------------------------------------------------------------------- /src/gourmand/plugins/unit_display_prefs/__init__.py: -------------------------------------------------------------------------------- 1 | from gi.repository import Gtk 2 | 3 | from gourmand.i18n import _ 4 | from gourmand.plugin import DatabasePlugin, ToolPlugin 5 | from gourmand.plugin_loader import PRE 6 | from gourmand.prefs import Prefs 7 | from gourmand.reccard import RecCardDisplay 8 | 9 | from . import unit_prefs_dialog 10 | 11 | 12 | class UnitDisplayPlugin(ToolPlugin): 13 | menu_items = """ 14 | 15 | """ 16 | menu_bars = ["RecipeDisplayMenuBar"] 17 | reccards = [] 18 | 19 | def __init__(self): 20 | ToolPlugin.__init__(self) 21 | 22 | def activate(self, pluggable): 23 | if isinstance(pluggable, RecCardDisplay): 24 | self.reccards.append(pluggable) 25 | self.add_to_uimanager(pluggable.ui_manager) 26 | 27 | def setup_action_groups(self): 28 | self.action_group = Gtk.ActionGroup(name="UnitAdjusterActionGroup") 29 | self.action_group.add_actions( 30 | [ 31 | ( 32 | "ShowUnitAdjusterDialog", 33 | None, 34 | _("Set _unit display preferences"), 35 | None, 36 | _("Automatically convert units to preferred system (metric, imperial, etc.) where possible."), 37 | self.show_converter_dialog, 38 | ), 39 | ] 40 | ) 41 | self.action_groups.append(self.action_group) 42 | 43 | def show_converter_dialog(self, *args): 44 | unit_prefs_dialog.UnitPrefsDialog(self.reccards).run() 45 | 46 | 47 | class UnitDisplayDatabasePlugin(DatabasePlugin): 48 | 49 | def activate(self, db): 50 | db.add_hook(PRE, "get_amount_and_unit", self.get_amount_and_unit_hook) 51 | 52 | def get_amount_and_unit_hook(self, db, *args, **kwargs): 53 | kwargs["preferred_unit_groups"] = Prefs.instance().get("preferred_unit_groups", []) 54 | return args, kwargs 55 | 56 | 57 | plugins = [UnitDisplayPlugin, UnitDisplayDatabasePlugin] 58 | -------------------------------------------------------------------------------- /src/gourmand/importers/clipboard_importer.py: -------------------------------------------------------------------------------- 1 | """Import recipes using the system's clipboard or via drag and drop.""" 2 | 3 | from pathlib import Path 4 | from tempfile import NamedTemporaryFile 5 | from urllib.parse import urlparse 6 | 7 | from gi.repository import Gdk, Gtk 8 | 9 | from gourmand.importers.importManager import ImportManager 10 | from gourmand.plugins.import_export.plaintext_plugin.plaintext_importer_plugin import PlainTextImporter 11 | 12 | 13 | def handle_import(data: str): 14 | """Deduce the correct importer from the content.""" 15 | if not data: 16 | return 17 | 18 | # Offer the regular import dialog if it's a uri 19 | is_supported = False 20 | 21 | uri = urlparse(data) 22 | if uri.netloc: 23 | is_supported = True 24 | elif uri.scheme == "file" and Path(uri.path).is_file(): 25 | is_supported = True # The import manager will do file-type validation 26 | data = uri.path 27 | 28 | if is_supported: 29 | importer = ImportManager.instance() 30 | importer.offer_import(default_value=data) 31 | 32 | else: # offer plaintext import 33 | with NamedTemporaryFile(delete=False) as tf: 34 | tf.write(data.encode()) 35 | importer = PlainTextImporter(tf.name) 36 | importer.do_run() 37 | 38 | from gourmand.main import get_application # work around circular import 39 | 40 | app = get_application() 41 | app.redo_search() 42 | 43 | 44 | def import_from_clipboard(action: Gtk.Action): 45 | clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) 46 | content = clipboard.wait_for_text().strip() 47 | 48 | handle_import(content) 49 | 50 | 51 | def import_from_drag_and_drop(treeview: Gtk.Widget, drag_context: Gdk.DragContext, x: int, y: int, data: Gtk.SelectionData, info: int, time: int) -> bool: 52 | """Handle imports from drag and drop action. 53 | 54 | This function is expected to be connected to a signal. 55 | If so, the signal will be done being handled here. 56 | """ 57 | content = data.get_text().strip() 58 | handle_import(content) 59 | return True # Done handling signal 60 | -------------------------------------------------------------------------------- /.flatpak/io.github.GourmandRecipeManager.Gourmand.yml: -------------------------------------------------------------------------------- 1 | app-id: io.github.GourmandRecipeManager.Gourmand 2 | runtime: org.gnome.Platform 3 | # https://release.gnome.org/calendar/ 4 | runtime-version: '49' 5 | sdk: org.gnome.Sdk 6 | command: gourmand 7 | 8 | finish-args: 9 | - --filesystem=host 10 | - --socket=fallback-x11 11 | - --socket=pulseaudio 12 | - --share=network 13 | modules: 14 | - name: poppler 15 | buildsystem: cmake-ninja 16 | config-opts: 17 | - -DENABLE_UTILS=OFF 18 | - -DENABLE_CPP=OFF 19 | - -DENABLE_QT5=OFF 20 | - -DENABLE_QT6=OFF 21 | - -DENABLE_NSS3=OFF 22 | - -DENABLE_GPGME=OFF 23 | - -DENABLE_BOOST=OFF 24 | - -DCMAKE_INSTALL_LIBDIR=lib 25 | sources: 26 | - url: https://poppler.freedesktop.org/poppler-25.10.0.tar.xz 27 | sha256: 6b5e9bb64dabb15787a14db1675291c7afaf9387438cc93a4fb7f6aec4ee6fe0 28 | type: archive 29 | 30 | # Python 3.11 seems to download too old dependency versions and non-wheels of `reportlab` and `lxml`. 31 | # This breaks the build itself. 32 | - name: cpython 33 | sources: 34 | - type: archive 35 | url: https://www.python.org/ftp/python/3.10.19/Python-3.10.19.tar.xz 36 | sha256: c8f4a596572201d81dd7df91f70e177e19a70f1d489968b54b5fbbf29a97c076 37 | 38 | - name: intltool 39 | buildsystem: autotools 40 | sources: 41 | - type: archive 42 | url: https://launchpad.net/intltool/trunk/0.51.0/+download/intltool-0.51.0.tar.gz 43 | sha256: 67c74d94196b153b774ab9f89b2fa6c6ba79352407037c8c14d5aeb334e959cd 44 | 45 | - name: gourmand 46 | buildsystem: simple 47 | build-options: 48 | build-args: 49 | - --share=network 50 | build-commands: 51 | - python3 -m pip install --upgrade pip setuptools wheel 52 | - pip3 install --prefix=/app .[epub-export,pdf-export,spellcheck,web-import] 53 | - install -Dm644 data/io.github.GourmandRecipeManager.Gourmand.desktop -t /app/share/applications/ 54 | - install -Dm644 data/io.github.GourmandRecipeManager.Gourmand.svg -t /app/share/icons/hicolor/scalable/apps/ 55 | sources: 56 | - type: git 57 | branch: main 58 | url: https://github.com/GourmandRecipeManager/gourmand 59 | -------------------------------------------------------------------------------- /tests/test_reccard_sortby.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from gourmand.main import get_application 4 | from gourmand.reccard import RecSelector 5 | 6 | 7 | def test_sort_by(tmp_path): 8 | """Test the sort_by property of the RecSelector class.""" 9 | with mock.patch("gourmand.gglobals.gourmanddir", tmp_path): 10 | rec_gui = get_application() 11 | 12 | # Mock UI-related calls within the RecSelector constructor to prevent 13 | # windows from being created during the test run. 14 | with mock.patch("gourmand.reccard.RecIndex.__init__", return_value=None), \ 15 | mock.patch("gi.repository.Gtk.Dialog") as mock_dialog, \ 16 | mock.patch("gi.repository.Gtk.Builder.get_object"): 17 | # The RecSelector constructor calls self.dialog.run(). We ensure the mock 18 | # for Gtk.Dialog doesn't block the test execution. 19 | mock_dialog.return_value.run.return_value = None 20 | 21 | # The RecSelector constructor expects an IngredientEditorModule instance. 22 | # We can mock this dependency. 23 | mock_ing_editor = mock.Mock() 24 | 25 | # Instantiate the class under test 26 | rec_selector = RecSelector(rec_gui, mock_ing_editor) 27 | 28 | # Test setting the sort_by property 29 | sort_order = [("name", 1), ("rating", -1)] 30 | rec_selector.sort_by = sort_order 31 | 32 | # Test the getter - use set to ignore order 33 | assert set(rec_selector.sort_by) == set(sort_order) 34 | 35 | # Test the underlying preference. RecSelector and rec_gui share 36 | # the same singleton Prefs instance. 37 | expected_prefs = {"name": True, "rating": False} 38 | assert rec_gui.prefs.get("sort_by") == expected_prefs 39 | 40 | # Test setting an empty value 41 | rec_selector.sort_by = [] 42 | assert "sort_by" not in rec_gui.prefs 43 | 44 | # Test setting to None 45 | rec_selector.sort_by = sort_order # set it again 46 | assert "sort_by" in rec_gui.prefs 47 | rec_selector.sort_by = None 48 | assert "sort_by" not in rec_gui.prefs 49 | -------------------------------------------------------------------------------- /src/gourmand/plugins/import_export/plaintext_plugin/plaintext_importer_plugin.py: -------------------------------------------------------------------------------- 1 | import fnmatch 2 | import os.path 3 | 4 | from gi.repository import Gtk 5 | 6 | from gourmand import check_encodings 7 | from gourmand.gtk_extras.dialog_extras import show_message 8 | from gourmand.i18n import _ 9 | from gourmand.importers.interactive_importer import InteractiveImporter 10 | from gourmand.plugin import ImporterPlugin 11 | 12 | MAX_PLAINTEXT_LENGTH = 100000 13 | 14 | 15 | class PlainTextImporter(InteractiveImporter): 16 | 17 | name = "Plain Text Importer" 18 | 19 | def __init__(self, filename): 20 | self.filename = filename 21 | InteractiveImporter.__init__(self) 22 | 23 | def do_run(self): 24 | if os.path.getsize(self.filename) > MAX_PLAINTEXT_LENGTH * 16: 25 | show_message( 26 | title=_("Big File"), 27 | label=_("File %s is too big to import" % self.filename), 28 | sublabel=_( 29 | "Your file exceeds the maximum length of %s characters. You probably didn't mean to import it anyway. " 30 | "If you really do want to import this file, use a text editor to split it into smaller files and try importing again." 31 | ) 32 | % MAX_PLAINTEXT_LENGTH, 33 | message_type=Gtk.MessageType.ERROR, 34 | ) 35 | return 36 | 37 | content = check_encodings.get_file(self.filename) 38 | if content is None: 39 | return 40 | 41 | data = "\n".join(content) 42 | self.set_text(data) 43 | return InteractiveImporter.do_run(self) 44 | 45 | 46 | class PlainTextImporterPlugin(ImporterPlugin): 47 | 48 | name = _("Plain Text file") 49 | patterns = ["*.txt", "[^.]*", "*"] 50 | mimetypes = ["text/plain"] 51 | 52 | antipatterns = ["*.html", "*.htm", "*.xml", "*.doc", "*.rtf"] 53 | 54 | def test_file(self, filename): 55 | """Given a filename, test whether the file is of this type.""" 56 | if filename.endswith(".txt"): 57 | return 1 58 | elif True not in [fnmatch.fnmatch(filename, p) for p in self.antipatterns]: 59 | return -1 # we are a fallback option 60 | 61 | def get_importer(self, filename): 62 | return PlainTextImporter(filename=filename) 63 | -------------------------------------------------------------------------------- /src/gourmand/plugins/email_plugin/emailer_plugin.py: -------------------------------------------------------------------------------- 1 | from gi.repository import Gtk 2 | 3 | import gourmand.gtk_extras.dialog_extras as de 4 | from gourmand.i18n import _ 5 | from gourmand.plugin import MainPlugin, UIPlugin 6 | 7 | from .recipe_emailer import RecipeEmailer 8 | 9 | 10 | class EmailRecipePlugin(MainPlugin, UIPlugin): 11 | 12 | ui_string = """ 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | """ 21 | 22 | def setup_action_groups(self): 23 | self.actionGroup = Gtk.ActionGroup(name="RecipeEmailerActionGroup") 24 | self.actionGroup.add_actions( 25 | [ 26 | ( 27 | "EmailRecipes", 28 | None, 29 | _("Email recipes"), 30 | None, 31 | _("Email all selected recipes (or all recipes if no recipes are selected"), 32 | self.email_selected, 33 | ), 34 | ] 35 | ) 36 | self.action_groups.append(self.actionGroup) 37 | 38 | def activate(self, pluggable): 39 | self.rg = self.pluggable = pluggable 40 | self.add_to_uimanager(pluggable.ui_manager) 41 | 42 | def get_selected_recs(self): 43 | recs = self.rg.get_selected_recs_from_rec_tree() 44 | if not recs: 45 | recs = self.rd.fetch_all(self.rd.recipe_table, deleted=False, sort_by=[("title", 1)]) 46 | return recs 47 | 48 | def email_selected(self, *args): 49 | recs = self.get_selected_recs() 50 | length = len(recs) 51 | if length > 20: 52 | if not de.getBoolean( 53 | title=_("Email recipes"), 54 | # only called for l>20, so fancy gettext methods 55 | # shouldn't be necessary if my knowledge of 56 | # linguistics serves me 57 | sublabel=_("Do you really want to email all %s selected recipes?") % length, 58 | custom_yes=_("Yes, e_mail them"), 59 | cancel=False, 60 | ): 61 | return 62 | re = RecipeEmailer(recs) 63 | re.send_email_with_attachments() 64 | -------------------------------------------------------------------------------- /src/gourmand/plugins/browse_recipes/__init__.py: -------------------------------------------------------------------------------- 1 | from gourmand.plugin import MainPlugin 2 | from gourmand.plugin_loader import POST, PRE 3 | 4 | from . import browser 5 | 6 | 7 | class BrowserPlugin(MainPlugin): 8 | 9 | def activate(self, pluggable): 10 | MainPlugin.activate(self, pluggable) 11 | self.browser = browser.RecipeBrowser(pluggable.rd) 12 | self.browser.view.connect("recipe-selected", self.recipe_activated_cb) 13 | self.browser.view.connect("selection-changed", self.selection_changed_cb) 14 | self.add_tab(self.browser, "Browse Recipes") 15 | pluggable.add_hook(POST, "get_selected_recs_from_rec_tree", self.get_selected_post_hook) 16 | pluggable.add_hook(PRE, "redo_search", self.reset_view) 17 | pluggable.add_hook(PRE, "update_recipe", self.update_recipe) 18 | 19 | def selection_changed_cb(self, iconview): 20 | paths = iconview.get_selected_items() 21 | if not paths: 22 | self.recipes_unselected() 23 | return 24 | model = iconview.get_model() 25 | rid = model[paths[0]][0] 26 | try: 27 | int(rid) 28 | except ValueError: 29 | self.recipes_unselected() 30 | else: 31 | # If we have an integer ID, we are selecting recipes! 32 | self.recipes_selected() 33 | 34 | def recipes_unselected(self): 35 | """Toggle our action items etc. for no recipes selected""" 36 | self.main.selection_changed(False) 37 | 38 | def recipes_selected(self): 39 | """Toggle our action items etc. for recipes selected""" 40 | self.main.selection_changed(True) 41 | 42 | def recipe_activated_cb(self, browser, rid): 43 | self.main.open_rec_card(self.main.rd.get_rec(rid)) 44 | 45 | def reset_view(self, *args): 46 | self.browser.view.reset_model() 47 | 48 | def update_recipe(self, recipe): 49 | self.reset_view() 50 | 51 | def get_selected_post_hook(self, recs_from_recindex, pluggable): 52 | if self.main.main_notebook.get_current_page() in self.added_tabs: 53 | # then get recipes from iconview... 54 | retval = self.browser.view.get_selected_recipes() 55 | return retval 56 | else: 57 | return recs_from_recindex 58 | 59 | 60 | plugins = [ 61 | BrowserPlugin, 62 | ] 63 | -------------------------------------------------------------------------------- /src/gourmand/exporters/xml_exporter.py: -------------------------------------------------------------------------------- 1 | import xml.dom 2 | from typing import List, Optional 3 | 4 | from .exporter import exporter_mult 5 | 6 | # Base XML exporter class 7 | 8 | 9 | class XmlExporter(exporter_mult): 10 | 11 | # doc_element = 'rec' 12 | # doctype_desc = '' 13 | # dtd_path = '' 14 | 15 | def __init__(self, rd, r, out, order: Optional[List[str]] = None, xmlDoc=None, **kwargs): 16 | order = order or ["attr", "image", "ings", "text"] 17 | if xmlDoc: 18 | self.xmlDoc = xmlDoc 19 | self.i_created_this_document = False 20 | self.top_element = self.xmlDoc.childNodes[1] 21 | else: 22 | self.create_xmldoc() 23 | exporter_mult.__init__(self, rd, r, out, use_ml=True, convert_attnames=False, do_markup=True, order=order, **kwargs) 24 | 25 | def create_xmldoc(self): 26 | self.i_created_this_document = True 27 | impl = xml.dom.getDOMImplementation() 28 | doctype = impl.createDocumentType(self.doc_element, self.doctype_desc, self.dtd_path) 29 | self.xmlDoc = impl.createDocument(None, self.doc_element, doctype) 30 | self.top_element = self.xmlDoc.documentElement 31 | 32 | # Convenience methods 33 | def set_attribute(self, element, attribute, value): 34 | a = self.xmlDoc.createAttribute(attribute) 35 | element.setAttributeNode(a) 36 | element.setAttribute(attribute, value) 37 | 38 | def append_text(self, element, text): 39 | try: 40 | assert isinstance(text, str) 41 | except AssertionError: 42 | print("Text is not text") 43 | print("append_text received", element, text) 44 | raise TypeError("%r is not a StringType" % text) 45 | try: 46 | t = self.xmlDoc.createTextNode(text) 47 | element.appendChild(t) 48 | except Exception as e: 49 | print("FAILED WHILE WORKING ON ", element) 50 | print("TRYING TO APPEND", text[:100]) 51 | raise e 52 | 53 | def create_text_element(self, element_name, text, attrs={}): 54 | element = self.create_element_with_attrs(element_name, attrs) 55 | self.append_text(element, text) 56 | return element 57 | 58 | def create_element_with_attrs(self, element_name, attdic): 59 | element = self.xmlDoc.createElement(element_name) 60 | for k, v in list(attdic.items()): 61 | self.set_attribute(element, k, str(v)) 62 | return element 63 | -------------------------------------------------------------------------------- /src/gourmand/gdebug.py: -------------------------------------------------------------------------------- 1 | import time 2 | import traceback 3 | 4 | from gourmand.optionparser import args 5 | 6 | debug_level = args.debug or 0 7 | debug_file = args.debug_file 8 | timestamp = args.time 9 | 10 | if debug_file: 11 | import re 12 | 13 | debug_file = re.compile(debug_file) 14 | 15 | if debug_level > 0: 16 | print("DEBUG_LEVEL=", debug_level) 17 | if debug_file: 18 | print("DEBUG_FILE=", debug_file) 19 | 20 | 21 | def debug(message, level=10): 22 | if timestamp: 23 | ts = "%s:" % time.time() 24 | else: 25 | ts = "" 26 | if level <= debug_level: 27 | stack = traceback.extract_stack() 28 | if len(stack) >= 2: 29 | caller = stack[-2] 30 | finame = caller[0] 31 | line = caller[1] 32 | else: 33 | finame = " ".join(stack) 34 | line = "" 35 | if args.debug_file: 36 | if debug_file.search(finame): 37 | print("DEBUG: ", ts, "%s: %s" % (finame, line), message) 38 | else: 39 | print("DEBUG: ", ts, "%s: %s" % (finame, line), message) 40 | 41 | 42 | timers = {} 43 | 44 | 45 | class TimeAction: 46 | def __init__(self, name, level=10): 47 | self.level = level 48 | if level <= debug_level: 49 | self.name = name 50 | self.start = time.time() 51 | 52 | def end(self): 53 | if self.level <= debug_level: 54 | end = time.time() 55 | t = end - self.start 56 | # grab our location 57 | stack = traceback.extract_stack() 58 | if len(stack) > 2: 59 | caller = stack[-2] 60 | finame = caller[0] 61 | else: 62 | finame = " ".join(stack) 63 | if not args.debug_file or debug_file.search(finame): 64 | print("DEBUG: %s TOOK %s SECONDS" % (self.name, t)) 65 | if self.name not in timers: 66 | timers[self.name] = [t] 67 | else: 68 | timers[self.name].append(t) 69 | 70 | 71 | def print_timer_info(): 72 | for n, times in list(timers.items()): 73 | print("%s:" % n, end=" ") 74 | for t in times: 75 | print("%.02e" % t, ",", end=" ") 76 | print("") 77 | 78 | 79 | if __name__ == "__main__": 80 | t = TimeAction("this is a test", 0) 81 | debug("This is a test", 0) 82 | debug("This is another test", 0) 83 | t.end() 84 | print_timer_info() 85 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ 'main' ] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [ 'main' ] 9 | schedule: 10 | - cron: '22 17 * * 6' 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | permissions: 17 | actions: read 18 | contents: read 19 | security-events: write 20 | 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | language: [ 'python' ] 25 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 26 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 27 | 28 | steps: 29 | - name: Checkout repository 30 | uses: actions/checkout@v6 31 | 32 | # Initializes the CodeQL tools for scanning. 33 | - name: Initialize CodeQL 34 | uses: github/codeql-action/init@v4 35 | with: 36 | languages: ${{ matrix.language }} 37 | # If you wish to specify custom queries, you can do so here or in a config file. 38 | # By default, queries listed here will override any specified in a config file. 39 | # Prefix the list here with "+" to use these queries and those in the config file. 40 | 41 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 42 | queries: +security-and-quality 43 | 44 | 45 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). 46 | # If this step fails, then you should remove it and run the build manually (see below) 47 | - name: Autobuild 48 | uses: github/codeql-action/autobuild@v4 49 | 50 | # ℹ️ Command-line programs to run using the OS shell. 51 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 52 | 53 | # If the Autobuild fails above, remove it and uncomment the following three lines. 54 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 55 | 56 | # - run: | 57 | # echo "Run, Build Application using script" 58 | # ./location_of_script_within_repo/buildscript.sh 59 | 60 | - name: Perform CodeQL Analysis 61 | uses: github/codeql-action/analyze@v4 62 | with: 63 | category: "/language:${{matrix.language}}" 64 | -------------------------------------------------------------------------------- /src/gourmand/plugins/import_export/gxml_plugin/gxml_exporter_plugin.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from gourmand.convert import float_to_frac, seconds_to_timestring 4 | from gourmand.i18n import _ 5 | from gourmand.plugin import ExporterPlugin 6 | 7 | from . import gxml2_exporter 8 | 9 | GXML = _("Gourmet XML File") 10 | 11 | 12 | class GourmetExportChecker: 13 | 14 | def check_rec(self, rec, file): 15 | self.txt = file.read() 16 | print("txt", self.txt) 17 | self.rec = rec 18 | self.check_attrs() 19 | 20 | def check_attrs(self): 21 | for attr in ["title", "cuisine", "source", "link"]: 22 | if getattr(self.rec, attr): 23 | assert re.search( 24 | r"<%(attr)s>\s*%(val)s\s*" % {"attr": attr, "val": getattr(self.rec, attr)}, self.txt 25 | ), "Did not find %s value %s" % (attr, getattr(self.rec, attr)) 26 | if self.rec.yields: 27 | assert re.search(r"\s*%s\s*%s\s*" % (self.rec.yields, self.rec.yield_unit), self.txt) or re.search( 28 | r"\s*%s\s*%s\s*" % (float_to_frac(self.rec.yields), self.rec.yield_unit), self.txt 29 | ), "Did not find yields value %s %s" % (self.rec.yields, self.rec.yield_unit) 30 | for att in ["preptime", "cooktime"]: 31 | if getattr(self.rec, att): 32 | tstr = seconds_to_timestring(getattr(self.rec, att)) 33 | assert re.search(r"<%(att)s>\s*%(tstr)s\s*" % locals(), self.txt), "Did not find %s value %s" % (att, tstr) 34 | 35 | 36 | class GourmetExporterPlugin(ExporterPlugin): 37 | 38 | label = _("Gourmet XML Export") 39 | sublabel = _("Exporting recipes to Gourmet XML file %(file)s.") 40 | single_completed_string = (_("Recipe saved in Gourmet XML file %(file)s."),) 41 | filetype_desc = GXML 42 | saveas_filters = [GXML, ["text/xml"], ["*.grmt", "*.xml", "*.XML"]] 43 | saveas_single_filters = saveas_filters 44 | 45 | def get_multiple_exporter(self, args): 46 | return gxml2_exporter.recipe_table_to_xml( 47 | args["rd"], 48 | args["rv"], 49 | args["file"], 50 | ) 51 | 52 | def do_single_export(self, args): 53 | gxml2_exporter.recipe_table_to_xml(args["rd"], [args["rec"]], args["out"], change_units=args["change_units"], mult=args["mult"]).run() 54 | 55 | def run_extra_prefs_dialog(self): 56 | pass 57 | 58 | def check_export(self, rec, file): 59 | gec = GourmetExportChecker() 60 | gec.check_rec(rec, file) 61 | -------------------------------------------------------------------------------- /src/gourmand/plugins/listsaver/shoppingSaverPlugin.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from gi.repository import Gtk 4 | 5 | import gourmand.main 6 | import gourmand.recipeManager 7 | from gourmand.i18n import _ 8 | from gourmand.plugin import ShoppingListPlugin 9 | 10 | 11 | class ShoppingListSaver(ShoppingListPlugin): 12 | 13 | ui_string = """ 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | """ 27 | name = "shopping_list_saver" 28 | label = _("Shopping List Saver") 29 | 30 | def setup_action_groups(self): 31 | self.shoppingListSaverActionGroup = Gtk.ActionGroup(name="ShoppingListSaverActionGroup") 32 | self.shoppingListSaverActionGroup.add_actions( 33 | [ 34 | ( 35 | "SaveAsRecipe", # name 36 | Gtk.STOCK_SAVE_AS, # stock 37 | _("Save List as Recipe"), # text 38 | _("S"), # key-command 39 | _("Save current shopping list as a recipe for future use"), # tooltip 40 | self.save_as_recipe, # callback 41 | ), 42 | ] 43 | ) 44 | self.action_groups.append(self.shoppingListSaverActionGroup) 45 | 46 | def save_as_recipe(self, *args): 47 | sg = self.pluggable 48 | rr = sg.recs 49 | rd = gourmand.recipeManager.get_recipe_manager() 50 | rg = gourmand.main.get_application() 51 | # print rr 52 | rec = rd.add_rec(dict(title=_("Menu for %s (%s)") % (time.strftime("%x"), time.strftime("%X")), category=_("Menu"))) 53 | for recipe, mult in list(rr.values()): 54 | # Add all recipes... 55 | rd.add_ing( 56 | { 57 | "amount": mult, 58 | "unit": "Recipe", 59 | "refid": recipe.id, 60 | "recipe_id": rec.id, 61 | "item": recipe.title, 62 | } 63 | ) 64 | for amt, unit, item in sg.extras: 65 | # Add all extras... 66 | rd.add_ing( 67 | { 68 | "amount": amt, 69 | "unit": unit, 70 | "item": item, 71 | "ingkey": item, 72 | } 73 | ) 74 | rg.open_rec_card(rec) 75 | -------------------------------------------------------------------------------- /src/gourmand/plugins/import_export/krecipe_plugin/krecipe_importer.py: -------------------------------------------------------------------------------- 1 | import base64 2 | 3 | from gourmand.importers import xml_importer 4 | 5 | 6 | class KrecHandler(xml_importer.RecHandler): 7 | ADD = 1 8 | IS = 2 9 | AND = 3 10 | BASE_64 = 4 11 | RECTAGS = { 12 | "title": ("title", IS), 13 | "author": ("source", ADD), 14 | # FIX ME: IMAGE SUPPORT IS BROKEN! 15 | "pic": ("image", BASE_64), 16 | "cat": ("category", ADD), 17 | "serving": ("servings", IS), 18 | "preparation-time": ("preptime", IS), 19 | "krecipes-instructions": ("instructions", ADD), 20 | } 21 | INGTAGS = { 22 | "name": (("item", "ingkey"), AND), 23 | "amount": ("amount", IS), 24 | "unit": ("unit", IS), 25 | "prep": ("item", ADD), 26 | } 27 | RECIPE_TAG = "krecipes-recipe" 28 | ING_TAG = "ingredient" 29 | 30 | def __init__(self, total=None, conv=None, parent_thread=None): 31 | self.in_mixed = 0 32 | self.rec = {} 33 | self.ing = {} 34 | xml_importer.RecHandler.__init__(self, total, conv=conv, parent_thread=parent_thread) 35 | 36 | def startElement(self, name, attrs): 37 | self.elbuf = "" 38 | if name == self.RECIPE_TAG: 39 | self.start_rec() 40 | if name == self.ING_TAG: 41 | self.start_ing() 42 | if name == "ingredient-group": 43 | self.group = attrs.get("name", "") 44 | 45 | def endElement(self, name): 46 | key, method = None, None 47 | # krecipe-recipe marks a recipe end! 48 | if name == self.RECIPE_TAG: 49 | self.commit_rec() 50 | return 51 | if name == "ingredient-group": 52 | self.group = None 53 | if name == self.ING_TAG: 54 | self.commit_ing() 55 | elif name in self.RECTAGS: 56 | obj = self.rec 57 | key, method = self.RECTAGS[name] 58 | elif name in self.INGTAGS: 59 | obj = self.ing 60 | key, method = self.INGTAGS[name] 61 | if key: 62 | if method == self.ADD and key in obj: 63 | obj[key] = obj[key] + ", " + self.elbuf 64 | elif method == self.AND: 65 | for k in key: 66 | obj[k] = self.elbuf 67 | elif method == self.BASE_64: 68 | obj[key] = base64.b64decode(self.elbuf) 69 | else: 70 | obj[key] = self.elbuf 71 | 72 | 73 | class Converter(xml_importer.Converter): 74 | def __init__(self, filename): 75 | xml_importer.Converter.__init__(self, filename, KrecHandler, recMarker="") 76 | -------------------------------------------------------------------------------- /tests/test_convert.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from gourmand import convert 4 | 5 | 6 | class ConvertTest(unittest.TestCase): 7 | 8 | def setUp(self): 9 | self.c = convert.get_converter() 10 | 11 | def test_equal(self): 12 | self.assertEqual(self.c.convert_simple("c", "c"), 1) 13 | 14 | def test_density(self): 15 | self.assertEqual(self.c.convert_w_density("ml", "g", item="water"), 1) 16 | self.assertEqual(self.c.convert_w_density("ml", "g", density=0.5), 0.5) 17 | 18 | def test_readability(self): 19 | self.assertTrue(self.c.readability_score(1, "cup") > self.c.readability_score(0.8, "cups")) 20 | self.assertTrue(self.c.readability_score(1 / 3.0, "tsp.") > self.c.readability_score(0.123, "tsp.")) 21 | 22 | def test_adjustments(self): 23 | amt, unit = self.c.adjust_unit(12, "Tbs.", "water") 24 | self.assertEqual(amt, 0.75) 25 | 26 | def test_integer_rounding(self): 27 | self.assertTrue(convert.integerp(0.99)) 28 | 29 | def test_fraction_generator(self): 30 | for d in [2, 3, 4, 5, 6, 8, 10, 16]: 31 | self.assertEqual(convert.float_to_frac(1.0 / d, fractions=convert.FRACTIONS_ASCII), ("1/%s" % d)) 32 | 33 | def test_fraction_to_float(self): 34 | for s, n in [ 35 | ("1", 1), 36 | ("123", 123), 37 | ("1 1/2", 1.5), 38 | ("74 2/5", 74.4), 39 | ("1/10", 0.1), 40 | ("one", 1), 41 | ("a half", 0.5), 42 | ("three quarters", 0.75), 43 | ("0.5", 0.5), 44 | ("0,5", 0.5), 45 | ]: 46 | self.assertEqual(convert.frac_to_float(s), n) 47 | 48 | def test_ingmatcher(self): 49 | for s, a, u, i in [ 50 | ("1 cup sugar", "1", "cup", "sugar"), 51 | ("1 1/2 cup sugar", "1 1/2", "cup", "sugar"), 52 | ("two cloves garlic", "two", "cloves", "garlic"), 53 | ("0.5 cl gin", "0.5", "cl", "gin"), 54 | ("0,5 cl gin", "0,5", "cl", "gin"), 55 | ]: 56 | match = convert.ING_MATCHER.match(s) 57 | self.assertTrue(match) 58 | self.assertEqual(match.group(convert.ING_MATCHER_AMT_GROUP).strip(), a) 59 | self.assertEqual(match.group(convert.ING_MATCHER_UNIT_GROUP).strip(), u) 60 | self.assertEqual(match.group(convert.ING_MATCHER_ITEM_GROUP).strip(), i) 61 | 62 | def test_timestring_to_seconds(self): 63 | converter_1 = convert.Converter() 64 | for timestring, seconds in [ 65 | ("15 seconds", 15), 66 | ("10 minutes", 600), 67 | ("3 hours", 3*60*60), 68 | ("3 days", 3*24*60*60), 69 | ]: 70 | result = convert.Converter.timestring_to_seconds(converter_1, timestring) 71 | self.assertEqual(result, seconds) 72 | -------------------------------------------------------------------------------- /.github/workflows/appimage.yml: -------------------------------------------------------------------------------- 1 | name: Create AppImage 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | branches: 8 | - 'main' 9 | pull_request: 10 | workflow_dispatch: 11 | 12 | jobs: 13 | AppImage: 14 | 15 | runs-on: ubuntu-22.04 16 | 17 | steps: 18 | - uses: actions/checkout@v6 19 | - name: Install Ubuntu dependencies 20 | run: > 21 | sudo apt-get update -q && sudo apt-get install 22 | --no-install-recommends -y xvfb python3-dev python3-gi 23 | python3-gi-cairo gir1.2-gtk-3.0 libgirepository1.0-dev libcairo2-dev 24 | intltool gir1.2-poppler-0.18 python3-gst-1.0 25 | python3-testresources imagemagick 26 | libfuse2 27 | 28 | - name: Setup AppImage Environment 29 | run: | 30 | wget -c https://github.com/$(wget -q https://github.com/niess/python-appimage/releases/expanded_assets/python3.10 -O - | grep "python3.10.*x86_64.AppImage" | head -n 1 | cut -d '"' -f 2) 31 | chmod +x ./python3*.AppImage 32 | ./python3*.AppImage --appimage-extract 33 | wget -c https://github.com/$(wget -q https://github.com/probonopd/go-appimage/releases/expanded_assets/655 -O - | grep "appimagetool-.*-x86_64.AppImage" | head -n 1 | cut -d '"' -f 2) 34 | chmod +x appimagetool-*.AppImage 35 | 36 | - name: Install Gourmand in AppImage 37 | run: | 38 | C_INCLUDE_PATH=/usr/include/python3.10 39 | export C_INCLUDE_PATH 40 | ./squashfs-root/AppRun -m pip install --upgrade pip build 41 | ./squashfs-root/AppRun -m build . 42 | ./squashfs-root/AppRun -m pip install .[epub-export,mycookbook,pdf-export,web-import] 43 | ./squashfs-root/AppRun -m pip install dist/gourmand*.whl 44 | sed -i -e 's|/opt/python3.10/bin/python3.10|/usr/bin/gourmand|g' ./squashfs-root/AppRun 45 | rm squashfs-root/*.desktop 46 | cp data/io.github.GourmandRecipeManager.Gourmand.desktop squashfs-root/usr/share/applications/io.github.GourmandRecipeManager.Gourmand.desktop 47 | cp data/io.github.GourmandRecipeManager.Gourmand.desktop squashfs-root/io.github.GourmandRecipeManager.Gourmand.desktop 48 | cp data/io.github.GourmandRecipeManager.Gourmand.appdata.xml squashfs-root/usr/share/metainfo/io.github.GourmandRecipeManager.Gourmand.desktop.appdata.xml 49 | convert data/io.github.GourmandRecipeManager.Gourmand.svg squashfs-root/usr/share/icons/hicolor/256x256/apps/io.github.GourmandRecipeManager.Gourmand.png 50 | convert data/io.github.GourmandRecipeManager.Gourmand.svg squashfs-root/io.github.GourmandRecipeManager.Gourmand.png 51 | 52 | - name: Pack AppImage 53 | run: | 54 | chmod 0775 squashfs-root 55 | VERSION=${GITHUB_SHA::8} ./appimagetool-*.AppImage squashfs-root/ 56 | 57 | - name: Upload AppImage 58 | uses: actions/upload-artifact@v6 59 | with: 60 | name: gourmand.AppImage 61 | path: ./Gourmand-*.AppImage 62 | -------------------------------------------------------------------------------- /src/gourmand/plugins/nutritional_information/export_plugin.py: -------------------------------------------------------------------------------- 1 | from xml.sax.saxutils import escape 2 | 3 | from gourmand.defaults.defaults import get_pluralized_form 4 | from gourmand.i18n import _ 5 | from gourmand.plugin import BaseExporterPlugin 6 | from gourmand.prefs import Prefs 7 | from gourmand.recipeManager import default_rec_manager 8 | 9 | from .nutritionLabel import MAIN_NUT_LAYOUT, MAJOR, SEP 10 | 11 | 12 | class NutritionBaseExporterPlugin(BaseExporterPlugin): 13 | 14 | def __init__(self): 15 | BaseExporterPlugin.__init__(self) 16 | if Prefs.instance().get("include_nutritional_info_in_export", True): 17 | self.add_field("Nutritional Information", self.get_nutritional_info_as_text_blob, self.TEXT) 18 | 19 | def get_nutritional_info_as_text_blob(self, rec): 20 | if not Prefs.instance().get("include_nutritional_info_in_export", True): 21 | return None 22 | txt = "" 23 | footnotes = "" 24 | rd = default_rec_manager() 25 | nd = rd.nd 26 | nutinfo = nd.get_nutinfo_for_inglist(rd.get_ings(rec), rd) 27 | ings = rd.get_ings(rec) 28 | vapor = nutinfo._get_vapor() 29 | if len(vapor) == len(ings): 30 | return None 31 | if len(vapor) >= 1 and not Prefs.instance().get("include_partial_nutritional_info", False): 32 | return None 33 | if rec.yields and rec.yield_unit: 34 | singular_unit = get_pluralized_form(rec.yield_unit, 1) 35 | txt += "%s" % ( 36 | (rec.yields and _("Nutritional information reflects amount per %s." % singular_unit)) 37 | or _("Nutritional information reflects amounts for entire recipe") 38 | ) 39 | 40 | if vapor: 41 | txt = txt + "*" 42 | footnotes = "\n*" + _("Nutritional information is missing for %s ingredients: %s") % ( 43 | len(vapor), 44 | ", ".join([escape(nv.__ingobject__.item) for nv in vapor]), 45 | ) 46 | for itm in MAIN_NUT_LAYOUT: 47 | if itm == SEP: 48 | # We don't have any nice way of outputting separator 49 | # lines in our export 50 | continue 51 | else: 52 | label, typ, name, properties, show_percent, unit = itm 53 | if typ == MAJOR: 54 | itm_text = "" + label + "" 55 | else: 56 | itm_text = label 57 | if unit: 58 | itm_text += " (%s)" % unit 59 | if isinstance(properties, list): 60 | amts = [getattr(nutinfo, att) for att in properties] 61 | amt = sum(amts) 62 | else: 63 | amt = getattr(nutinfo, properties) 64 | if rec.yields: 65 | amt = amt / rec.yields 66 | itm_text += " %d" % round(amt) 67 | txt += "\n" + itm_text 68 | return "\n".join([txt, footnotes]) 69 | -------------------------------------------------------------------------------- /src/gourmand/plugins/import_export/mealmaster_plugin/mealmaster_importer_plugin.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | 3 | from gourmand.i18n import _ 4 | from gourmand.importers.importer import Tester 5 | from gourmand.plugin import ImporterPlugin 6 | from gourmand.recipeManager import get_recipe_manager 7 | 8 | from . import mealmaster_importer 9 | 10 | test_dir = os.path.split(__file__)[0] # our directory src/lib/plugins/import_export/plugin/*/ 11 | test_dir = os.path.split(test_dir)[0] # one back... src/lib/plugins/import_export/plugin/ 12 | test_dir = os.path.split(test_dir)[0] # one back... src/lib/plugins/import_export/ 13 | test_dir = os.path.split(test_dir)[0] # one back... src/lib/plugins/ 14 | test_dir = os.path.split(test_dir)[0] # one back... src/lib/ 15 | test_dir = os.path.split(test_dir)[0] # one back... src/ 16 | test_dir = os.path.join(test_dir, "test", "recipe_files") 17 | 18 | 19 | class MealmasterImporterPlugin(ImporterPlugin): 20 | 21 | get_source = "source" 22 | name = _("MealMaster file") 23 | patterns = ["*.mmf", "*.txt"] 24 | mimetypes = ["text/mealmaster", "text/plain"] 25 | 26 | def test_file(self, filename): 27 | """Given a filename, test whether the file is of this type.""" 28 | return Tester(mealmaster_importer.mm_start_pattern).test(filename) 29 | 30 | def get_importer(self, filename): 31 | return mealmaster_importer.mmf_importer(filename=filename) 32 | 33 | def get_import_tests(self): 34 | return [(os.path.join(test_dir, "mealmaster_2_col.mmf"), test_2_col), (os.path.join(test_dir, "mealmaster.mmf"), test_mmf)] 35 | 36 | 37 | def assert_equal(val1, val2): 38 | assert val1 == val2, "Value expected: %s, Actual value: %s" % (val2, val1) 39 | 40 | 41 | def assert_equal_ignorecase(val1, val2): 42 | return assert_equal(val1.lower(), val2.lower()) 43 | 44 | 45 | def test_mmf(recs, filename): 46 | rd = get_recipe_manager() 47 | assert_equal(recs[0].title, "Almond Mushroom Pate") 48 | assert_equal(recs[0].yields, 6) 49 | assert_equal(recs[0].yield_unit, "servings") 50 | assert_equal(recs[3].title, "Anchovy Olive Dip") 51 | ings = rd.get_ings(recs[3]) 52 | assert_equal(ings[1].item, "Finely chopped stuffed green olives") # test line-wrap 53 | 54 | 55 | def test_2_col(recs, filename): 56 | rd = get_recipe_manager() 57 | assert len(recs) == 1, "Expected 1 recipes; got %s (%s)" % (len(recs), recs) 58 | chile_ings = rd.get_ings(recs[0]) 59 | print("chile_ings=", chile_ings) 60 | assert_equal(chile_ings[0].amount, 2) 61 | assert_equal(chile_ings[1].amount, 1) # second column 62 | assert_equal_ignorecase(chile_ings[1].ingkey, "eggs") 63 | assert_equal(chile_ings[1].item, "Eggs; separated") 64 | assert_equal_ignorecase(chile_ings[0].ingkey, "Chiles, calif.") 65 | assert_equal(recs[0].yields, 2) 66 | assert_equal(recs[0].yield_unit, "servings") 67 | assert_equal(recs[0].title, "Chiles Rellenos de Queso") 68 | assert_equal(chile_ings[5].item, "Tomatoes; peeled") 69 | assert_equal_ignorecase(chile_ings[5].inggroup, "Tomato Sauce") 70 | -------------------------------------------------------------------------------- /src/gourmand/importers/xml_importer.py: -------------------------------------------------------------------------------- 1 | import xml.sax 2 | import xml.sax.saxutils 3 | 4 | from gourmand.gdebug import TimeAction 5 | 6 | from . import importer 7 | 8 | 9 | def unquoteattr(str): 10 | return xml.sax.saxutils.unescape(str).replace("_", " ") 11 | 12 | 13 | class RecHandler(xml.sax.ContentHandler, importer.Importer): 14 | def __init__(self, total=None, conv=None, parent_thread=None): 15 | self.elbuf = "" 16 | xml.sax.ContentHandler.__init__(self) 17 | importer.Importer.__init__(self, total=total, do_markup=True, conv=conv) 18 | self.parent_thread = parent_thread 19 | self.check_for_sleep = parent_thread.check_for_sleep 20 | self.terminate = parent_thread.terminate 21 | self.resume = parent_thread.resume 22 | self.suspend = parent_thread.suspend 23 | self.emit = parent_thread.emit 24 | 25 | def characters(self, ch): 26 | self.elbuf += ch 27 | 28 | 29 | class Converter(importer.Importer): 30 | def __init__(self, filename, recHandler, recMarker=None, conv=None, name="XML Importer"): 31 | """Initialize an XML converter which will use recHandler to parse data. 32 | 33 | filename - our file to parse (or the name of the file). 34 | 35 | rd - our recdata object. 36 | 37 | recHandler - our recHandler class. 38 | 39 | recMarker - a string that identifies a recipe, so we can 40 | quickly count total recipes and update users as to progress) 41 | and our recHandler class. 42 | 43 | We expect subclasses effectively to call as as we are with 44 | their own recHandlers. 45 | """ 46 | 47 | self.recMarker = recMarker 48 | self.fn = filename 49 | self.rh = recHandler(conv=conv, parent_thread=self) 50 | self.added_ings = self.rh.added_ings 51 | self.added_recs = self.rh.added_recs 52 | self.terminate = self.rh.terminate 53 | self.suspend = self.rh.suspend 54 | self.resume = self.rh.resume 55 | importer.Importer.__init__(self, name=name) 56 | 57 | def do_run(self): 58 | # count the recipes in the file 59 | t = TimeAction("rxml_to_metakit.run counting lines", 0) 60 | if isinstance(self.fn, str): 61 | # Latin-1 can decode any bytes, letting us open ASCII-compatible 62 | # text files and sniff their contents - e.g. for XML tags - 63 | # without worrying about their real text encoding. 64 | f = open(self.fn, "r", encoding="latin1") 65 | else: 66 | f = self.fn 67 | recs = 0 68 | for line in f.readlines(): 69 | if line.find(self.recMarker) >= 0: 70 | recs += 1 71 | if recs % 5 == 0: 72 | self.check_for_sleep() 73 | f.close() 74 | t.end() 75 | self.rh.total = recs 76 | self.parse = xml.sax.parse(self.fn, self.rh) 77 | self.added_ings = self.rh.added_ings 78 | self.added_recs = self.rh.added_recs 79 | importer.Importer._run_cleanup_(self.rh) 80 | -------------------------------------------------------------------------------- /src/gourmand/exporters/printer.py: -------------------------------------------------------------------------------- 1 | import gettext 2 | 3 | import gourmand.plugin_loader as plugin_loader 4 | from gourmand.gtk_extras.dialog_extras import show_message, show_traceback 5 | from gourmand.i18n import _ 6 | from gourmand.plugin import PrinterPlugin 7 | 8 | ERR_NO_PLUGIN_MSG = "To print, activate a plugin that provides printing, " "such as the 'Printing & PDF export' plugin." 9 | 10 | 11 | class NoRecRenderer: 12 | 13 | def __init__(self, *args, **kwargs): 14 | from gourmand.gtk_extras.dialog_extras import show_message 15 | 16 | show_message(label=_("Unable to print: no print plugins are active!"), sublabel=_(ERR_NO_PLUGIN_MSG)) 17 | raise NotImplementedError 18 | 19 | 20 | class NoSimpleWriter: 21 | 22 | def __init__(self, *args, **kwargs): 23 | from gourmand.gtk_extras.dialog_extras import show_message 24 | 25 | show_message(label=_("Unable to print: no print plugins are active!"), sublabel=_(ERR_NO_PLUGIN_MSG)) 26 | raise NotImplementedError 27 | 28 | 29 | class PrintManager(plugin_loader.Pluggable): 30 | 31 | __single = None 32 | 33 | @classmethod 34 | def instance(cls): 35 | if not PrintManager.__single: 36 | PrintManager.__single = PrintManager() 37 | 38 | return PrintManager.__single 39 | 40 | def __init__(self): 41 | self.sws = [(-1, NoSimpleWriter)] 42 | self.rrs = [(-1, NoRecRenderer)] 43 | plugin_loader.Pluggable.__init__(self, [PrinterPlugin]) 44 | 45 | def register_plugin(self, plugin): 46 | assert isinstance(plugin.simpleWriterPriority, int) 47 | assert plugin.SimpleWriter 48 | self.sws.append((plugin.simpleWriterPriority, plugin.SimpleWriter)) 49 | assert isinstance(plugin.recWriterPriority, int) 50 | assert plugin.RecWriter 51 | self.rrs.append((plugin.recWriterPriority, plugin.RecWriter)) 52 | 53 | def unregister_plugin(self, plugin): 54 | self.sws.remove(plugin.simpleWriterPriority, plugin.SimpleWriter) 55 | self.rrs.remove(plugin.recWriterPriority, plugin.RecWriter) 56 | 57 | def get_simple_writer(self): 58 | self.sws.sort() 59 | return self.sws[-1][1] 60 | 61 | def get_rec_renderer(self): 62 | self.rrs.sort() 63 | return self.rrs[-1][1] 64 | 65 | def print_recipes(self, rd, recs, parent=None, change_units=None, **kwargs): 66 | renderer = self.get_rec_renderer() 67 | if len(recs) == 1: 68 | title = f'Print recipe "{recs[0].title}"' 69 | else: 70 | title = gettext.ngettext("Print %s recipe", "Print %s recipes", len(recs)) % len(recs) 71 | try: 72 | renderer(rd, recs, dialog_title=title, dialog_parent=parent, change_units=change_units, **kwargs) 73 | except Exception: 74 | msg = "Well this is embarassing." "Something went wrong printing your recipe." 75 | show_traceback(label="Error printing", sublabel=_(msg)) 76 | 77 | def show_error(self, *args): 78 | show_message(sublabel="There was an error printing. Apologies") 79 | -------------------------------------------------------------------------------- /src/gourmand/plugins/email_plugin/recipe_emailer.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os.path 3 | import tempfile 4 | 5 | import gourmand.exporters.exporter as exporter 6 | from gourmand.exporters.exportManager import ExportManager 7 | from gourmand.i18n import _ 8 | from gourmand.main import get_application 9 | 10 | from .emailer import Emailer 11 | 12 | 13 | class StringIOfaker(io.StringIO): 14 | def __init__(self, *args, **kwargs): 15 | io.StringIO.__init__(self, *args, **kwargs) 16 | 17 | def close(self, *args): 18 | pass 19 | 20 | def close_really(self): 21 | io.StringIO.close(self) 22 | 23 | 24 | class RecipeEmailer(Emailer): 25 | def __init__(self, recipes, attachment_types=["pdf"], do_text=True): 26 | Emailer.__init__(self) 27 | self.attachments_left = self.attachment_types = list(attachment_types) 28 | self.attached = [] 29 | self.recipes = recipes 30 | self.rg = get_application() 31 | self.rd = self.rg.rd 32 | self.change_units = self.rg.prefs.get("readableUnits", True) 33 | if len(recipes) > 1: 34 | self.subject = _("Recipes") 35 | elif recipes: 36 | self.subject = recipes[0].title 37 | 38 | def write_email_text(self): 39 | s = StringIOfaker() 40 | e = exporter.ExporterMultirec(self.rd, self.recipes, s, padding="\n\n-----\n") 41 | e.run() 42 | if not self.body: 43 | self.body = "" 44 | self.body += s.getvalue() 45 | s.close_really() 46 | 47 | def write_attachments(self): 48 | em = ExportManager.instance() 49 | for typ in self.attachment_types: 50 | name = _("Recipes") 51 | if len(self.recipes) == 1: 52 | name = self.recipes[0].title.replace(":", "-").replace("\\", "-").replace("/", "-") 53 | fn = os.path.join(tempfile.gettempdir(), "%s.%s" % (name, typ)) 54 | self.attachments.append(fn) 55 | instance = em.do_multiple_export(self.recipes, fn) 56 | instance.connect("completed", self.attachment_complete, typ) 57 | print("Start thread to create ", typ, "!", "->", fn) 58 | 59 | def attachment_complete(self, thread, typ): 60 | self.attachments_left.remove(typ) 61 | if not self.attachments_left: 62 | print("Attachments complete! Send email!") 63 | self.send_email() 64 | 65 | def send_email_with_attachments(self, emailaddress=None): 66 | if emailaddress: 67 | self.emailaddress = emailaddress 68 | self.write_email_text() 69 | self.write_attachments() 70 | 71 | # def send_email_html (self, emailaddress=None, include_plain_text=True): 72 | # if include_plain_text: self.write_email_text() 73 | # else: self.body = None 74 | # if emailaddress: self.emailaddress=emailaddress 75 | # self.write_email_html() 76 | # self.send_email() 77 | 78 | def send_email_text(self, emailaddress=None): 79 | if emailaddress: 80 | self.emailaddress = emailaddress 81 | self.write_email_text() 82 | self.send_email() 83 | -------------------------------------------------------------------------------- /src/gourmand/plugins/duplicate_finder/recipeMergerPlugin.py: -------------------------------------------------------------------------------- 1 | from gi.repository import Gtk 2 | 3 | from gourmand.i18n import _ 4 | from gourmand.plugin import ImportManagerPlugin, ToolPlugin 5 | 6 | # from gourmand.gglobals import gt # for threading protection on import 7 | # # hooks 8 | from gourmand.plugin_loader import PRE 9 | 10 | from . import recipeMerger 11 | 12 | 13 | class RecipeMergerImportManagerPlugin(ImportManagerPlugin): 14 | 15 | def activate(self, pluggable): 16 | pluggable.add_hook(PRE, "follow_up", self.follow_up_pre_hook) 17 | 18 | def follow_up_pre_hook(self, importManager, threadmanager, importer): 19 | print("Running recipeMergerPlugin follow up post hook!") 20 | if importer.added_recs: 21 | print("There are ", len(importer.added_recs), "added recs!") 22 | rmd = recipeMerger.RecipeMergerDialog( 23 | in_recipes=importer.added_recs, 24 | ) 25 | rmd.show_if_there_are_dups( 26 | label=_("Some of the imported recipes appear to be duplicates. You can merge them here, or close this dialog to leave them as they are.") 27 | ) 28 | return [threadmanager, importer], {} 29 | 30 | 31 | class RecipeMergerPlugin(ToolPlugin): 32 | 33 | menu_items = """ 34 | 35 | 36 | """ 37 | 38 | menu_bars = ["RecipeIndexMenuBar"] 39 | 40 | def activate(self, pluggable): 41 | ToolPlugin.activate(self, pluggable) 42 | pluggable.add_hook(PRE, "import_cleanup", self.import_cleanup_hook) 43 | 44 | def deactivate(self, pluggable): 45 | if hasattr(self, "pluggable"): 46 | pluggable.remove_hook(PRE, "import_cleanup", self.import_cleanup_hook) 47 | 48 | def remove(self): 49 | if hasattr(self, "pluggable"): 50 | self.pluggable.remove_hook(PRE, "import_cleanup", self.import_cleanup_hook) 51 | ToolPlugin.remove(self) 52 | 53 | def import_cleanup_hook(self, rg, retval, *args, **kwargs): 54 | # Check for duplicates 55 | # gt.gtk_enter() 56 | if rg.last_impClass and rg.last_impClass.added_recs: 57 | rmd = recipeMerger.RecipeMergerDialog(rg.rd, in_recipes=rg.last_impClass.added_recs, on_close_callback=lambda *args: rg.redo_search()) 58 | rmd.show_if_there_are_dups( 59 | label=_("Some of the imported recipes appear to be duplicates. You can merge them here, or close this dialog to leave them as they are.") 60 | ) 61 | # gt.gtk_leave() 62 | 63 | def setup_action_groups(self): 64 | self.action_group = Gtk.ActionGroup(name="RecipeMergerPluginActionGroup") 65 | self.action_group.add_actions( 66 | [("DuplicateMerger", None, _("Find _duplicate recipes"), None, _("Find and remove duplicate recipes"), self.show_duplicate_merger)] 67 | ) 68 | self.action_groups.append(self.action_group) 69 | 70 | def show_duplicate_merger(self, *args): 71 | rmd = recipeMerger.RecipeMergerDialog(self.pluggable.rg.rd, on_close_callback=lambda *args: self.pluggable.rg.redo_search()) 72 | rmd.populate_tree_if_possible() 73 | rmd.show() 74 | -------------------------------------------------------------------------------- /src/gourmand/exporters/clipboard_exporter.py: -------------------------------------------------------------------------------- 1 | """Export a recipe to the system's clipboard or via drag and drop. 2 | 3 | This plugin demonstrates how to create an export plugin. 4 | """ 5 | 6 | from gi.repository import Gdk, Gtk 7 | 8 | 9 | def _format(recipes): 10 | """Format recipes as a string. 11 | 12 | The expected list should contain tupes of (recipe, ingredients) that 13 | belong together. 14 | """ 15 | # recipes = List[Tuple["RowProxy", "RowProxy"]] 16 | formatted_recipes = [] 17 | 18 | # Each item in self.recipes is a set of (a recipe, its ingredients). 19 | for recipe, ingredients in recipes: 20 | 21 | # The ingredients have the name, quantity, and units attached 22 | formatted_ingredients = [] 23 | for ingredient in ingredients: 24 | string = f"{ingredient.amount} " if ingredient.amount else "" 25 | string += f"{ingredient.unit} " if ingredient.unit else "" 26 | string += ingredient.item 27 | formatted_ingredients.append(string) 28 | 29 | formatted_ingredients = "\n".join(formatted_ingredients) 30 | 31 | # Now that the ingredients are formatted, the title, yield, 32 | # description etc. can be extracted. 33 | # The rating, for instance, is omitted: let the recipient make 34 | # their opinion! 35 | formatted_recipe = f"# {recipe.title}\n\n" 36 | formatted_recipe += f"{recipe.source}\n" if recipe.source else "" 37 | formatted_recipe += f"{recipe.link}\n" if recipe.link else "" 38 | formatted_recipe += "\n" if recipe.source or recipe.link else "" 39 | formatted_recipe += f"{recipe.yields} {recipe.yield_unit}\n\n" if recipe.yields else "" 40 | 41 | formatted_recipe += f"{recipe.description}\n\n" if recipe.description else "" 42 | formatted_recipe += f"{formatted_ingredients}\n\n" 43 | 44 | formatted_recipe += f"{recipe.instructions}\n" 45 | 46 | formatted_recipes.append(formatted_recipe) 47 | 48 | # Join all the recipes as one string. 49 | formatted_recipes = "\n\n".join(formatted_recipes) 50 | 51 | # Although not used here, the image can also be retrieved. 52 | # They are stored as jpegs in the database: 53 | # if recipe.image is not None: 54 | # image_filename = self.export_path / f'{recipe.title}.jpg' 55 | # with open(image_filename, 'wb') as fout: 56 | # fout.write(recipe.image) 57 | 58 | return formatted_recipes 59 | 60 | 61 | def copy_to_clipboard(recipes): 62 | # recipes = List[Tuple["RowProxy", "RowProxy"]] 63 | clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) 64 | formatted_recipes = _format(recipes) 65 | clipboard.set_text(formatted_recipes, -1) 66 | 67 | 68 | def copy_to_drag(recipes, widget: Gtk.TreeView, drag_context: Gdk.DragContext, data: Gtk.SelectionData, info: int, time: int) -> bool: 69 | """Export recipes to text via drag and drop. 70 | 71 | This function is expected to be connected to a signal. 72 | If so, the signal will be done being handled here. 73 | """ 74 | # recipes = List[Tuple["RowProxy", "RowProxy"]] 75 | if info == 0: # Only support text export 76 | data.set_text(_format(recipes), -1) 77 | return True # Done handling signal 78 | -------------------------------------------------------------------------------- /tests/test_interactive_importer.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from bs4 import BeautifulSoup 4 | 5 | from gourmand.importers import interactive_importer 6 | 7 | 8 | class TestGetImages(unittest.TestCase): 9 | 10 | def test_get_images(self): 11 | html = """ 12 | 13 | 14 | 15 |
16 | 17 | 20 | 21 | 22 | Turismo de Tenerife 24 | 25 | 30 |
31 | 32 | 33 | """ 34 | soup = BeautifulSoup(html, "html.parser") 35 | 36 | images = list(interactive_importer._get_images(soup)) 37 | self.assertListEqual(["https://www.webtenerifefr.com/-/media/project/webtenerife/common/logos_tenerife/tenerife_logo_degradado_fra.svg"], images) 38 | 39 | 40 | class TestConvenientImporter(unittest.TestCase): 41 | 42 | def setUp(self): 43 | self.ci = interactive_importer.ConvenientImporter() 44 | 45 | def test_import(self): 46 | self.ci.start_rec() 47 | self.ci.add_attribute("title", "Test") 48 | self.ci.add_attribute("category", "foo") 49 | self.ci.add_attribute("category", "bar") 50 | self.ci.add_ings_from_text( 51 | """6 garlic cloves, peeled 52 | 1/2 pound linguine 53 | 1/4 cup plus 1 tablespoon olive oil 54 | 2 to 2 1/2 pounds small fresh squid (about 10), cleaned and cut into 3/4-inch thick rings, tentacles cut in half* 55 | 1 1/2 teaspoons Baby Bam or Emeril's Original Essence, to taste 56 | 1/4 cup chopped green onions 57 | 1 teaspoon crushed red pepper, or to taste 58 | 1/4 teaspoon salt 59 | 1/4 cup fish stock, shrimp stock, or water 60 | 2 tablespoons fresh lemon juice 61 | 1 tablespoon unsalted butter 62 | 1/4 cup chopped fresh parsley leaves 63 | 1/2 cup freshly grated Parmesan""" 64 | ) 65 | self.ci.commit_rec() 66 | rec = self.ci.added_recs[-1] 67 | self.assertEqual(rec.title, "Test") 68 | cats = self.ci.rd.get_cats(rec) 69 | cats.sort() 70 | self.assertEqual(len(cats), 2) 71 | self.assertEqual(cats[0], "bar") 72 | self.assertEqual(cats[1], "foo") 73 | ings = self.ci.rd.get_ings(rec) 74 | self.assertEqual(len(ings), 13) 75 | self.assertEqual(ings[1].amount, 0.5) 76 | self.assertEqual(ings[1].unit, "pound") 77 | self.assertEqual(ings[1].item, "linguine") 78 | -------------------------------------------------------------------------------- /src/gourmand/recipeManager.py: -------------------------------------------------------------------------------- 1 | from gourmand import convert, shopping 2 | 3 | from . import gglobals 4 | from .backends.db import RecipeManager, dbDic 5 | from .optionparser import args 6 | 7 | # Follow commandline db specification if given 8 | dbargs = {} 9 | 10 | if "file" not in dbargs: 11 | dbargs["file"] = gglobals.gourmanddir / "recipes.db" 12 | if args.db_url: 13 | print("We have a db_url and it is,", args.db_url) 14 | dbargs["custom_url"] = args.db_url 15 | 16 | 17 | class DatabaseShopper(shopping.Shopper): 18 | """We are a Shopper class that conveniently saves our key dictionaries 19 | in our database""" 20 | 21 | def __init__(self, lst, db, conv=None): 22 | self.db = db 23 | self.cnv = conv 24 | shopping.Shopper.__init__(self, lst) 25 | 26 | def init_converter(self): 27 | # self.cnv = DatabaseConverter(self.db) 28 | if not self.cnv: 29 | self.cnv = convert.get_converter() 30 | 31 | def init_orgdic(self): 32 | self.orgdic = dbDic("ingkey", "shopcategory", self.db.shopcats_table, db=self.db) 33 | if len(list(self.orgdic.items())) == 0: 34 | dic = shopping.setup_default_orgdic() 35 | self.orgdic.initialize(dic) 36 | 37 | def init_ingorder_dic(self): 38 | self.ingorder_dic = dbDic("ingkey", "position", self.db.shopcats_table, db=self.db) 39 | 40 | def init_catorder_dic(self): 41 | self.catorder_dic = dbDic("shopcategory", "position", self.db.shopcatsorder_table, db=self.db) 42 | 43 | def init_pantry(self): 44 | self.pantry = dbDic("ingkey", "pantry", self.db.pantry_table, db=self.db) 45 | if len(self.pantry.items()) == 0: 46 | self.pantry.initialize(dict([(i, True) for i in self.default_pantry])) 47 | 48 | 49 | # A simple CLI for mucking about our DB without firing up gourmet proper 50 | class SimpleCLI: 51 | def __init__(self, rmclass=None, rmargs=None): 52 | if not rmclass: 53 | self.rmclass = RecipeManager 54 | else: 55 | self.rmclass = rmclass 56 | if not rmargs: 57 | self.args = dbargs 58 | else: 59 | self.args = rmargs 60 | self.rm = self.rmclass(**self.args) 61 | 62 | def __call__(self): 63 | print( 64 | """Welcome to GRM's handy debugging interface straight to our database. 65 | You are now in the midst of our caller class. You can access your recipeManager 66 | class through self.rm. 67 | 68 | One major limitation: You can only execute a single expression 69 | at a time (i.e. what you you could put in a lambda expression). 70 | """ 71 | ) 72 | while True: 73 | inp = input("GRM>") 74 | if inp == "quit" or inp == "" or inp == "": 75 | break 76 | else: 77 | try: 78 | print("result: %s" % eval(inp)) 79 | except Exception: 80 | print("invalid input.") 81 | 82 | 83 | def get_recipe_manager(**kwargs): 84 | return RecipeManager.instance_for(**kwargs) 85 | 86 | 87 | def default_rec_manager(): 88 | return get_recipe_manager(**dbargs) 89 | 90 | 91 | if __name__ == "__main__": 92 | # rm = RecipeManager(**dbargs) 93 | rm = RecipeManager(file="/tmp/0112/recipes.db") 94 | # s=SimpleCLI() 95 | # s() 96 | -------------------------------------------------------------------------------- /src/gourmand/importers/rezkonv_importer.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import mealmaster_importer 4 | import plaintext_importer 5 | 6 | import gourmand.convert as convert 7 | from gourmand.gdebug import TimeAction, debug 8 | 9 | 10 | class rezconf_constants(mealmaster_importer.mmf_constants): 11 | def __init__(self): 12 | mealmaster_importer.mmf_constants.__init__(self) 13 | for k, v in list( 14 | { 15 | "Titel": "title", 16 | "Kategorien": "category", 17 | "Menge": "servings", 18 | }.items() 19 | ): 20 | self.recattrs[k] = v 21 | for k, v in list({}.items()): 22 | self.unit_conv[k] = v 23 | self.unit_convr = {} 24 | for k, v in list(self.unit_conv.items()): 25 | self.unit_convr[v] = k 26 | 27 | 28 | rzc = rezconf_constants() 29 | rzc_start_pattern = r"^(?i)([m=-][m=-][m=-][m=-][m=-]+)-*\s*(rezkonv).*" 30 | 31 | 32 | class rezkonv_importer(mealmaster_importer.mmf_importer): 33 | # with long German words, you can end up with short lines in the middle 34 | # of a block of text, so we'll shorten the length at which we assume 35 | # a short line means the end of a paragraph. 36 | end_paragraph_length = 45 37 | 38 | def compile_regexps(self): 39 | """Compile our regular expressions for the rezkonv format.""" 40 | testtimer = TimeAction("mealmaster_importer.compile_regexps", 10) 41 | debug("start compile_regexps", 5) 42 | plaintext_importer.TextImporter.compile_regexps(self) 43 | self.start_matcher = re.compile(rzc_start_pattern) 44 | self.end_matcher = re.compile(r"^[=M-][=M-][=M-][=M-][=M-]\s*$") 45 | self.group_matcher = re.compile(r"^\s*([=M-][=M-][=M-][=M-][=M-]+)-*\s*([^-]+)\s*-*", re.IGNORECASE) 46 | self.ing_cont_matcher = re.compile(r"^\s*[-;]") 47 | self.ing_opt_matcher = re.compile(r"(.+?)\s*\(?\s*optional\)?\s*$", re.IGNORECASE) 48 | # or or the German, oder 49 | self.ing_or_matcher = re.compile(r"^[-= ]*[Oo][dD]?[eE]?[Rr][-= ]*$", re.IGNORECASE) 50 | self.variation_matcher = re.compile(r"^\s*(VARIATION|HINT|NOTES?|VERÄNDERUNG|VARIANTEN|TIPANMERKUNGEN)(:.*)?", re.IGNORECASE) 51 | # a crude ingredient matcher -- we look for two numbers, intermingled with spaces 52 | # followed by a space or more, followed by a two digit unit (or spaces) 53 | self.ing_num_matcher = re.compile( 54 | r"^\s*%(top)s%(num)s+\s+[A-Za-z ][A-Za-z ]? .*" % {"top": convert.DIVIDEND_REGEXP, "num": convert.NUMBER_REGEXP}, re.IGNORECASE 55 | ) 56 | self.amt_field_matcher = convert.NUMBER_MATCHER 57 | # we build a regexp to match anything that looks like 58 | # this: ^\s*ATTRIBUTE: Some entry of some kind...$ 59 | attrmatch = r"^\s*(" 60 | self.mmf = rzc 61 | for k in list(self.mmf.recattrs.keys()): 62 | attrmatch += "%s|" % re.escape(k) 63 | attrmatch = r"%s):\s*(.*)\s*$" % attrmatch[0:-1] 64 | self.attr_matcher = re.compile(attrmatch) 65 | testtimer.end() 66 | 67 | def is_ingredient(self, line): 68 | """Return true if the line looks like an ingredient.""" 69 | if self.ing_num_matcher.match(line): 70 | return True 71 | if len(line) >= 5 and self.blank_matcher.match(line[0:2]): 72 | return True 73 | -------------------------------------------------------------------------------- /src/gourmand/plugins/import_export/gxml_plugin/gxml2_importer.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import xml.sax 3 | import xml.sax.saxutils 4 | 5 | from gourmand.convert import NUMBER_FINDER 6 | from gourmand.gglobals import REC_ATTRS, TEXT_ATTR_DIC 7 | from gourmand.importers import xml_importer 8 | 9 | 10 | class RecHandler(xml_importer.RecHandler): 11 | ING_ATTRS = { 12 | # XML : DATABASE COLUMN 13 | "item": "item", 14 | "unit": "unit", 15 | "amount": "amount", 16 | "key": "ingkey", 17 | } 18 | 19 | def __init__(self, total=None, conv=None, parent_thread=None): 20 | xml_importer.RecHandler.__init__(self, total, conv=conv, parent_thread=parent_thread) 21 | self.REC_ATTRS = [r[0] for r in REC_ATTRS] 22 | self.REC_ATTRS += [r for r in list(TEXT_ATTR_DIC.keys())] 23 | 24 | def startElement(self, name, attrs): 25 | self.elbuf = "" 26 | if name == "recipe": 27 | id = attrs.get("id", None) 28 | if id: 29 | self.start_rec(dict={"id": id}) 30 | else: 31 | self.start_rec() 32 | 33 | if name == "ingredient": 34 | self.start_ing(recipe_id=self.rec["id"]) 35 | if attrs.get("optional", False): 36 | if attrs.get("optional", False) not in ["no", "No", "False", "false", "None"]: 37 | self.ing["optional"] = True 38 | if name == "ingref": 39 | self.start_ing(id=self.rec["id"]) 40 | self.add_ref(unquoteattr(attrs.get("refid"))) 41 | self.add_amt(unquoteattr(attrs.get("amount"))) 42 | 43 | def endElement(self, name): 44 | if name == "recipe": 45 | self.commit_rec() 46 | elif name == "groupname": 47 | self.group = xml.sax.saxutils.unescape(self.elbuf.strip()) 48 | elif name == "inggroup": 49 | self.group = None 50 | elif name == "ingref": 51 | self.add_item(xml.sax.saxutils.unescape(self.elbuf.strip())) 52 | self.commit_ing() 53 | elif name == "ingredient": 54 | self.commit_ing() 55 | elif name == "image": 56 | self.rec["image"] = base64.b64decode(self.elbuf.strip()) 57 | elif name == "yields": 58 | txt = xml.sax.saxutils.unescape(self.elbuf.strip()) 59 | match = NUMBER_FINDER.search(txt) 60 | if match: 61 | number = txt[match.start() : match.end()] 62 | unit = txt[match.end() :].strip() 63 | self.rec["yields"] = number 64 | self.rec["yield_unit"] = unit 65 | else: 66 | self.rec["yields"] = 1 67 | self.rec["yield_unit"] = unit 68 | print("Warning, recorded", txt, "as 1 ", unit) 69 | elif name in self.REC_ATTRS: 70 | if name in ['instructions', 'modifications']: 71 | self.rec[str(name)] = self.elbuf.strip() 72 | else: 73 | self.rec[str(name)] = xml.sax.saxutils.unescape(self.elbuf.strip()) 74 | elif name in list(self.ING_ATTRS.keys()): 75 | self.ing[str(self.ING_ATTRS[name])] = xml.sax.saxutils.unescape(self.elbuf.strip()) 76 | 77 | 78 | class Converter(xml_importer.Converter): 79 | 80 | def __init__(self, filename, conv=None): 81 | xml_importer.Converter.__init__(self, filename, RecHandler, recMarker="", conv=conv, name="GXML2 Importer") 82 | 83 | 84 | def unquoteattr(str): 85 | return xml.sax.saxutils.unescape(str).replace("_", " ") 86 | -------------------------------------------------------------------------------- /src/gourmand/plugins/nutritional_information/shopping_plugin.py: -------------------------------------------------------------------------------- 1 | from gi.repository import Gtk 2 | 3 | import gourmand.main 4 | import gourmand.recipeManager 5 | from gourmand.i18n import _ 6 | from gourmand.plugin import ShoppingListPlugin 7 | from gourmand.prefs import Prefs 8 | 9 | from .nutritionLabel import NutritionLabel 10 | 11 | 12 | class ShoppingNutritionalInfoPlugin(ShoppingListPlugin): 13 | 14 | ui_string = """ 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | """ 26 | name = "shopping_nutritional_info" 27 | 28 | def setup_action_groups(self): 29 | self.nutritionShoppingActionGroup = Gtk.ActionGroup(name="NutritionShoppingActionGroup") 30 | self.nutritionShoppingActionGroup.add_actions( 31 | [ 32 | ("Tools", None, _("Tools")), 33 | ( 34 | "ShoppingNutritionalInfo", # name 35 | "nutritional-info", # stock 36 | _("Nutritional Information"), # label 37 | "N", # key-command 38 | _("Get nutritional information for current list"), 39 | self.show_nutinfo, # callback 40 | ), 41 | ] 42 | ) 43 | self.action_groups.append(self.nutritionShoppingActionGroup) 44 | 45 | def show_nutinfo(self, *args): 46 | sg = self.pluggable 47 | rr = sg.recs 48 | rd = gourmand.recipeManager.get_recipe_manager() 49 | if not hasattr(self, "nutrition_window"): 50 | self.create_nutrition_window() 51 | nutinfo = None 52 | # Add recipes... 53 | for rec in rr: 54 | ings = rd.get_ings(rec) 55 | ni = rd.nd.get_nutinfo_for_inglist(ings, rd) 56 | if nutinfo: 57 | nutinfo = nutinfo + ni 58 | else: 59 | nutinfo = ni 60 | # Add extras... 61 | for amt, unit, item in sg.extras: 62 | ni = rd.nd.get_nutinfo_for_item(item, amt, unit) 63 | if nutinfo: 64 | nutinfo = nutinfo + ni 65 | else: 66 | nutinfo = ni 67 | self.nl.set_nutinfo(nutinfo) 68 | self.nutrition_window.present() 69 | 70 | def create_nutrition_window(self): 71 | self.nutrition_window = Gtk.Dialog(_("Nutritional Information"), self.pluggable.w, buttons=(Gtk.STOCK_CLOSE, Gtk.ResponseType.CLOSE)) 72 | self.nutrition_window.set_default_size(400, 550) 73 | self.nutrition_window.set_icon(self.nutrition_window.render_icon("nutritional-info", Gtk.IconSize.MENU)) 74 | self.nl = NutritionLabel(Prefs.instance()) 75 | self.sw = Gtk.ScrolledWindow() 76 | self.sw.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) 77 | self.sw.add_with_viewport(self.nl) 78 | self.sw.show() 79 | self.nutrition_window.vbox.pack_start(self.sw, True, True, 0) 80 | self.nutrition_window.connect("response", self.response_cb) 81 | self.nutrition_window.connect("close", self.response_cb) 82 | self.nl.yieldLabel.set_markup("" + _("Amount for Shopping List") + "") 83 | self.nl.show() 84 | 85 | def response_cb(self, *args): 86 | # We only allow one response -- closing the window! 87 | self.nutrition_window.hide() 88 | -------------------------------------------------------------------------------- /src/gourmand/plugins/nutritional_information/nutritionGrabberGui.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from gi.repository import Gtk 4 | 5 | import gourmand.gtk_extras.dialog_extras as de 6 | from gourmand.i18n import _ 7 | 8 | from . import databaseGrabber 9 | 10 | 11 | class DatabaseGrabberGui(databaseGrabber.DatabaseGrabber): 12 | def __init__(self, db): 13 | databaseGrabber.DatabaseGrabber.__init__(self, db, self.show_progress) 14 | self.paused = False 15 | self.terminated = False 16 | 17 | def pausecb(self, button, *args): 18 | if button.get_active(): 19 | self.paused = True 20 | else: 21 | self.paused = False 22 | 23 | def stopcb(self, *args): 24 | self.terminated = True 25 | 26 | def load_db(self): 27 | # filename=None 28 | # if de.getBoolean( 29 | # label=_('Load nutritional database.'), 30 | # sublabel=_("It looks like you haven\'t yet initialized your nutritional database. To do so, you'll need to download the USDA nutritional database for use with your program. If you are not currently online, but have already downloaded the USDA sr17 database, you can point Gourmand to the ABBREV.txt file now. If you are online, Gourmand can download the file automatically."), # noqa: E501 31 | # custom_yes=_('Browse for ABBREV.txt file'), 32 | # custom_no=_('Download file automatically')): 33 | # filename=de.select_file( 34 | # 'Find ABBREV.txt file', 35 | # filters=[['Plain Text',['text/plain'],['*txt']]] 36 | # ) 37 | self.progdialog = de.ProgressDialog(label=_("Loading Nutritional Data"), pause=self.pausecb, stop=self.stopcb) 38 | self.progdialog.show() 39 | self.grab_data() 40 | self.show_progress(1, _("Nutritonal database import complete!")) 41 | self.progdialog.set_response_sensitive(Gtk.ResponseType.OK, True) 42 | self.progdialog.hide() 43 | 44 | def show_progress(self, fract, msg): 45 | self.progdialog.progress_bar.set_fraction(fract) 46 | self.progdialog.progress_bar.set_text(msg) 47 | while self.paused: 48 | time.sleep(0.1) 49 | self.gui_update() 50 | self.gui_update() 51 | 52 | def gui_update(self): 53 | if self.terminated: 54 | raise Exception("Terminated!") 55 | while Gtk.events_pending(): 56 | Gtk.main_iteration() 57 | 58 | def get_zip_file(self): 59 | self.show_progress(0.01, _("Fetching nutritional database from zip archive %s") % self.USDA_ZIP_URL) 60 | return databaseGrabber.DatabaseGrabber.get_zip_file(self) 61 | 62 | def get_abbrev_from_url(self): 63 | self.show_progress(0.05, _("Extracting %s from zip archive.") % self.ABBREV_FILE_NAME) 64 | return databaseGrabber.DatabaseGrabber.get_abbrev_from_url(self) 65 | 66 | 67 | def check_for_db(db): 68 | if not hasattr(db, "nutrition_table"): 69 | return 70 | if db.fetch_len(db.nutrition_table) < 10: 71 | print("Grabbing nutrition database!") 72 | dgg = DatabaseGrabberGui(db) 73 | dgg.load_db() 74 | # Check if we have choline in our DB... butter (1123) has choline... 75 | elif not db.fetch_one(db.nutrition_table, ndbno=1123).choline: 76 | dgg = DatabaseGrabberGui(db) 77 | dgg.load_db() 78 | 79 | 80 | if __name__ == "__main__": 81 | import gourmand.recipeManager 82 | 83 | print("loading db") 84 | db = gourmand.recipeManager.RecipeManager(**gourmand.recipeManager.dbargs) 85 | print("checking for nutrition_table") 86 | check_for_db(db) 87 | -------------------------------------------------------------------------------- /src/gourmand/plugins/import_export/gxml_plugin/gxml_importer_plugin.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import os.path 5 | 6 | from gi.repository import Pango 7 | 8 | from gourmand.i18n import _ 9 | from gourmand.importers.importer import Tester 10 | from gourmand.plugin import ImporterPlugin 11 | from gourmand.recipeManager import get_recipe_manager 12 | 13 | from . import gxml2_importer, gxml_importer 14 | 15 | test_dir = os.path.split(__file__)[0] # our directory src/lib/plugins/import_export/plugin/*/ 16 | test_dir = os.path.split(test_dir)[0] # one back... src/lib/plugins/import_export/plugin/ 17 | test_dir = os.path.split(test_dir)[0] # one back... src/lib/plugins/import_export/ 18 | test_dir = os.path.split(test_dir)[0] # one back... src/lib/plugins/ 19 | test_dir = os.path.join(test_dir, "tests", "recipe_files") 20 | 21 | 22 | class GxmlImportTester: 23 | 24 | def __init__(self): 25 | self.rm = get_recipe_manager() 26 | 27 | def run_test(self, recipe_objects, filename): 28 | if filename.endswith("test_set.grmt"): 29 | self.run_test_set_test(recipe_objects) 30 | 31 | def run_test_set_test(self, recs): 32 | assert "Amazing rice" in [r.title for r in recs], "Titles were: %s" % ([r.title for r in recs]) 33 | rice = recs[0] 34 | if rice.title != "Amazing rice": 35 | rice = recs[1] 36 | sauce = recs[0] 37 | else: 38 | sauce = recs[1] 39 | assert sauce.source == "Tom's imagination", "value was %s" % sauce.source 40 | assert sauce.link == "http://slashdot.org", "value was %s" % sauce.link 41 | ings = self.rm.get_ings(rice) 42 | assert ings[1].refid == sauce.id, "Ingredient reference did not export properly" 43 | sings = self.rm.get_ings(sauce) 44 | assert sings[1].inggroup == "veggies", "value was %s" % sings[0].inggroup 45 | assert sings[1].item == "jalapeño peppers", 'value was "%s",%s' % (sings[1].item, type(sings[1].item)) 46 | self.is_markup_valid(sauce) 47 | self.is_markup_valid(rice) 48 | assert "well" in sauce.instructions, "value was %s" % sauce.instructions 49 | assert sauce.image 50 | assert sauce.thumb 51 | 52 | def is_markup_valid(self, rec): 53 | Pango.parse_markup(rec.instructions or "") 54 | Pango.parse_markup(rec.modifications or "") 55 | 56 | 57 | class GourmetXML2Plugin(ImporterPlugin): 58 | 59 | name = _("Gourmet XML File") 60 | patterns = ["*.xml", "*.grmt", "*.gourmet"] 61 | mimetypes = ["text/xml", "application/xml", "text/plain"] 62 | 63 | def test_file(self, filename): 64 | return Tester(".* ]").test(filename) 65 | 66 | def get_importer(self, filename): 67 | return gxml2_importer.Converter(filename) 68 | 69 | 70 | class GourmetXMLPlugin(ImporterPlugin): 71 | 72 | name = _("Gourmet XML File (Obsolete)") 73 | patterns = ["*.xml", "*.grmt", "*.gourmet"] 74 | mimetypes = ["text/xml", "application/xml", "text/plain"] 75 | 76 | def test_file(self, filename): 77 | return Tester(".* ]").test(filename) 78 | 79 | def get_importer(self, filename): 80 | return gxml_importer.Converter(filename) 81 | 82 | def get_import_tests(self): 83 | """Return an alist with files to check and tester functions 84 | that will be run to test the imported recipes. The function 85 | will be called with the following signature 86 | 87 | tester(recipe_objects, filename, rd) 88 | """ 89 | return [ 90 | (os.path.join(test_dir, "test_set.grmt"), GxmlImportTester().run_test), 91 | ] 92 | -------------------------------------------------------------------------------- /src/gourmand/plugins/import_export/pdf_plugin/page_drawer.py: -------------------------------------------------------------------------------- 1 | from gi.repository import Gdk, Gtk 2 | 3 | 4 | class PageDrawer(Gtk.DrawingArea): 5 | 6 | def __init__(self, page_width=None, page_height=None, sub_areas=[], xalign=0.5, yalign=0.5): 7 | """Draw a page based on page areas given to us. 8 | 9 | The areas can be given in any scale they like. 10 | 11 | sub_areas are each (X1,Y1,WIDTH,HEIGHT) where the point defines 12 | the upper-left corner of the rectangle. 13 | 14 | """ 15 | self.xalign = xalign 16 | self.yalign = yalign 17 | Gtk.DrawingArea.__init__(self) 18 | self.gc = None # initialized in realize-event handler 19 | self.width = 0 # updated in size-allocate handler 20 | self.height = 0 # idem 21 | if page_width and page_height: 22 | self.set_page_area(page_width, page_height, sub_areas) 23 | self.connect("size-allocate", self.on_size_allocate) 24 | # self.connect('expose-event', self.on_expose_event) 25 | self.connect("realize", self.on_realize) 26 | 27 | def set_page_area(self, page_width, page_height, sub_areas=[]): 28 | self.xy_ratio = page_width / page_height 29 | self.areas = [] 30 | for x1, y1, w, h in sub_areas: 31 | width = float(w) / page_width 32 | height = float(h) / page_height 33 | x = float(x1) / page_width 34 | y = float(y1) / page_height 35 | self.areas.append((x, y, width, height)) 36 | 37 | def on_realize(self, widget): 38 | # TODO: refactor this function out 39 | # self.gc = widget.window.new_gc() 40 | # self.gc.set_line_attributes(3, Gdk.LINE_ON_OFF_DASH, 41 | # Gdk.CAP_ROUND, Gdk.JOIN_ROUND) 42 | pass 43 | 44 | def on_size_allocate(self, widget, allocation): 45 | self.width = allocation.width 46 | self.height = allocation.height 47 | 48 | def on_expose_event(self, widget, event): 49 | if not hasattr(self, "xy_ratio"): 50 | return 51 | # This is where the drawing takes place 52 | if self.xy_ratio * self.height > self.width: 53 | width = int(self.width * 0.9) 54 | height = int((self.width / self.xy_ratio) * 0.9) 55 | else: 56 | width = int(self.xy_ratio * self.height * 0.9) 57 | height = int(self.height * 0.9) 58 | xpadding = int((self.width - width) * self.xalign) 59 | ypadding = int((self.height - height) * self.yalign) 60 | self.gc.set_line_attributes(3, Gdk.LINE_SOLID, Gdk.CAP_BUTT, Gdk.JOIN_MITER) 61 | widget.window.draw_rectangle(self.gc, False, xpadding, ypadding, width, height) 62 | self.gc.set_line_attributes(1, Gdk.LINE_ON_OFF_DASH, Gdk.CAP_BUTT, Gdk.JOIN_MITER) 63 | for sub_area in self.areas: 64 | x, y, w, h = sub_area 65 | self.window.draw_rectangle(self.gc, False, int(xpadding + (x * width)), int(ypadding + (y * height)), int(w * width), int(h * height)) 66 | # widget.window.draw_line(self.gc, 67 | # 0, 0, self.width - 1, self.height - 1) 68 | # widget.window.draw_line(self.gc, 69 | # self.width - 1, 0, 0, self.height - 1) 70 | 71 | 72 | if __name__ == "__main__": 73 | w = Gtk.Window() 74 | w.add( 75 | PageDrawer( 76 | 8.5, 77 | 11, 78 | [ 79 | (1, 1, 3, 9.5), 80 | (4.5, 1, 3, 9.5), 81 | ], 82 | ) 83 | ) 84 | w.show_all() 85 | w.connect("delete-event", lambda *args: Gtk.main_quit()) 86 | Gtk.main() 87 | -------------------------------------------------------------------------------- /src/gourmand/batchEditor.py: -------------------------------------------------------------------------------- 1 | from pkgutil import get_data 2 | 3 | from gi.repository import Gtk 4 | 5 | from . import gglobals 6 | from .gtk_extras import cb_extras 7 | 8 | 9 | class BatchEditor: 10 | 11 | def __init__(self, rg): 12 | self.rg = rg 13 | self.setup_ui() 14 | 15 | def setup_ui(self): 16 | self.ui = Gtk.Builder() 17 | self.ui.add_from_string(get_data("gourmand", "ui/batchEditor.ui").decode()) 18 | self.dialog = self.ui.get_object("batchEditorDialog") 19 | self.setFieldWhereBlankButton = self.ui.get_object("setFieldWhereBlankButton") 20 | self.setup_boxes() 21 | self.dialog.connect("response", self.response_cb) 22 | 23 | def setup_boxes(self): 24 | self.attribute_widgets = {} 25 | self.get_data_methods = {} 26 | for a, ll, w in gglobals.REC_ATTRS: 27 | checkbutton = self.ui.get_object("%sCheckButton" % a) 28 | if checkbutton: 29 | setattr(self, "%sCheckButton" % a, checkbutton) 30 | box = self.ui.get_object("%sBox" % a) 31 | self.attribute_widgets[a] = box 32 | setattr(self, "%sBox" % a, box) 33 | checkbutton.connect("toggled", self.toggle_cb, a) 34 | box.set_sensitive(False) 35 | if w == "Combo": 36 | # If this is a combo box, we'll get info via the child's get_text method... 37 | self.get_data_methods[a] = (checkbutton, getattr(self, "%sBox" % a).get_children()[0].get_text) 38 | 39 | box.set_model(self.rg.get_attribute_model(a)) 40 | box.set_entry_text_column(0) 41 | cb_extras.setup_completion(box) 42 | elif w == "Entry": 43 | if hasattr(box, "get_value"): 44 | method = box.get_value 45 | else: 46 | method = box.get_text 47 | self.get_data_methods[a] = (checkbutton, method) 48 | 49 | def set_values_from_recipe(self, recipe): 50 | for attribute, box in list(self.attribute_widgets.items()): 51 | if hasattr(recipe, attribute): 52 | val = getattr(recipe, attribute) 53 | elif attribute == "category": 54 | val = ", ".join(self.rg.rd.get_cats(recipe)) 55 | if val: 56 | if hasattr(box, "set_value"): 57 | box.set_value(val) 58 | elif hasattr(box, "set_text"): 59 | box.set_text(val) 60 | elif hasattr(box.get_children()[0], "set_text"): 61 | box.get_children()[0].set_text(val) 62 | else: 63 | print("Can't figure out how to set value for ", attribute, box) 64 | 65 | def toggle_cb(self, widg, attr): 66 | box = self.attribute_widgets[attr] 67 | if widg.get_active(): 68 | box.set_sensitive(True) 69 | else: 70 | box.set_sensitive(False) 71 | 72 | def get_values(self): 73 | changed = {} 74 | for attribute in list(self.get_data_methods.keys()): 75 | cb, get_method = self.get_data_methods[attribute] 76 | if cb.get_active(): 77 | val = get_method() 78 | changed[attribute] = val 79 | return changed 80 | 81 | def response_cb(self, dialog, resp): 82 | if resp == Gtk.ResponseType.OK: 83 | self.setFieldWhereBlank = self.setFieldWhereBlankButton.get_active() 84 | self.values = self.get_values() 85 | else: 86 | self.setFieldWhereBlank = None 87 | self.values = None 88 | -------------------------------------------------------------------------------- /src/gourmand/prefs.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | from pathlib import Path 3 | from sys import version_info 4 | from typing import Any, Optional 5 | 6 | if version_info >= (3, 11): 7 | from tomllib import loads as toml_loads 8 | else: 9 | from tomli import loads as toml_loads 10 | 11 | from tomli_w import dumps as toml_dumps 12 | 13 | from gourmand.gglobals import gourmanddir 14 | 15 | 16 | class Prefs(dict): 17 | """A singleton dictionary for handling preferences.""" 18 | 19 | __single = None 20 | 21 | @classmethod 22 | def instance(cls): 23 | if Prefs.__single is None: 24 | Prefs.__single = cls() 25 | 26 | return Prefs.__single 27 | 28 | def __init__(self, filename="preferences.toml"): 29 | super().__init__() 30 | self.filename = Path(gourmanddir) / filename 31 | self.load() 32 | 33 | def get(self, key: str, default: Optional[Any] = None) -> Optional[Any]: 34 | if key not in self and default is not None: 35 | self[key] = default 36 | return super().get(key) 37 | 38 | def save(self): 39 | self.filename.parent.mkdir(exist_ok=True) 40 | with open(self.filename, "w") as fout: 41 | fout.write(toml_dumps(self)) 42 | 43 | def load(self) -> bool: 44 | if self.filename.is_file(): 45 | with open(self.filename) as fin: 46 | for k, v in toml_loads(fin.read()).items(): 47 | self.__setitem__(k, v) 48 | return True 49 | return False 50 | 51 | 52 | def update_preferences_file_format(target_dir: Path = gourmanddir): 53 | """Update saved preferences upon updates. 54 | 55 | This function is called upon launch to handle changes in the structure of the preference. 56 | Each change applied is documented inline. 57 | """ 58 | filename = target_dir / "preferences.toml" 59 | if not filename.is_file(): 60 | return 61 | 62 | with open(filename) as fin: 63 | prefs = toml_loads(fin.read()) 64 | 65 | # Gourmand 1.2.0: several sorting parameters can be saved. 66 | # The old format had `column=name` and `ascending=bool`, which are now `name=bool` 67 | sort_by = prefs.get("sort_by") 68 | if sort_by is not None: 69 | if "column" in sort_by.keys(): # old format 70 | prefs["sort_by"] = {sort_by["column"]: sort_by["ascending"]} 71 | 72 | with open(filename, "w") as fout: 73 | fout.write(toml_dumps(prefs)) 74 | 75 | 76 | def copy_old_installation_or_initialize(target_dir: Path): 77 | """Initialize or migrate earlier installations. 78 | 79 | Previous installations of Gourmand or Gourmet, stored in "~/gourmand" or 80 | "~/gourmet" will be copied across if the specified directory does not 81 | exist. 82 | 83 | If both gourmand and gourmet directories exist, then the gourmet directory, 84 | presumably newer, is migrated. 85 | """ 86 | target_db = target_dir / "recipes.db" 87 | if target_db.is_file(): 88 | return 89 | 90 | legacy_gourmet = Path("~/.gourmet").expanduser() 91 | legacy_gourmand = Path("~/.gourmand").expanduser() 92 | 93 | source_dir = None 94 | if legacy_gourmet.is_dir(): 95 | source_dir = legacy_gourmet 96 | if legacy_gourmand.is_dir(): 97 | source_dir = legacy_gourmand 98 | 99 | if source_dir is not None: 100 | shutil.copytree(source_dir, target_dir, dirs_exist_ok=True) 101 | 102 | if not target_db.is_file(): 103 | print("First time? We're setting you up with yummy recipes.") 104 | target_dir.mkdir(exist_ok=True) 105 | default_db = Path(__file__).parent.absolute() / "backends" / "default.db" 106 | shutil.copyfile(default_db, target_dir / "recipes.db") 107 | -------------------------------------------------------------------------------- /src/gourmand/plugins/key_editor/keyEditorPluggable.py: -------------------------------------------------------------------------------- 1 | # This library provides a pluggable that lets plugins that *use* our 2 | # key editor to provide extra information based on the ingredient 3 | # key. This will be used to show info in both the key editor and 4 | # recipe card view and possibly to allow editing etc. 5 | 6 | from gourmand.plugin import PluginPlugin 7 | from gourmand.plugin_loader import Pluggable 8 | 9 | # Here's our template -- those implementing will have to take this as 10 | # boilerplate code rather than subclassing it, since it's not possible 11 | # to reliably access one plugin's module from another. 12 | 13 | 14 | # Begin boilerplate... 15 | # 16 | # For a fuller example, see shopping_associations 17 | class KeyEditorPlugin(PluginPlugin): 18 | 19 | target_pluggable = "KeyEditorPlugin" 20 | 21 | selected_ingkeys = [] 22 | 23 | def setup_treeview_column(self, ike, key_col, instant_apply=False): 24 | """Set up a treeview column to display your data. 25 | 26 | The key_col is the column in the treemodel which will contain 27 | your data in the model. It\'s your responsibility to get 28 | whatever other data you need yourself. 29 | 30 | If you make this editable, it\'s up to you to apply the 31 | changes as well to the database. If instant_apply is True, 32 | then apply them instantly; if False, apply them when this 33 | class\'s save method is called. 34 | """ 35 | raise NotImplementedError 36 | 37 | def save(self): 38 | """Save any data the user has entered in your treeview column.""" 39 | pass 40 | 41 | def offers_edit_widget(self): 42 | """Return True if this plugin provides an edit button for 43 | editing data (if you need more than an editable cellrenderer 44 | to let users edit your data, or would like to act on multiple 45 | rows. 46 | """ 47 | return False 48 | 49 | def setup_edit_widget(self): 50 | """Return an edit button to let users edit your data.""" 51 | raise NotImplementedError 52 | 53 | def selection_changed(self, ingkeys): 54 | """Selected ingkeys have changed -- currently ingkeys are 55 | selected (and should be acted on by our edit_widget 56 | """ 57 | self.selected_ingkeys = ingkeys 58 | 59 | 60 | # End boilerplate 61 | 62 | 63 | class KeyEditorPluginManager(Pluggable): 64 | """Manage plugins that provide users the ability to edit extra 65 | associations, such as nutritional information, shopping list 66 | categories, etc.""" 67 | 68 | title = "Title of Whatever we Do" 69 | targets = ["KeyEditorPlugin"] 70 | 71 | __single = None 72 | 73 | @classmethod 74 | def instance(cls): 75 | if KeyEditorPluginManager.__single is None: 76 | KeyEditorPluginManager.__single = cls() 77 | 78 | return KeyEditorPluginManager.__single 79 | 80 | def __init__(self): 81 | Pluggable.__init__(self, [PluginPlugin]) 82 | 83 | def get_treeview_columns(self, ike, key_col, instant_apply=False): 84 | return [p.setup_treeview_column(ike, key_col, instant_apply) for p in self.plugins] 85 | 86 | def get_edit_buttons(self, ike): 87 | buttons = [] 88 | for p in self.plugins: 89 | if p.offer_edit_button(): 90 | try: 91 | buttons.append(p.setup_edit_button()) 92 | except Exception: 93 | "Trouble initializing edit button for plugin", p 94 | import traceback 95 | 96 | traceback.print_exc() 97 | return buttons 98 | 99 | 100 | def get_key_editor_plugin_manager(): 101 | return KeyEditorPluginManager.instance() 102 | -------------------------------------------------------------------------------- /tests/test_time_scanner.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from gourmand.timeScanner import make_time_links 4 | 5 | 6 | class TimeScannerTest(unittest.TestCase): 7 | def test_single_time(self): 8 | text = "Bake for 15 minutes or until slightly brown." 9 | expected = 'Bake for 15 minutes or until slightly brown.' 10 | result = make_time_links(text) 11 | self.assertEqual(expected, result) 12 | 13 | def test_multiple_single_time(self): 14 | text = "Bake for 15 minutes or until slightly brown. Refrigerate 3 hours, until cooled." 15 | expected = 'Bake for 15 minutes or until slightly brown. Refrigerate 3 hours, until cooled.' 16 | result = make_time_links(text) 17 | self.assertEqual(expected, result) 18 | 19 | def test_time_range_with_to(self): 20 | text = "Bake for 15 to 20 minutes." 21 | expected = 'Bake for 15 to 20 minutes.' 22 | result = make_time_links(text) 23 | self.assertEqual(expected, result) 24 | 25 | def test_time_range_with_dash_no_spaces(self): 26 | text = "Bake for 15-20 minutes." 27 | expected = 'Bake for 15-20 minutes.' 28 | result = make_time_links(text) 29 | self.assertEqual(expected, result) 30 | 31 | def test_time_range_with_dash_with_spaces(self): 32 | text = "Bake for 15 - 20 minutes." 33 | expected = 'Bake for 15 - 20 minutes.' 34 | result = make_time_links(text) 35 | self.assertEqual(expected, result) 36 | 37 | def test_time_range_multiple_types(self): 38 | text = ("Mix cream cheese, sugar and vanilla with electric mixer on " 39 | "medium speed until well blended. Add eggs; mix until blended." 40 | " Stir in white chocolate. Pour into crust.\nMicrowave " 41 | "preserves in small bowl on HIGH 15 seconds or until melted. " 42 | "Dot top of cheesecake with small spoonfuls of preserves. Cut " 43 | "through batter with knife several times for marble effect.\n" 44 | "Bake at 350 degrees for 35 to 40 minutes or until center is " 45 | "almost set. Cool. Refrigerate 3 hours or overnight.\nThis " 46 | "recipe yields 8 servings.\nGreat Substitute: To lower the " 47 | "fat, prepare as directed, substituting Philadelphia Neufchatel" 48 | " Cheese, 1/3 Less Fat than Cream Cheese, for cream cheese.") 49 | expected = ('Mix cream cheese, sugar and vanilla with electric mixer on' 50 | ' medium speed until well blended. Add eggs; mix until ' 51 | 'blended. Stir in white chocolate. Pour into crust.\n' 52 | 'Microwave preserves in small bowl on HIGH 15 seconds or until melted. Dot top of ' 54 | 'cheesecake with small spoonfuls of preserves. Cut through' 55 | ' batter with knife several times for marble effect.\nBake ' 56 | 'at 350 degrees for 35 to 40 minutes or until center is almost set.' 58 | ' Cool. Refrigerate 3 hours or ' 59 | 'overnight.\nThis recipe yields 8 servings.\nGreat ' 60 | 'Substitute: To lower the fat, prepare as directed, ' 61 | 'substituting Philadelphia Neufchatel Cheese, 1/3 Less Fat ' 62 | 'than Cream Cheese, for cream cheese.') 63 | result = make_time_links(text) 64 | self.assertEqual(expected, result) 65 | -------------------------------------------------------------------------------- /tests/test_importer.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from gourmand.importers import importer 4 | 5 | 6 | class TestImporter(unittest.TestCase): 7 | 8 | def setUp(self): 9 | self.i = importer.Importer() 10 | 11 | def _get_last_rec_(self): 12 | return self.i.added_recs[-1] 13 | 14 | def test_recipe_import(self): 15 | self.i.start_rec() 16 | attrs = [("title", "Foo"), ("cuisine", "Bar"), ("yields", 3), ("yield_unit", "cups")] 17 | for att, val in attrs: 18 | self.i.rec[att] = val 19 | self.i.commit_rec() 20 | rec = self._get_last_rec_() 21 | for att, val in attrs: 22 | self.assertEqual(getattr(rec, att), val) 23 | 24 | def test_ingredient_import(self): 25 | self.i.start_rec() 26 | self.i.rec["title"] = "Ingredient Import Test" 27 | self.i.start_ing() 28 | self.i.add_amt(2) 29 | self.i.add_unit("cups") 30 | self.i.add_item("water") 31 | self.i.commit_ing() 32 | self.i.commit_rec() 33 | ings = self.i.rd.get_ings(self._get_last_rec_()) 34 | self.assertEqual(len(ings), 1) 35 | ing = ings[0] 36 | self.assertEqual(ing.amount, 2) 37 | self.assertEqual(ing.unit, "cups") 38 | self.assertEqual(ing.item, "water") 39 | 40 | 41 | class ImporterTest(unittest.TestCase): 42 | 43 | def setUp(self): 44 | self.importer = importer.Importer() 45 | 46 | def test_parse_simple_yields(self): 47 | assert self.importer.parse_yields("3 cups") == (3, "cups") 48 | assert self.importer.parse_yields("7 servings") == (7, "servings") 49 | assert self.importer.parse_yields("12 muffins") == (12, "muffins") 50 | assert self.importer.parse_yields("10 loaves") == (10, "loaves") 51 | 52 | def test_parse_complex_yields(self): 53 | assert self.importer.parse_yields("Makes 12 muffins") == (12, "muffins") 54 | assert self.importer.parse_yields("Makes 4 servings") == (4, "servings") 55 | assert self.importer.parse_yields("Serves 7") == (7, "servings") 56 | 57 | def test_parse_fractional_yields(self): 58 | assert self.importer.parse_yields("Makes 4 3/4 muffins") == (4.75, "muffins") 59 | assert self.importer.parse_yields("Makes 4 3/4") == (4.75, "servings") 60 | assert self.importer.parse_yields("19/4") == (4.75, "servings") 61 | assert self.importer.parse_yields("Makes 19/4") == (4.75, "servings") 62 | 63 | @unittest.expectedFailure 64 | def test_failed_parsing_fractional_yields(self): 65 | assert self.importer.parse_yields("Makes 19/4") == (4.75, "muffins") 66 | 67 | 68 | class RatingConverterTest(unittest.TestCase): 69 | 70 | def setUp(self): 71 | 72 | class FakeDB: 73 | 74 | recs = dict([(n, {}) for n in range(20)]) 75 | 76 | def get_rec(self, n): 77 | return n 78 | 79 | def modify_rec(self, n, d): 80 | for attr, val in list(d.items()): 81 | self.recs[n][attr] = val 82 | 83 | self.db = FakeDB() 84 | 85 | def test_automatic_converter(self): 86 | rc = importer.RatingConverter() 87 | tests = [("good", 6), ("Great", 8), ("Excellent", 10), ("poor", 2), ("okay", 4)] 88 | for n, (rating, number) in enumerate(tests): 89 | rc.add(n, rating) 90 | self.db.recs[n]["rating"] = rating 91 | rc.do_conversions(self.db) 92 | print("Conversions: ") 93 | for n, (rating, number) in enumerate(tests): 94 | print("Converted", rating, "->", self.db.recs[n]["rating"]) 95 | self.assertEqual(number, self.db.recs[n]["rating"]) 96 | 97 | def test_string_to_rating_converter(self): 98 | assert importer.string_to_rating("4/5 stars") == 8 99 | assert importer.string_to_rating("3 1/2 / 5 stars") == 7 100 | assert importer.string_to_rating("4/10 stars") == 4 101 | -------------------------------------------------------------------------------- /src/gourmand/defaults/defaults.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import locale 3 | import os 4 | import sys 5 | from collections import defaultdict 6 | from typing import Optional 7 | 8 | from .abstractLang import AbstractLanguage 9 | 10 | deflang = "en" 11 | lang: AbstractLanguage 12 | 13 | if os.name == "posix": 14 | try: 15 | locale.setlocale(locale.LC_ALL, "") 16 | except locale.Error: 17 | loc, enc = locale.getdefaultlocale() 18 | else: 19 | loc, enc = locale.getlocale() 20 | 21 | # Windows locales are named differently, e.g. German_Austria instead of de_AT 22 | # Fortunately, we can find the POSIX-like type using a different method. 23 | # sys.platform is the correct check per mypy convention (https://mypy.readthedocs.io/en/stable/common_issues.html?highlight=platform#python-version-and-system-platform-checks) 24 | elif sys.platform == "win32": 25 | from ctypes import windll 26 | 27 | locid = windll.kernel32.GetUserDefaultLangID() 28 | loc = locale.windows_locale[locid] 29 | 30 | importLang: Optional[AbstractLanguage] = None 31 | if loc: 32 | try: 33 | importLang = importlib.import_module("gourmand.defaults.defaults_%s" % loc).Language 34 | except ImportError: 35 | try: 36 | importLang = importlib.import_module("gourmand.defaults.defaults_%s" % loc[0:2]).Language 37 | except ImportError: 38 | importLang = importlib.import_module("gourmand.defaults.defaults_%s" % deflang).Language 39 | 40 | if not importLang: 41 | lang = importlib.import_module("gourmand.defaults.defaults_%s" % deflang).Language 42 | else: 43 | lang = importLang 44 | 45 | # The next item is used to allow us to know some things about handling the language 46 | try: 47 | langProperties = lang.LANG_PROPERTIES 48 | except AttributeError: 49 | lang.LANG_PROPERTIES = langProperties = {"hasAccents": False, "capitalisedNouns": False, "useFractions": True} 50 | # 'hasAccents' includes accents, umlauts etc, that might not be correctly handled 51 | # by eg lower() 52 | # 'capitalisedNouns' means that you don't want to use lower() anyway, cos it's 53 | # ungramatical e.g. in the german Language, Nouns are written with Capital-Letters. 54 | 55 | ## now we set up our dictionaries 56 | lang.keydic = defaultdict(list) 57 | for variants in lang.SYNONYMS: 58 | preferred = variants[0] 59 | lang.keydic[preferred].extend(variants) 60 | 61 | for preferred, alternatives in lang.AMBIGUOUS.items(): 62 | lang.keydic[preferred].extend(alternatives) 63 | 64 | for itemname, key, _ in lang.INGREDIENT_DATA: 65 | lang.keydic[key].append(itemname) 66 | 67 | lang.shopdic = {key: shoppingCategory for (_, key, shoppingCategory) in lang.INGREDIENT_DATA} 68 | 69 | lang.unit_group_lookup = {} 70 | 71 | unit_rounding_guide = { 72 | "ml": 1, 73 | "l": 0.001, 74 | "mg": 1, 75 | "g": 1, 76 | "tsp.": 0.075, 77 | "Tbs.": 0.02, 78 | "c.": 0.125, 79 | } 80 | 81 | if hasattr(lang, "unit_rounding_guide") and lang.unit_rounding_guide: 82 | unit_rounding_guide.update(lang.unit_rounding_guide) 83 | 84 | lang.unit_rounding_guide = unit_rounding_guide 85 | 86 | 87 | for groupname, magnitudes in lang.UNIT_GROUPS.items(): 88 | for no, (unit, _) in enumerate(magnitudes): 89 | lang.unit_group_lookup[unit] = groupname, no 90 | 91 | WORD_TO_SING_PLUR_PAIR = {} 92 | if hasattr(lang, "PLURALS"): 93 | for forms in lang.PLURALS: 94 | for f in forms: 95 | WORD_TO_SING_PLUR_PAIR[f] = forms 96 | 97 | 98 | def get_pluralized_form(word, n): 99 | from gettext import ngettext 100 | 101 | if not word: 102 | return "" 103 | lword = word.lower() 104 | if lword in WORD_TO_SING_PLUR_PAIR: 105 | forms = list(WORD_TO_SING_PLUR_PAIR[lword]) 106 | forms += [n] 107 | return ngettext(*forms) 108 | elif lang.guess_singulars(lword): 109 | # Arbitrarily use the first item in the list returned by 110 | # lang.guess_singulars(). 111 | forms = lang.guess_singulars(lword)[0:1] 112 | forms += [lword] 113 | forms += [n] 114 | return ngettext(*forms) 115 | else: 116 | return word 117 | -------------------------------------------------------------------------------- /src/gourmand/plugins/nutritional_information/data_plugin.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, Float, ForeignKey, Integer, String, Table, Text 2 | 3 | import gourmand.backends.db 4 | from gourmand.plugin import DatabasePlugin 5 | 6 | from . import parser_data 7 | 8 | 9 | class NutritionDataPlugin(DatabasePlugin): 10 | 11 | name = "nutritondata" 12 | version = 4 13 | 14 | def setup_usda_weights_table(self): 15 | self.db.usda_weights_table = Table( 16 | "usda_weights", 17 | self.db.metadata, 18 | Column("id", Integer(), primary_key=True), 19 | *[Column(name, gourmand.backends.db.map_type_to_sqlalchemy(typ), **{}) for lname, name, typ in parser_data.WEIGHT_FIELDS], 20 | ) 21 | 22 | class UsdaWeight(object): 23 | pass 24 | 25 | self.db._setup_object_for_table(self.db.usda_weights_table, UsdaWeight) 26 | 27 | def setup_nutritionconversions_table(self): 28 | self.db.nutritionconversions_table = Table( 29 | "nutritionconversions", 30 | self.db.metadata, 31 | Column("id", Integer(), primary_key=True), 32 | Column("ingkey", String(length=255), **{}), 33 | Column("unit", String(length=255), **{}), 34 | Column("factor", Float(), **{}), # Factor is the amount we multiply 35 | # from unit to get 100 grams 36 | ) # NUTRITION_CONVERSIONS 37 | 38 | class NutritionConversion(object): 39 | pass 40 | 41 | self.db._setup_object_for_table(self.db.nutritionconversions_table, NutritionConversion) 42 | 43 | def setup_nutritionaliases_table(self): 44 | self.db.nutritionaliases_table = Table( 45 | "nutritionaliases", 46 | self.db.metadata, 47 | Column("id", Integer(), primary_key=True), 48 | Column("ingkey", Text()), 49 | Column("ndbno", Integer, ForeignKey("nutrition.ndbno")), 50 | Column("density_equivalent", Text(length=20)), 51 | ) 52 | 53 | class NutritionAlias(object): 54 | pass 55 | 56 | self.db._setup_object_for_table(self.db.nutritionaliases_table, NutritionAlias) 57 | 58 | def do_add_nutrition(self, d): 59 | return self.db.do_add_and_return_item(self.db.nutrition_table, d, id_prop="ndbno") 60 | 61 | def create_tables(self, *args): 62 | # print 'nutritional_information.data_plugin.create_tables()' 63 | cols = [ 64 | Column(name, gourmand.backends.db.map_type_to_sqlalchemy(typ), **(name == "ndbno" and {"primary_key": True} or {})) 65 | for lname, name, typ in parser_data.NUTRITION_FIELDS 66 | ] + [Column("foodgroup", Text(), **{})] 67 | # print 'nutrition cols:',cols 68 | self.db.nutrition_table = Table("nutrition", self.db.metadata, *cols) 69 | 70 | class Nutrition(object): 71 | pass 72 | 73 | self.db._setup_object_for_table(self.db.nutrition_table, Nutrition) 74 | 75 | self.setup_usda_weights_table() 76 | self.setup_nutritionaliases_table() 77 | self.setup_nutritionconversions_table() 78 | self.db.do_add_nutrition = self.do_add_nutrition 79 | 80 | def update_version(self, gourmand_stored, plugin_stored, gourmand_current, plugin_current): 81 | if (gourmand_stored[0] == 0 and gourmand_stored[1] < 14) or (plugin_stored < 1): 82 | print("RECREATE USDA WEIGHTS TABLE") 83 | self.db.alter_table("usda_weights", self.setup_usda_weights_table, {}, [name for lname, name, typ in parser_data.WEIGHT_FIELDS]) 84 | self.db.alter_table("nutritionconversions", self.setup_nutritionconversions_table, {}, ["ingkey", "unit", "factor"]) 85 | if plugin_stored == "1": 86 | # Add choline 87 | self.db.add_column_to_table(self.db.nutrition_table, ("choline", gourmand.backends.db.map_type_to_sqlalchemy("float"), {})) 88 | if plugin_stored in ["1", "2"]: 89 | # Add a primary key Integer column named id. 90 | self.db.alter_table("nutritionaliases", self.setup_nutritionaliases_table, {}, ["ingkey", "ndbno", "density_equivalent"]) 91 | 92 | if plugin_stored in ["1", "2", "3"]: 93 | # Set the length parameter of the ingkey and unit Strings to 255. 94 | self.db.alter_table("nutritionconversions", self.setup_nutritionconversions_table, {}, ["id", "factor"]) 95 | -------------------------------------------------------------------------------- /src/gourmand/exporters/MarkupString.py: -------------------------------------------------------------------------------- 1 | import xml.sax 2 | 3 | 4 | class simpleHandler(xml.sax.ContentHandler): 5 | """A simple handler that provides us with indices of marked up content.""" 6 | 7 | def __init__(self): 8 | self.elements = [] # this will contain a list of elements and their start/end indices 9 | self.open_elements = [] # this holds info on open elements while we wait for their close 10 | self.content = "" 11 | 12 | def startElement(self, name, attrs): 13 | if name == "foobar": 14 | return # we require an outer wrapper, which we promptly ignore. 15 | self.open_elements.append( 16 | { 17 | "name": name, 18 | "attrs": attrs.copy(), 19 | "start": len(self.content), 20 | } 21 | ) 22 | 23 | def endElement(self, name): 24 | if name == "foobar": 25 | return # we require an outer wrapper, which we promptly ignore. 26 | for i in range(len(self.open_elements)): 27 | e = self.open_elements[i] 28 | if e["name"] == name: 29 | # append a (start,end), name, attrs 30 | self.elements.append(((e["start"], len(self.content)), e["name"], e["attrs"])) # start position # current (end) position 31 | del self.open_elements[i] 32 | return 33 | 34 | def characters(self, chunk): 35 | self.content += chunk 36 | 37 | 38 | class MarkupString(str): 39 | """A simple class for dealing with marked up strings. When we are sliced, we return 40 | valid marked up strings, preserving markup.""" 41 | 42 | def __init__(self, string): 43 | str.__init__(self, string) 44 | self.handler = simpleHandler() 45 | try: 46 | xml.sax.parseString("%s" % str(string), self.handler) 47 | except: 48 | print('Unable to parse "%s"' % string) 49 | raise 50 | self.raw = self.handler.content 51 | 52 | def __getitem__(self, n): 53 | return self.__getslice__(n, n + 1) 54 | 55 | def __getslice__(self, s, e): 56 | # only include relevant elements 57 | if not e or e > len(self.raw): 58 | e = len(self.raw) 59 | elements = [tp for tp in self.handler.elements if (tp[0][1] >= s and tp[0][0] <= e)] # end after the start... # and start before the end 60 | ends = {} 61 | starts = {} 62 | for el in elements: 63 | # cycle through elements that effect our slice and keep track of 64 | # where their start and end tags should go. 65 | pos = el[0] 66 | name = el[1] 67 | attrs = el[2] 68 | # write our start tag 69 | stag = "<%s" % name 70 | for k, v in list(attrs.items()): 71 | stag += " %s=%s" % (k, xml.sax.saxutils.quoteattr(v)) 72 | stag += ">" 73 | etag = "" % name # simple end tag 74 | spos = pos[0] 75 | epos = pos[1] 76 | if spos < s: 77 | spos = s 78 | if epos > e: 79 | epos = e 80 | if epos != spos: # we don't care about tags that don't markup any text 81 | if spos not in starts: 82 | starts[spos] = [] 83 | starts[spos].append(stag) 84 | if epos not in ends: 85 | ends[epos] = [] 86 | ends[epos].append(etag) 87 | outbuf = "" # our actual output string 88 | for pos in range(s, e): # we move through positions 89 | char = self.raw[pos] 90 | if pos in ends: # if there are endtags to insert... 91 | for et in ends[pos]: 92 | outbuf += et 93 | if pos in starts: # if there are start tags to insert 94 | mystarts = starts[pos] 95 | # reverse these so the order works out,e.g. 96 | mystarts.reverse() 97 | for st in mystarts: 98 | outbuf += st 99 | outbuf += char 100 | if e in ends: 101 | for et in ends[e]: 102 | outbuf += et 103 | return MarkupString(str(outbuf)) # the str call is necessary to avoid unicode messiness 104 | --------------------------------------------------------------------------------