Prepare public release with MIT license and private user storage

This commit is contained in:
Edward Betts 2026-10-07 11:32:59 +01:00
parent 41772b4d1a
commit eb8932134a
12 changed files with 175 additions and 26 deletions

View file

@ -61,7 +61,7 @@ class Grocy:
class Journal:
"""Commit intent before each write; ambiguous writes require explicit reconciliation."""
def __init__(self, path: Path):
path.parent.mkdir(parents=True, exist_ok=True)
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
self.db = sqlite3.connect(path)
self.db.execute("""CREATE TABLE IF NOT EXISTS imports (
server TEXT, order_id TEXT, line INTEGER, fingerprint TEXT,

View file

@ -14,6 +14,7 @@ import click
from lxml import html
from .grocy import Grocy, Journal, import_receipt, refresh_imported_products, align_recorded_lines
from .paths import config_file, archive_directory, browser_directory, journal_file
from .pantry import classify
from .receipt import ImportError, parse_document, parse_ocado_order, receipt_date
@ -228,9 +229,7 @@ def run_import(receipts, config, config_path, archive, dry_run, failures, echo=p
if not url or not key:
raise ImportError("Configure Grocy URL and API key before importing")
api = Grocy(url,key)
state = Path(settings.get("state_file", "imports.sqlite3"))
if not state.is_absolute():
state = config_path.resolve().parent / state
state = journal_file(config, config_path)
journal = Journal(state)
for receipt in receipts:
if not dry_run:
@ -266,9 +265,9 @@ def run_import(receipts, config, config_path, archive, dry_run, failures, echo=p
@click.command(context_settings={"help_option_names":["-h","--help"]})
@click.option("--config", "config_path", type=click.Path(path_type=Path,dir_okay=False),default="config.toml",show_default=True)
@click.option("--profile", type=click.Path(path_type=Path,file_okay=False),default=".ocado-browser",show_default=True)
@click.option("--archive", type=click.Path(path_type=Path,file_okay=False),default="history",show_default=True)
@click.option("--config", "config_path", type=click.Path(path_type=Path,dir_okay=False),default=config_file,show_default="~/.config/ocado-grocy/config.toml")
@click.option("--profile", type=click.Path(path_type=Path,file_okay=False),default=browser_directory,show_default="~/.local/state/ocado-grocy/browser")
@click.option("--archive", type=click.Path(path_type=Path,file_okay=False),default=archive_directory,show_default="~/.local/share/ocado-grocy/history")
@click.option("--since", help="Only orders delivered on or after YYYY-MM-DD.")
@click.option("--limit", type=click.IntRange(min=1),help="Process at most this many new delivered orders (or explicitly selected orders).")
@click.option("--dry-run", is_flag=True,help="Download and report selections without writing to Grocy.")
@ -296,9 +295,7 @@ def main(config_path,profile,archive,since,limit,dry_run,cached,login_only,login
connection = config.get('grocy', {})
server = Grocy(os.environ.get('GROCY_URL', connection.get('url', '')),
os.environ.get('GROCY_API_KEY', connection.get('api_key', ''))).url
state = Path(config.get('import', {}).get('state_file', 'imports.sqlite3'))
if not state.is_absolute():
state = config_path.resolve().parent / state
state = journal_file(config, config_path)
completed = completed_orders(state, server, archive, config)
if not dry_run and not login_only:
mark_orders(state, server, completed, 'complete')

View file

@ -10,6 +10,7 @@ import click
import requests
from .grocy import Grocy, imported_products
from .paths import config_file, archive_directory, journal_file
from .history import private_json
from .product_metadata import read_metadata
@ -72,15 +73,15 @@ def add_picture(client, downloader, product):
@click.command()
@click.option('--config', type=click.Path(path_type=Path), default=Path('config.toml'))
@click.option('--report', type=click.Path(path_type=Path), default=Path('history/images/report.json'))
@click.option('--config', type=click.Path(path_type=Path), default=config_file, show_default='~/.config/ocado-grocy/config.toml')
@click.option('--report', type=click.Path(path_type=Path), default=lambda: archive_directory() / 'images/report.json', show_default='archive/images/report.json')
@click.option('--apply', is_flag=True, help='Upload and assign missing product pictures.')
@click.option('--order-id', 'order_ids', multiple=True, help='Limit to products imported from these orders.')
def main(config, report, apply, order_ids):
"""Add Ocado images to journal-linked products; keep existing pictures."""
settings = tomllib.loads(config.read_text())
client = Grocy(**settings['grocy'])
state = config.parent / settings.get('import', {}).get('state_file', 'imports.sqlite3')
state = journal_file(settings, config)
products = imported_products(client, state, order_ids)
return run_images(client, products, report, apply=apply)

View file

@ -12,6 +12,7 @@ import click
import requests
from .grocy import Grocy, imported_products
from .paths import config_file, archive_directory, journal_file
from .history import private_json
from .product_metadata import read_metadata, replace_metadata
@ -166,8 +167,8 @@ def apply_match(client, product, candidate, barcodes):
@click.command()
@click.option('--config', type=click.Path(path_type=Path), default=Path('config.toml'))
@click.option('--cache', type=click.Path(path_type=Path), default=Path('history/openfoodfacts'))
@click.option('--config', type=click.Path(path_type=Path), default=config_file, show_default='~/.config/ocado-grocy/config.toml')
@click.option('--cache', type=click.Path(path_type=Path), default=lambda: archive_directory() / 'openfoodfacts', show_default='archive/openfoodfacts')
@click.option('--contact', help='Contact for the Open Food Facts User-Agent; defaults to config [openfoodfacts].contact.')
@click.option('--refresh-cache', is_flag=True, help='Fetch fresh OFF responses, replacing cached responses.')
@click.option('--apply', is_flag=True, help='Apply unambiguous matches and explicitly reviewed mappings.')
@ -184,7 +185,7 @@ def main(config, cache, contact, refresh_cache, apply, offline, mapping, limit,
if refresh_cache and offline:
raise click.UsageError('--refresh-cache cannot be combined with --offline')
client = Grocy(**settings['grocy'])
state = config.parent / settings.get('import', {}).get('state_file', 'imports.sqlite3')
state = journal_file(settings, config)
products = imported_products(client, state, order_ids)
mappings = json.loads(mapping.read_text()) if mapping else {}
return run_openfoodfacts(client, products, cache, contact, apply=apply, offline=offline,

View file

@ -45,7 +45,7 @@ def completed_orders(state, server, archive, config):
def mark_orders(state, server, orders, status):
if not orders:
return
state.parent.mkdir(parents=True, exist_ok=True)
state.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
with sqlite3.connect(state) as db:
db.execute('''CREATE TABLE IF NOT EXISTS loaded_orders (
server TEXT NOT NULL, order_id TEXT NOT NULL, status TEXT NOT NULL,

44
ocado_grocy/paths.py Normal file
View file

@ -0,0 +1,44 @@
"""User-specific paths, with compatibility for existing checkout installations."""
import os
from pathlib import Path
def user_directory(variable, fallback):
value = os.environ.get(variable, '')
# XDG paths must be absolute; ignore empty or relative values.
base = Path(value) if value and Path(value).is_absolute() else Path.home() / fallback
return base / 'ocado-grocy'
def config_file():
# Keep an existing installation on its original journal until migrated.
legacy = Path('config.toml')
return legacy if legacy.exists() else user_directory('XDG_CONFIG_HOME', '.config') / 'config.toml'
def state_directory():
return user_directory('XDG_STATE_HOME', '.local/state')
def archive_directory():
if Path('config.toml').exists():
return Path('history')
return user_directory('XDG_DATA_HOME', '.local/share') / 'history'
def browser_directory():
if Path('config.toml').exists():
return Path('.ocado-browser')
return state_directory() / 'browser'
def journal_file(settings, config_path):
configured = settings.get('import', {}).get('state_file')
if configured:
path = Path(configured).expanduser()
return path if path.is_absolute() else config_path.resolve().parent / path
user_config = user_directory('XDG_CONFIG_HOME', '.config') / 'config.toml'
if config_path.resolve() == user_config.resolve():
return state_directory() / 'imports.sqlite3'
# Explicit custom/legacy configs retain the historical relative default.
return config_path.resolve().parent / 'imports.sqlite3'

View file

@ -6,6 +6,7 @@ from pathlib import Path
import click
from .grocy import Grocy, imported_products
from .paths import journal_file
from .history import private_json
from .images import run_images
from .openfoodfacts import run_openfoodfacts
@ -18,9 +19,7 @@ def enrich_orders(config, config_path, archive, order_ids, *, images=True, openf
connection = config.get('grocy', {})
client = Grocy(os.environ.get('GROCY_URL', connection.get('url', '')),
os.environ.get('GROCY_API_KEY', connection.get('api_key', '')))
state = Path(config.get('import', {}).get('state_file', 'imports.sqlite3'))
if not state.is_absolute():
state = config_path.resolve().parent / state
state = journal_file(config, config_path)
products = imported_products(client, state, order_ids)
key = order_ids[0] if len(order_ids) == 1 and order_ids[0].isdigit() else hashlib.sha256(','.join(sorted(order_ids)).encode()).hexdigest()[:16]
output = archive / 'enrichment' / key
@ -65,7 +64,7 @@ def retry_enrichment(config, config_path, archive, *, images=True, openfoodfacts
connection = config.get('grocy', {})
client = Grocy(os.environ.get('GROCY_URL', connection.get('url','')),
os.environ.get('GROCY_API_KEY', connection.get('api_key','')))
state = config_path.resolve().parent / config.get('import',{}).get('state_file','imports.sqlite3')
state = journal_file(config, config_path)
if not state.exists():
return {'errors':[]}
queue = RetryQueue(state, client.url)