From 371b95ba7ece8fc3b2e16daf9853f83afe975d69 Mon Sep 17 00:00:00 2001 From: Bastian Machek <16717398+bmachek@users.noreply.github.com> Date: Mon, 15 Jun 2026 11:56:13 +0200 Subject: [PATCH 1/4] Initial implementation of lrc-picpeak Lightroom Classic plugin Export and Publish workflows for uploading photos to a self-hosted PicPeak gallery server via its v1 API (Bearer token auth, events + photo upload endpoints). Adapted from lrc-immich-plugin architecture. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 72 + picpeak-plugin.lrplugin/ErrorHandler.lua | 60 + .../ExportDialogSections.lua | 156 ++ .../ExportServiceProvider.lua | 31 + picpeak-plugin.lrplugin/ExportTask.lua | 267 +++ picpeak-plugin.lrplugin/Info.lua | 30 + picpeak-plugin.lrplugin/Init.lua | 44 + picpeak-plugin.lrplugin/JSON.lua | 1529 +++++++++++++++++ picpeak-plugin.lrplugin/MetadataProvider.lua | 25 + picpeak-plugin.lrplugin/MetadataTask.lua | 49 + picpeak-plugin.lrplugin/PicPeakAPI.lua | 429 +++++ picpeak-plugin.lrplugin/PluginInfo.lua | 7 + .../PluginInfoDialogSections.lua | 48 + .../PublishDialogSections.lua | 16 + .../PublishServiceProvider.lua | 38 + picpeak-plugin.lrplugin/PublishTask.lua | 342 ++++ .../SharedDialogSections.lua | 77 + picpeak-plugin.lrplugin/inspect.lua | 380 ++++ picpeak-plugin.lrplugin/util.lua | 166 ++ 19 files changed, 3766 insertions(+) create mode 100644 CLAUDE.md create mode 100644 picpeak-plugin.lrplugin/ErrorHandler.lua create mode 100644 picpeak-plugin.lrplugin/ExportDialogSections.lua create mode 100644 picpeak-plugin.lrplugin/ExportServiceProvider.lua create mode 100644 picpeak-plugin.lrplugin/ExportTask.lua create mode 100644 picpeak-plugin.lrplugin/Info.lua create mode 100644 picpeak-plugin.lrplugin/Init.lua create mode 100644 picpeak-plugin.lrplugin/JSON.lua create mode 100644 picpeak-plugin.lrplugin/MetadataProvider.lua create mode 100644 picpeak-plugin.lrplugin/MetadataTask.lua create mode 100644 picpeak-plugin.lrplugin/PicPeakAPI.lua create mode 100644 picpeak-plugin.lrplugin/PluginInfo.lua create mode 100644 picpeak-plugin.lrplugin/PluginInfoDialogSections.lua create mode 100644 picpeak-plugin.lrplugin/PublishDialogSections.lua create mode 100644 picpeak-plugin.lrplugin/PublishServiceProvider.lua create mode 100644 picpeak-plugin.lrplugin/PublishTask.lua create mode 100644 picpeak-plugin.lrplugin/SharedDialogSections.lua create mode 100644 picpeak-plugin.lrplugin/inspect.lua create mode 100644 picpeak-plugin.lrplugin/util.lua diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..205696d --- /dev/null +++ b/CLAUDE.md @@ -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 diff --git a/picpeak-plugin.lrplugin/ErrorHandler.lua b/picpeak-plugin.lrplugin/ErrorHandler.lua new file mode 100644 index 0000000..148dbee --- /dev/null +++ b/picpeak-plugin.lrplugin/ErrorHandler.lua @@ -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 = "", + width = share("labelWidth"), + }), + f:static_text({ + title = msg, + alignment = "left", + font = "", + }), + }), + 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 diff --git a/picpeak-plugin.lrplugin/ExportDialogSections.lua b/picpeak-plugin.lrplugin/ExportDialogSections.lua new file mode 100644 index 0000000..ec989f9 --- /dev/null +++ b/picpeak-plugin.lrplugin/ExportDialogSections.lua @@ -0,0 +1,156 @@ +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 + + return { + { + title = "PicPeak Gallery Event", + bind_to_object = propertyTable, + f:column({ + spacing = f:control_spacing(), + + -- Event mode selector + f:row({ + f:static_text({ + title = "Upload to event:", + alignment = "right", + width = LrView.share("labelWidth"), + }), + 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 (visible when eventMode == "existing") + f:row({ + visible = LrBinding.keyEquals("eventMode", "existing"), + f:static_text({ + title = "Event:", + alignment = "right", + width = LrView.share("labelWidth"), + }), + 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 (visible when eventMode == "new") + f:column({ + visible = LrBinding.keyEquals("eventMode", "new"), + spacing = f:control_spacing(), + + f:row({ + f:static_text({ + title = "Event name:", + alignment = "right", + width = LrView.share("labelWidth"), + }), + f:edit_field({ + value = bind("newEventName"), + fill_horizontal = 1, + immediate = true, + }), + }), + f:row({ + f:static_text({ + title = "Event type:", + alignment = "right", + width = LrView.share("labelWidth"), + }), + f:popup_menu({ + value = bind("newEventType"), + items = SharedDialogSections.EVENT_TYPES, + immediate = true, + }), + }), + f:row({ + f:static_text({ + title = "Date (YYYY-MM-DD):", + alignment = "right", + width = LrView.share("labelWidth"), + }), + f:edit_field({ + value = bind("newEventDate"), + width_in_chars = 14, + immediate = true, + }), + f:static_text({ + title = "optional", + font = "", + }), + }), + f:row({ + f:static_text({ + title = "Password protect:", + alignment = "right", + width = LrView.share("labelWidth"), + }), + f:checkbox({ + title = "Require password to view gallery", + value = bind("newEventRequirePassword"), + }), + }), + f:row({ + visible = bind("newEventRequirePassword"), + f:static_text({ + title = "Password:", + alignment = "right", + width = LrView.share("labelWidth"), + }), + f:password_field({ + value = bind("newEventPassword"), + fill_horizontal = 1, + immediate = true, + }), + }), + }), + }), + }, + } +end diff --git a/picpeak-plugin.lrplugin/ExportServiceProvider.lua b/picpeak-plugin.lrplugin/ExportServiceProvider.lua new file mode 100644 index 0000000..52244d7 --- /dev/null +++ b/picpeak-plugin.lrplugin/ExportServiceProvider.lua @@ -0,0 +1,31 @@ +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 }, + { key = "newEventName", default = "" }, + { key = "newEventType", default = "other" }, + { key = "newEventDate", default = "" }, + { key = "newEventRequirePassword", default = false }, + { key = "newEventPassword", default = "" }, + }, + + canExportVideo = false, + + startDialog = ExportDialogSections.startDialog, + sectionsForTopOfDialog = ExportDialogSections.sectionsForTopOfDialog, + sectionsForBottomOfDialog = ExportDialogSections.sectionsForBottomOfDialog, + + processRenderedPhotos = ExportTask.processRenderedPhotos, +} diff --git a/picpeak-plugin.lrplugin/ExportTask.lua b/picpeak-plugin.lrplugin/ExportTask.lua new file mode 100644 index 0000000..3da6320 --- /dev/null +++ b/picpeak-plugin.lrplugin/ExportTask.lua @@ -0,0 +1,267 @@ +require("PicPeakAPI") +require("MetadataTask") +require("SharedDialogSections") + +ExportTask = {} + +-- --------------------------------------------------------------------------- +-- Show "choose event" modal when eventMode is 'onexport' +-- --------------------------------------------------------------------------- +local function showEventOptionsDialog(picpeak, exportParams) + local result = LrFunctionContext.callWithContext("eventChooser", function(context) + local f = LrView.osFactory() + exportParams.eventMode = "none" + exportParams.events = picpeak:getEvents() or {} + + local dialogContent = f:column({ + bind_to_object = exportParams, + spacing = f:control_spacing(), + + f:group_box({ + title = "PicPeak Gallery Event", + fill_horizontal = 1, + f:column({ + spacing = f:control_spacing(), + fill_horizontal = 1, + margin_top = 5, + margin_bottom = 5, + + f:static_text({ + title = "Select the event (gallery) to upload photos to.", + alignment = "left", + font = "", + fill_horizontal = 1, + }), + + f:separator({ fill_horizontal = 1 }), + + f:row({ + f:static_text({ + title = "Event mode:", + alignment = "right", + width = LrView.share("label_width"), + }), + f:popup_menu({ + fill_horizontal = 1, + items = { + { title = "Do not use an event", value = "none" }, + { title = "Existing event", value = "existing" }, + { title = "Create new event", value = "new" }, + }, + value = LrView.bind("eventMode"), + immediate = true, + }), + }), + + -- Existing picker + f:row({ + visible = LrBinding.keyEquals("eventMode", "existing"), + f:static_text({ + title = "Event:", + alignment = "right", + width = LrView.share("label_width"), + }), + f:popup_menu({ + fill_horizontal = 1, + value = LrView.bind("eventId"), + items = LrView.bind("events"), + immediate = true, + }), + }), + + -- New event name + f:row({ + visible = LrBinding.keyEquals("eventMode", "new"), + f:static_text({ + title = "Event name:", + alignment = "right", + width = LrView.share("label_width"), + }), + f:edit_field({ + fill_horizontal = 1, + value = LrView.bind("newEventName"), + immediate = true, + }), + }), + f:row({ + visible = LrBinding.keyEquals("eventMode", "new"), + f:static_text({ + title = "Event type:", + alignment = "right", + width = LrView.share("label_width"), + }), + f:popup_menu({ + value = LrView.bind("newEventType"), + items = require("SharedDialogSections").EVENT_TYPES, + immediate = true, + }), + }), + }), + }), + }) + + local dialogResult = LrDialogs.presentModalDialog({ + title = "PicPeak event options", + contents = dialogContent, + }) + + if dialogResult ~= "ok" then + LrDialogs.message("Export canceled.") + return false + end + return true + end, exportParams) + + return result == true +end + +-- --------------------------------------------------------------------------- +-- Resolve event for export +-- --------------------------------------------------------------------------- +local function resolveEventForExport(picpeak, exportParams) + if exportParams.eventMode == "onexport" then + if not showEventOptionsDialog(picpeak, exportParams) then + return true, nil -- canceled + end + end + + if exportParams.eventMode == "existing" then + local id = exportParams.eventId + if util.nilOrEmpty(id) then + log:warn("ExportTask: no event selected") + return false, nil + end + log:trace("ExportTask: using existing event id=" .. tostring(id)) + return false, tostring(id) + + elseif exportParams.eventMode == "new" then + local name = exportParams.newEventName + if util.nilOrEmpty(name) then + ErrorHandler.handleError( + "Event name is required when creating a new event.", + "ExportTask: newEventName empty" + ) + return true, nil -- treat as canceled / error + end + log:trace("ExportTask: creating new event '" .. name .. "'") + local newId = picpeak:createEvent({ + event_name = name, + event_type = exportParams.newEventType or "other", + event_date = exportParams.newEventDate or "", + require_password = exportParams.newEventRequirePassword, + password = exportParams.newEventPassword or "", + }) + if not newId then + ErrorHandler.handleError( + "Failed to create PicPeak event. Check connection and logs.", + "ExportTask: createEvent returned nil" + ) + return true, nil + end + log:info("ExportTask: created event id=" .. tostring(newId)) + return false, tostring(newId) + + elseif exportParams.eventMode == "none" then + log:trace("ExportTask: no event mode, uploading without event association") + return false, nil + end + + log:warn("ExportTask: unknown eventMode=" .. tostring(exportParams.eventMode)) + return false, nil +end + +-- --------------------------------------------------------------------------- +-- Main export entry point +-- --------------------------------------------------------------------------- + +function ExportTask.processRenderedPhotos(functionContext, exportContext) + local exportSession, exportParams, picpeak = util.validateExportContextAndConnect(exportContext, "Export") + if not exportSession then + return nil + end + + local nPhotos = exportSession:countRenditions() + log:info("=== PicPeak Export START: " .. nPhotos .. " photos | url=" .. tostring(exportParams.url) + .. " | eventMode=" .. tostring(exportParams.eventMode) .. " ===") + + local canceled, eventId = resolveEventForExport(picpeak, exportParams) + if canceled then + return + end + + local progressScope = LrProgressScope({ + title = util.buildSimpleUploadProgressTitle(nPhotos, "Exporting", exportParams.url or "PicPeak"), + functionContext = functionContext, + }) + + local failures = {} + local atLeastOneSuccess = false + local done = 0 + + for _, rendition in exportContext:renditions({ stopIfCanceled = true }) do + local success, pathOrMessage = rendition:waitForRender() + if progressScope:isCanceled() then + break + end + + if success then + local photo = rendition.photo + local fileName = photo:getFormattedMetadata("fileName") + + local photoId, errReason = picpeak:uploadPhoto(eventId, pathOrMessage, fileName) + util.safeDeleteTempFile(pathOrMessage) + + if not photoId then + log:error("ExportTask: upload failed for " .. fileName .. ": " .. tostring(errReason)) + table.insert(failures, fileName .. " (" .. (errReason or "Upload failed") .. ")") + else + atLeastOneSuccess = true + MetadataTask.setPhotoId(photo, tostring(photoId)) + if eventId then + MetadataTask.setEventId(photo, eventId) + end + log:info("ExportTask: uploaded " .. fileName .. " -> photoId=" .. tostring(photoId)) + end + else + log:warn("ExportTask: render failed for photo: " .. tostring(pathOrMessage)) + util.safeDeleteTempFile(pathOrMessage) + end + + done = done + 1 + progressScope:setPortionComplete(done, nPhotos) + if done == 1 or done % 10 == 0 or done == nPhotos then + log:info("Export progress: " .. done .. "/" .. nPhotos) + end + end + + progressScope:done() + + -- If we created a new event but nothing uploaded, it will remain as an empty gallery. + -- PicPeak v1 API has no delete event endpoint, so we just warn. + if not atLeastOneSuccess and exportParams.eventMode == "new" and eventId then + LrDialogs.message( + "PicPeak Export", + "No photos were uploaded successfully. The empty gallery event was left on the server.", + "warning" + ) + end + + log:info("=== PicPeak Export DONE: " .. nPhotos .. " photos | failures=" .. #failures .. " ===") + util.reportUploadFailures(failures) + + -- Show share link if we have an event + if atLeastOneSuccess and eventId then + local shareUrl = picpeak:getEventShareUrl(eventId) + if shareUrl then + local result = LrDialogs.confirm( + "Upload complete", + "Gallery share link:\n" .. shareUrl, + "Copy to clipboard", + "Close" + ) + if result == "ok" then + LrDialogs.message("Copied!", shareUrl) + end + end + end +end diff --git a/picpeak-plugin.lrplugin/Info.lua b/picpeak-plugin.lrplugin/Info.lua new file mode 100644 index 0000000..47d04ee --- /dev/null +++ b/picpeak-plugin.lrplugin/Info.lua @@ -0,0 +1,30 @@ +return { + + LrSdkVersion = 3.0, + LrSdkMinimumVersion = 3.0, + + LrToolkitIdentifier = "lrc-picpeak-plugin", + + LrPluginName = "PicPeak", + + LrInitPlugin = "Init.lua", + + LrExportServiceProvider = { + { + title = "PicPeak Exporter", + file = "ExportServiceProvider.lua", + }, + { + title = "PicPeak Publisher", + file = "PublishServiceProvider.lua", + }, + }, + + LrMetadataProvider = "MetadataProvider.lua", + + LrPluginInfoProvider = "PluginInfo.lua", + + LrPluginInfoURL = "https://github.com/bmachek/lrc-picpeak", + + VERSION = { major = 1, minor = 0, revision = 0, build = 0 }, +} diff --git a/picpeak-plugin.lrplugin/Init.lua b/picpeak-plugin.lrplugin/Init.lua new file mode 100644 index 0000000..8ca57a4 --- /dev/null +++ b/picpeak-plugin.lrplugin/Init.lua @@ -0,0 +1,44 @@ +---@diagnostic disable: undefined-global + +-- Global imports +_G.LrHttp = import("LrHttp") +_G.LrDate = import("LrDate") +_G.LrPathUtils = import("LrPathUtils") +_G.LrFileUtils = import("LrFileUtils") +_G.LrTasks = import("LrTasks") +_G.LrErrors = import("LrErrors") +_G.LrDialogs = import("LrDialogs") +_G.LrView = import("LrView") +_G.LrBinding = import("LrBinding") +_G.LrColor = import("LrColor") +_G.LrFunctionContext = import("LrFunctionContext") +_G.LrApplication = import("LrApplication") +_G.LrPrefs = import("LrPrefs") +_G.LrShell = import("LrShell") +_G.LrSystemInfo = import("LrSystemInfo") +_G.LrProgressScope = import("LrProgressScope") +_G.LrLogger = import("LrLogger") + +_G.JSON = require("JSON") +_G.inspect = require("inspect") +require("util") +require("ErrorHandler") + +-- Global initializations +_G.prefs = _G.LrPrefs.prefsForPlugin() +_G.log = import("LrLogger")("PicPeakPlugin") +if _G.prefs.logging == nil then + _G.prefs.logging = false +end +if _G.prefs.logging then + _G.log:enable("logfile") +else + _G.log:disable() +end + +if _G.prefs.apiToken == nil then + _G.prefs.apiToken = "" +end +if _G.prefs.url == nil then + _G.prefs.url = "" +end diff --git a/picpeak-plugin.lrplugin/JSON.lua b/picpeak-plugin.lrplugin/JSON.lua new file mode 100644 index 0000000..9e479c6 --- /dev/null +++ b/picpeak-plugin.lrplugin/JSON.lua @@ -0,0 +1,1529 @@ +---@diagnostic disable: need-check-nil +-- -*- coding: utf-8 -*- +-- +-- Simple JSON encoding and decoding in pure Lua. +-- +-- Copyright 2010-2016 Jeffrey Friedl +-- http://regex.info/blog/ +-- Latest version: http://regex.info/blog/lua/json +-- +-- This code is released under a Creative Commons CC-BY "Attribution" License: +-- http://creativecommons.org/licenses/by/3.0/deed.en_US +-- +-- It can be used for any purpose so long as: +-- 1) the copyright notice above is maintained +-- 2) the web-page links above are maintained +-- 3) the 'AUTHOR_NOTE' string below is maintained +-- +local VERSION = '20161109.21' -- version history at end of file +local AUTHOR_NOTE = "-[ JSON.lua package by Jeffrey Friedl (http://regex.info/blog/lua/json) version 20161109.21 ]-" + +-- +-- The 'AUTHOR_NOTE' variable exists so that information about the source +-- of the package is maintained even in compiled versions. It's also +-- included in OBJDEF below mostly to quiet warnings about unused variables. +-- +local OBJDEF = { + VERSION = VERSION, + AUTHOR_NOTE = AUTHOR_NOTE, +} + + +-- +-- Simple JSON encoding and decoding in pure Lua. +-- JSON definition: http://www.json.org/ +-- +-- +-- JSON = assert(loadfile "JSON.lua")() -- one-time load of the routines +-- +-- local lua_value = JSON:decode(raw_json_text) +-- +-- local raw_json_text = JSON:encode(lua_table_or_value) +-- local pretty_json_text = JSON:encode_pretty(lua_table_or_value) -- "pretty printed" version for human readability +-- +-- +-- +-- DECODING (from a JSON string to a Lua table) +-- +-- +-- JSON = assert(loadfile "JSON.lua")() -- one-time load of the routines +-- +-- local lua_value = JSON:decode(raw_json_text) +-- +-- If the JSON text is for an object or an array, e.g. +-- { "what": "books", "count": 3 } +-- or +-- [ "Larry", "Curly", "Moe" ] +-- +-- the result is a Lua table, e.g. +-- { what = "books", count = 3 } +-- or +-- { "Larry", "Curly", "Moe" } +-- +-- +-- The encode and decode routines accept an optional second argument, +-- "etc", which is not used during encoding or decoding, but upon error +-- is passed along to error handlers. It can be of any type (including nil). +-- +-- +-- +-- ERROR HANDLING +-- +-- With most errors during decoding, this code calls +-- +-- JSON:onDecodeError(message, text, location, etc) +-- +-- with a message about the error, and if known, the JSON text being +-- parsed and the byte count where the problem was discovered. You can +-- replace the default JSON:onDecodeError() with your own function. +-- +-- The default onDecodeError() merely augments the message with data +-- about the text and the location if known (and if a second 'etc' +-- argument had been provided to decode(), its value is tacked onto the +-- message as well), and then calls JSON.assert(), which itself defaults +-- to Lua's built-in assert(), and can also be overridden. +-- +-- For example, in an Adobe Lightroom plugin, you might use something like +-- +-- function JSON:onDecodeError(message, text, location, etc) +-- LrErrors.throwUserError("Internal Error: invalid JSON data") +-- end +-- +-- or even just +-- +-- function JSON.assert(message) +-- LrErrors.throwUserError("Internal Error: " .. message) +-- end +-- +-- If JSON:decode() is passed a nil, this is called instead: +-- +-- JSON:onDecodeOfNilError(message, nil, nil, etc) +-- +-- and if JSON:decode() is passed HTML instead of JSON, this is called: +-- +-- JSON:onDecodeOfHTMLError(message, text, nil, etc) +-- +-- The use of the fourth 'etc' argument allows stronger coordination +-- between decoding and error reporting, especially when you provide your +-- own error-handling routines. Continuing with the the Adobe Lightroom +-- plugin example: +-- +-- function JSON:onDecodeError(message, text, location, etc) +-- local note = "Internal Error: invalid JSON data" +-- if type(etc) = 'table' and etc.photo then +-- note = note .. " while processing for " .. etc.photo:getFormattedMetadata('fileName') +-- end +-- LrErrors.throwUserError(note) +-- end +-- +-- : +-- : +-- +-- for i, photo in ipairs(photosToProcess) do +-- : +-- : +-- local data = JSON:decode(someJsonText, { photo = photo }) +-- : +-- : +-- end +-- +-- +-- +-- If the JSON text passed to decode() has trailing garbage (e.g. as with the JSON "[123]xyzzy"), +-- the method +-- +-- JSON:onTrailingGarbage(json_text, location, parsed_value, etc) +-- +-- is invoked, where: +-- +-- json_text is the original JSON text being parsed, +-- location is the count of bytes into json_text where the garbage starts (6 in the example), +-- parsed_value is the Lua result of what was successfully parsed ({123} in the example), +-- etc is as above. +-- +-- If JSON:onTrailingGarbage() does not abort, it should return the value decode() should return, +-- or nil + an error message. +-- +-- local new_value, error_message = JSON:onTrailingGarbage() +-- +-- The default handler just invokes JSON:onDecodeError("trailing garbage"...), but you can have +-- this package ignore trailing garbage via +-- +-- function JSON:onTrailingGarbage(json_text, location, parsed_value, etc) +-- return parsed_value +-- end +-- +-- +-- DECODING AND STRICT TYPES +-- +-- Because both JSON objects and JSON arrays are converted to Lua tables, +-- it's not normally possible to tell which original JSON type a +-- particular Lua table was derived from, or guarantee decode-encode +-- round-trip equivalency. +-- +-- However, if you enable strictTypes, e.g. +-- +-- JSON = assert(loadfile "JSON.lua")() --load the routines +-- JSON.strictTypes = true +-- +-- then the Lua table resulting from the decoding of a JSON object or +-- JSON array is marked via Lua metatable, so that when re-encoded with +-- JSON:encode() it ends up as the appropriate JSON type. +-- +-- (This is not the default because other routines may not work well with +-- tables that have a metatable set, for example, Lightroom API calls.) +-- +-- +-- ENCODING (from a lua table to a JSON string) +-- +-- JSON = assert(loadfile "JSON.lua")() -- one-time load of the routines +-- +-- local raw_json_text = JSON:encode(lua_table_or_value) +-- local pretty_json_text = JSON:encode_pretty(lua_table_or_value) -- "pretty printed" version for human readability +-- local custom_pretty = JSON:encode(lua_table_or_value, etc, { pretty = true, indent = "| ", align_keys = false }) +-- +-- On error during encoding, this code calls: +-- +-- JSON:onEncodeError(message, etc) +-- +-- which you can override in your local JSON object. +-- +-- The 'etc' in the error call is the second argument to encode() +-- and encode_pretty(), or nil if it wasn't provided. +-- +-- +-- ENCODING OPTIONS +-- +-- An optional third argument, a table of options, can be provided to encode(). +-- +-- encode_options = { +-- -- options for making "pretty" human-readable JSON (see "PRETTY-PRINTING" below) +-- pretty = true, +-- indent = " ", +-- align_keys = false, +-- array_newline = false, +-- +-- -- other output-related options +-- null = "\0", -- see "ENCODING JSON NULL VALUES" below +-- stringsAreUtf8 = false, -- see "HANDLING UNICODE LINE AND PARAGRAPH SEPARATORS FOR JAVA" below +-- } +-- +-- json_string = JSON:encode(mytable, etc, encode_options) +-- +-- +-- +-- For reference, the defaults are: +-- +-- pretty = false +-- null = nil, +-- stringsAreUtf8 = false, +-- array_newline = false, +-- +-- +-- +-- PRETTY-PRINTING +-- +-- Enabling the 'pretty' encode option helps generate human-readable JSON. +-- +-- pretty = JSON:encode(val, etc, { +-- pretty = true, +-- indent = " ", +-- align_keys = false, +-- }) +-- +-- encode_pretty() is also provided: it's identical to encode() except +-- that encode_pretty() provides a default options table if none given in the call: +-- +-- { pretty = true, align_keys = false, indent = " " } +-- +-- For example, if +-- +-- JSON:encode(data) +-- +-- produces: +-- +-- {"city":"Kyoto","climate":{"avg_temp":16,"humidity":"high","snowfall":"minimal"},"country":"Japan","wards":11} +-- +-- then +-- +-- JSON:encode_pretty(data) +-- +-- produces: +-- +-- { +-- "city": "Kyoto", +-- "climate": { +-- "avg_temp": 16, +-- "humidity": "high", +-- "snowfall": "minimal" +-- }, +-- "country": "Japan", +-- "wards": 11 +-- } +-- +-- The following three lines return identical results: +-- JSON:encode_pretty(data) +-- JSON:encode_pretty(data, nil, { pretty = true, align_keys = false, indent = " " }) +-- JSON:encode (data, nil, { pretty = true, align_keys = false, indent = " " }) +-- +-- An example of setting your own indent string: +-- +-- JSON:encode_pretty(data, nil, { pretty = true, indent = "| " }) +-- +-- produces: +-- +-- { +-- | "city": "Kyoto", +-- | "climate": { +-- | | "avg_temp": 16, +-- | | "humidity": "high", +-- | | "snowfall": "minimal" +-- | }, +-- | "country": "Japan", +-- | "wards": 11 +-- } +-- +-- An example of setting align_keys to true: +-- +-- JSON:encode_pretty(data, nil, { pretty = true, indent = " ", align_keys = true }) +-- +-- produces: +-- +-- { +-- "city": "Kyoto", +-- "climate": { +-- "avg_temp": 16, +-- "humidity": "high", +-- "snowfall": "minimal" +-- }, +-- "country": "Japan", +-- "wards": 11 +-- } +-- +-- which I must admit is kinda ugly, sorry. This was the default for +-- encode_pretty() prior to version 20141223.14. +-- +-- +-- HANDLING UNICODE LINE AND PARAGRAPH SEPARATORS FOR JAVA +-- +-- If the 'stringsAreUtf8' encode option is set to true, consider Lua strings not as a sequence of bytes, +-- but as a sequence of UTF-8 characters. +-- +-- Currently, the only practical effect of setting this option is that Unicode LINE and PARAGRAPH +-- separators, if found in a string, are encoded with a JSON escape instead of being dumped as is. +-- The JSON is valid either way, but encoding this way, apparently, allows the resulting JSON +-- to also be valid Java. +-- +-- AMBIGUOUS SITUATIONS DURING THE ENCODING +-- +-- During the encode, if a Lua table being encoded contains both string +-- and numeric keys, it fits neither JSON's idea of an object, nor its +-- idea of an array. To get around this, when any string key exists (or +-- when non-positive numeric keys exist), numeric keys are converted to +-- strings. +-- +-- For example, +-- JSON:encode({ "one", "two", "three", SOMESTRING = "some string" })) +-- produces the JSON object +-- {"1":"one","2":"two","3":"three","SOMESTRING":"some string"} +-- +-- To prohibit this conversion and instead make it an error condition, set +-- JSON.noKeyConversion = true +-- +-- +-- ENCODING JSON NULL VALUES +-- +-- Lua tables completely omit keys whose value is nil, so without special handling there's +-- no way to get a field in a JSON object with a null value. For example +-- JSON:encode({ username = "admin", password = nil }) +-- produces +-- {"username":"admin"} +-- +-- In order to actually produce +-- {"username":"admin", "password":null} +-- one can include a string value for a "null" field in the options table passed to encode().... +-- any Lua table entry with that value becomes null in the JSON output: +-- JSON:encode({ username = "admin", password = "xyzzy" }, nil, { null = "xyzzy" }) +-- produces +-- {"username":"admin", "password":null} +-- +-- Just be sure to use a string that is otherwise unlikely to appear in your data. +-- The string "\0" (a string with one null byte) may well be appropriate for many applications. +-- +-- The "null" options also applies to Lua tables that become JSON arrays. +-- JSON:encode({ "one", "two", nil, nil }) +-- produces +-- ["one","two"] +-- while +-- NULL = "\0" +-- JSON:encode({ "one", "two", NULL, NULL}, nil, { null = NULL }) +-- produces +-- ["one","two",null,null] +-- +-- +-- +-- +-- HANDLING LARGE AND/OR PRECISE NUMBERS +-- +-- +-- Without special handling, numbers in JSON can lose precision in Lua. +-- For example: +-- +-- T = JSON:decode('{ "small":12345, "big":12345678901234567890123456789, "precise":9876.67890123456789012345 }') +-- +-- print("small: ", type(T.small), T.small) +-- print("big: ", type(T.big), T.big) +-- print("precise: ", type(T.precise), T.precise) +-- +-- produces +-- +-- small: number 12345 +-- big: number 1.2345678901235e+28 +-- precise: number 9876.6789012346 +-- +-- Precision is lost with both 'big' and 'precise'. +-- +-- This package offers ways to try to handle this better (for some definitions of "better")... +-- +-- The most precise method is by setting the global: +-- +-- JSON.decodeNumbersAsObjects = true +-- +-- When this is set, numeric JSON data is encoded into Lua in a form that preserves the exact +-- JSON numeric presentation when re-encoded back out to JSON, or accessed in Lua as a string. +-- +-- (This is done by encoding the numeric data with a Lua table/metatable that returns +-- the possibly-imprecise numeric form when accessed numerically, but the original precise +-- representation when accessed as a string. You can also explicitly access +-- via JSON:forceString() and JSON:forceNumber()) +-- +-- Consider the example above, with this option turned on: +-- +-- JSON.decodeNumbersAsObjects = true +-- +-- T = JSON:decode('{ "small":12345, "big":12345678901234567890123456789, "precise":9876.67890123456789012345 }') +-- +-- print("small: ", type(T.small), T.small) +-- print("big: ", type(T.big), T.big) +-- print("precise: ", type(T.precise), T.precise) +-- +-- This now produces: +-- +-- small: table 12345 +-- big: table 12345678901234567890123456789 +-- precise: table 9876.67890123456789012345 +-- +-- However, within Lua you can still use the values (e.g. T.precise in the example above) in numeric +-- contexts. In such cases you'll get the possibly-imprecise numeric version, but in string contexts +-- and when the data finds its way to this package's encode() function, the original full-precision +-- representation is used. +-- +-- Even without using the JSON.decodeNumbersAsObjects option, you can encode numbers +-- in your Lua table that retain high precision upon encoding to JSON, by using the JSON:asNumber() +-- function: +-- +-- T = { +-- imprecise = 123456789123456789.123456789123456789, +-- precise = JSON:asNumber("123456789123456789.123456789123456789") +-- } +-- +-- print(JSON:encode_pretty(T)) +-- +-- This produces: +-- +-- { +-- "precise": 123456789123456789.123456789123456789, +-- "imprecise": 1.2345678912346e+17 +-- } +-- +-- +-- +-- A different way to handle big/precise JSON numbers is to have decode() merely return +-- the exact string representation of the number instead of the number itself. +-- This approach might be useful when the numbers are merely some kind of opaque +-- object identifier and you want to work with them in Lua as strings anyway. +-- +-- This approach is enabled by setting +-- +-- JSON.decodeIntegerStringificationLength = 10 +-- +-- The value is the number of digits (of the integer part of the number) at which to stringify numbers. +-- +-- Consider our previous example with this option set to 10: +-- +-- JSON.decodeIntegerStringificationLength = 10 +-- +-- T = JSON:decode('{ "small":12345, "big":12345678901234567890123456789, "precise":9876.67890123456789012345 }') +-- +-- print("small: ", type(T.small), T.small) +-- print("big: ", type(T.big), T.big) +-- print("precise: ", type(T.precise), T.precise) +-- +-- This produces: +-- +-- small: number 12345 +-- big: string 12345678901234567890123456789 +-- precise: number 9876.6789012346 +-- +-- The long integer of the 'big' field is at least JSON.decodeIntegerStringificationLength digits +-- in length, so it's converted not to a Lua integer but to a Lua string. Using a value of 0 or 1 ensures +-- that all JSON numeric data becomes strings in Lua. +-- +-- Note that unlike +-- JSON.decodeNumbersAsObjects = true +-- this stringification is simple and unintelligent: the JSON number simply becomes a Lua string, and that's the end of it. +-- If the string is then converted back to JSON, it's still a string. After running the code above, adding +-- print(JSON:encode(T)) +-- produces +-- {"big":"12345678901234567890123456789","precise":9876.6789012346,"small":12345} +-- which is unlikely to be desired. +-- +-- There's a comparable option for the length of the decimal part of a number: +-- +-- JSON.decodeDecimalStringificationLength +-- +-- This can be used alone or in conjunction with +-- +-- JSON.decodeIntegerStringificationLength +-- +-- to trip stringification on precise numbers with at least JSON.decodeIntegerStringificationLength digits after +-- the decimal point. +-- +-- This example: +-- +-- JSON.decodeIntegerStringificationLength = 10 +-- JSON.decodeDecimalStringificationLength = 5 +-- +-- T = JSON:decode('{ "small":12345, "big":12345678901234567890123456789, "precise":9876.67890123456789012345 }') +-- +-- print("small: ", type(T.small), T.small) +-- print("big: ", type(T.big), T.big) +-- print("precise: ", type(T.precise), T.precise) +-- +-- produces: +-- +-- small: number 12345 +-- big: string 12345678901234567890123456789 +-- precise: string 9876.67890123456789012345 +-- +-- +-- +-- +-- +-- SUMMARY OF METHODS YOU CAN OVERRIDE IN YOUR LOCAL LUA JSON OBJECT +-- +-- assert +-- onDecodeError +-- onDecodeOfNilError +-- onDecodeOfHTMLError +-- onTrailingGarbage +-- onEncodeError +-- +-- If you want to create a separate Lua JSON object with its own error handlers, +-- you can reload JSON.lua or use the :new() method. +-- +--------------------------------------------------------------------------- + +local default_pretty_indent = " " +local default_pretty_options = { pretty = true, align_keys = false, indent = default_pretty_indent } + +local isArray = { __tostring = function() return "JSON array" end } +isArray.__index = isArray +local isObject = { __tostring = function() return "JSON object" end } +isObject.__index = isObject + +function OBJDEF:newArray(tbl) + return setmetatable(tbl or {}, isArray) +end + +function OBJDEF:newObject(tbl) + return setmetatable(tbl or {}, isObject) +end + +local function getnum(op) + return type(op) == 'number' and op or op.N +end + +local isNumber = { + __tostring = function(T) return T.S end, + __unm = function(op) return getnum(op) end, + + __concat = function(op1, op2) return tostring(op1) .. tostring(op2) end, + __add = function(op1, op2) return getnum(op1) + getnum(op2) end, + __sub = function(op1, op2) return getnum(op1) - getnum(op2) end, + __mul = function(op1, op2) return getnum(op1) * getnum(op2) end, + __div = function(op1, op2) return getnum(op1) / getnum(op2) end, + __mod = function(op1, op2) return getnum(op1) % getnum(op2) end, + __pow = function(op1, op2) return getnum(op1) ^ getnum(op2) end, + __lt = function(op1, op2) return getnum(op1) < getnum(op2) end, + __eq = function(op1, op2) return getnum(op1) == getnum(op2) end, + __le = function(op1, op2) return getnum(op1) <= getnum(op2) end, +} +isNumber.__index = isNumber + +function OBJDEF:asNumber(item) + if getmetatable(item) == isNumber then + -- it's already a JSON number object. + return item + elseif type(item) == 'table' and type(item.S) == 'string' and type(item.N) == 'number' then + -- it's a number-object table that lost its metatable, so give it one + return setmetatable(item, isNumber) + else + -- the normal situation... given a number or a string representation of a number.... + local holder = { + S = tostring(item), -- S is the representation of the number as a string, which remains precise + N = tonumber(item), -- N is the number as a Lua number. + } + return setmetatable(holder, isNumber) + end +end + +-- +-- Given an item that might be a normal string or number, or might be an 'isNumber' object defined above, +-- return the string version. This shouldn't be needed often because the 'isNumber' object should autoconvert +-- to a string in most cases, but it's here to allow it to be forced when needed. +-- +function OBJDEF:forceString(item) + if type(item) == 'table' and type(item.S) == 'string' then + return item.S + else + return tostring(item) + end +end + +-- +-- Given an item that might be a normal string or number, or might be an 'isNumber' object defined above, +-- return the numeric version. +-- +function OBJDEF:forceNumber(item) + if type(item) == 'table' and type(item.N) == 'number' then + return item.N + else + return tonumber(item) + end +end + +local function unicode_codepoint_as_utf8(codepoint) + -- + -- codepoint is a number + -- + if codepoint <= 127 then + return string.char(codepoint) + elseif codepoint <= 2047 then + -- + -- 110yyyxx 10xxxxxx <-- useful notation from http://en.wikipedia.org/wiki/Utf8 + -- + local highpart = math.floor(codepoint / 0x40) + local lowpart = codepoint - (0x40 * highpart) + return string.char(0xC0 + highpart, + 0x80 + lowpart) + elseif codepoint <= 65535 then + -- + -- 1110yyyy 10yyyyxx 10xxxxxx + -- + local highpart = math.floor(codepoint / 0x1000) + local remainder = codepoint - 0x1000 * highpart + local midpart = math.floor(remainder / 0x40) + local lowpart = remainder - 0x40 * midpart + + highpart = 0xE0 + highpart + midpart = 0x80 + midpart + lowpart = 0x80 + lowpart + + -- + -- Check for an invalid character (thanks Andy R. at Adobe). + -- See table 3.7, page 93, in http://www.unicode.org/versions/Unicode5.2.0/ch03.pdf#G28070 + -- + if (highpart == 0xE0 and midpart < 0xA0) or + (highpart == 0xED and midpart > 0x9F) or + (highpart == 0xF0 and midpart < 0x90) or + (highpart == 0xF4 and midpart > 0x8F) + then + return "?" + else + return string.char(highpart, + midpart, + lowpart) + end + else + -- + -- 11110zzz 10zzyyyy 10yyyyxx 10xxxxxx + -- + local highpart = math.floor(codepoint / 0x40000) + local remainder = codepoint - 0x40000 * highpart + local midA = math.floor(remainder / 0x1000) + remainder = remainder - 0x1000 * midA + local midB = math.floor(remainder / 0x40) + local lowpart = remainder - 0x40 * midB + + return string.char(0xF0 + highpart, + 0x80 + midA, + 0x80 + midB, + 0x80 + lowpart) + end +end + +function OBJDEF:onDecodeError(message, text, location, etc) + if text then + if location then + message = string.format("%s at byte %d of: %s", message, location, text) + else + message = string.format("%s: %s", message, text) + end + end + + if etc ~= nil then + message = message .. " (" .. OBJDEF:encode(etc) .. ")" + end + + if self.assert then + self.assert(false, message) + else + assert(false, message) + end +end + +function OBJDEF:onTrailingGarbage(json_text, location, parsed_value, etc) + return self:onDecodeError("trailing garbage", json_text, location, etc) +end + +OBJDEF.onDecodeOfNilError = OBJDEF.onDecodeError +OBJDEF.onDecodeOfHTMLError = OBJDEF.onDecodeError + +function OBJDEF:onEncodeError(message, etc) + if etc ~= nil then + message = message .. " (" .. OBJDEF:encode(etc) .. ")" + end + + if self.assert then + self.assert(false, message) + else + assert(false, message) + end +end + +local function grok_number(self, text, start, options) + -- + -- Grab the integer part + -- + local integer_part = text:match('^-?[1-9]%d*', start) + or text:match("^-?0", start) + + if not integer_part then + self:onDecodeError("expected number", text, start, options.etc) + return nil, start -- in case the error method doesn't abort, return something sensible + end + + local i = start + integer_part:len() + + -- + -- Grab an optional decimal part + -- + local decimal_part = text:match('^%.%d+', i) or "" + + i = i + decimal_part:len() + + -- + -- Grab an optional exponential part + -- + local exponent_part = text:match('^[eE][-+]?%d+', i) or "" + + i = i + exponent_part:len() + + local full_number_text = integer_part .. decimal_part .. exponent_part + + if options.decodeNumbersAsObjects then + return OBJDEF:asNumber(full_number_text), i + end + + -- + -- If we're told to stringify under certain conditions, so do. + -- We punt a bit when there's an exponent by just stringifying no matter what. + -- I suppose we should really look to see whether the exponent is actually big enough one + -- way or the other to trip stringification, but I'll be lazy about it until someone asks. + -- + if (options.decodeIntegerStringificationLength + and + (integer_part:len() >= options.decodeIntegerStringificationLength or exponent_part:len() > 0)) + + or + + (options.decodeDecimalStringificationLength + and + (decimal_part:len() >= options.decodeDecimalStringificationLength or exponent_part:len() > 0)) + then + return full_number_text, i -- this returns the exact string representation seen in the original JSON + end + + + + local as_number = tonumber(full_number_text) + + if not as_number then + self:onDecodeError("bad number", text, start, options.etc) + return nil, start -- in case the error method doesn't abort, return something sensible + end + + return as_number, i +end + + +local function grok_string(self, text, start, options) + if text:sub(start, start) ~= '"' then + self:onDecodeError("expected string's opening quote", text, start, options.etc) + return nil, start -- in case the error method doesn't abort, return something sensible + end + + local i = start + 1 -- +1 to bypass the initial quote + local text_len = text:len() + local VALUE = "" + while i <= text_len do + local c = text:sub(i, i) + if c == '"' then + return VALUE, i + 1 + end + if c ~= '\\' then + VALUE = VALUE .. c + i = i + 1 + elseif text:match('^\\b', i) then + VALUE = VALUE .. "\b" + i = i + 2 + elseif text:match('^\\f', i) then + VALUE = VALUE .. "\f" + i = i + 2 + elseif text:match('^\\n', i) then + VALUE = VALUE .. "\n" + i = i + 2 + elseif text:match('^\\r', i) then + VALUE = VALUE .. "\r" + i = i + 2 + elseif text:match('^\\t', i) then + VALUE = VALUE .. "\t" + i = i + 2 + else + local hex = text:match( + '^\\u([0123456789aAbBcCdDeEfF][0123456789aAbBcCdDeEfF][0123456789aAbBcCdDeEfF][0123456789aAbBcCdDeEfF])', i) + if hex then + i = i + 6 -- bypass what we just read + + -- We have a Unicode codepoint. It could be standalone, or if in the proper range and + -- followed by another in a specific range, it'll be a two-code surrogate pair. + local codepoint = tonumber(hex, 16) + if codepoint >= 0xD800 and codepoint <= 0xDBFF then + -- it's a hi surrogate... see whether we have a following low + local lo_surrogate = text:match('^\\u([dD][cdefCDEF][0123456789aAbBcCdDeEfF][0123456789aAbBcCdDeEfF])', i) + if lo_surrogate then + i = i + 6 -- bypass the low surrogate we just read + codepoint = 0x2400 + (codepoint - 0xD800) * 0x400 + tonumber(lo_surrogate, 16) + else + -- not a proper low, so we'll just leave the first codepoint as is and spit it out. + end + end + VALUE = VALUE .. unicode_codepoint_as_utf8(codepoint) + else + -- just pass through what's escaped + VALUE = VALUE .. text:match('^\\(.)', i) + i = i + 2 + end + end + end + + self:onDecodeError("unclosed string", text, start, options.etc) + return nil, start -- in case the error method doesn't abort, return something sensible +end + +local function skip_whitespace(text, start) + local _, match_end = text:find("^[ \n\r\t]+", start) -- [http://www.ietf.org/rfc/rfc4627.txt] Section 2 + if match_end then + return match_end + 1 + else + return start + end +end + +local grok_one -- assigned later + +local function grok_object(self, text, start, options) + if text:sub(start, start) ~= '{' then + self:onDecodeError("expected '{'", text, start, options.etc) + return nil, start -- in case the error method doesn't abort, return something sensible + end + + local i = skip_whitespace(text, start + 1) -- +1 to skip the '{' + + local VALUE = self.strictTypes and self:newObject {} or {} + + if text:sub(i, i) == '}' then + return VALUE, i + 1 + end + local text_len = text:len() + while i <= text_len do + local key, new_i = grok_string(self, text, i, options) + + i = skip_whitespace(text, new_i) + + if text:sub(i, i) ~= ':' then + self:onDecodeError("expected colon", text, i, options.etc) + return nil, i -- in case the error method doesn't abort, return something sensible + end + + i = skip_whitespace(text, i + 1) + + local new_val, new_i = grok_one(self, text, i, options) + + VALUE[key] = new_val + + -- + -- Expect now either '}' to end things, or a ',' to allow us to continue. + -- + i = skip_whitespace(text, new_i) + + local c = text:sub(i, i) + + if c == '}' then + return VALUE, i + 1 + end + + if text:sub(i, i) ~= ',' then + self:onDecodeError("expected comma or '}'", text, i, options.etc) + return nil, i -- in case the error method doesn't abort, return something sensible + end + + i = skip_whitespace(text, i + 1) + end + + self:onDecodeError("unclosed '{'", text, start, options.etc) + return nil, start -- in case the error method doesn't abort, return something sensible +end + +local function grok_array(self, text, start, options) + if text:sub(start, start) ~= '[' then + self:onDecodeError("expected '['", text, start, options.etc) + return nil, start -- in case the error method doesn't abort, return something sensible + end + + local i = skip_whitespace(text, start + 1) -- +1 to skip the '[' + local VALUE = self.strictTypes and self:newArray {} or {} + if text:sub(i, i) == ']' then + return VALUE, i + 1 + end + + local VALUE_INDEX = 1 + + local text_len = text:len() + while i <= text_len do + local val, new_i = grok_one(self, text, i, options) + + -- can't table.insert(VALUE, val) here because it's a no-op if val is nil + VALUE[VALUE_INDEX] = val + VALUE_INDEX = VALUE_INDEX + 1 + + i = skip_whitespace(text, new_i) + + -- + -- Expect now either ']' to end things, or a ',' to allow us to continue. + -- + local c = text:sub(i, i) + if c == ']' then + return VALUE, i + 1 + end + if text:sub(i, i) ~= ',' then + self:onDecodeError("expected comma or ']'", text, i, options.etc) + return nil, i -- in case the error method doesn't abort, return something sensible + end + i = skip_whitespace(text, i + 1) + end + self:onDecodeError("unclosed '['", text, start, options.etc) + return nil, i -- in case the error method doesn't abort, return something sensible +end + + +grok_one = function(self, text, start, options) + -- Skip any whitespace + start = skip_whitespace(text, start) + + if start > text:len() then + self:onDecodeError("unexpected end of string", text, nil, options.etc) + return nil, start -- in case the error method doesn't abort, return something sensible + end + + if text:find('^"', start) then + return grok_string(self, text, start, options.etc) + elseif text:find('^[-0123456789 ]', start) then + return grok_number(self, text, start, options) + elseif text:find('^%{', start) then + return grok_object(self, text, start, options) + elseif text:find('^%[', start) then + return grok_array(self, text, start, options) + elseif text:find('^true', start) then + return true, start + 4 + elseif text:find('^false', start) then + return false, start + 5 + elseif text:find('^null', start) then + return nil, start + 4 + else + self:onDecodeError("can't parse JSON", text, start, options.etc) + return nil, 1 -- in case the error method doesn't abort, return something sensible + end +end + +function OBJDEF:decode(text, etc, options) + -- + -- If the user didn't pass in a table of decode options, make an empty one. + -- + if type(options) ~= 'table' then + options = {} + end + + -- + -- If they passed in an 'etc' argument, stuff it into the options. + -- (If not, any 'etc' field in the options they passed in remains to be used) + -- + if etc ~= nil then + options.etc = etc + end + + + if type(self) ~= 'table' or self.__index ~= OBJDEF then + local error_message = "JSON:decode must be called in method format" + OBJDEF:onDecodeError(error_message, nil, nil, options.etc) + return nil, error_message -- in case the error method doesn't abort, return something sensible + end + + if text == nil then + local error_message = "nil passed to JSON:decode()" + self:onDecodeOfNilError(error_message, nil, nil, options.etc) + return nil, error_message -- in case the error method doesn't abort, return something sensible + elseif type(text) ~= 'string' then + local error_message = "expected string argument to JSON:decode()" + self:onDecodeError(string.format("%s, got %s", error_message, type(text)), nil, nil, options.etc) + return nil, error_message -- in case the error method doesn't abort, return something sensible + end + + if text:match('^%s*$') then + -- an empty string is nothing, but not an error + return nil + end + + if text:match('^%s*<') then + -- Can't be JSON... we'll assume it's HTML + local error_message = "HTML passed to JSON:decode()" + self:onDecodeOfHTMLError(error_message, text, nil, options.etc) + return nil, error_message -- in case the error method doesn't abort, return something sensible + end + + -- + -- Ensure that it's not UTF-32 or UTF-16. + -- Those are perfectly valid encodings for JSON (as per RFC 4627 section 3), + -- but this package can't handle them. + -- + if text:sub(1, 1):byte() == 0 or (text:len() >= 2 and text:sub(2, 2):byte() == 0) then + local error_message = "JSON package groks only UTF-8, sorry" + self:onDecodeError(error_message, text, nil, options.etc) + return nil, error_message -- in case the error method doesn't abort, return something sensible + end + + -- + -- apply global options + -- + if options.decodeNumbersAsObjects == nil then + options.decodeNumbersAsObjects = self.decodeNumbersAsObjects + end + if options.decodeIntegerStringificationLength == nil then + options.decodeIntegerStringificationLength = self.decodeIntegerStringificationLength + end + if options.decodeDecimalStringificationLength == nil then + options.decodeDecimalStringificationLength = self.decodeDecimalStringificationLength + end + + -- + -- Finally, go parse it + -- + local success, value, next_i = LrTasks.pcall(grok_one, self, text, 1, options) + + if success then + local error_message = nil + if next_i ~= #text + 1 then + -- something's left over after we parsed the first thing.... whitespace is allowed. + next_i = skip_whitespace(text, next_i) + + -- if we have something left over now, it's trailing garbage + if next_i ~= #text + 1 then + value, error_message = self:onTrailingGarbage(text, next_i, value, options.etc) + end + end + return value, error_message + else + -- If JSON:onDecodeError() didn't abort out of the LrTasks.pcall, we'll have received + -- the error message here as "value", so pass it along as an assert. + local error_message = value + if self.assert then + self.assert(false, error_message) + else + assert(false, error_message) + end + -- ...and if we're still here (because the assert didn't throw an error), + -- return a nil and throw the error message on as a second arg + return nil, error_message + end +end + +local function backslash_replacement_function(c) + if c == "\n" then + return "\\n" + elseif c == "\r" then + return "\\r" + elseif c == "\t" then + return "\\t" + elseif c == "\b" then + return "\\b" + elseif c == "\f" then + return "\\f" + elseif c == '"' then + return '\\"' + elseif c == '\\' then + return '\\\\' + else + return string.format("\\u%04x", c:byte()) + end +end + +local chars_to_be_escaped_in_JSON_string += '[' + .. '"' -- class sub-pattern to match a double quote + .. '%\\' -- class sub-pattern to match a backslash + .. '%z' -- class sub-pattern to match a null + .. '\001' .. '-' .. '\031' -- class sub-pattern to match control characters + .. ']' + + +local LINE_SEPARATOR_as_utf8 = unicode_codepoint_as_utf8(0x2028) +local PARAGRAPH_SEPARATOR_as_utf8 = unicode_codepoint_as_utf8(0x2029) +local function json_string_literal(value, options) + local newval = value:gsub(chars_to_be_escaped_in_JSON_string, backslash_replacement_function) + if options.stringsAreUtf8 then + -- + -- This feels really ugly to just look into a string for the sequence of bytes that we know to be a particular utf8 character, + -- but utf8 was designed purposefully to make this kind of thing possible. Still, feels dirty. + -- I'd rather decode the byte stream into a character stream, but it's not technically needed so + -- not technically worth it. + -- + newval = newval:gsub(LINE_SEPARATOR_as_utf8, '\\u2028'):gsub(PARAGRAPH_SEPARATOR_as_utf8, '\\u2029') + end + return '"' .. newval .. '"' +end + +local function object_or_array(self, T, etc) + -- + -- We need to inspect all the keys... if there are any strings, we'll convert to a JSON + -- object. If there are only numbers, it's a JSON array. + -- + -- If we'll be converting to a JSON object, we'll want to sort the keys so that the + -- end result is deterministic. + -- + local string_keys = {} + local number_keys = {} + local number_keys_must_be_strings = false + local maximum_number_key + + for key in pairs(T) do + if type(key) == 'string' then + table.insert(string_keys, key) + elseif type(key) == 'number' then + table.insert(number_keys, key) + if key <= 0 or key >= math.huge then + number_keys_must_be_strings = true + elseif not maximum_number_key or key > maximum_number_key then + maximum_number_key = key + end + else + self:onEncodeError("can't encode table with a key of type " .. type(key), etc) + end + end + + if #string_keys == 0 and not number_keys_must_be_strings then + -- + -- An empty table, or a numeric-only array + -- + if #number_keys > 0 then + return nil, maximum_number_key -- an array + elseif tostring(T) == "JSON array" then + return nil + elseif tostring(T) == "JSON object" then + return {} + else + -- have to guess, so we'll pick array, since empty arrays are likely more common than empty objects + return nil + end + end + + table.sort(string_keys) + + local map + if #number_keys > 0 then + -- + -- If we're here then we have either mixed string/number keys, or numbers inappropriate for a JSON array + -- It's not ideal, but we'll turn the numbers into strings so that we can at least create a JSON object. + -- + + if self.noKeyConversion then + self:onEncodeError("a table with both numeric and string keys could be an object or array; aborting", etc) + end + + -- + -- Have to make a shallow copy of the source table so we can remap the numeric keys to be strings + -- + map = {} + for key, val in pairs(T) do + map[key] = val + end + + table.sort(number_keys) + + -- + -- Throw numeric keys in there as strings + -- + for _, number_key in ipairs(number_keys) do + local string_key = tostring(number_key) + if map[string_key] == nil then + table.insert(string_keys, string_key) + map[string_key] = T[number_key] + else + self:onEncodeError( + "conflict converting table with mixed-type keys into a JSON object: key " .. + number_key .. " exists both as a string and a number.", etc) + end + end + end + + return string_keys, nil, map +end + +-- +-- Encode +-- +-- 'options' is nil, or a table with possible keys: +-- +-- pretty -- If true, return a pretty-printed version. +-- +-- indent -- A string (usually of spaces) used to indent each nested level. +-- +-- align_keys -- If true, align all the keys when formatting a table. +-- +-- null -- If this exists with a string value, table elements with this value are output as JSON null. +-- +-- stringsAreUtf8 -- If true, consider Lua strings not as a sequence of bytes, but as a sequence of UTF-8 characters. +-- (Currently, the only practical effect of setting this option is that Unicode LINE and PARAGRAPH +-- separators, if found in a string, are encoded with a JSON escape instead of as raw UTF-8. +-- The JSON is valid either way, but encoding this way, apparently, allows the resulting JSON +-- to also be valid Java.) +-- +-- +local encode_value -- must predeclare because it calls itself +function encode_value(self, value, parents, etc, options, indent, for_key) + -- + -- keys in a JSON object can never be null, so we don't even consider options.null when converting a key value + -- + if value == nil or (not for_key and options and options.null and value == options.null) then + return 'null' + elseif type(value) == 'string' then + return json_string_literal(value, options) + elseif type(value) == 'number' then + if value ~= value then + -- + -- NaN (Not a Number). + -- JSON has no NaN, so we have to fudge the best we can. This should really be a package option. + -- + return "null" + elseif value >= math.huge then + -- + -- Positive infinity. JSON has no INF, so we have to fudge the best we can. This should + -- really be a package option. Note: at least with some implementations, positive infinity + -- is both ">= math.huge" and "<= -math.huge", which makes no sense but that's how it is. + -- Negative infinity is properly "<= -math.huge". So, we must be sure to check the ">=" + -- case first. + -- + return "1e+9999" + elseif value <= -math.huge then + -- + -- Negative infinity. + -- JSON has no INF, so we have to fudge the best we can. This should really be a package option. + -- + return "-1e+9999" + else + return tostring(value) + end + elseif type(value) == 'boolean' then + return tostring(value) + elseif type(value) ~= 'table' then + self:onEncodeError("can't convert " .. type(value) .. " to JSON", etc) + elseif getmetatable(value) == isNumber then + return tostring(value) + else + -- + -- A table to be converted to either a JSON object or array. + -- + local T = value + + if type(options) ~= 'table' then + options = {} + end + if type(indent) ~= 'string' then + indent = "" + end + + if parents[T] then + self:onEncodeError("table " .. tostring(T) .. " is a child of itself", etc) + else + parents[T] = true + end + + local result_value + + local object_keys, maximum_number_key, map = object_or_array(self, T, etc) + if maximum_number_key then + -- + -- An array... + -- + local ITEMS = {} + local key_indent = indent .. tostring(options.indent or "") + for i = 1, maximum_number_key do + if not options.array_newline then + table.insert(ITEMS, encode_value(self, T[i], parents, etc, options, indent)) + else + table.insert(ITEMS, encode_value(self, T[i], parents, etc, options, key_indent)) + end + end + + if options.pretty then + if not options.array_newline then + result_value = "[ " .. table.concat(ITEMS, ", ") .. " ]" + else + result_value = "[\n" .. key_indent .. table.concat(ITEMS, ",\n" .. key_indent) .. "\n" .. indent .. "]" + end + else + result_value = "[" .. table.concat(ITEMS, ",") .. "]" + end + elseif object_keys then + -- + -- An object + -- + local TT = map or T + + if options.pretty then + local KEYS = {} + local max_key_length = 0 + for _, key in ipairs(object_keys) do + local encoded = encode_value(self, tostring(key), parents, etc, options, indent, true) + if options.align_keys then + max_key_length = math.max(max_key_length, #encoded) + end + table.insert(KEYS, encoded) + end + local key_indent = indent .. tostring(options.indent or "") + local subtable_indent = key_indent .. string.rep(" ", max_key_length) .. (options.align_keys and " " or "") + local FORMAT = "%s%" .. string.format("%d", max_key_length) .. "s: %s" + + local COMBINED_PARTS = {} + for i, key in ipairs(object_keys) do + local encoded_val = encode_value(self, TT[key], parents, etc, options, subtable_indent) + table.insert(COMBINED_PARTS, string.format(FORMAT, key_indent, KEYS[i], encoded_val)) + end + result_value = "{\n" .. table.concat(COMBINED_PARTS, ",\n") .. "\n" .. indent .. "}" + else + local PARTS = {} + for _, key in ipairs(object_keys) do + local encoded_val = encode_value(self, TT[key], parents, etc, options, indent) + local encoded_key = encode_value(self, tostring(key), parents, etc, options, indent, true) + table.insert(PARTS, string.format("%s:%s", encoded_key, encoded_val)) + end + result_value = "{" .. table.concat(PARTS, ",") .. "}" + end + else + -- + -- An empty array/object... we'll treat it as an array, though it should really be an option + -- + result_value = "[]" + end + + parents[T] = false + return result_value + end +end + +local function top_level_encode(self, value, etc, options) + local val = encode_value(self, value, {}, etc, options) + if val == nil then + --PRIVATE("may need to revert to the previous public verison if I can't figure out what the guy wanted") + return val + else + return val + end +end + +function OBJDEF:encode(value, etc, options) + if type(self) ~= 'table' or self.__index ~= OBJDEF then + OBJDEF:onEncodeError("JSON:encode must be called in method format", etc) + end + + -- + -- If the user didn't pass in a table of decode options, make an empty one. + -- + if type(options) ~= 'table' then + options = {} + end + + return top_level_encode(self, value, etc, options) +end + +function OBJDEF:encode_pretty(value, etc, options) + if type(self) ~= 'table' or self.__index ~= OBJDEF then + OBJDEF:onEncodeError("JSON:encode_pretty must be called in method format", etc) + end + + -- + -- If the user didn't pass in a table of decode options, use the default pretty ones + -- + if type(options) ~= 'table' then + options = default_pretty_options + end + + return top_level_encode(self, value, etc, options) +end + +function OBJDEF.__tostring() + return "JSON encode/decode package" +end + +OBJDEF.__index = OBJDEF + +function OBJDEF:new(args) + local new = {} + + if args then + for key, val in pairs(args) do + new[key] = val + end + end + + return setmetatable(new, OBJDEF) +end + +return OBJDEF:new() + +-- +-- Version history: +-- +-- 20161109.21 Oops, had a small boo-boo in the previous update. +-- +-- 20161103.20 Used to silently ignore trailing garbage when decoding. Now fails via JSON:onTrailingGarbage() +-- http://seriot.ch/parsing_json.php +-- +-- Built-in error message about "expected comma or ']'" had mistakenly referred to '[' +-- +-- Updated the built-in error reporting to refer to bytes rather than characters. +-- +-- The decode() method no longer assumes that error handlers abort. +-- +-- Made the VERSION string a string instead of a number +-- + +-- 20160916.19 Fixed the isNumber.__index assignment (thanks to Jack Taylor) +-- +-- 20160730.18 Added JSON:forceString() and JSON:forceNumber() +-- +-- 20160728.17 Added concatenation to the metatable for JSON:asNumber() +-- +-- 20160709.16 Could crash if not passed an options table (thanks jarno heikkinen ). +-- +-- Made JSON:asNumber() a bit more resilient to being passed the results of itself. +-- +-- 20160526.15 Added the ability to easily encode null values in JSON, via the new "null" encoding option. +-- (Thanks to Adam B for bringing up the issue.) +-- +-- Added some support for very large numbers and precise floats via +-- JSON.decodeNumbersAsObjects +-- JSON.decodeIntegerStringificationLength +-- JSON.decodeDecimalStringificationLength +-- +-- Added the "stringsAreUtf8" encoding option. (Hat tip to http://lua-users.org/wiki/JsonModules ) +-- +-- 20141223.14 The encode_pretty() routine produced fine results for small datasets, but isn't really +-- appropriate for anything large, so with help from Alex Aulbach I've made the encode routines +-- more flexible, and changed the default encode_pretty() to be more generally useful. +-- +-- Added a third 'options' argument to the encode() and encode_pretty() routines, to control +-- how the encoding takes place. +-- +-- Updated docs to add assert() call to the loadfile() line, just as good practice so that +-- if there is a problem loading JSON.lua, the appropriate error message will percolate up. +-- +-- 20140920.13 Put back (in a way that doesn't cause warnings about unused variables) the author string, +-- so that the source of the package, and its version number, are visible in compiled copies. +-- +-- 20140911.12 Minor lua cleanup. +-- Fixed internal reference to 'JSON.noKeyConversion' to reference 'self' instead of 'JSON'. +-- (Thanks to SmugMug's David Parry for these.) +-- +-- 20140418.11 JSON nulls embedded within an array were being ignored, such that +-- ["1",null,null,null,null,null,"seven"], +-- would return +-- {1,"seven"} +-- It's now fixed to properly return +-- {1, nil, nil, nil, nil, nil, "seven"} +-- Thanks to "haddock" for catching the error. +-- +-- 20140116.10 The user's JSON.assert() wasn't always being used. Thanks to "blue" for the heads up. +-- +-- 20131118.9 Update for Lua 5.3... it seems that tostring(2/1) produces "2.0" instead of "2", +-- and this caused some problems. +-- +-- 20131031.8 Unified the code for encode() and encode_pretty(); they had been stupidly separate, +-- and had of course diverged (encode_pretty didn't get the fixes that encode got, so +-- sometimes produced incorrect results; thanks to Mattie for the heads up). +-- +-- Handle encoding tables with non-positive numeric keys (unlikely, but possible). +-- +-- If a table has both numeric and string keys, or its numeric keys are inappropriate +-- (such as being non-positive or infinite), the numeric keys are turned into +-- string keys appropriate for a JSON object. So, as before, +-- JSON:encode({ "one", "two", "three" }) +-- produces the array +-- ["one","two","three"] +-- but now something with mixed key types like +-- JSON:encode({ "one", "two", "three", SOMESTRING = "some string" })) +-- instead of throwing an error produces an object: +-- {"1":"one","2":"two","3":"three","SOMESTRING":"some string"} +-- +-- To maintain the prior throw-an-error semantics, set +-- JSON.noKeyConversion = true +-- +-- 20131004.7 Release under a Creative Commons CC-BY license, which I should have done from day one, sorry. +-- +-- 20130120.6 Comment update: added a link to the specific page on my blog where this code can +-- be found, so that folks who come across the code outside of my blog can find updates +-- more easily. +-- +-- 20111207.5 Added support for the 'etc' arguments, for better error reporting. +-- +-- 20110731.4 More feedback from David Kolf on how to make the tests for Nan/Infinity system independent. +-- +-- 20110730.3 Incorporated feedback from David Kolf at http://lua-users.org/wiki/JsonModules: +-- +-- * When encoding lua for JSON, Sparse numeric arrays are now handled by +-- spitting out full arrays, such that +-- JSON:encode({"one", "two", [10] = "ten"}) +-- returns +-- ["one","two",null,null,null,null,null,null,null,"ten"] +-- +-- In 20100810.2 and earlier, only up to the first non-null value would have been retained. +-- +-- * When encoding lua for JSON, numeric value NaN gets spit out as null, and infinity as "1+e9999". +-- Version 20100810.2 and earlier created invalid JSON in both cases. +-- +-- * Unicode surrogate pairs are now detected when decoding JSON. +-- +-- 20100810.2 added some checking to ensure that an invalid Unicode character couldn't leak in to the UTF-8 encoding +-- +-- 20100731.1 initial public release +-- diff --git a/picpeak-plugin.lrplugin/MetadataProvider.lua b/picpeak-plugin.lrplugin/MetadataProvider.lua new file mode 100644 index 0000000..d946666 --- /dev/null +++ b/picpeak-plugin.lrplugin/MetadataProvider.lua @@ -0,0 +1,25 @@ +require("MetadataTask") + +return { + + metadataFieldsForPhotos = { + { + id = "picpeakPhotoId", + title = "PicPeak Photo ID", + dataType = "string", + readOnly = true, + browsable = true, + searchable = true, + }, + { + id = "picpeakEventId", + title = "PicPeak Event ID", + dataType = "string", + readOnly = true, + browsable = true, + searchable = false, + }, + }, + + schemaVersion = 1, +} diff --git a/picpeak-plugin.lrplugin/MetadataTask.lua b/picpeak-plugin.lrplugin/MetadataTask.lua new file mode 100644 index 0000000..84f2e82 --- /dev/null +++ b/picpeak-plugin.lrplugin/MetadataTask.lua @@ -0,0 +1,49 @@ +MetadataTask = {} + +local keyPhotoId = "picpeakPhotoId" +local keyEventId = "picpeakEventId" + +local function writeField(photo, key, value) + if not photo then + log:warn("MetadataTask.writeField: photo is nil") + return false + end + local catalog = LrApplication.activeCatalog() + if not catalog then + log:warn("MetadataTask.writeField: cannot access catalog") + return false + end + local valueToSet = (value ~= nil and value ~= "") and tostring(value) or "" + local success = false + local ok, err = LrTasks.pcall(function() + catalog:withPrivateWriteAccessDo(function() + photo:setPropertyForPlugin(_PLUGIN, key, valueToSet) + success = true + end, { timeout = 5 }) + end) + if not ok then + log:error("MetadataTask.writeField: failed to write " .. key .. ": " .. tostring(err)) + return false + end + return success +end + +function MetadataTask.setPhotoId(photo, photoId) + return writeField(photo, keyPhotoId, photoId) +end + +function MetadataTask.getPhotoId(photo) + if not photo then return nil end + local v = photo:getPropertyForPlugin(_PLUGIN, keyPhotoId) + return (v and v ~= "") and v or nil +end + +function MetadataTask.setEventId(photo, eventId) + return writeField(photo, keyEventId, eventId) +end + +function MetadataTask.getEventId(photo) + if not photo then return nil end + local v = photo:getPropertyForPlugin(_PLUGIN, keyEventId) + return (v and v ~= "") and v or nil +end diff --git a/picpeak-plugin.lrplugin/PicPeakAPI.lua b/picpeak-plugin.lrplugin/PicPeakAPI.lua new file mode 100644 index 0000000..6b2d2ea --- /dev/null +++ b/picpeak-plugin.lrplugin/PicPeakAPI.lua @@ -0,0 +1,429 @@ +--[[ + PicPeakAPI – Lua client for PicPeak v1 API. + Authentication: Bearer token (Authorization: Bearer pp_live_xxx). + Base path: /api/v1 +]] + +local API_BASE_PATH = "/api/v1" +local HTTP_TIMEOUT_DEFAULT = 30 +local HTTP_TIMEOUT_UPLOAD = 300 + +local SUCCESS_STATUS_GET = 200 +local SUCCESS_STATUS_POST = { [200] = true, [201] = true } +local SUCCESS_STATUS_CUSTOM = { [200] = true, [201] = true, [204] = true } + +PicPeakAPI = {} +PicPeakAPI.__index = PicPeakAPI + +-- --------------------------------------------------------------------------- +-- Private helpers +-- --------------------------------------------------------------------------- + +local function safeDecodeJson(response, context) + local ok, decoded = LrTasks.pcall(function() + return JSON:decode(response or "{}") + end) + if not ok or decoded == nil then + log:error("PicPeakAPI " .. context .. ": JSON decode failed: " .. tostring(decoded)) + return nil + end + return decoded +end + +local function logRequestStart(api, method, apiPath) + log:trace("PicPeakAPI: Preparing " .. method .. " request " .. api.url .. API_BASE_PATH .. apiPath) +end + +local function handleRequestFailure(method, apiPath, status, headers, response) + log:error( + "PicPeakAPI " + .. tostring(method) + .. " request failed: " + .. apiPath + .. " (status " + .. tostring(status or "?") + .. ")" + ) + if headers then + log:error("Response headers: " .. util.dumpTable(headers)) + end + local parsedErrorString = "HTTP " .. tostring(status or "Error") + if response ~= nil then + log:error("Response body: " .. tostring(response)) + local decoded = safeDecodeJson(response, "handleRequestFailure") + if type(decoded) == "table" then + local msg = decoded.error or decoded.message + if type(msg) == "string" then + parsedErrorString = parsedErrorString .. " - " .. msg + end + end + end + return parsedErrorString +end + +-- --------------------------------------------------------------------------- +-- Constructor +-- --------------------------------------------------------------------------- + +function PicPeakAPI:new(url, apiToken) + local o = setmetatable({}, PicPeakAPI) + o.url = (url ~= nil and type(url) == "string") and url or "" + o.apiToken = (apiToken ~= nil and type(apiToken) == "string") and apiToken or "" + return o +end + +function PicPeakAPI:reconfigure(url, apiToken) + self.url = (url ~= nil and type(url) == "string") and url or self.url or "" + self.apiToken = (apiToken ~= nil and type(apiToken) == "string") and apiToken or self.apiToken or "" + log:trace("PicPeak reconfigured with URL: " .. self.url) +end + +-- --------------------------------------------------------------------------- +-- Headers +-- --------------------------------------------------------------------------- + +function PicPeakAPI:createHeaders() + local token = (self.apiToken ~= nil and type(self.apiToken) == "string") and self.apiToken or "" + return { + { field = "Authorization", value = "Bearer " .. token }, + { field = "Accept", value = "application/json" }, + { field = "Content-Type", value = "application/json" }, + } +end + +function PicPeakAPI:createHeadersForMultipart() + local token = (self.apiToken ~= nil and type(self.apiToken) == "string") and self.apiToken or "" + return { + { field = "Authorization", value = "Bearer " .. token }, + { field = "Accept", value = "application/json" }, + } +end + +-- --------------------------------------------------------------------------- +-- URL sanitization & connectivity +-- --------------------------------------------------------------------------- + +function PicPeakAPI:sanityCheckAndFixURL(url) + if util.nilOrEmpty(url) then + return false + end + if not string.match(url, "^https?://") then + return nil + end + local sanitized = string.match(url, "^https?://[%w%.%-]+[:%d]*") + if not sanitized then + return nil + end + if string.len(sanitized) < string.len(url) then + log:trace("sanityCheckAndFixURL: removed trailing path from URL.") + end + return sanitized +end + +function PicPeakAPI:checkConnectivity() + if util.nilOrEmpty(self.url) or util.nilOrEmpty(self.apiToken) then + log:error("checkConnectivity: URL or API token is empty.") + return false + end + + local response, headers = LrHttp.get( + self.url .. API_BASE_PATH .. "/events?limit=1", + self:createHeaders() + ) + + if not headers then + log:error("checkConnectivity: no response headers (network error or invalid URL)") + return false + end + if headers.status == 200 then + return true + else + log:error("checkConnectivity: test failed, status=" .. tostring(headers.status)) + if response then + log:error("Response: " .. tostring(response)) + end + local errReason = "HTTP " .. tostring(headers.status) + return false, errReason + end +end + +-- --------------------------------------------------------------------------- +-- Dialog helpers +-- --------------------------------------------------------------------------- + +local function _trimString(s) + if type(s) ~= "string" then return "" end + return s:match("^%s*(.-)%s*$") or "" +end + +function PicPeakAPI.validateUrlForDialog(url, baseUrl, baseApiToken) + local raw = (type(url) == "string") and url or "" + local trimmed = _trimString(raw) + if trimmed == "" then + return false, url, "URL must not be empty. Example: https://photos.example.com" + end + local api = PicPeakAPI:new(baseUrl or "", baseApiToken or "") + local result = api:sanityCheckAndFixURL(trimmed) + if result == false then + return false, url, "URL must not be empty. Example: https://photos.example.com" + end + if result == nil then + return false, url, "Invalid URL format. Example: https://photos.example.com" + end + if result ~= trimmed then + if LrDialogs and LrDialogs.message then + LrDialogs.message("URL was autocorrected to: " .. result) + end + end + return true, result, "" +end + +function PicPeakAPI.testConnection(url, apiToken, existingApi) + local u = _trimString(type(url) == "string" and url or "") + local token = (type(apiToken) == "string") and apiToken or "" + if u == "" or token == "" then + return false, "Please enter URL and API token first.", nil + end + local api = existingApi + if api and type(api.reconfigure) == "function" then + api:reconfigure(u, token) + else + api = PicPeakAPI:new(u, token) + end + local ok, errReason = api:checkConnectivity() + if ok then + return true, "Connection test successful", api + end + return false, "Connection test failed: " .. tostring(errReason or "Check URL, API token, and network."), api +end + +-- --------------------------------------------------------------------------- +-- Events (galleries) +-- --------------------------------------------------------------------------- + +-- Returns paginated list of events as { title, value } table for popup menus. +-- Fetches up to maxEvents (default 100) events. +function PicPeakAPI:getEvents(maxEvents) + maxEvents = maxEvents or 100 + local limit = math.min(maxEvents, 100) + local path = "/events?limit=" .. limit .. "&page=1" + local parsedResponse = self:doGetRequest(path) + local events = {} + if parsedResponse and type(parsedResponse.events) == "table" then + for _, row in ipairs(parsedResponse.events) do + if row and row.id and row.event_name then + local dateStr = (row.event_date and type(row.event_date) == "string") + and (" – " .. string.sub(row.event_date, 1, 10)) + or "" + table.insert(events, { title = row.event_name .. dateStr, value = tostring(row.id) }) + end + end + end + return events +end + +-- Get event details by ID. Returns event table or nil. +function PicPeakAPI:getEvent(eventId) + if util.nilOrEmpty(eventId) then + log:warn("getEvent: eventId empty") + return nil + end + return self:doGetRequest("/events/" .. tostring(eventId)) +end + +-- Check if an event exists on the server. +function PicPeakAPI:checkIfEventExists(eventId) + if util.nilOrEmpty(eventId) then + return false + end + local event = self:doGetRequestAllow404("/events/" .. tostring(eventId)) + return event ~= nil +end + +-- Get event name by ID. +function PicPeakAPI:getEventName(eventId) + local event = self:getEvent(eventId) + return event and event.event_name or nil +end + +-- Get event share URL. +function PicPeakAPI:getEventShareUrl(eventId) + if util.nilOrEmpty(eventId) then + return nil + end + local resp = self:doGetRequest("/events/" .. tostring(eventId) .. "/share-link") + return resp and resp.share_url or nil +end + +-- Create a new gallery event. +-- params: { event_name, event_type, event_date, require_password, password } +-- Returns event id (integer) on success, nil on failure. +function PicPeakAPI:createEvent(params) + if util.nilOrEmpty(params.event_name) then + ErrorHandler.handleError("No event name given.", "createEvent: event_name empty") + return nil + end + local eventType = (params.event_type and params.event_type ~= "") and params.event_type or "other" + + local body = { + event_name = params.event_name, + event_type = eventType, + } + if params.event_date and params.event_date ~= "" then + body.event_date = params.event_date + end + if params.require_password then + body.require_password = true + body.password = params.password or "" + else + body.require_password = false + end + + local parsedResponse = self:doPostRequest("/events", body) + if parsedResponse and parsedResponse.id then + log:info("createEvent: created event id=" .. tostring(parsedResponse.id) .. " slug=" .. tostring(parsedResponse.slug)) + return parsedResponse.id, parsedResponse.share_url + end + return nil +end + +-- --------------------------------------------------------------------------- +-- Photos +-- --------------------------------------------------------------------------- + +local MIME_TYPES = { + jpg = "image/jpeg", jpeg = "image/jpeg", + png = "image/png", tiff = "image/tiff", tif = "image/tiff", + gif = "image/gif", webp = "image/webp", heic = "image/heic", + heif = "image/heif", bmp = "image/bmp", +} + +local function mimeTypeForFile(path) + local ext = string.lower(string.match(path or "", "%.([^%.]+)$") or "") + return MIME_TYPES[ext] or "image/jpeg" +end + +-- Upload a single photo file to an event. +-- Returns: photo id (integer) on success, nil + errReason on failure. +function PicPeakAPI:uploadPhoto(eventId, filePath, fileName) + if util.nilOrEmpty(eventId) then + ErrorHandler.handleError("No event ID given.", "uploadPhoto: eventId empty") + return nil, "No event ID" + end + if util.nilOrEmpty(filePath) then + ErrorHandler.handleError("No file path given.", "uploadPhoto: filePath empty") + return nil, "No file path" + end + + local apiPath = "/events/" .. tostring(eventId) .. "/photos" + local name = fileName or LrPathUtils.leafName(filePath) + + local mimeChunks = { + { + name = "photo", + filePath = filePath, + fileName = name, + contentType = mimeTypeForFile(filePath), + }, + } + + local parsedResponse, errReason = self:doMultiPartPostRequest(apiPath, mimeChunks) + if parsedResponse and parsedResponse.id then + log:info("uploadPhoto: " .. name .. " -> id=" .. tostring(parsedResponse.id)) + return parsedResponse.id + end + return nil, errReason +end + +-- --------------------------------------------------------------------------- +-- HTTP request layer +-- --------------------------------------------------------------------------- + +function PicPeakAPI:doGetRequest(apiPath) + logRequestStart(self, "GET", apiPath) + local response, headers = LrHttp.get( + self.url .. API_BASE_PATH .. apiPath, + self:createHeaders() + ) + + if not headers then + log:error("PicPeakAPI GET: no response headers (network error): " .. apiPath) + ErrorHandler.handleError("No response from PicPeak server. Check URL and network.", "Connection failed") + return nil + end + if headers.status == SUCCESS_STATUS_GET then + log:trace("PicPeakAPI GET request succeeded") + return safeDecodeJson(response, "GET") + end + local errReason = handleRequestFailure("GET", apiPath, headers.status, headers, response) + return nil, errReason +end + +function PicPeakAPI:doGetRequestAllow404(apiPath) + logRequestStart(self, "GET", apiPath) + local response, headers = LrHttp.get( + self.url .. API_BASE_PATH .. apiPath, + self:createHeaders() + ) + + if not headers then + log:error("PicPeakAPI GET: no response headers: " .. apiPath) + return nil + end + if headers.status == SUCCESS_STATUS_GET then + return safeDecodeJson(response, "GET") + end + if headers.status == 404 or headers.status == 400 then + log:trace("PicPeakAPI GET: resource not found (" .. tostring(headers.status) .. "): " .. apiPath) + return nil + end + handleRequestFailure("GET", apiPath, headers.status, headers, response) + return nil +end + +function PicPeakAPI:doPostRequest(apiPath, postBody) + logRequestStart(self, "POST", apiPath) + if postBody ~= nil then + log:trace("PicPeakAPI: POST body " .. JSON:encode(postBody)) + end + local response, headers = LrHttp.post( + self.url .. API_BASE_PATH .. apiPath, + JSON:encode(postBody), + self:createHeaders(), + "POST", + HTTP_TIMEOUT_DEFAULT + ) + + if not headers then + log:error("PicPeakAPI POST: no response headers: " .. apiPath) + ErrorHandler.handleError("No response from PicPeak server. Check URL and network.", "Connection failed") + return nil + end + if SUCCESS_STATUS_POST[headers.status] then + log:trace("PicPeakAPI POST request succeeded") + return safeDecodeJson(response, "POST") + end + local errReason = handleRequestFailure("POST", apiPath, headers.status, headers, response) + return nil, errReason +end + +function PicPeakAPI:doMultiPartPostRequest(apiPath, mimeChunks) + logRequestStart(self, "multipart POST", apiPath) + local response, headers = LrHttp.postMultipart( + self.url .. API_BASE_PATH .. apiPath, + mimeChunks, + self:createHeadersForMultipart(), + HTTP_TIMEOUT_UPLOAD + ) + + if not headers then + log:error("PicPeakAPI multipart POST: no response headers: " .. apiPath) + ErrorHandler.handleError("No response from PicPeak server. Check URL and network.", "Connection failed") + return nil + end + if SUCCESS_STATUS_POST[headers.status] then + return safeDecodeJson(response, "multipart POST") + end + local errReason = handleRequestFailure("multipart POST", apiPath, headers.status, headers, response) + return nil, errReason +end diff --git a/picpeak-plugin.lrplugin/PluginInfo.lua b/picpeak-plugin.lrplugin/PluginInfo.lua new file mode 100644 index 0000000..0d533c8 --- /dev/null +++ b/picpeak-plugin.lrplugin/PluginInfo.lua @@ -0,0 +1,7 @@ +require("PluginInfoDialogSections") + +return { + startDialog = PluginInfoDialogSections.startDialog, + endDialog = PluginInfoDialogSections.endDialog, + sectionsForBottomOfDialog = PluginInfoDialogSections.sectionsForBottomOfDialog, +} diff --git a/picpeak-plugin.lrplugin/PluginInfoDialogSections.lua b/picpeak-plugin.lrplugin/PluginInfoDialogSections.lua new file mode 100644 index 0000000..6329f0b --- /dev/null +++ b/picpeak-plugin.lrplugin/PluginInfoDialogSections.lua @@ -0,0 +1,48 @@ +require("PicPeakAPI") + +PluginInfoDialogSections = {} + +function PluginInfoDialogSections.startDialog(propertyTable) + if prefs.logging == nil then + prefs.logging = false + end + propertyTable.logging = prefs.logging +end + +function PluginInfoDialogSections.sectionsForBottomOfDialog(f, propertyTable) + local bind = LrView.bind + + return { + { + bind_to_object = propertyTable, + title = "PicPeak Plugin Logging", + f:row({ + f:static_text({ + title = util.getLogfilePath(), + }), + }), + f:row({ + spacing = f:control_spacing(), + f:checkbox({ + title = "Enable debug logging", + value = bind("logging"), + }), + f:push_button({ + title = "Show logfile", + action = function() + LrShell.revealInShell(util.getLogfilePath()) + end, + }), + }), + }, + } +end + +function PluginInfoDialogSections.endDialog(propertyTable) + prefs.logging = propertyTable.logging + if propertyTable.logging then + log:enable("logfile") + else + log:disable() + end +end diff --git a/picpeak-plugin.lrplugin/PublishDialogSections.lua b/picpeak-plugin.lrplugin/PublishDialogSections.lua new file mode 100644 index 0000000..938bf61 --- /dev/null +++ b/picpeak-plugin.lrplugin/PublishDialogSections.lua @@ -0,0 +1,16 @@ +require("PicPeakAPI") +require("SharedDialogSections") + +PublishDialogSections = {} + +function PublishDialogSections.startDialog(propertyTable) + LrTasks.startAsyncTask(function() + propertyTable.picpeak = PicPeakAPI:new(propertyTable.url, propertyTable.apiToken) + end) +end + +function PublishDialogSections.sectionsForTopOfDialog(f, propertyTable) + return { + SharedDialogSections.getServerConnectionSection(f, propertyTable), + } +end diff --git a/picpeak-plugin.lrplugin/PublishServiceProvider.lua b/picpeak-plugin.lrplugin/PublishServiceProvider.lua new file mode 100644 index 0000000..8d1c912 --- /dev/null +++ b/picpeak-plugin.lrplugin/PublishServiceProvider.lua @@ -0,0 +1,38 @@ +require("PublishDialogSections") +require("PublishTask") + +return { + startDialog = PublishDialogSections.startDialog, + sectionsForTopOfDialog = PublishDialogSections.sectionsForTopOfDialog, + sectionsForBottomOfDialog = PublishDialogSections.sectionsForBottomOfDialog, + hideSections = { "exportLocation" }, + allowFileFormats = nil, + allowColorSpaces = nil, + canExportVideo = false, + supportsCustomSortOrder = false, + supportsIncrementalPublish = "only", + + exportPresetFields = { + { key = "url", default = "" }, + { key = "apiToken", default = "" }, + }, + + titleForPublishedCollection = "PicPeak event", + titleForPublishedSmartCollection = "PicPeak event (Smart collection)", + + getCollectionBehaviorInfo = PublishTask.getCollectionBehaviorInfo, + + processRenderedPhotos = PublishTask.processRenderedPhotos, + + canAddCommentsToService = false, + + deletePhotosFromPublishedCollection = PublishTask.deletePhotosFromPublishedCollection, + deletePublishedCollection = PublishTask.deletePublishedCollection, + renamePublishedCollection = PublishTask.renamePublishedCollection, + shouldDeletePhotosFromServiceOnDeleteFromCatalog = PublishTask.shouldDeletePhotosFromServiceOnDeleteFromCatalog, + validatePublishedCollectionName = PublishTask.validatePublishedCollectionName, + + viewForCollectionSettings = PublishTask.viewForCollectionSettings, + endDialogForCollectionSettings = PublishTask.endDialogForCollectionSettings, + updateCollectionSettings = PublishTask.updateCollectionSettings, +} diff --git a/picpeak-plugin.lrplugin/PublishTask.lua b/picpeak-plugin.lrplugin/PublishTask.lua new file mode 100644 index 0000000..dbc919f --- /dev/null +++ b/picpeak-plugin.lrplugin/PublishTask.lua @@ -0,0 +1,342 @@ +require("PicPeakAPI") +require("MetadataTask") +require("SharedDialogSections") + +PublishTask = {} + +-- --------------------------------------------------------------------------- +-- Resolve or create the event for a publish collection. +-- Returns: eventId (string) or nil. +-- --------------------------------------------------------------------------- +local function resolvePublishEvent(picpeak, exportContext) + local publishedCollection = exportContext.publishedCollection + local collectionSettings = publishedCollection:getCollectionInfoSummary().collectionSettings + local strategy = collectionSettings.albumCreationStrategy or "collection" + local eventId = publishedCollection:getRemoteId() + local collectionName = publishedCollection:getName() + local exportSession = exportContext.exportSession + + log:trace("resolvePublishEvent: strategy=" .. strategy .. " remoteId=" .. tostring(eventId)) + + if strategy == "existing" and collectionSettings.remoteId then + eventId = tostring(collectionSettings.remoteId) + end + + if eventId and eventId ~= "" then + if picpeak:checkIfEventExists(eventId) then + local shareUrl = picpeak:getEventShareUrl(eventId) + exportSession:recordRemoteCollectionId(eventId) + if shareUrl then + exportSession:recordRemoteCollectionUrl(shareUrl) + end + return eventId + else + log:warn("resolvePublishEvent: stored event " .. eventId .. " no longer exists, creating new one") + eventId = nil + end + end + + -- Create new event from collection name + log:info("resolvePublishEvent: creating new event '" .. collectionName .. "'") + local eventType = collectionSettings.eventType or "other" + local newId = picpeak:createEvent({ + event_name = collectionName, + event_type = eventType, + require_password = false, + }) + if not newId then + ErrorHandler.handleError( + "Failed to create PicPeak event for collection '" .. collectionName .. "'. Check connection.", + "resolvePublishEvent: createEvent returned nil" + ) + return nil + end + newId = tostring(newId) + local shareUrl = picpeak:getEventShareUrl(newId) + exportSession:recordRemoteCollectionId(newId) + if shareUrl then + exportSession:recordRemoteCollectionUrl(shareUrl) + end + log:info("resolvePublishEvent: created event id=" .. newId) + return newId +end + +-- --------------------------------------------------------------------------- +-- Main publish entry point +-- --------------------------------------------------------------------------- + +function PublishTask.processRenderedPhotos(functionContext, exportContext) + local exportSession, exportParams, picpeak = util.validateExportContextAndConnect(exportContext, "Publish") + if not exportSession then + return nil + end + + local eventId = resolvePublishEvent(picpeak, exportContext) + if not eventId then + return nil + end + + local nPhotos = exportSession:countRenditions() + local progressTitle = (exportParams.url and exportParams.url ~= "") and exportParams.url or "PicPeak" + log:info("=== PicPeak Publish START: " .. nPhotos .. " photos | url=" .. tostring(exportParams.url) + .. " | eventId=" .. tostring(eventId) .. " ===") + + local progressScope = LrProgressScope({ + title = util.buildSimpleUploadProgressTitle(nPhotos, "Publishing", progressTitle), + functionContext = functionContext, + }) + + local failures = {} + local done = 0 + + for _, rendition in exportContext:renditions({ stopIfCanceled = true }) do + local success, pathOrMessage = rendition:waitForRender() + if progressScope:isCanceled() then + break + end + + if success then + local photo = rendition.photo + local fileName = photo:getFormattedMetadata("fileName") + + local photoId, errReason = picpeak:uploadPhoto(eventId, pathOrMessage, fileName) + util.safeDeleteTempFile(pathOrMessage) + + if not photoId then + log:error("PublishTask: upload failed for " .. fileName .. ": " .. tostring(errReason)) + table.insert(failures, fileName .. " (" .. (errReason or "Upload failed") .. ")") + else + local photoIdStr = tostring(photoId) + MetadataTask.setPhotoId(photo, photoIdStr) + MetadataTask.setEventId(photo, eventId) + rendition:recordPublishedPhotoId(photoIdStr) + local shareUrl = picpeak:getEventShareUrl(eventId) + if shareUrl then + rendition:recordPublishedPhotoUrl(shareUrl) + end + log:info("PublishTask: uploaded " .. fileName .. " -> photoId=" .. photoIdStr) + end + else + log:warn("PublishTask: render failed: " .. tostring(pathOrMessage)) + util.safeDeleteTempFile(pathOrMessage) + end + + done = done + 1 + progressScope:setPortionComplete(done, nPhotos) + if done == 1 or done % 10 == 0 or done == nPhotos then + log:info("Publish progress: " .. done .. "/" .. nPhotos) + end + end + + progressScope:done() + log:info("=== PicPeak Publish DONE: " .. nPhotos .. " photos | failures=" .. #failures .. " ===") + util.reportUploadFailures(failures) +end + +-- --------------------------------------------------------------------------- +-- Collection management callbacks +-- --------------------------------------------------------------------------- + +function PublishTask.deletePhotosFromPublishedCollection( + publishSettings, + arrayOfPhotoIds, + deletedCallback, + localCollectionId +) + -- PicPeak v1 API has no endpoint to delete individual photos. + -- We mark them as deleted in Lightroom so they won't be republished, + -- but they remain in the PicPeak gallery on the server. + if #arrayOfPhotoIds > 0 then + LrDialogs.message( + "PicPeak: Photos removed from collection", + "Note: The PicPeak API does not support deleting photos via the API. " + .. tostring(#arrayOfPhotoIds) + .. " photo(s) were removed from the Lightroom publish collection but remain in the PicPeak gallery." + .. " Remove them manually in PicPeak if needed.", + "info" + ) + end + for _, photoId in ipairs(arrayOfPhotoIds) do + deletedCallback(photoId) + end +end + +function PublishTask.deletePublishedCollection(publishSettings, info) + -- PicPeak v1 API has no delete event endpoint (admin-only in UI). + if info.remoteId and info.remoteId ~= "" then + LrDialogs.message( + "PicPeak: Gallery not deleted", + "The PicPeak API does not support deleting gallery events. " + .. "The Lightroom collection was removed, but the gallery (event id=" + .. tostring(info.remoteId) + .. ") still exists in PicPeak. Delete it manually if needed.", + "info" + ) + end +end + +function PublishTask.renamePublishedCollection(publishSettings, info) + -- PicPeak v1 API has no rename event endpoint. + log:trace("renamePublishedCollection: rename not supported by PicPeak v1 API") +end + +function PublishTask.shouldDeletePhotosFromServiceOnDeleteFromCatalog(publishSettings, nPhotos) + return nil +end + +function PublishTask.validatePublishedCollectionName(name) + if util.nilOrEmpty(name) then + return false, "Event name must not be empty." + end + return true, "" +end + +function PublishTask.getCollectionBehaviorInfo(publishSettings) + return { + defaultCollectionName = "My Gallery", + defaultCollectionCanBeDeleted = true, + canAddCollection = true, + } +end + +-- --------------------------------------------------------------------------- +-- Per-collection settings (event type selection) +-- --------------------------------------------------------------------------- + +function PublishTask.viewForCollectionSettings(f, publishSettings, info) + if info.publishedCollection ~= nil then + -- Editing an existing collection: show read-only info + local remoteId = info.publishedCollection:getRemoteId() + if remoteId then + return f:row({ + f:static_text({ + title = "PicPeak event ID: " .. tostring(remoteId), + alignment = "left", + font = "", + }), + }) + end + return f:row({}) + end + + -- New collection: choose event type + info.pluginContext.eventType = "other" + info.pluginContext.albumCreationStrategy = "collection" + info.pluginContext.picpeakEvents = { { title = "Please select", value = "0" } } + + LrTasks.startAsyncTask(function() + local picpeak = PicPeakAPI:new(publishSettings.url, publishSettings.apiToken) + local events = picpeak:getEvents() + if events and #events > 0 then + local items = { { title = "Please select", value = "0" } } + for _, e in ipairs(events) do + table.insert(items, e) + end + info.pluginContext.picpeakEvents = items + end + end) + + local bind = LrView.bind + local share = LrView.share + + return f:group_box({ + bind_to_object = info.pluginContext, + title = "PicPeak Event Settings", + fill_horizontal = 1, + f:column({ + spacing = f:control_spacing(), + fill_horizontal = 1, + f:row({ + f:static_text({ + title = "This collection will be synced to a PicPeak gallery event.", + alignment = "left", + font = "", + fill_horizontal = 1, + }), + }), + f:separator({ fill_horizontal = 1 }), + f:radio_button({ + title = "Create new event from collection name", + checked_value = "collection", + value = bind("albumCreationStrategy"), + }), + f:row({ + f:static_text({ + title = " Event type:", + alignment = "right", + }), + f:popup_menu({ + value = bind("eventType"), + items = require("SharedDialogSections").EVENT_TYPES, + immediate = true, + enabled = LrBinding.keyEquals("albumCreationStrategy", "collection"), + }), + }), + f:row({ + f:radio_button({ + title = "Use existing event", + checked_value = "existing", + value = bind("albumCreationStrategy"), + }), + f:popup_menu({ + items = bind("picpeakEvents"), + value = bind("selectedEventId"), + width_in_chars = 28, + enabled = LrBinding.keyEquals("albumCreationStrategy", "existing"), + immediate = true, + }), + }), + }), + }) +end + +function PublishTask.endDialogForCollectionSettings(publishSettings, info) + log:trace("endDialogForCollectionSettings") + local props = info.pluginContext + if info.why == "ok" then + local strategy = props.albumCreationStrategy or "collection" + info.collectionSettings.albumCreationStrategy = strategy + info.collectionSettings.eventType = props.eventType or "other" + + if strategy == "existing" then + local sel = props.selectedEventId + if util.nilOrEmpty(sel) or sel == "0" then + ErrorHandler.handleError("No event selected.", "endDialogForCollectionSettings: no event selected") + return + end + info.collectionSettings.remoteId = sel + end + end +end + +function PublishTask.updateCollectionSettings(publishSettings, info) + log:trace("updateCollectionSettings") + if not info or not info.collectionSettings then + return + end + local props = info.collectionSettings + if props.albumCreationStrategy == "existing" and props.remoteId then + local picpeak = PicPeakAPI:new(publishSettings.url, publishSettings.apiToken) + if not picpeak:checkConnectivity() then + log:warn("updateCollectionSettings: PicPeak not reachable") + return + end + local eventId = tostring(props.remoteId) + local name = picpeak:getEventName(eventId) + local shareUrl = picpeak:getEventShareUrl(eventId) + if not name then + name = "Event " .. eventId + end + log:trace("updateCollectionSettings: binding to event id=" .. eventId .. " name=" .. name) + local catalog = LrApplication.activeCatalog() + if catalog and info.publishedCollection then + catalog:withWriteAccessDo("Bind PicPeak event", function() + info.publishedCollection:setRemoteId(eventId) + if shareUrl then + info.publishedCollection:setRemoteUrl(shareUrl) + end + info.publishedCollection:setName(name) + end) + end + end +end diff --git a/picpeak-plugin.lrplugin/SharedDialogSections.lua b/picpeak-plugin.lrplugin/SharedDialogSections.lua new file mode 100644 index 0000000..8f0a9e1 --- /dev/null +++ b/picpeak-plugin.lrplugin/SharedDialogSections.lua @@ -0,0 +1,77 @@ +require("PicPeakAPI") + +SharedDialogSections = {} + +-- PicPeak event types for UI menus +SharedDialogSections.EVENT_TYPES = { + { title = "Other", value = "other" }, + { title = "Wedding", value = "wedding" }, + { title = "Birthday", value = "birthday" }, + { title = "Corporate", value = "corporate" }, + { title = "Family", value = "family" }, +} + +-- Generate the 'PicPeak Server connection' dialog section +function SharedDialogSections.getServerConnectionSection(f, propertyTable) + local bind = LrView.bind + local share = LrView.share + + return { + title = "PicPeak Server connection", + bind_to_object = propertyTable, + f:row({ + f:static_text({ + title = "URL:", + alignment = "right", + width = share("labelWidth"), + }), + f:edit_field({ + value = bind("url"), + truncation = "middle", + immediate = false, + fill_horizontal = 1, + validate = function(_, url) + return PicPeakAPI.validateUrlForDialog(url, propertyTable.url, propertyTable.apiToken) + end, + }), + f:push_button({ + title = "Test connection", + action = function() + LrTasks.startAsyncTask(function() + local _, message, api = + PicPeakAPI.testConnection(propertyTable.url, propertyTable.apiToken, propertyTable.picpeak) + if api then + propertyTable.picpeak = api + end + LrDialogs.message(message) + end) + end, + }), + }), + f:row({ + f:static_text({ + title = "API Token:", + alignment = "right", + width = share("labelWidth"), + }), + f:password_field({ + value = bind("apiToken"), + truncation = "middle", + immediate = false, + fill_horizontal = 1, + }), + }), + f:row({ + margin_top = 2, + f:static_text({ title = "", alignment = "right", width = share("labelWidth") }), + f:static_text({ + title = "Token must have 'write' and 'admin' scopes. Create one in PicPeak → Settings → API Tokens.", + alignment = "left", + fill_horizontal = 1, + font = "", + }), + }), + } +end + +return SharedDialogSections diff --git a/picpeak-plugin.lrplugin/inspect.lua b/picpeak-plugin.lrplugin/inspect.lua new file mode 100644 index 0000000..969a97d --- /dev/null +++ b/picpeak-plugin.lrplugin/inspect.lua @@ -0,0 +1,380 @@ +local _tl_compat; if (tonumber((_VERSION or ''):match('[%d.]*$')) or 0) < 5.3 then + local p, m = LrTasks.pcall(require, 'compat53.module'); if p then _tl_compat = m end +end; local math = _tl_compat and _tl_compat.math or math; local string = _tl_compat and _tl_compat.string or string; local table = +_tl_compat and _tl_compat.table or table +local inspect = { Options = {}, } + + + + + + + + + + + + + + + + + +inspect._VERSION = 'inspect.lua 3.1.0' +inspect._URL = 'http://github.com/kikito/inspect.lua' +inspect._DESCRIPTION = 'human-readable representations of tables' +inspect._LICENSE = [[ + MIT LICENSE + + Copyright (c) 2022 Enrique García Cota + + Permission is hereby granted, free of charge, to any person obtaining a + copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be included + in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +]] +inspect.KEY = setmetatable({}, { __tostring = function() return 'inspect.KEY' end }) +inspect.METATABLE = setmetatable({}, { __tostring = function() return 'inspect.METATABLE' end }) + +local tostring = tostring +local rep = string.rep +local match = string.match +local char = string.char +local gsub = string.gsub +local fmt = string.format + +local _rawget +if rawget then + _rawget = rawget +else + _rawget = function(t, k) return t[k] end +end + +local function rawpairs(t) + return next, t, nil +end + + + +local function smartQuote(str) + if match(str, '"') and not match(str, "'") then + return "'" .. str .. "'" + end + return '"' .. gsub(str, '"', '\\"') .. '"' +end + + +local shortControlCharEscapes = { + ["\a"] = "\\a", + ["\b"] = "\\b", + ["\f"] = "\\f", + ["\n"] = "\\n", + ["\r"] = "\\r", + ["\t"] = "\\t", + ["\v"] = "\\v", + ["\127"] = "\\127", +} +local longControlCharEscapes = { ["\127"] = "\127" } +for i = 0, 31 do + local ch = char(i) + if not shortControlCharEscapes[ch] then + shortControlCharEscapes[ch] = "\\" .. i + longControlCharEscapes[ch] = fmt("\\%03d", i) + end +end + +local function escape(str) + return (gsub(gsub(gsub(str, "\\", "\\\\"), + "(%c)%f[0-9]", longControlCharEscapes), + "%c", shortControlCharEscapes)) +end + +local luaKeywords = { + ['and'] = true, + ['break'] = true, + ['do'] = true, + ['else'] = true, + ['elseif'] = true, + ['end'] = true, + ['false'] = true, + ['for'] = true, + ['function'] = true, + ['goto'] = true, + ['if'] = true, + ['in'] = true, + ['local'] = true, + ['nil'] = true, + ['not'] = true, + ['or'] = true, + ['repeat'] = true, + ['return'] = true, + ['then'] = true, + ['true'] = true, + ['until'] = true, + ['while'] = true, +} + +local function isIdentifier(str) + return type(str) == "string" and + not not str:match("^[_%a][_%a%d]*$") and + not luaKeywords[str] +end + +local flr = math.floor +local function isSequenceKey(k, sequenceLength) + return type(k) == "number" and + flr(k) == k and + 1 <= (k) and + k <= sequenceLength +end + +local defaultTypeOrders = { + ['number'] = 1, + ['boolean'] = 2, + ['string'] = 3, + ['table'] = 4, + ['function'] = 5, + ['userdata'] = 6, + ['thread'] = 7, +} + +local function sortKeys(a, b) + local ta, tb = type(a), type(b) + + + if ta == tb and (ta == 'string' or ta == 'number') then + return (a) < (b) + end + + local dta = defaultTypeOrders[ta] or 100 + local dtb = defaultTypeOrders[tb] or 100 + + + return dta == dtb and ta < tb or dta < dtb +end + +local function getKeys(t) + local seqLen = 1 + while _rawget(t, seqLen) ~= nil do + seqLen = seqLen + 1 + end + seqLen = seqLen - 1 + + local keys, keysLen = {}, 0 + for k in rawpairs(t) do + if not isSequenceKey(k, seqLen) then + keysLen = keysLen + 1 + keys[keysLen] = k + end + end + table.sort(keys, sortKeys) + return keys, keysLen, seqLen +end + +local function countCycles(x, cycles) + if type(x) == "table" then + if cycles[x] then + cycles[x] = cycles[x] + 1 + else + cycles[x] = 1 + for k, v in rawpairs(x) do + countCycles(k, cycles) + countCycles(v, cycles) + end + countCycles(getmetatable(x), cycles) + end + end +end + +local function makePath(path, a, b) + local newPath = {} + local len = #path + for i = 1, len do newPath[i] = path[i] end + + newPath[len + 1] = a + newPath[len + 2] = b + + return newPath +end + + +local function processRecursive(process, + item, + path, + visited) + if item == nil then return nil end + if visited[item] then return visited[item] end + + local processed = process(item, path) + if type(processed) == "table" then + local processedCopy = {} + visited[item] = processedCopy + local processedKey + + for k, v in rawpairs(processed) do + processedKey = processRecursive(process, k, makePath(path, k, inspect.KEY), visited) + if processedKey ~= nil then + processedCopy[processedKey] = processRecursive(process, v, makePath(path, processedKey), visited) + end + end + + local mt = processRecursive(process, getmetatable(processed), makePath(path, inspect.METATABLE), visited) + if type(mt) ~= 'table' then mt = nil end + setmetatable(processedCopy, mt) + processed = processedCopy + end + return processed +end + +local function puts(buf, str) + buf.n = buf.n + 1 + buf[buf.n] = str +end + + + +local Inspector = {} + + + + + + + + + + +local Inspector_mt = { __index = Inspector } + +local function tabify(inspector) + puts(inspector.buf, inspector.newline .. rep(inspector.indent, inspector.level)) +end + +function Inspector:getId(v) + local id = self.ids[v] + local ids = self.ids + if not id then + local tv = type(v) + id = (ids[tv] or 0) + 1 + ids[v], ids[tv] = id, id + end + return tostring(id) +end + +function Inspector:putValue(v) + local buf = self.buf + local tv = type(v) + if tv == 'string' then + puts(buf, smartQuote(escape(v))) + elseif tv == 'number' or tv == 'boolean' or tv == 'nil' or + tv == 'cdata' or tv == 'ctype' then + puts(buf, tostring(v)) + elseif tv == 'table' and not self.ids[v] then + local t = v + + if t == inspect.KEY or t == inspect.METATABLE then + puts(buf, tostring(t)) + elseif self.level >= self.depth then + puts(buf, '{...}') + else + if self.cycles[t] > 1 then puts(buf, fmt('<%d>', self:getId(t))) end + + local keys, keysLen, seqLen = getKeys(t) + + puts(buf, '{') + self.level = self.level + 1 + + for i = 1, seqLen + keysLen do + if i > 1 then puts(buf, ',') end + if i <= seqLen then + puts(buf, ' ') + self:putValue(t[i]) + else + local k = keys[i - seqLen] + tabify(self) + if isIdentifier(k) then + puts(buf, k) + else + puts(buf, "[") + self:putValue(k) + puts(buf, "]") + end + puts(buf, ' = ') + self:putValue(t[k]) + end + end + + local mt = getmetatable(t) + if type(mt) == 'table' then + if seqLen + keysLen > 0 then puts(buf, ',') end + tabify(self) + puts(buf, ' = ') + self:putValue(mt) + end + + self.level = self.level - 1 + + if keysLen > 0 or type(mt) == 'table' then + tabify(self) + elseif seqLen > 0 then + puts(buf, ' ') + end + + puts(buf, '}') + end + else + puts(buf, fmt('<%s %d>', tv, self:getId(v))) + end +end + +function inspect.inspect(root, options) + options = options or {} + + local depth = options.depth or (math.huge) + local newline = options.newline or '\n' + local indent = options.indent or ' ' + local process = options.process + + if process then + root = processRecursive(process, root, {}, {}) + end + + local cycles = {} + countCycles(root, cycles) + + local inspector = setmetatable({ + buf = { n = 0 }, + ids = {}, + cycles = cycles, + depth = depth, + level = 0, + newline = newline, + indent = indent, + }, Inspector_mt) + + inspector:putValue(root) + + return table.concat(inspector.buf) +end + +setmetatable(inspect, { + __call = function(_, root, options) + return inspect.inspect(root, options) + end, +}) + +return inspect diff --git a/picpeak-plugin.lrplugin/util.lua b/picpeak-plugin.lrplugin/util.lua new file mode 100644 index 0000000..607345c --- /dev/null +++ b/picpeak-plugin.lrplugin/util.lua @@ -0,0 +1,166 @@ +-- Helper functions + +util = {} + +function util.table_contains(tbl, x) + if type(tbl) ~= "table" then + return false + end + local found = false + for _, v in pairs(tbl) do + if v == x then + found = true + break + end + end + return found +end + +function util.dumpTable(t) + if t == nil then + return "nil" + end + local ok, s = LrTasks.pcall(function() + return inspect(t) + end) + if not ok or s == nil then + return tostring(t) + end + local pattern = '(field = "Authorization",%s+value = "[Bb]earer %w%w%w%w%w%w%w%w%w%w%w)(%w+)(")' + return s:gsub(pattern, "%1...%3") +end + +local function trim(s) + return s:match("^%s*(.-)%s*$") +end + +function util.nilOrEmpty(val) + if type(val) == "string" then + return val == nil or trim(val) == "" + else + return val == nil + end +end + +function util.getExtension(path) + if not path or type(path) ~= "string" then + return "" + end + return string.lower(string.match(path, "%.([^%.]+)$") or "") +end + +function util.cutToken(key) + if key == nil or type(key) ~= "string" then + return "(no token)" + end + if key == "" then + return "(empty token)" + end + if #key <= 20 then + return string.sub(key, 1, 8) .. "..." + end + return string.sub(key, 1, 20) .. "..." +end + +function util.getLogfilePath() + local filename = "PicPeakPlugin.log" + local macPath14 = LrPathUtils.getStandardFilePath("home") .. "/Library/Logs/Adobe/Lightroom/LrClassicLogs/" + local winPath14 = LrPathUtils.getStandardFilePath("home") + .. "\\AppData\\Local\\Adobe\\Lightroom\\Logs\\LrClassicLogs\\" + local macPathOld = LrPathUtils.getStandardFilePath("documents") .. "/LrClassicLogs/" + local winPathOld = LrPathUtils.getStandardFilePath("documents") .. "\\LrClassicLogs\\" + + local lightroomVersion = LrApplication.versionTable() + + if lightroomVersion.major >= 14 then + if MAC_ENV then + return macPath14 .. filename + else + return winPath14 .. filename + end + else + if MAC_ENV then + return macPathOld .. filename + else + return winPathOld .. filename + end + end +end + +function util.getPhotoDeviceId(photo) + if not photo then + return nil + end + local uuid = photo:getRawMetadata("uuid") + if uuid and uuid ~= "" then + return tostring(uuid) + end + if photo.localIdentifier then + return tostring(photo.localIdentifier) + end + return nil +end + +-- Shared: validate export context and connect to PicPeak. +function util.validateExportContextAndConnect(exportContext, contextLabel) + if not exportContext or not exportContext.exportSession or not exportContext.propertyTable then + ErrorHandler.handleError( + "Export context is missing. Please try again.", + (contextLabel or "Export") .. "Task: invalid export context" + ) + return nil + end + local exportSession = exportContext.exportSession + local exportParams = exportContext.propertyTable + local settingsText = (contextLabel == "Publish") and "plugin settings" or "export settings" + if util.nilOrEmpty(exportParams.url) or util.nilOrEmpty(exportParams.apiToken) then + ErrorHandler.handleError( + "Configure PicPeak URL and API token in the " .. settingsText .. ".", + (contextLabel or "Export") .. "Task: URL or API token not set" + ) + return nil + end + local picpeak = PicPeakAPI:new(exportParams.url, exportParams.apiToken) + if not picpeak:checkConnectivity() then + ErrorHandler.handleError( + "PicPeak connection not working. Check URL and API token in " .. settingsText .. ".", + "PicPeak connection not working. Export stopped." + ) + return nil + end + return exportSession, exportParams, picpeak +end + +function util.buildSimpleUploadProgressTitle(nPhotos, verb, suffix) + local countStr = (nPhotos > 1) and (nPhotos .. " photos") or "one photo" + return verb .. " " .. countStr .. " to " .. (suffix or "PicPeak") +end + +function util.reportUploadFailures(failures) + if failures and #failures > 0 then + local message = (#failures == 1) and "1 file failed to upload correctly." + or (tostring(#failures) .. " files failed to upload correctly.") + local formattedFailures = {} + for i = 1, math.min(#failures, 20) do + table.insert(formattedFailures, "• " .. failures[i]) + end + if #failures > 20 then + table.insert(formattedFailures, "... and " .. tostring(#failures - 20) .. " more failures.") + table.insert(formattedFailures, "(Check PicPeakPlugin.log for full details)") + end + LrDialogs.message(message, table.concat(formattedFailures, "\n"), "critical") + end +end + +-- Safe delete of a rendered temp file (no-op if path is nil or file doesn't exist). +function util.safeDeleteTempFile(path) + if not path or path == "" then + return + end + local ok, err = LrTasks.pcall(function() + LrFileUtils.delete(path) + end) + if not ok then + log:warn("safeDeleteTempFile: could not delete " .. tostring(path) .. ": " .. tostring(err)) + end +end From f54977e30796bd66e452efd15fcde97766d8fa06 Mon Sep 17 00:00:00 2001 From: Bastian Machek <16717398+bmachek@users.noreply.github.com> Date: Mon, 15 Jun 2026 12:13:58 +0200 Subject: [PATCH 2/4] Fix MetadataProvider: browsable field must also be searchable LR SDK rejects browsable=true with searchable=false. Set picpeakEventId to browsable=false since it's an internal tracking field, not something users need to browse in the Library filter bar. Co-Authored-By: Claude Sonnet 4.6 --- picpeak-plugin.lrplugin/MetadataProvider.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/picpeak-plugin.lrplugin/MetadataProvider.lua b/picpeak-plugin.lrplugin/MetadataProvider.lua index d946666..8d56a1a 100644 --- a/picpeak-plugin.lrplugin/MetadataProvider.lua +++ b/picpeak-plugin.lrplugin/MetadataProvider.lua @@ -16,7 +16,7 @@ return { title = "PicPeak Event ID", dataType = "string", readOnly = true, - browsable = true, + browsable = false, searchable = false, }, }, From c9ac8adfa51df1a5e3247aeca24334e4ae242bde Mon Sep 17 00:00:00 2001 From: Bastian Machek <16717398+bmachek@users.noreply.github.com> Date: Mon, 15 Jun 2026 12:30:13 +0200 Subject: [PATCH 3/4] Add full event creation fields: customer info, password, expiry, feedback Export and Publish dialogs now expose all fields supported by the PicPeak v1 POST /events endpoint: - Customer name, email, phone and admin notification email - Password protection with password field - Expiration date (ISO 8601) - Guest feedback toggle (ratings, likes, comments, favorites) - Color theme preset name Publish collection settings persist all creation params so events are auto-created with the right metadata on first publish. createEvent() in PicPeakAPI now accepts and forwards all optional fields cleanly. Co-Authored-By: Claude Sonnet 4.6 --- .../ExportDialogSections.lua | 194 ++++++---- .../ExportServiceProvider.lua | 26 +- picpeak-plugin.lrplugin/ExportTask.lua | 67 ++-- picpeak-plugin.lrplugin/PicPeakAPI.lua | 40 +- picpeak-plugin.lrplugin/PublishTask.lua | 353 +++++++++++------- 5 files changed, 431 insertions(+), 249 deletions(-) diff --git a/picpeak-plugin.lrplugin/ExportDialogSections.lua b/picpeak-plugin.lrplugin/ExportDialogSections.lua index ec989f9..b862781 100644 --- a/picpeak-plugin.lrplugin/ExportDialogSections.lua +++ b/picpeak-plugin.lrplugin/ExportDialogSections.lua @@ -23,6 +23,7 @@ end function ExportDialogSections.sectionsForTopOfDialog(_, propertyTable) local f = LrView.osFactory() local bind = LrView.bind + local lw = LrView.share("labelWidth") return { { @@ -31,34 +32,26 @@ function ExportDialogSections.sectionsForTopOfDialog(_, propertyTable) f:column({ spacing = f:control_spacing(), - -- Event mode selector + -- Mode selector f:row({ - f:static_text({ - title = "Upload to event:", - alignment = "right", - width = LrView.share("labelWidth"), - }), + 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" }, + { 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 (visible when eventMode == "existing") + -- Existing event picker f:row({ visible = LrBinding.keyEquals("eventMode", "existing"), - f:static_text({ - title = "Event:", - alignment = "right", - width = LrView.share("labelWidth"), - }), + f:static_text({ title = "Event:", alignment = "right", width = lw }), f:popup_menu({ truncation = "middle", width_in_chars = 30, @@ -80,73 +73,132 @@ function ExportDialogSections.sectionsForTopOfDialog(_, propertyTable) }), }), - -- New event fields (visible when eventMode == "new") + -- New event fields f:column({ visible = LrBinding.keyEquals("eventMode", "new"), spacing = f:control_spacing(), - f:row({ - f:static_text({ - title = "Event name:", - alignment = "right", - width = LrView.share("labelWidth"), - }), - f:edit_field({ - value = bind("newEventName"), + -- Basic info + f:group_box({ + title = "Event Details", + fill_horizontal = 1, + f:column({ + spacing = f:control_spacing(), fill_horizontal = 1, - immediate = true, - }), - }), - f:row({ - f:static_text({ - title = "Event type:", - alignment = "right", - width = LrView.share("labelWidth"), - }), - f:popup_menu({ - value = bind("newEventType"), - items = SharedDialogSections.EVENT_TYPES, - immediate = true, + 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 = "" }), + }), }), }), - f:row({ - f:static_text({ - title = "Date (YYYY-MM-DD):", - alignment = "right", - width = LrView.share("labelWidth"), - }), - f:edit_field({ - value = bind("newEventDate"), - width_in_chars = 14, - immediate = true, - }), - f:static_text({ - title = "optional", - font = "", + + -- 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 = "" }), + }), + 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 = "" }), + }), }), }), - f:row({ - f:static_text({ - title = "Password protect:", - alignment = "right", - width = LrView.share("labelWidth"), - }), - f:checkbox({ - title = "Require password to view gallery", - value = bind("newEventRequirePassword"), + + -- 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 = "" }), + }), }), }), - f:row({ - visible = bind("newEventRequirePassword"), - f:static_text({ - title = "Password:", - alignment = "right", - width = LrView.share("labelWidth"), - }), - f:password_field({ - value = bind("newEventPassword"), + + -- Gallery options + f:group_box({ + title = "Gallery Options", + fill_horizontal = 1, + f:column({ + spacing = f:control_spacing(), fill_horizontal = 1, - immediate = true, + 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 = "" }), + }), }), }), }), diff --git a/picpeak-plugin.lrplugin/ExportServiceProvider.lua b/picpeak-plugin.lrplugin/ExportServiceProvider.lua index 52244d7..c96e613 100644 --- a/picpeak-plugin.lrplugin/ExportServiceProvider.lua +++ b/picpeak-plugin.lrplugin/ExportServiceProvider.lua @@ -10,15 +10,23 @@ return { allowColorSpaces = nil, exportPresetFields = { - { key = "url", default = "" }, - { key = "apiToken", default = "" }, - { key = "eventMode", default = "none" }, - { key = "eventId", default = nil }, - { key = "newEventName", default = "" }, - { key = "newEventType", default = "other" }, - { key = "newEventDate", default = "" }, - { key = "newEventRequirePassword", default = false }, - { key = "newEventPassword", default = "" }, + { 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, diff --git a/picpeak-plugin.lrplugin/ExportTask.lua b/picpeak-plugin.lrplugin/ExportTask.lua index 3da6320..12d3649 100644 --- a/picpeak-plugin.lrplugin/ExportTask.lua +++ b/picpeak-plugin.lrplugin/ExportTask.lua @@ -69,31 +69,39 @@ local function showEventOptionsDialog(picpeak, exportParams) }), }), - -- New event name - f:row({ + -- New event fields + f:column({ visible = LrBinding.keyEquals("eventMode", "new"), - f:static_text({ - title = "Event name:", - alignment = "right", - width = LrView.share("label_width"), + spacing = f:control_spacing(), + fill_horizontal = 1, + f:row({ + f:static_text({ title = "Event name:", alignment = "right", width = LrView.share("label_width") }), + f:edit_field({ fill_horizontal = 1, value = LrView.bind("newEventName"), immediate = true }), }), - f:edit_field({ - fill_horizontal = 1, - value = LrView.bind("newEventName"), - immediate = true, + f:row({ + f:static_text({ title = "Event type:", alignment = "right", width = LrView.share("label_width") }), + f:popup_menu({ + value = LrView.bind("newEventType"), + items = SharedDialogSections.EVENT_TYPES, + immediate = true, + }), }), - }), - f:row({ - visible = LrBinding.keyEquals("eventMode", "new"), - f:static_text({ - title = "Event type:", - alignment = "right", - width = LrView.share("label_width"), + f:row({ + f:static_text({ title = "Customer name:", alignment = "right", width = LrView.share("label_width") }), + f:edit_field({ fill_horizontal = 1, value = LrView.bind("newEventCustomerName"), immediate = true }), }), - f:popup_menu({ - value = LrView.bind("newEventType"), - items = require("SharedDialogSections").EVENT_TYPES, - immediate = true, + f:row({ + f:static_text({ title = "Customer email:", alignment = "right", width = LrView.share("label_width") }), + f:edit_field({ fill_horizontal = 1, value = LrView.bind("newEventCustomerEmail"), immediate = true }), + }), + f:row({ + f:static_text({ title = "Password protect:", alignment = "right", width = LrView.share("label_width") }), + f:checkbox({ title = "Require password", value = LrView.bind("newEventRequirePassword") }), + }), + f:row({ + visible = LrView.bind("newEventRequirePassword"), + f:static_text({ title = "Password:", alignment = "right", width = LrView.share("label_width") }), + f:password_field({ fill_horizontal = 1, value = LrView.bind("newEventPassword"), immediate = true }), }), }), }), @@ -145,11 +153,18 @@ local function resolveEventForExport(picpeak, exportParams) end log:trace("ExportTask: creating new event '" .. name .. "'") local newId = picpeak:createEvent({ - event_name = name, - event_type = exportParams.newEventType or "other", - event_date = exportParams.newEventDate or "", - require_password = exportParams.newEventRequirePassword, - password = exportParams.newEventPassword or "", + event_name = name, + event_type = exportParams.newEventType, + event_date = exportParams.newEventDate, + customer_name = exportParams.newEventCustomerName, + customer_email = exportParams.newEventCustomerEmail, + customer_phone = exportParams.newEventCustomerPhone, + admin_email = exportParams.newEventAdminEmail, + require_password = exportParams.newEventRequirePassword, + password = exportParams.newEventPassword, + expires_at = exportParams.newEventExpiresAt, + feedback_enabled = exportParams.newEventFeedbackEnabled, + color_theme = exportParams.newEventColorTheme, }) if not newId then ErrorHandler.handleError( diff --git a/picpeak-plugin.lrplugin/PicPeakAPI.lua b/picpeak-plugin.lrplugin/PicPeakAPI.lua index 6b2d2ea..093d8d6 100644 --- a/picpeak-plugin.lrplugin/PicPeakAPI.lua +++ b/picpeak-plugin.lrplugin/PicPeakAPI.lua @@ -256,32 +256,48 @@ function PicPeakAPI:getEventShareUrl(eventId) end -- Create a new gallery event. --- params: { event_name, event_type, event_date, require_password, password } --- Returns event id (integer) on success, nil on failure. +-- params table (all optional except event_name and event_type): +-- event_name, event_type, event_date, +-- customer_name, customer_email, customer_phone, admin_email, +-- require_password, password, +-- expires_at, feedback_enabled, color_theme +-- Returns: event id, share_url on success; nil on failure. function PicPeakAPI:createEvent(params) if util.nilOrEmpty(params.event_name) then ErrorHandler.handleError("No event name given.", "createEvent: event_name empty") return nil end - local eventType = (params.event_type and params.event_type ~= "") and params.event_type or "other" local body = { event_name = params.event_name, - event_type = eventType, + event_type = (not util.nilOrEmpty(params.event_type)) and params.event_type or "other", } - if params.event_date and params.event_date ~= "" then - body.event_date = params.event_date + + -- Optional string fields: only include when non-empty + local function addStr(key) if not util.nilOrEmpty(params[key]) then body[key] = params[key] end end + addStr("event_date") + addStr("customer_name") + addStr("customer_email") + addStr("customer_phone") + addStr("admin_email") + addStr("expires_at") + addStr("color_theme") + + -- Password protection + body.require_password = params.require_password and true or false + if body.require_password and not util.nilOrEmpty(params.password) then + body.password = params.password end - if params.require_password then - body.require_password = true - body.password = params.password or "" - else - body.require_password = false + + -- Feedback (explicit boolean so server applies it vs inheriting the global default) + if params.feedback_enabled ~= nil then + body.feedback_enabled = params.feedback_enabled and true or false end + log:trace("createEvent body: " .. JSON:encode(body)) local parsedResponse = self:doPostRequest("/events", body) if parsedResponse and parsedResponse.id then - log:info("createEvent: created event id=" .. tostring(parsedResponse.id) .. " slug=" .. tostring(parsedResponse.slug)) + log:info("createEvent: id=" .. tostring(parsedResponse.id) .. " slug=" .. tostring(parsedResponse.slug)) return parsedResponse.id, parsedResponse.share_url end return nil diff --git a/picpeak-plugin.lrplugin/PublishTask.lua b/picpeak-plugin.lrplugin/PublishTask.lua index dbc919f..2755556 100644 --- a/picpeak-plugin.lrplugin/PublishTask.lua +++ b/picpeak-plugin.lrplugin/PublishTask.lua @@ -6,57 +6,58 @@ PublishTask = {} -- --------------------------------------------------------------------------- -- Resolve or create the event for a publish collection. --- Returns: eventId (string) or nil. -- --------------------------------------------------------------------------- local function resolvePublishEvent(picpeak, exportContext) local publishedCollection = exportContext.publishedCollection - local collectionSettings = publishedCollection:getCollectionInfoSummary().collectionSettings - local strategy = collectionSettings.albumCreationStrategy or "collection" + local cs = publishedCollection:getCollectionInfoSummary().collectionSettings + local strategy = cs.albumCreationStrategy or "collection" local eventId = publishedCollection:getRemoteId() local collectionName = publishedCollection:getName() local exportSession = exportContext.exportSession log:trace("resolvePublishEvent: strategy=" .. strategy .. " remoteId=" .. tostring(eventId)) - if strategy == "existing" and collectionSettings.remoteId then - eventId = tostring(collectionSettings.remoteId) + if strategy == "existing" and cs.remoteId then + eventId = tostring(cs.remoteId) end if eventId and eventId ~= "" then if picpeak:checkIfEventExists(eventId) then local shareUrl = picpeak:getEventShareUrl(eventId) exportSession:recordRemoteCollectionId(eventId) - if shareUrl then - exportSession:recordRemoteCollectionUrl(shareUrl) - end + if shareUrl then exportSession:recordRemoteCollectionUrl(shareUrl) end return eventId else - log:warn("resolvePublishEvent: stored event " .. eventId .. " no longer exists, creating new one") + log:warn("resolvePublishEvent: event " .. eventId .. " gone, creating new one") eventId = nil end end - -- Create new event from collection name - log:info("resolvePublishEvent: creating new event '" .. collectionName .. "'") - local eventType = collectionSettings.eventType or "other" - local newId = picpeak:createEvent({ - event_name = collectionName, - event_type = eventType, - require_password = false, + -- Build creation params from collection settings + local newId, shareUrl = picpeak:createEvent({ + event_name = collectionName, + event_type = cs.eventType, + event_date = cs.eventDate, + customer_name = cs.customerName, + customer_email = cs.customerEmail, + customer_phone = cs.customerPhone, + admin_email = cs.adminEmail, + require_password = cs.requirePassword, + password = cs.password, + expires_at = cs.expiresAt, + feedback_enabled = cs.feedbackEnabled, + color_theme = cs.colorTheme, }) if not newId then ErrorHandler.handleError( - "Failed to create PicPeak event for collection '" .. collectionName .. "'. Check connection.", + "Failed to create PicPeak event for collection '" .. collectionName .. "'.", "resolvePublishEvent: createEvent returned nil" ) return nil end newId = tostring(newId) - local shareUrl = picpeak:getEventShareUrl(newId) exportSession:recordRemoteCollectionId(newId) - if shareUrl then - exportSession:recordRemoteCollectionUrl(shareUrl) - end + if shareUrl then exportSession:recordRemoteCollectionUrl(shareUrl) end log:info("resolvePublishEvent: created event id=" .. newId) return newId end @@ -67,19 +68,14 @@ end function PublishTask.processRenderedPhotos(functionContext, exportContext) local exportSession, exportParams, picpeak = util.validateExportContextAndConnect(exportContext, "Publish") - if not exportSession then - return nil - end + if not exportSession then return nil end local eventId = resolvePublishEvent(picpeak, exportContext) - if not eventId then - return nil - end + if not eventId then return nil end local nPhotos = exportSession:countRenditions() - local progressTitle = (exportParams.url and exportParams.url ~= "") and exportParams.url or "PicPeak" - log:info("=== PicPeak Publish START: " .. nPhotos .. " photos | url=" .. tostring(exportParams.url) - .. " | eventId=" .. tostring(eventId) .. " ===") + local progressTitle = (exportParams.url ~= "") and exportParams.url or "PicPeak" + log:info("=== PicPeak Publish START: " .. nPhotos .. " photos | eventId=" .. eventId .. " ===") local progressScope = LrProgressScope({ title = util.buildSimpleUploadProgressTitle(nPhotos, "Publishing", progressTitle), @@ -91,14 +87,11 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) for _, rendition in exportContext:renditions({ stopIfCanceled = true }) do local success, pathOrMessage = rendition:waitForRender() - if progressScope:isCanceled() then - break - end + if progressScope:isCanceled() then break end if success then local photo = rendition.photo local fileName = photo:getFormattedMetadata("fileName") - local photoId, errReason = picpeak:uploadPhoto(eventId, pathOrMessage, fileName) util.safeDeleteTempFile(pathOrMessage) @@ -111,13 +104,10 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) MetadataTask.setEventId(photo, eventId) rendition:recordPublishedPhotoId(photoIdStr) local shareUrl = picpeak:getEventShareUrl(eventId) - if shareUrl then - rendition:recordPublishedPhotoUrl(shareUrl) - end - log:info("PublishTask: uploaded " .. fileName .. " -> photoId=" .. photoIdStr) + if shareUrl then rendition:recordPublishedPhotoUrl(shareUrl) end + log:info("PublishTask: " .. fileName .. " -> photoId=" .. photoIdStr) end else - log:warn("PublishTask: render failed: " .. tostring(pathOrMessage)) util.safeDeleteTempFile(pathOrMessage) end @@ -129,54 +119,42 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) end progressScope:done() - log:info("=== PicPeak Publish DONE: " .. nPhotos .. " photos | failures=" .. #failures .. " ===") + log:info("=== PicPeak Publish DONE: " .. nPhotos .. " | failures=" .. #failures .. " ===") util.reportUploadFailures(failures) end -- --------------------------------------------------------------------------- --- Collection management callbacks +-- Collection management -- --------------------------------------------------------------------------- -function PublishTask.deletePhotosFromPublishedCollection( - publishSettings, - arrayOfPhotoIds, - deletedCallback, - localCollectionId -) - -- PicPeak v1 API has no endpoint to delete individual photos. - -- We mark them as deleted in Lightroom so they won't be republished, - -- but they remain in the PicPeak gallery on the server. +function PublishTask.deletePhotosFromPublishedCollection(publishSettings, arrayOfPhotoIds, deletedCallback, localCollectionId) if #arrayOfPhotoIds > 0 then LrDialogs.message( "PicPeak: Photos removed from collection", - "Note: The PicPeak API does not support deleting photos via the API. " + "The PicPeak API (v1) does not support deleting individual photos remotely. " .. tostring(#arrayOfPhotoIds) - .. " photo(s) were removed from the Lightroom publish collection but remain in the PicPeak gallery." - .. " Remove them manually in PicPeak if needed.", + .. " photo(s) were removed from the Lightroom publish collection but remain in the PicPeak gallery. " + .. "Delete them manually in PicPeak if needed.", "info" ) end - for _, photoId in ipairs(arrayOfPhotoIds) do - deletedCallback(photoId) - end + for _, photoId in ipairs(arrayOfPhotoIds) do deletedCallback(photoId) end end function PublishTask.deletePublishedCollection(publishSettings, info) - -- PicPeak v1 API has no delete event endpoint (admin-only in UI). if info.remoteId and info.remoteId ~= "" then LrDialogs.message( "PicPeak: Gallery not deleted", - "The PicPeak API does not support deleting gallery events. " + "The PicPeak API (v1) does not support deleting gallery events remotely. " .. "The Lightroom collection was removed, but the gallery (event id=" .. tostring(info.remoteId) - .. ") still exists in PicPeak. Delete it manually if needed.", + .. ") still exists in PicPeak. Delete it manually in the PicPeak admin interface.", "info" ) end end function PublishTask.renamePublishedCollection(publishSettings, info) - -- PicPeak v1 API has no rename event endpoint. log:trace("renamePublishedCollection: rename not supported by PicPeak v1 API") end @@ -200,78 +178,80 @@ function PublishTask.getCollectionBehaviorInfo(publishSettings) end -- --------------------------------------------------------------------------- --- Per-collection settings (event type selection) +-- Per-collection settings dialog -- --------------------------------------------------------------------------- +-- Helper: seed pluginContext from persisted collectionSettings (for re-opens) +local function seedPluginContext(ctx, cs) + ctx.albumCreationStrategy = cs.albumCreationStrategy or "collection" + ctx.eventType = cs.eventType or "other" + ctx.eventDate = cs.eventDate or "" + ctx.customerName = cs.customerName or "" + ctx.customerEmail = cs.customerEmail or "" + ctx.customerPhone = cs.customerPhone or "" + ctx.adminEmail = cs.adminEmail or "" + ctx.requirePassword = cs.requirePassword or false + ctx.password = cs.password or "" + ctx.expiresAt = cs.expiresAt or "" + ctx.feedbackEnabled = cs.feedbackEnabled or false + ctx.colorTheme = cs.colorTheme or "" + ctx.selectedEventId = cs.remoteId or "0" + ctx.picpeakEvents = { { title = "Loading…", value = "0" } } +end + function PublishTask.viewForCollectionSettings(f, publishSettings, info) + -- Existing published collection: show read-only summary if info.publishedCollection ~= nil then - -- Editing an existing collection: show read-only info local remoteId = info.publishedCollection:getRemoteId() + local name = info.publishedCollection:getName() + local rows = {} if remoteId then - return f:row({ - f:static_text({ - title = "PicPeak event ID: " .. tostring(remoteId), - alignment = "left", - font = "", - }), - }) + table.insert(rows, f:row({ + f:static_text({ title = "PicPeak event ID: ", font = "" }), + f:static_text({ title = tostring(remoteId), font = "" }), + })) + end + if name then + table.insert(rows, f:row({ + f:static_text({ title = "Collection name: ", font = "" }), + f:static_text({ title = tostring(name), font = "" }), + })) end - return f:row({}) + if #rows == 0 then table.insert(rows, f:row({})) end + return f:column(rows) end - -- New collection: choose event type - info.pluginContext.eventType = "other" - info.pluginContext.albumCreationStrategy = "collection" - info.pluginContext.picpeakEvents = { { title = "Please select", value = "0" } } + -- New collection: seed context and build form + local ctx = info.pluginContext + local cs = info.collectionSettings or {} + seedPluginContext(ctx, cs) + -- Async: load event list for "use existing" picker LrTasks.startAsyncTask(function() local picpeak = PicPeakAPI:new(publishSettings.url, publishSettings.apiToken) local events = picpeak:getEvents() - if events and #events > 0 then - local items = { { title = "Please select", value = "0" } } - for _, e in ipairs(events) do - table.insert(items, e) - end - info.pluginContext.picpeakEvents = items - end + local items = { { title = "Please select", value = "0" } } + for _, e in ipairs(events or {}) do table.insert(items, e) end + ctx.picpeakEvents = items end) local bind = LrView.bind - local share = LrView.share + local lw = LrView.share("cs_lw") return f:group_box({ - bind_to_object = info.pluginContext, + bind_to_object = ctx, title = "PicPeak Event Settings", fill_horizontal = 1, f:column({ spacing = f:control_spacing(), fill_horizontal = 1, - f:row({ - f:static_text({ - title = "This collection will be synced to a PicPeak gallery event.", - alignment = "left", - font = "", - fill_horizontal = 1, - }), - }), - f:separator({ fill_horizontal = 1 }), + + -- Strategy f:radio_button({ title = "Create new event from collection name", checked_value = "collection", value = bind("albumCreationStrategy"), }), - f:row({ - f:static_text({ - title = " Event type:", - alignment = "right", - }), - f:popup_menu({ - value = bind("eventType"), - items = require("SharedDialogSections").EVENT_TYPES, - immediate = true, - enabled = LrBinding.keyEquals("albumCreationStrategy", "collection"), - }), - }), f:row({ f:radio_button({ title = "Use existing event", @@ -286,55 +266,166 @@ function PublishTask.viewForCollectionSettings(f, publishSettings, info) immediate = true, }), }), + + f:separator({ fill_horizontal = 1 }), + + -- New event details (shown when strategy == "collection") + f:column({ + visible = LrBinding.keyEquals("albumCreationStrategy", "collection"), + spacing = f:control_spacing(), + fill_horizontal = 1, + + 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 type:", alignment = "right", width = lw }), + f:popup_menu({ + value = bind("eventType"), + items = SharedDialogSections.EVENT_TYPES, + immediate = true, + }), + }), + f:row({ + f:static_text({ title = "Event date:", alignment = "right", width = lw }), + f:edit_field({ value = bind("eventDate"), width_in_chars = 14, immediate = true }), + f:static_text({ title = "YYYY-MM-DD, optional", font = "" }), + }), + }), + }), + + 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("customerName"), fill_horizontal = 1, immediate = true }), + }), + f:row({ + f:static_text({ title = "Customer email:", alignment = "right", width = lw }), + f:edit_field({ value = bind("customerEmail"), fill_horizontal = 1, immediate = true }), + }), + f:row({ + f:static_text({ title = "Customer phone:", alignment = "right", width = lw }), + f:edit_field({ value = bind("customerPhone"), fill_horizontal = 1, immediate = true }), + f:static_text({ title = "optional", font = "" }), + }), + f:row({ + f:static_text({ title = "Admin email:", alignment = "right", width = lw }), + f:edit_field({ value = bind("adminEmail"), fill_horizontal = 1, immediate = true }), + f:static_text({ title = "for notifications", font = "" }), + }), + }), + }), + + 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("requirePassword"), + }), + }), + f:row({ + visible = bind("requirePassword"), + f:static_text({ title = "Gallery password:", alignment = "right", width = lw }), + f:password_field({ value = bind("password"), fill_horizontal = 1, immediate = true }), + }), + f:row({ + f:static_text({ title = "Expires at:", alignment = "right", width = lw }), + f:edit_field({ value = bind("expiresAt"), width_in_chars = 22, immediate = true }), + f:static_text({ title = "YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS", font = "" }), + }), + }), + }), + + 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("feedbackEnabled"), + }), + }), + f:row({ + f:static_text({ title = "Color theme:", alignment = "right", width = lw }), + f:edit_field({ value = bind("colorTheme"), width_in_chars = 20, immediate = true }), + f:static_text({ title = "PicPeak preset name, optional", font = "" }), + }), + }), + }), + }), }), }) end function PublishTask.endDialogForCollectionSettings(publishSettings, info) log:trace("endDialogForCollectionSettings") - local props = info.pluginContext - if info.why == "ok" then - local strategy = props.albumCreationStrategy or "collection" - info.collectionSettings.albumCreationStrategy = strategy - info.collectionSettings.eventType = props.eventType or "other" - - if strategy == "existing" then - local sel = props.selectedEventId - if util.nilOrEmpty(sel) or sel == "0" then - ErrorHandler.handleError("No event selected.", "endDialogForCollectionSettings: no event selected") - return - end - info.collectionSettings.remoteId = sel + if info.why ~= "ok" then return end + + local ctx = info.pluginContext + local cs = info.collectionSettings + + cs.albumCreationStrategy = ctx.albumCreationStrategy or "collection" + + if cs.albumCreationStrategy == "existing" then + local sel = ctx.selectedEventId + if util.nilOrEmpty(sel) or sel == "0" then + ErrorHandler.handleError("No event selected.", "endDialogForCollectionSettings: no event selected") + return end + cs.remoteId = sel + else + -- Persist all "new event" creation params into collection settings + cs.eventType = ctx.eventType + cs.eventDate = ctx.eventDate + cs.customerName = ctx.customerName + cs.customerEmail = ctx.customerEmail + cs.customerPhone = ctx.customerPhone + cs.adminEmail = ctx.adminEmail + cs.requirePassword = ctx.requirePassword + cs.password = ctx.password + cs.expiresAt = ctx.expiresAt + cs.feedbackEnabled = ctx.feedbackEnabled + cs.colorTheme = ctx.colorTheme end end function PublishTask.updateCollectionSettings(publishSettings, info) log:trace("updateCollectionSettings") - if not info or not info.collectionSettings then - return - end - local props = info.collectionSettings - if props.albumCreationStrategy == "existing" and props.remoteId then + if not info or not info.collectionSettings then return end + local cs = info.collectionSettings + if cs.albumCreationStrategy == "existing" and cs.remoteId then local picpeak = PicPeakAPI:new(publishSettings.url, publishSettings.apiToken) if not picpeak:checkConnectivity() then log:warn("updateCollectionSettings: PicPeak not reachable") return end - local eventId = tostring(props.remoteId) - local name = picpeak:getEventName(eventId) + local eventId = tostring(cs.remoteId) + local name = picpeak:getEventName(eventId) or ("Event " .. eventId) local shareUrl = picpeak:getEventShareUrl(eventId) - if not name then - name = "Event " .. eventId - end - log:trace("updateCollectionSettings: binding to event id=" .. eventId .. " name=" .. name) + log:trace("updateCollectionSettings: binding event id=" .. eventId .. " name=" .. name) local catalog = LrApplication.activeCatalog() if catalog and info.publishedCollection then catalog:withWriteAccessDo("Bind PicPeak event", function() info.publishedCollection:setRemoteId(eventId) - if shareUrl then - info.publishedCollection:setRemoteUrl(shareUrl) - end + if shareUrl then info.publishedCollection:setRemoteUrl(shareUrl) end info.publishedCollection:setName(name) end) end From 7cbb1c8b5b6eff259bf66686902c7624c748848b Mon Sep 17 00:00:00 2001 From: Bastian Machek <16717398+bmachek@users.noreply.github.com> Date: Mon, 15 Jun 2026 12:53:59 +0200 Subject: [PATCH 4/4] Fix publish duplicates: skip re-upload for already-published photos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Lightroom marks a photo as "modified / to re-publish", the rendition carries the previously recorded remote ID via publishedPhoto:getRemoteId(). We now detect this case and skip the upload, re-recording the existing PicPeak photo ID so Lightroom marks the photo as up-to-date. PicPeak v1 API has no replace/delete photo endpoint (admin-only, session auth only), so uploading again would always create a duplicate. A one-time info dialog after the run tells the user how many photos were skipped and how to force a replacement (remove → delete in PicPeak → re-add). Co-Authored-By: Claude Sonnet 4.6 --- picpeak-plugin.lrplugin/PublishTask.lua | 53 +++++++++++++++++++------ 1 file changed, 40 insertions(+), 13 deletions(-) diff --git a/picpeak-plugin.lrplugin/PublishTask.lua b/picpeak-plugin.lrplugin/PublishTask.lua index 2755556..55371f7 100644 --- a/picpeak-plugin.lrplugin/PublishTask.lua +++ b/picpeak-plugin.lrplugin/PublishTask.lua @@ -83,7 +83,9 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) }) local failures = {} + local skipped = 0 local done = 0 + local shareUrl = picpeak:getEventShareUrl(eventId) for _, rendition in exportContext:renditions({ stopIfCanceled = true }) do local success, pathOrMessage = rendition:waitForRender() @@ -92,20 +94,34 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) if success then local photo = rendition.photo local fileName = photo:getFormattedMetadata("fileName") - local photoId, errReason = picpeak:uploadPhoto(eventId, pathOrMessage, fileName) - util.safeDeleteTempFile(pathOrMessage) - if not photoId then - log:error("PublishTask: upload failed for " .. fileName .. ": " .. tostring(errReason)) - table.insert(failures, fileName .. " (" .. (errReason or "Upload failed") .. ")") - else - local photoIdStr = tostring(photoId) - MetadataTask.setPhotoId(photo, photoIdStr) - MetadataTask.setEventId(photo, eventId) - rendition:recordPublishedPhotoId(photoIdStr) - local shareUrl = picpeak:getEventShareUrl(eventId) + -- Detect re-publish: Lightroom passes an existing remote ID for photos + -- that were previously published and have since been modified. + -- PicPeak v1 API has no replace/delete photo endpoint, so we cannot + -- push the updated version without creating a duplicate. Skip the upload + -- and re-record the existing ID so Lightroom marks the photo as up-to-date. + local existingId = rendition.publishedPhoto and rendition.publishedPhoto:getRemoteId() + if existingId and existingId ~= "" then + util.safeDeleteTempFile(pathOrMessage) + rendition:recordPublishedPhotoId(existingId) if shareUrl then rendition:recordPublishedPhotoUrl(shareUrl) end - log:info("PublishTask: " .. fileName .. " -> photoId=" .. photoIdStr) + skipped = skipped + 1 + log:info("PublishTask: skip re-upload for " .. fileName .. " (keeping id=" .. existingId .. ")") + else + local photoId, errReason = picpeak:uploadPhoto(eventId, pathOrMessage, fileName) + util.safeDeleteTempFile(pathOrMessage) + + if not photoId then + log:error("PublishTask: upload failed for " .. fileName .. ": " .. tostring(errReason)) + table.insert(failures, fileName .. " (" .. (errReason or "Upload failed") .. ")") + else + local photoIdStr = tostring(photoId) + MetadataTask.setPhotoId(photo, photoIdStr) + MetadataTask.setEventId(photo, eventId) + rendition:recordPublishedPhotoId(photoIdStr) + if shareUrl then rendition:recordPublishedPhotoUrl(shareUrl) end + log:info("PublishTask: " .. fileName .. " -> photoId=" .. photoIdStr) + end end else util.safeDeleteTempFile(pathOrMessage) @@ -119,7 +135,18 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) end progressScope:done() - log:info("=== PicPeak Publish DONE: " .. nPhotos .. " | failures=" .. #failures .. " ===") + log:info("=== PicPeak Publish DONE: " .. nPhotos .. " | failures=" .. #failures .. " | skipped=" .. skipped .. " ===") + if skipped > 0 then + LrDialogs.message( + "PicPeak: " .. skipped .. " photo(s) not re-uploaded", + "The PicPeak API does not support replacing photos remotely. " + .. tostring(skipped) + .. " already-published photo(s) were skipped — the originals remain in PicPeak unchanged.\n\n" + .. "To push an updated version: remove the photo from this collection, delete it in PicPeak, " + .. "then re-add and re-publish.", + "info" + ) + end util.reportUploadFailures(failures) end