cookcli 0.36.0

A command-line interface for managing and working with Cooklang recipes
Documentation
{% extends "base.html" %}

{% block title %}{{ tr.t("nav-preferences") }} - Cook{% endblock %}

{% block content %}
<div>
    <h1 class="text-display font-bold text-text mb-6">{{ tr.t("pref-title") }}</h1>

    <div class="space-y-6">
        <!-- Language Selector -->
        <div class="card p-6">
            <h2 class="text-title font-semibold mb-4 text-text">{{ tr.t("pref-language") }}</h2>
            <div class="flex flex-wrap gap-3">
                <button onclick="setLanguage('en-US')" aria-pressed="{% if tr.lang_string() == "en-US" %}true{% else %}false{% endif %}"
                        class="language-btn btn {% if tr.lang_string() == "en-US" %}btn-primary{% endif %}">
                    🇺🇸 English
                </button>
                <button onclick="setLanguage('de-DE')" aria-pressed="{% if tr.lang_string() == "de-DE" %}true{% else %}false{% endif %}"
                        class="language-btn btn {% if tr.lang_string() == "de-DE" %}btn-primary{% endif %}">
                    🇩🇪 Deutsch
                </button>
                <button onclick="setLanguage('nl-NL')" aria-pressed="{% if tr.lang_string() == "nl-NL" %}true{% else %}false{% endif %}"
                        class="language-btn btn {% if tr.lang_string() == "nl-NL" %}btn-primary{% endif %}">
                    🇳🇱 Nederlands
                </button>
                <button onclick="setLanguage('fr-FR')" aria-pressed="{% if tr.lang_string() == "fr-FR" %}true{% else %}false{% endif %}"
                        class="language-btn btn {% if tr.lang_string() == "fr-FR" %}btn-primary{% endif %}">
                    🇫🇷 Français
                </button>
                <button onclick="setLanguage('es-ES')" aria-pressed="{% if tr.lang_string() == "es-ES" %}true{% else %}false{% endif %}"
                        class="language-btn btn {% if tr.lang_string() == "es-ES" %}btn-primary{% endif %}">
                    🇪🇸 Español
                </button>
                <button onclick="setLanguage('eu-ES')" aria-pressed="{% if tr.lang_string() == "eu-ES" %}true{% else %}false{% endif %}"
                        class="language-btn btn {% if tr.lang_string() == "eu-ES" %}btn-primary{% endif %}">
                    Euskara
                </button>
                <button onclick="setLanguage('sv-SE')" aria-pressed="{% if tr.lang_string() == "sv-SE" %}true{% else %}false{% endif %}"
                        class="language-btn btn {% if tr.lang_string() == "sv-SE" %}btn-primary{% endif %}">
                    🇸🇪 Svenska
                </button>
            </div>
            <p class="text-sm text-muted mt-3">Your language preference will be saved in a cookie.</p>
        </div>

        <!-- Features -->
        {% if !static_mode %}
        <div class="card p-6">
            <h2 class="text-title font-semibold mb-2 text-text">{{ tr.t("pref-features") }}</h2>
            <p class="text-sm text-muted mb-4">{{ tr.t("pref-features-desc") }}</p>
            <div class="flex flex-wrap gap-3">
                <button onclick="toggleFeature('show_shopping_list', {{ features.show_shopping_list }})"
                        aria-pressed="{% if features.show_shopping_list %}true{% else %}false{% endif %}"
                        class="btn {% if features.show_shopping_list %}btn-primary{% endif %}">
                    🛒 {{ tr.t("nav-shopping-list") }}
                </button>
                <button onclick="toggleFeature('show_pantry', {{ features.show_pantry }})"
                        aria-pressed="{% if features.show_pantry %}true{% else %}false{% endif %}"
                        class="btn {% if features.show_pantry %}btn-primary{% endif %}">
                    🥫 {{ tr.t("nav-pantry") }}
                </button>
            </div>
        </div>
        {% endif %}

        <!-- CookCloud Sync -->
        {% if sync_enabled %}
        <div class="card p-6">
            <h2 class="text-title font-semibold mb-3 text-text">CookCloud Sync</h2>
            <div id="sync-section">
                {% if sync_logged_in %}
                <div class="flex items-center justify-between">
                    <div>
                        <p class="text-sm">
                            Signed in as <span class="font-medium">{{ sync_email.as_deref().unwrap_or("Unknown") }}</span>
                        </p>
                        <p class="text-xs text-muted mt-1" id="sync-status-text">{% if sync_syncing %}Syncing recipes...{% else %}Sync idle{% endif %}</p>
                    </div>
                    <button onclick="syncLogout()" class="btn">
                        Logout
                    </button>
                </div>
                {% else %}
                <div id="sync-login-section" class="flex items-center justify-between">
                    <p class="text-sm text-muted" id="sync-login-message">Sync your recipes across devices with CookCloud.</p>
                    <button id="sync-login-btn" onclick="syncLogin()" class="btn btn-primary">
                        Login to CookCloud
                    </button>
                </div>

                <div id="sync-login-card" class="hidden mt-4 card p-6 bg-sunk">
                    <h3 class="text-title font-semibold mb-2 text-text">Sign in to CookCloud</h3>
                    <ol class="mt-3 space-y-2 text-sm text-text">
                        <li>1. Open <a id="sync-login-link" href="#" target="_blank" rel="noopener" class="underline text-accent-text">cook.md/device</a> in any browser.</li>
                        <li>2. Enter this code:</li>
                    </ol>
                    <div class="mt-3 flex items-center gap-2">
                        <span id="sync-login-code" class="text-2xl tracking-widest bg-surface border border-line-strong px-4 py-2 rounded-[var(--radius-control)] font-mono">----  ----</span>
                        <button id="sync-login-copy" type="button" class="btn">Copy</button>
                    </div>
                    <p id="sync-login-expires" class="mt-3 text-sm text-muted"></p>
                    <div class="mt-4 flex gap-2">
                        <a id="sync-login-open" href="#" target="_blank" rel="noopener" class="btn btn-primary">Open cook.md/device</a>
                        <button id="sync-login-cancel" type="button" class="btn">Cancel</button>
                    </div>
                </div>
                {% endif %}
            </div>
        </div>
        {% endif %}

        <div class="card p-6">
            <h2 class="text-title font-semibold mb-3 text-text">Configuration Files</h2>
            <div class="space-y-2 text-sm">
                <div>
                    <span class="font-medium">{{ tr.t("pref-aisle-path") }}:</span>
                    <span class="text-muted ml-2">{{ aisle_path }}</span>
                </div>
                <div>
                    <span class="font-medium">{{ tr.t("pref-pantry-path") }}:</span>
                    <span class="text-muted ml-2">{{ pantry_path }}</span>
                </div>
            </div>
        </div>

        <div class="card p-6">
            <h2 class="text-title font-semibold mb-3 text-text">Recipe Directory</h2>
            <div class="text-sm">
                <span class="font-medium">{{ tr.t("pref-base-path") }}:</span>
                <span class="text-muted ml-2">{{ base_path }}</span>
            </div>
        </div>

        <div class="card p-6">
            <h2 class="text-title font-semibold mb-3 text-text">About</h2>
            <div class="space-y-2 text-sm">
                <div>
                    <span class="font-medium">{{ tr.t("pref-version") }}:</span>
                    <span class="text-muted ml-2">{{ version }}</span>
                </div>
                <div>
                    <span class="font-medium">Cooklang CLI:</span>
                    <span class="text-muted ml-2">A command-line tool for managing Cooklang recipes</span>
                </div>
            </div>
        </div>

        <div class="card p-6">
            <h2 class="text-title font-semibold mb-3 text-text">Documentation & Resources</h2>
            <div class="space-y-2 text-sm">
                {% if !static_mode %}
                <div>
                    <a href="{{ prefix }}/api-docs"
                       class="text-accent-text hover:underline font-medium">
                        🔌 Server API
                    </a>
                    <span class="text-muted ml-2">- HTTP endpoints for building integrations</span>
                </div>
                {% endif %}
                <div>
                    <a href="https://cooklang.org/cli/" target="_blank" rel="noopener noreferrer"
                       class="text-accent-text hover:underline font-medium">
                        📚 CLI Documentation
                    </a>
                    <span class="text-muted ml-2">- Learn about all CLI commands and features</span>
                </div>
                <div>
                    <a href="https://cooklang.org/docs/spec/" target="_blank" rel="noopener noreferrer"
                       class="text-accent-text hover:underline font-medium">
                        📖 Cooklang Specification
                    </a>
                    <span class="text-muted ml-2">- Recipe markup language syntax guide</span>
                </div>
                <div>
                    <a href="https://cooklang.org/docs/getting-started/" target="_blank" rel="noopener noreferrer"
                       class="text-accent-text hover:underline font-medium">
                        🚀 Getting Started
                    </a>
                    <span class="text-muted ml-2">- Introduction to Cooklang basics</span>
                </div>
                <div>
                    <a href="https://github.com/cooklang/CookCLI" target="_blank" rel="noopener noreferrer"
                       class="text-accent-text hover:underline font-medium">
                        🔧 GitHub Repository
                    </a>
                    <span class="text-muted ml-2">- Source code and issue tracker</span>
                </div>
            </div>
        </div>
    </div>
</div>
{% endblock %}

{% block scripts %}
<script>
    function toggleFeature(name, current) {
        const val = current ? '0' : '1';
        const maxAge = 365 * 24 * 60 * 60;
        document.cookie = `${name}=${val}; path={{ prefix }}/; max-age=${maxAge}; SameSite=Lax`;
        window.location.reload();
    }

    function setLanguage(lang) {
        // Set cookie for 1 year
        const maxAge = 365 * 24 * 60 * 60;
        document.cookie = `lang=${lang}; path={{ prefix }}/; max-age=${maxAge}; SameSite=Lax`;

        // Reload page to apply new language
        window.location.reload();
    }

    {% if sync_enabled %}
    let pollHandle = null;
    let countdownHandle = null;

    function renderPending(p) {
      document.getElementById('sync-login-section').classList.add('hidden');
      const card = document.getElementById('sync-login-card');
      card.classList.remove('hidden');
      document.getElementById('sync-login-code').textContent = p.user_code;
      document.getElementById('sync-login-link').href = p.verification_uri;
      document.getElementById('sync-login-link').textContent = p.verification_uri;
      document.getElementById('sync-login-open').href = p.verification_uri_complete;

      if (countdownHandle) clearInterval(countdownHandle);
      const deadline = Date.now() + p.expires_in_secs * 1000;
      const expEl = document.getElementById('sync-login-expires');
      const tick = () => {
        const remaining = Math.max(0, Math.round((deadline - Date.now()) / 1000));
        if (remaining <= 0) {
          expEl.textContent = 'Code expired.';
          stopPolling();
          resetLoginUi('Code expired — try again.');
          return;
        }
        const m = Math.floor(remaining / 60);
        const s = String(remaining % 60).padStart(2, '0');
        expEl.textContent = `Expires in ${m}:${s}`;
      };
      tick();
      countdownHandle = setInterval(tick, 1000);
    }

    function stopPolling() {
      if (pollHandle) { clearInterval(pollHandle); pollHandle = null; }
      if (countdownHandle) { clearInterval(countdownHandle); countdownHandle = null; }
    }

    function startPolling() {
      stopPolling();
      pollHandle = setInterval(async () => {
        const status = await fetch('{{ prefix }}/api/sync/status').then(r => r.json()).catch(() => null);
        if (!status) return; // network blip — keep polling
        if (status.logged_in) {
          stopPolling();
          window.location.reload();
        } else if (!status.pending_login) {
          stopPolling();
          resetLoginUi('Login was cancelled or expired.');
        }
      }, 2000);
    }

    function resetLoginUi(msg) {
      document.getElementById('sync-login-card').classList.add('hidden');
      document.getElementById('sync-login-section').classList.remove('hidden');
      const btn = document.getElementById('sync-login-btn');
      if (btn) btn.disabled = false;
      document.getElementById('sync-login-message').textContent = msg || '';
    }

    async function syncLogin() {
      const btn = document.getElementById('sync-login-btn');
      const msg = document.getElementById('sync-login-message');
      try {
        btn.disabled = true;
        msg.textContent = 'Requesting code...';
        const resp = await fetch('{{ prefix }}/api/sync/login', { method: 'POST' });
        if (!resp.ok) {
          const err = await resp.json().catch(() => ({}));
          btn.disabled = false;
          msg.textContent = err.error || 'Login failed.';
          return;
        }
        const body = await resp.json();
        renderPending(body);

        startPolling();
      } catch (e) {
        btn.disabled = false;
        msg.textContent = 'Failed to start login: ' + e.message;
      }
    }

    document.addEventListener('DOMContentLoaded', () => {
      const copyBtn = document.getElementById('sync-login-copy');
      if (copyBtn) {
        copyBtn.addEventListener('click', async () => {
          const code = document.getElementById('sync-login-code').textContent;
          try {
            await navigator.clipboard.writeText(code.replace(/\s+/g, ''));
            copyBtn.textContent = 'Copied!';
          } catch (e) {
            copyBtn.textContent = 'Copy failed';
          }
          setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500);
        });
      }
      const cancelBtn = document.getElementById('sync-login-cancel');
      if (cancelBtn) {
        cancelBtn.addEventListener('click', async () => {
          stopPolling();
          await fetch('{{ prefix }}/api/sync/cancel_login', { method: 'POST' });
          resetLoginUi('Login cancelled.');
        });
      }

      // Resume on page load if a flow is in progress server-side.
      fetch('{{ prefix }}/api/sync/status').then(r => r.json()).then(status => {
        if (!status.logged_in && status.pending_login) {
          renderPending(status.pending_login);
          startPolling();
        }
      });
    });

    async function syncLogout() {
        try {
            await fetch('{{ prefix }}/api/sync/logout', { method: 'POST' });
            window.location.reload();
        } catch (e) {
            alert('Logout failed: ' + e.message);
        }
    }
    {% endif %}
</script>
{% endblock %}