10 Commits

Author SHA1 Message Date
Paul R Kartchner
8dbc24f335 chore: bump version to 2026.01.004
All checks were successful
Basil CI/CD Pipeline / Shared Package Tests (push) Successful in 1m8s
Basil CI/CD Pipeline / Code Linting (push) Successful in 1m13s
Basil CI/CD Pipeline / Web Tests (push) Successful in 1m27s
Basil CI/CD Pipeline / API Tests (push) Successful in 1m36s
Basil CI/CD Pipeline / Security Scanning (push) Successful in 1m9s
Basil CI/CD Pipeline / Build All Packages (push) Successful in 1m32s
Basil CI/CD Pipeline / Build & Push Docker Images (push) Has been skipped
Basil CI/CD Pipeline / Trigger Deployment (push) Has been skipped
Basil CI/CD Pipeline / E2E Tests (push) Has been skipped
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-17 00:25:10 -07:00
Paul R Kartchner
2953bb9f04 fix: ensure tag input maintains focus after adding tags [dev]
Some checks failed
Basil CI/CD Pipeline / Code Linting (push) Successful in 1m21s
Basil CI/CD Pipeline / Shared Package Tests (push) Successful in 1m10s
Basil CI/CD Pipeline / Web Tests (push) Successful in 1m40s
Basil CI/CD Pipeline / API Tests (push) Successful in 1m52s
Basil CI/CD Pipeline / Build All Packages (push) Has been cancelled
Basil CI/CD Pipeline / E2E Tests (push) Has been cancelled
Basil CI/CD Pipeline / Build & Push Docker Images (push) Has been cancelled
Basil CI/CD Pipeline / Trigger Deployment (push) Has been cancelled
Basil CI/CD Pipeline / Security Scanning (push) Has been cancelled
- Add focus restoration after recipe state update
- Add focus in finally block to ensure it happens even on error
- Keeps cursor in tag input field for rapid tag entry

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-17 00:22:50 -07:00
Paul R Kartchner
beff2d1b4b feat: add local Docker dev environment setup [dev]
Some checks failed
Basil CI/CD Pipeline / Shared Package Tests (push) Successful in 1m33s
Basil CI/CD Pipeline / Code Linting (push) Successful in 1m35s
Basil CI/CD Pipeline / Security Scanning (push) Successful in 1m39s
Basil CI/CD Pipeline / Web Tests (push) Successful in 1m46s
Basil CI/CD Pipeline / API Tests (push) Successful in 1m59s
Basil CI/CD Pipeline / Build All Packages (push) Successful in 1m31s
Basil CI/CD Pipeline / E2E Tests (push) Has been skipped
Basil CI/CD Pipeline / Trigger Deployment (push) Has been cancelled
Basil CI/CD Pipeline / Build & Push Docker Images (push) Has been cancelled
- Add .env.dev with localhost configuration
- Docker Compose builds dev-tagged images
- Access dev environment at http://localhost:8088
- CI/CD skips deployment for commits with [dev] tag

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-17 00:15:42 -07:00
Paul R Kartchner
1ec5e5f189 ci: skip deployment for commits with [dev] or [skip-deploy]
Allows building Docker images without triggering production deployment by
adding [dev] or [skip-deploy] to the commit message.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-17 00:09:06 -07:00
Paul R Kartchner
d87210f8d3 fix: prevent page jump when adding/removing tags with optimistic updates
- Update UI immediately when adding/removing tags without full page reload
- Fetch updated recipe data in background to get proper tag IDs
- Revert optimistic update on error and reload
- Maintains scroll position and focus in tag input field

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-17 00:06:00 -07:00
Paul R Kartchner
022d0c9529 chore: bump version to 2026.01.003
All checks were successful
Basil CI/CD Pipeline / Code Linting (push) Successful in 1m19s
Basil CI/CD Pipeline / Shared Package Tests (push) Successful in 1m19s
Basil CI/CD Pipeline / Web Tests (push) Successful in 1m32s
Basil CI/CD Pipeline / API Tests (push) Successful in 1m39s
Basil CI/CD Pipeline / Security Scanning (push) Successful in 1m11s
Basil CI/CD Pipeline / Build All Packages (push) Successful in 1m32s
Basil CI/CD Pipeline / E2E Tests (push) Has been skipped
Basil CI/CD Pipeline / Build & Push Docker Images (push) Has been skipped
Basil CI/CD Pipeline / Trigger Deployment (push) Has been skipped
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-16 23:49:50 -07:00
Paul R Kartchner
e20be988ce fix: recipe import from unsupported websites and external URL deletion
Some checks failed
Basil CI/CD Pipeline / Build All Packages (push) Has been cancelled
Basil CI/CD Pipeline / E2E Tests (push) Has been cancelled
Basil CI/CD Pipeline / Build & Push Docker Images (push) Has been cancelled
Basil CI/CD Pipeline / Trigger Deployment (push) Has been cancelled
Basil CI/CD Pipeline / Web Tests (push) Has been cancelled
Basil CI/CD Pipeline / Shared Package Tests (push) Has been cancelled
Basil CI/CD Pipeline / API Tests (push) Has been cancelled
Basil CI/CD Pipeline / Security Scanning (push) Has been cancelled
Basil CI/CD Pipeline / Code Linting (push) Has been cancelled
- Enable wild mode in recipe scraper (supported_only=False) to work with any
  website that uses schema.org structured data, not just officially supported sites
- Fix storage service to skip deletion of external URLs (imported recipe images)
  instead of treating them as local file paths

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-16 23:49:38 -07:00
Paul R Kartchner
0480f398ac chore: bump version to 2026.01.002 and document version policy
Some checks failed
Basil CI/CD Pipeline / Code Linting (push) Successful in 1m12s
Basil CI/CD Pipeline / Shared Package Tests (push) Successful in 1m28s
Basil CI/CD Pipeline / API Tests (push) Successful in 2m1s
Basil CI/CD Pipeline / Web Tests (push) Successful in 1m40s
Basil CI/CD Pipeline / Security Scanning (push) Successful in 1m22s
Basil CI/CD Pipeline / Build All Packages (push) Successful in 1m29s
Basil CI/CD Pipeline / E2E Tests (push) Has been skipped
Basil CI/CD Pipeline / Trigger Deployment (push) Has been cancelled
Basil CI/CD Pipeline / Build & Push Docker Images (push) Has been cancelled
- Update version format to YYYY.MM.PPP (zero-padded)
- Current version: 2026.01.002
- Document version management policy in CLAUDE.md
- Version increments with every production deployment
- Patch resets to 001 when month changes
2026-01-16 23:40:45 -07:00
Paul R Kartchner
7df625b65f test: update recipe update test for new behavior
Some checks failed
Basil CI/CD Pipeline / Shared Package Tests (push) Successful in 1m34s
Basil CI/CD Pipeline / Code Linting (push) Successful in 1m37s
Basil CI/CD Pipeline / Security Scanning (push) Successful in 1m53s
Basil CI/CD Pipeline / Web Tests (push) Successful in 1m55s
Basil CI/CD Pipeline / API Tests (push) Successful in 1m58s
Basil CI/CD Pipeline / Build All Packages (push) Successful in 1m31s
Basil CI/CD Pipeline / E2E Tests (push) Has been skipped
Basil CI/CD Pipeline / Trigger Deployment (push) Has been cancelled
Basil CI/CD Pipeline / Build & Push Docker Images (push) Has been cancelled
- Test now validates that only specified relations are deleted
- First test: updating only title doesn't delete any relations
- Second test: updating tags and ingredients only deletes those
- Reflects new patch-like behavior of PUT endpoint
2026-01-16 23:33:45 -07:00
Paul R Kartchner
c8ecda67bd hotfix: only delete recipe relations that are being updated
Some checks failed
Basil CI/CD Pipeline / Shared Package Tests (push) Successful in 1m5s
Basil CI/CD Pipeline / Code Linting (push) Successful in 1m10s
Basil CI/CD Pipeline / Web Tests (push) Successful in 1m18s
Basil CI/CD Pipeline / API Tests (push) Failing after 1m31s
Basil CI/CD Pipeline / Security Scanning (push) Successful in 1m9s
Basil CI/CD Pipeline / Build All Packages (push) Has been skipped
Basil CI/CD Pipeline / E2E Tests (push) Has been skipped
Basil CI/CD Pipeline / Build & Push Docker Images (push) Has been skipped
Basil CI/CD Pipeline / Trigger Deployment (push) Has been skipped
CRITICAL BUG FIX:
- Change PUT /recipes/:id to only delete relations present in request
- Prevents deleting ingredients/instructions when only updating tags
- Fixes production bug where adding quick tags removed all recipe content
- Makes update endpoint behave like PATCH for nested relations

This was causing all ingredients and instructions to disappear
when adding tags via the quick tag feature.
2026-01-16 23:30:38 -07:00
12 changed files with 212 additions and 30 deletions

23
.env.dev Normal file
View File

@@ -0,0 +1,23 @@
# Development Environment Variables
IMAGE_TAG=dev
DOCKER_REGISTRY=localhost
DOCKER_USERNAME=basil
# Database - uses local postgres from docker-compose
DATABASE_URL=postgresql://basil:basil@postgres:5432/basil?schema=public
# CORS for local development
CORS_ORIGIN=http://localhost
# JWT Secrets (dev only - not secure)
JWT_SECRET=dev-secret-change-this-in-production-min-32-chars
JWT_REFRESH_SECRET=dev-refresh-secret-change-this-in-prod-min-32
# Google OAuth (optional for dev)
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_CALLBACK_URL=http://localhost/api/auth/google/callback
# Application URLs
APP_URL=http://localhost
API_URL=http://localhost/api

View File

@@ -398,7 +398,8 @@ jobs:
name: Trigger Deployment
runs-on: ubuntu-latest
needs: docker-build-and-push
if: success()
# Skip deployment if commit message contains [skip-deploy] or [dev]
if: success() && !contains(github.event.head_commit.message, '[skip-deploy]') && !contains(github.event.head_commit.message, '[dev]')
steps:
- name: Trigger webhook
run: |

View File

@@ -270,3 +270,57 @@ Basil includes a complete CI/CD pipeline with Gitea Actions for automated testin
- `DOCKER_USERNAME` - Docker Hub username
- `DOCKER_PASSWORD` - Docker Hub access token
- `DEPLOY_WEBHOOK_URL` - Webhook endpoint for deployments
## Version Management
**IMPORTANT**: Increment the version with **every production deployment**.
### Version Format
Basil uses calendar versioning with the format: `YYYY.MM.PPP`
- `YYYY` - Four-digit year (e.g., 2026)
- `MM` - Two-digit month with zero-padding (e.g., 01 for January, 12 for December)
- `PPP` - Three-digit patch number with zero-padding that increases with each deployment in a month
### Examples
- `2026.01.001` - First deployment in January 2026
- `2026.01.002` - Second deployment in January 2026
- `2026.02.001` - First deployment in February 2026 (patch resets to 001)
- `2026.02.003` - Third deployment in February 2026
### Version Update Process
When deploying to production:
1. **Update version files:**
```bash
# Update both version files with new version
# packages/api/src/version.ts
# packages/web/src/version.ts
export const APP_VERSION = '2026.01.002';
```
2. **Commit the version bump:**
```bash
git add packages/api/src/version.ts packages/web/src/version.ts
git commit -m "chore: bump version to 2026.01.002"
git push origin main
```
3. **Create Git tag and release:**
```bash
# Tag should match version with 'v' prefix
git tag v2026.01.002
git push origin v2026.01.002
# Or use Gitea MCP to create tag and release
```
4. **Document in release notes:**
- Summarize changes since last version
- List bug fixes, features, and breaking changes
- Reference related pull requests or issues
### Version Display
The current version is displayed in:
- API: `GET /api/version` endpoint returns `{ version: '2026.01.002' }`
- Web: Footer or about section shows current version
- Both packages export `APP_VERSION` constant for internal use

View File

@@ -2,7 +2,7 @@
"""
Recipe scraper script using the recipe-scrapers library.
This script is called by the Node.js API to scrape recipes from URLs.
Uses wild mode (supported_only=False) to work with any website, not just officially supported ones.
Uses wild mode (supported_only=False) to work with any website that uses schema.org structured data.
"""
import sys
@@ -52,8 +52,8 @@ def scrape_recipe(url):
html = fetch_html(url)
# Use scrape_html to scrape the recipe
# Works with officially supported websites
scraper = scrape_html(html, org_url=url)
# supported_only=False enables wild mode for any website with schema.org data
scraper = scrape_html(html, org_url=url, supported_only=False)
# Extract recipe data with safe extraction
recipe_data = {

View File

@@ -390,7 +390,7 @@ describe('Recipes Routes - Real Integration Tests', () => {
expect(prisma.recipe.update).toHaveBeenCalled();
});
it('should delete old relations before update', async () => {
it('should only delete relations that are being updated', async () => {
(prisma.recipeSection.deleteMany as any).mockResolvedValue({});
(prisma.ingredient.deleteMany as any).mockResolvedValue({});
(prisma.instruction.deleteMany as any).mockResolvedValue({});
@@ -406,22 +406,46 @@ describe('Recipes Routes - Real Integration Tests', () => {
});
(prisma.cookbook.findMany as any).mockResolvedValue([]);
// Test 1: Only updating title - should not delete any relations
await request(app)
.put('/api/recipes/1')
.send({ title: 'Updated' });
expect(prisma.recipeSection.deleteMany).toHaveBeenCalledWith({
where: { recipeId: '1' },
expect(prisma.recipeSection.deleteMany).not.toHaveBeenCalled();
expect(prisma.ingredient.deleteMany).not.toHaveBeenCalled();
expect(prisma.instruction.deleteMany).not.toHaveBeenCalled();
expect(prisma.recipeTag.deleteMany).not.toHaveBeenCalled();
// Reset mocks
vi.clearAllMocks();
(prisma.ingredient.deleteMany as any).mockResolvedValue({});
(prisma.recipeTag.deleteMany as any).mockResolvedValue({});
(prisma.recipe.update as any).mockResolvedValue({
id: '1',
title: 'Updated',
ingredients: [],
tags: [],
});
(prisma.cookbook.findMany as any).mockResolvedValue([]);
// Test 2: Updating tags and ingredients - should only delete those
await request(app)
.put('/api/recipes/1')
.send({
title: 'Updated',
ingredients: [],
tags: []
});
expect(prisma.ingredient.deleteMany).toHaveBeenCalledWith({
where: { recipeId: '1' },
});
expect(prisma.instruction.deleteMany).toHaveBeenCalledWith({
where: { recipeId: '1' },
});
expect(prisma.recipeTag.deleteMany).toHaveBeenCalledWith({
where: { recipeId: '1' },
});
// These should NOT be called since we didn't send them
expect(prisma.recipeSection.deleteMany).not.toHaveBeenCalled();
expect(prisma.instruction.deleteMany).not.toHaveBeenCalled();
});
it('should return 500 on update error', async () => {

View File

@@ -363,11 +363,19 @@ router.put('/:id', async (req, res) => {
try {
const { sections, ingredients, instructions, tags, ...recipeData } = req.body;
// Delete existing relations
await prisma.recipeSection.deleteMany({ where: { recipeId: req.params.id } });
await prisma.ingredient.deleteMany({ where: { recipeId: req.params.id } });
await prisma.instruction.deleteMany({ where: { recipeId: req.params.id } });
await prisma.recipeTag.deleteMany({ where: { recipeId: req.params.id } });
// Only delete relations that are being updated (not undefined)
if (sections !== undefined) {
await prisma.recipeSection.deleteMany({ where: { recipeId: req.params.id } });
}
if (ingredients !== undefined) {
await prisma.ingredient.deleteMany({ where: { recipeId: req.params.id } });
}
if (instructions !== undefined) {
await prisma.instruction.deleteMany({ where: { recipeId: req.params.id } });
}
if (tags !== undefined) {
await prisma.recipeTag.deleteMany({ where: { recipeId: req.params.id } });
}
// Helper to clean IDs from nested data
const cleanIngredient = (ing: any, index: number) => ({

View File

@@ -51,6 +51,11 @@ export class StorageService {
}
async deleteFile(fileUrl: string): Promise<void> {
// Skip deletion if this is an external URL (from imported recipes)
if (fileUrl.startsWith('http://') || fileUrl.startsWith('https://')) {
return;
}
if (storageConfig.type === 'local') {
const basePath = storageConfig.localPath || './uploads';
const filePath = path.join(basePath, fileUrl.replace('/uploads/', ''));

View File

@@ -1,5 +1,6 @@
/**
* Application version following the pattern: YYYY.MM.PATCH
* Example: 2026.01.1 (January 2026, patch 1)
* Application version following the pattern: YYYY.MM.PPP
* Example: 2026.01.002 (January 2026, patch 2), 2026.02.003 (February 2026, patch 3)
* Month and patch are zero-padded. Patch increments with each deployment in a month.
*/
export const APP_VERSION = '2026.01.1';
export const APP_VERSION = '2026.01.004';

View File

@@ -141,36 +141,62 @@ function RecipeDetail() {
return;
}
// Optimistically update the UI immediately
const optimisticTag = { tag: { id: 'temp', name: trimmedTag } };
setRecipe({
...recipe,
tags: [...(recipe.tags || []), optimisticTag]
});
setTagInput('');
// Keep focus in input field
setTimeout(() => tagInputRef.current?.focus(), 0);
try {
setSavingTags(true);
// Send array of tag names (strings) to API
const updatedTags = [...existingTagNames, trimmedTag];
await recipesApi.update(id, { tags: updatedTags });
// Reload the recipe to get the updated tag structure from API
await loadRecipe(id);
setTagInput('');
// Reload available tags to include newly created ones
loadTags();
// Keep focus in input field
setTimeout(() => tagInputRef.current?.focus(), 0);
// Fetch the updated recipe to get the proper tag IDs, but don't reload the whole page
const response = await recipesApi.getById(id);
if (response.data) {
setRecipe(response.data);
// Restore focus after state update
setTimeout(() => tagInputRef.current?.focus(), 0);
}
} catch (err) {
console.error('Failed to add tag:', err);
alert('Failed to add tag');
// Revert optimistic update on error
await loadRecipe(id);
} finally {
setSavingTags(false);
// Ensure focus is maintained
setTimeout(() => tagInputRef.current?.focus(), 0);
}
};
const handleRemoveTag = async (tagToRemove: string) => {
if (!id || !recipe) return;
// Optimistically update the UI immediately
const previousTags = recipe.tags;
const updatedTagsOptimistic = (recipe.tags || []).filter(tagItem => {
const tagName = typeof tagItem === 'string' ? tagItem : tagItem.tag?.name || tagItem.name;
return tagName !== tagToRemove;
});
setRecipe({
...recipe,
tags: updatedTagsOptimistic
});
try {
setSavingTags(true);
// Convert existing tags to string array and filter out the removed tag
const existingTagNames = (recipe.tags || [])
const existingTagNames = (previousTags || [])
.map(tagItem =>
typeof tagItem === 'string' ? tagItem : tagItem.tag?.name || tagItem.name
)
@@ -178,11 +204,16 @@ function RecipeDetail() {
const updatedTags = existingTagNames.filter(tag => tag !== tagToRemove);
await recipesApi.update(id, { tags: updatedTags });
// Reload the recipe to get the updated tag structure from API
await loadRecipe(id);
// Fetch the updated recipe to get the proper tag structure
const response = await recipesApi.getById(id);
if (response.data) {
setRecipe(response.data);
}
} catch (err) {
console.error('Failed to remove tag:', err);
alert('Failed to remove tag');
// Revert optimistic update on error
await loadRecipe(id);
} finally {
setSavingTags(false);
}

View File

@@ -1,5 +1,6 @@
/**
* Application version following the pattern: YYYY.MM.PATCH
* Example: 2026.01.1 (January 2026, patch 1)
* Application version following the pattern: YYYY.MM.PPP
* Example: 2026.01.002 (January 2026, patch 2), 2026.02.003 (February 2026, patch 3)
* Month and patch are zero-padded. Patch increments with each deployment in a month.
*/
export const APP_VERSION = '2026.01.1';
export const APP_VERSION = '2026.01.004';

View File

@@ -0,0 +1,14 @@
http:
routers:
basil-dev:
rule: "Host(`localhost`) || Host(`127.0.0.1`)"
entryPoints:
- http
service: basil-dev-service
priority: 1000 # Higher priority than Docker labels (default is 0)
services:
basil-dev-service:
loadBalancer:
servers:
- url: "http://basil-web:80"

20
traefik-local/traefik.yml Normal file
View File

@@ -0,0 +1,20 @@
# Static Traefik configuration for local development
entryPoints:
http:
address: ":80"
providers:
docker:
endpoint: "unix:///var/run/docker.sock"
exposedByDefault: false
network: traefik
file:
filename: /dynamic-dev.yml
watch: true
api:
insecure: true
dashboard: true
log:
level: INFO