Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# CLAUDE.md

A Lightroom Classic plugin (Lua) that uploads photos to a self-hosted PicPeak gallery server. It provides two workflows: **Export** (upload selected photos to a PicPeak event/gallery) and **Publish** (maintain synced collections mapped to PicPeak events).

## What is PicPeak?

PicPeak (https://github.com/the-luap/picpeak) is a self-hosted photo sharing platform for photographers and events. It organizes photos into time-limited, optionally password-protected gallery **events** (weddings, birthdays, corporate events, etc.).

## Development & Build

No build step — the plugin runs directly from `picpeak-plugin.lrplugin/` inside Lightroom Classic. Install via Lightroom's Plugin Manager pointing at that directory.

## Architecture

### Entry Points

- **`Info.lua`** — Plugin manifest; declares export/publish providers, metadata provider, SDK version.
- **`Init.lua`** — Runs at load; imports Lightroom SDK globals into `_G` and initializes preferences (`url`, `apiToken`, `logging`).

### Core Modules

- **`PicPeakAPI.lua`** — REST client for PicPeak v1 API (`/api/v1`). Auth: `Authorization: Bearer pp_live_xxx`. Key methods: `getEvents()`, `createEvent(params)`, `uploadPhoto(eventId, filePath, fileName)`, `checkConnectivity()`, `getEventShareUrl(eventId)`.
- **`ExportTask.lua`** — Export workflow: resolve event → iterate renditions → upload each photo → write metadata → show share link.
- **`PublishTask.lua`** — Publish workflow: map collection to event (create if needed) → incremental uploads → collection management callbacks.

### PicPeak API Summary (v1)

Base path: `/api/v1`. Token must have `write` + `admin` scopes.

| Method | Path | Purpose |
|--------|------|---------|
| GET | `/events?limit=100` | List events (paginated, max 100) |
| POST | `/events` | Create event (needs event_name, event_type) |
| GET | `/events/:id` | Get event details |
| POST | `/events/:id/photos` | Upload photo (multipart `photo` field) |
| GET | `/events/:id/share-link` | Get share URL |

**Limitations of v1 API**: No delete photo, no delete event, no rename event endpoints. The publish plugin warns users when these operations are attempted.

### Event types

`wedding`, `birthday`, `corporate`, `other`, `family`

### UI Modules

- **`SharedDialogSections.lua`** — Server connection section (URL + token + test button). Also exports `EVENT_TYPES` list.
- **`ExportDialogSections.lua`** / **`PublishDialogSections.lua`** — Service-specific dialog sections.

### Supporting Modules

- **`MetadataTask.lua`** / **`MetadataProvider.lua`** — Store `picpeakPhotoId` and `picpeakEventId` on photos via plugin metadata.
- **`util.lua`** — Shared helpers: `validateExportContextAndConnect`, `buildSimpleUploadProgressTitle`, `reportUploadFailures`, `safeDeleteTempFile`, `getPhotoDeviceId`, `getLogfilePath`, `cutToken`.
- **`ErrorHandler.lua`** — Centralized error dialogs.
- **`JSON.lua`** / **`inspect.lua`** — External libraries (copied from lrc-immich-plugin).

### Lightroom SDK Patterns

- **Async tasks**: All API calls in `LrTasks.startAsyncTask()`.
- **Property tables**: Dialog state two-way bound via `LrBinding`.
- **Progress scopes**: `LrProgressScope` with `functionContext` (not `configureProgress`) for accurate bars.
- **Error handling**: `LrTasks.pcall()` everywhere (not bare `pcall`).
- **Preferences**: Global settings stored in `LrPrefs.prefsForPlugin()`.

### Publish Collection → Event Mapping

A Lightroom publish collection maps to a single PicPeak event by `remoteId`. When creating a new collection, the user can:
- Create a new event from the collection name (with event type)
- Bind to an existing event

Since PicPeak v1 has no delete/rename endpoints, those operations show informational dialogs and mark photos as handled in Lightroom without touching the server.

@.claude/skills/lrc-plugin-dev.md
60 changes: 60 additions & 0 deletions picpeak-plugin.lrplugin/ErrorHandler.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
ErrorHandler = {}

function ErrorHandler.handleError(errorMessage, detailedInfo)
local msg = (type(errorMessage) == "string" and errorMessage ~= "") and errorMessage or "An error occurred."
local detail = (type(detailedInfo) == "string" and detailedInfo ~= "") and detailedInfo
or "No additional details provided."
if log and log.error then
log:error("Error: " .. msg)
log:error("Details: " .. detail)
end
ErrorHandler.customErrorDialog(msg, detail)
end

function ErrorHandler.customErrorDialog(errorMessage, detailedInfo)
local msg = (type(errorMessage) == "string" and errorMessage ~= "") and errorMessage or "An error occurred."
local detail = (type(detailedInfo) == "string" and detailedInfo ~= "") and detailedInfo
or "No additional details provided."
if not LrView or not LrView.osFactory then
if LrDialogs and LrDialogs.showError then
LrDialogs.showError(msg .. "\n\n" .. detail)
end
return
end
local f = LrView.osFactory()
local share = LrView.share

local dialogView = f:column({
f:row({
f:static_text({
title = "Error",
alignment = "left",
font = "<system/bold>",
width = share("labelWidth"),
}),
f:static_text({
title = msg,
alignment = "left",
font = "<system/bold>",
}),
}),
f:row({
margin_top = 10,
f:static_text({
title = "Details",
alignment = "left",
width = share("labelWidth"),
}),
f:static_text({
title = detail,
alignment = "left",
size = "small",
}),
}),
})

LrDialogs.presentModalDialog({
title = "PicPeak Error",
contents = dialogView,
})
end
208 changes: 208 additions & 0 deletions picpeak-plugin.lrplugin/ExportDialogSections.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
require("PicPeakAPI")
require("SharedDialogSections")

ExportDialogSections = {}

function ExportDialogSections.startDialog(propertyTable)
LrTasks.startAsyncTask(function()
propertyTable.picpeak = PicPeakAPI:new(propertyTable.url, propertyTable.apiToken)
propertyTable.events = propertyTable.picpeak:getEvents()
end)
end

-------------------------------------------------------------------------------

function ExportDialogSections.sectionsForBottomOfDialog(f, propertyTable)
return {
SharedDialogSections.getServerConnectionSection(f, propertyTable),
}
end

-------------------------------------------------------------------------------

function ExportDialogSections.sectionsForTopOfDialog(_, propertyTable)
local f = LrView.osFactory()
local bind = LrView.bind
local lw = LrView.share("labelWidth")

return {
{
title = "PicPeak Gallery Event",
bind_to_object = propertyTable,
f:column({
spacing = f:control_spacing(),

-- Mode selector
f:row({
f:static_text({ title = "Upload to event:", alignment = "right", width = lw }),
f:popup_menu({
alignment = "left",
immediate = true,
items = {
{ title = "Choose on export", value = "onexport" },
{ title = "Existing event", value = "existing" },
{ title = "Create new event", value = "new" },
{ title = "Do not use an event", value = "none" },
},
value = bind("eventMode"),
}),
}),

-- Existing event picker
f:row({
visible = LrBinding.keyEquals("eventMode", "existing"),
f:static_text({ title = "Event:", alignment = "right", width = lw }),
f:popup_menu({
truncation = "middle",
width_in_chars = 30,
fill_horizontal = 1,
value = bind("eventId"),
items = bind("events"),
immediate = true,
}),
f:push_button({
title = "Refresh",
action = function()
LrTasks.startAsyncTask(function()
if not propertyTable.picpeak then
propertyTable.picpeak = PicPeakAPI:new(propertyTable.url, propertyTable.apiToken)
end
propertyTable.events = propertyTable.picpeak:getEvents()
end)
end,
}),
}),

-- New event fields
f:column({
visible = LrBinding.keyEquals("eventMode", "new"),
spacing = f:control_spacing(),

-- Basic info
f:group_box({
title = "Event Details",
fill_horizontal = 1,
f:column({
spacing = f:control_spacing(),
fill_horizontal = 1,
f:row({
f:static_text({ title = "Event name:", alignment = "right", width = lw }),
f:edit_field({ value = bind("newEventName"), fill_horizontal = 1, immediate = true }),
}),
f:row({
f:static_text({ title = "Event type:", alignment = "right", width = lw }),
f:popup_menu({
value = bind("newEventType"),
items = SharedDialogSections.EVENT_TYPES,
immediate = true,
}),
}),
f:row({
f:static_text({ title = "Event date:", alignment = "right", width = lw }),
f:edit_field({
value = bind("newEventDate"),
width_in_chars = 14,
immediate = true,
}),
f:static_text({ title = "YYYY-MM-DD, optional", font = "<system/small>" }),
}),
}),
}),

-- Customer information
f:group_box({
title = "Customer Information",
fill_horizontal = 1,
f:column({
spacing = f:control_spacing(),
fill_horizontal = 1,
f:row({
f:static_text({ title = "Customer name:", alignment = "right", width = lw }),
f:edit_field({ value = bind("newEventCustomerName"), fill_horizontal = 1, immediate = true }),
}),
f:row({
f:static_text({ title = "Customer email:", alignment = "right", width = lw }),
f:edit_field({ value = bind("newEventCustomerEmail"), fill_horizontal = 1, immediate = true }),
}),
f:row({
f:static_text({ title = "Customer phone:", alignment = "right", width = lw }),
f:edit_field({
value = bind("newEventCustomerPhone"),
fill_horizontal = 1,
immediate = true,
}),
f:static_text({ title = "optional", font = "<system/small>" }),
}),
f:row({
f:static_text({ title = "Admin email:", alignment = "right", width = lw }),
f:edit_field({ value = bind("newEventAdminEmail"), fill_horizontal = 1, immediate = true }),
f:static_text({ title = "for notifications", font = "<system/small>" }),
}),
}),
}),

-- Access & expiry
f:group_box({
title = "Access & Expiry",
fill_horizontal = 1,
f:column({
spacing = f:control_spacing(),
fill_horizontal = 1,
f:row({
f:static_text({ title = "Password protect:", alignment = "right", width = lw }),
f:checkbox({
title = "Require password to view gallery",
value = bind("newEventRequirePassword"),
}),
}),
f:row({
visible = bind("newEventRequirePassword"),
f:static_text({ title = "Gallery password:", alignment = "right", width = lw }),
f:password_field({
value = bind("newEventPassword"),
fill_horizontal = 1,
immediate = true,
}),
}),
f:row({
f:static_text({ title = "Expires at:", alignment = "right", width = lw }),
f:edit_field({
value = bind("newEventExpiresAt"),
width_in_chars = 22,
immediate = true,
}),
f:static_text({ title = "YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS, optional", font = "<system/small>" }),
}),
}),
}),

-- Gallery options
f:group_box({
title = "Gallery Options",
fill_horizontal = 1,
f:column({
spacing = f:control_spacing(),
fill_horizontal = 1,
f:row({
f:static_text({ title = "Guest feedback:", alignment = "right", width = lw }),
f:checkbox({
title = "Enable ratings, likes, comments & favorites",
value = bind("newEventFeedbackEnabled"),
}),
}),
f:row({
f:static_text({ title = "Color theme:", alignment = "right", width = lw }),
f:edit_field({
value = bind("newEventColorTheme"),
width_in_chars = 20,
immediate = true,
}),
f:static_text({ title = "PicPeak preset name, optional", font = "<system/small>" }),
}),
}),
}),
}),
}),
},
}
end
39 changes: 39 additions & 0 deletions picpeak-plugin.lrplugin/ExportServiceProvider.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
require("ExportDialogSections")
require("ExportTask")

return {

hideSections = { "exportLocation" },

allowFileFormats = nil,

allowColorSpaces = nil,

exportPresetFields = {
{ key = "url", default = "" },
{ key = "apiToken", default = "" },
{ key = "eventMode", default = "none" },
{ key = "eventId", default = nil },
-- New event fields
{ key = "newEventName", default = "" },
{ key = "newEventType", default = "other" },
{ key = "newEventDate", default = "" },
{ key = "newEventCustomerName", default = "" },
{ key = "newEventCustomerEmail", default = "" },
{ key = "newEventCustomerPhone", default = "" },
{ key = "newEventAdminEmail", default = "" },
{ key = "newEventRequirePassword", default = false },
{ key = "newEventPassword", default = "" },
{ key = "newEventExpiresAt", default = "" },
{ key = "newEventFeedbackEnabled", default = false },
{ key = "newEventColorTheme", default = "" },
},

canExportVideo = false,

startDialog = ExportDialogSections.startDialog,
sectionsForTopOfDialog = ExportDialogSections.sectionsForTopOfDialog,
sectionsForBottomOfDialog = ExportDialogSections.sectionsForBottomOfDialog,

processRenderedPhotos = ExportTask.processRenderedPhotos,
}
Loading